feat(story2): level-based damage modifiers — diff method, 50% scaling for level gaps, transcripts

This commit is contained in:
2026-06-13 16:08:50 +01:00
parent 39983a30c9
commit 38ea78f91e
10 changed files with 30890 additions and 4 deletions
+12 -2
View File
@@ -74,8 +74,18 @@ export class Character {
if (target.status.kind === 'dead') return target;
// Negative damage is invalid
if (damage < 0) throw new Error(`Damage must be non-negative, got ${damage}`);
// Reduce health
const newHealth = target.health.sub(damage);
// Level-based damage modifier
const levelDiff = this.level.diff(target.level); // = this.level - target.level
let actualDamage = damage;
if (levelDiff <= -5) {
// Target is ≥5 levels above → damage reduced by 50%
actualDamage = Math.floor(damage * 0.5);
} else if (levelDiff >= 5) {
// Target is ≥5 levels below → damage increased by 50%
actualDamage = Math.floor(damage * 1.5);
}
// Reduce health by the (possibly modified) damage amount
const newHealth = target.health.sub(actualDamage);
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
return new Character(
new CharacterState(target.name, newHealth, newStatus, target.level, target.factions),
+5
View File
@@ -26,4 +26,9 @@ export class Level {
static maxHealthForLevel(level: number): number {
return level >= 6 ? 1500 : 1000;
}
/** Signed level difference: this level minus target level. Positive = this is higher. */
diff(target: Level): number {
return this.value - target.value;
}
}
+122
View File
@@ -0,0 +1,122 @@
import fc from 'fast-check';
import { describe, it } from 'vitest';
import { Character } from './Character.ts';
import { Level } from './Level.ts';
describe('Healing', () => {
describe('SelfHealIncreasesHealth', () => {
it('property: healing increases health by the heal amount (when below cap)', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 999 }),
fc.integer({ min: 1, max: 500 }),
(health, healAmount) => {
// Constrain: ensure health + healAmount <= 1000 (level 1 cap)
fc.pre(health + healAmount <= 1000);
const c = Character.createWithHealth({
name: 'hero',
level: Level.create(1),
health,
});
const result = c.healSelf(healAmount);
return result.health.value === health + healAmount;
},
),
);
});
it('property: healing is capped at max health for level', () => {
fc.assert(
fc.property(
fc.integer({ min: 900, max: 1000 }),
fc.integer({ min: 1, max: 500 }),
(health, healAmount) => {
const c = Character.createWithHealth({
name: 'hero',
level: Level.create(1),
health,
});
const result = c.healSelf(healAmount);
const maxForLevel = Level.maxHealthForLevel(c.level.value);
return result.health.value === Math.min(health + healAmount, maxForLevel);
},
),
);
});
it('property: healing at level 6+ is capped at 1500', () => {
fc.assert(
fc.property(
fc.integer({ min: 1400, max: 1500 }),
fc.integer({ min: 1, max: 500 }),
(health, healAmount) => {
// Constrain: ensure health + healAmount > 1500
fc.pre(health + healAmount > 1500);
const c = Character.createWithHealth({
name: 'hero',
level: Level.create(6),
health,
});
const result = c.healSelf(healAmount);
return result.health.value === 1500;
},
),
);
});
});
describe('ZeroHeal', () => {
it('property: zero heal changes nothing', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 1000 }), (health) => {
const c = Character.createWithHealth({
name: 'hero',
level: Level.create(1),
health,
});
const result = c.healSelf(0);
return result.health.value === health && result.status.kind === 'alive';
}),
);
});
});
describe('DeadCannotHeal', () => {
it('property: dead characters cannot heal — returns same reference, state unchanged', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 500 }), (healAmount) => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const hero = Character.create({ name: 'hero', level: Level.create(1) });
// Kill the hero first using a different attacker
const deadHero = attacker.dealDamage(hero, 10000);
const healthBefore = deadHero.health.value;
const statusBefore = deadHero.status.kind;
// Try to heal the dead character
const result = deadHero.healSelf(healAmount);
return (
result === deadHero &&
result.health.value === healthBefore &&
result.status.kind === statusBefore
);
}),
);
});
});
describe('NegativeHealForbidden', () => {
it('property: negative heal amount throws an error', () => {
fc.assert(
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeHeal) => {
const c = Character.create({ name: 'hero', level: Level.create(1) });
let threw = false;
try {
c.healSelf(negativeHeal);
} catch {
threw = true;
}
return threw;
}),
);
});
});
});
+44
View File
@@ -0,0 +1,44 @@
-- allium: 3
-- allium: levels
------------------------------------------------------------
-- Rules
------------------------------------------------------------
rule LevelDiff {
for attacker in Characters, target in Characters:
diff = target.level - attacker.level
}
rule HighLevelTargetModifier {
when: CharacterDealsDamage(attacker, target, baseDamage)
requires: target.level - attacker.level >= 5
ensures: actualDamage = floor(baseDamage * 0.5)
}
rule LowLevelTargetModifier {
when: CharacterDealsDamage(attacker, target, baseDamage)
requires: attacker.level - target.level >= 5
ensures: actualDamage = floor(baseDamage * 1.5)
}
rule CloseLevelNoModifier {
when: CharacterDealsDamage(attacker, target, baseDamage)
requires: abs(target.level - attacker.level) < 5
ensures: actualDamage = baseDamage
}
------------------------------------------------------------
-- Invariants
------------------------------------------------------------
invariant DamageModifierComputationComplete {
for a in Characters, t in Characters, d in NonNegativeIntegers:
let diff = t.level - a.level
let actualDamage =
if diff >= 5 then floor(d * 0.5)
else if diff <= -5 then floor(d * 1.5)
else d
a.dealDamage(t, d) implies t.health = old(t.health) - actualDamage
}
+181
View File
@@ -0,0 +1,181 @@
import fc from 'fast-check';
import { describe, it } from 'vitest';
import { Character } from './Character.ts';
import { Level } from './Level.ts';
describe('Levels', () => {
describe('CloseLevelNoModifier', () => {
it('property: level gap < 5 → no modifier applied', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10 }),
fc.integer({ min: 1, max: 10 }),
fc.integer({ min: 0, max: 10000 }),
(attackerLevel, targetLevel, baseDamage) => {
const diff = Math.abs(attackerLevel - targetLevel);
if (diff >= 5) return true; // skip non-applicable cases
const attacker = Character.create({ name: 'a', level: Level.create(attackerLevel) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(targetLevel),
health: 1000,
});
const result = attacker.dealDamage(target, baseDamage);
return result.health.value === Math.max(0, 1000 - baseDamage);
},
),
);
});
});
describe('HighLevelTargetModifier', () => {
it('property: target ≥5 levels above → damage halved (floored)', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }),
fc.integer({ min: 6, max: 10 }),
fc.integer({ min: 0, max: 10000 }),
(attackerLevel, targetLevel, baseDamage) => {
const diff = targetLevel - attackerLevel;
if (diff < 5) return true; // skip non-applicable cases
const attacker = Character.create({ name: 'a', level: Level.create(attackerLevel) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(targetLevel),
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 0.5);
const result = attacker.dealDamage(target, baseDamage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
);
});
it('property: odd damage halved floors correctly', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 9999 }), (oddDamage) => {
if (oddDamage % 2 === 0) return true; // only odd values
const attacker = Character.create({ name: 'a', level: Level.create(1) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(6),
health: 1000,
});
const expectedDamage = Math.floor(oddDamage * 0.5);
const result = attacker.dealDamage(target, oddDamage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
});
});
describe('LowLevelTargetModifier', () => {
it('property: target ≥5 levels below → damage ×1.5 (floored)', () => {
fc.assert(
fc.property(
fc.integer({ min: 6, max: 10 }),
fc.integer({ min: 1, max: 5 }),
fc.integer({ min: 0, max: 10000 }),
(attackerLevel, targetLevel, baseDamage) => {
const diff = attackerLevel - targetLevel;
if (diff < 5) return true; // skip non-applicable cases
const attacker = Character.create({ name: 'a', level: Level.create(attackerLevel) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(targetLevel),
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 1.5);
const result = attacker.dealDamage(target, baseDamage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
);
});
});
describe('LevelDiffBoundary', () => {
it('property: exactly 5 levels above → modifier applies (halved)', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
// Level 1 vs Level 6 → diff = -5
const attacker = Character.create({ name: 'a', level: Level.create(1) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(6),
health: 1000,
});
const expectedDamage = Math.floor(damage * 0.5);
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
});
it('property: exactly 5 levels below → modifier applies (×1.5)', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
// Level 6 vs Level 1 → diff = 5
const attacker = Character.create({ name: 'a', level: Level.create(6) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(1),
health: 1000,
});
const expectedDamage = Math.floor(damage * 1.5);
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
});
it('property: 4 levels → no modifier', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
// Level 1 vs Level 5 → diff = 4
const attacker = Character.create({ name: 'a', level: Level.create(1) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(5),
health: 1000,
});
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - damage);
}),
);
});
it('property: 4 levels below → no modifier', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
// Level 5 vs Level 1 → diff = 4
const attacker = Character.create({ name: 'a', level: Level.create(5) });
const target = Character.createWithHealth({
name: 't',
level: Level.create(1),
health: 1000,
});
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - damage);
}),
);
});
});
describe('LevelDiff', () => {
it('property: diff(target) = this.value - target.value', () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 10 }), fc.integer({ min: 1, max: 10 }), (a, b) => {
const levelA = Level.create(a);
const levelB = Level.create(b);
return levelA.diff(levelB) === a - b;
}),
);
});
});
});