refactor(story2): immutable dealDamage, reference guard, negative damage validation, spec precision

- dealDamage returns new Character instead of mutating in-place
- SelfDamageForbidden uses reference equality (this === target)
- Negative damage throws at the boundary
- Removed duplicate Health.maxHealthForLevel (Level.ts is source of truth)
- Allium spec uses max(0, ...) for health floor precision
- New property: NegativeDamageForbidden (11 total properties)
This commit is contained in:
Willem van den Ende 2026-06-13 15:44:46 +01:00
parent a9c20a5f1b
commit 4cdb048dfc
4 changed files with 62 additions and 36 deletions

View File

@ -10,9 +10,9 @@ rule DamageReducesHealth {
when: Character.dealDamage(attacker, target, damage) when: Character.dealDamage(attacker, target, damage)
requires: attacker.name != target.name requires: attacker.name != target.name
requires: target.status = alive requires: target.status = alive
ensures: target.health.value = target.health.value - damage ensures: target.health.value = max(0, target.health.value - damage)
ensures: ensures:
if target.health.value - damage <= 0: if max(0, target.health.value - damage) = 0:
target.status = dead target.status = dead
else: else:
target.status = alive target.status = alive

View File

@ -24,9 +24,11 @@ export interface CharacterCtorWithHealth {
export class Character { export class Character {
#state: CharacterState; #state: CharacterState;
readonly #name: string;
private constructor(state: CharacterState) { private constructor(state: CharacterState) {
this.#state = state; this.#state = state;
this.#name = state.name;
} }
/** Create a new character with default health (1000) and alive status. */ /** Create a new character with default health (1000) and alive status. */
@ -42,7 +44,7 @@ export class Character {
} }
get name(): string { get name(): string {
return this.#state.name; return this.#name;
} }
get health(): Health { get health(): Health {
@ -61,21 +63,22 @@ export class Character {
return this.#state.factions; return this.#state.factions;
} }
/** Deal damage to another character. Mutates target in place. */ /**
dealDamage(target: Character, damage: number): void { * Deal damage to another character. Returns a new Character with updated state.
// Self-damage is forbidden * Does not mutate the attacker or the original target reference.
if (this.name === target.name) return; */
dealDamage(target: Character, damage: number): Character {
// Self-damage is forbidden — use reference equality, not name
if (this === target) return target;
// Dead characters cannot take damage // Dead characters cannot take damage
if (target.status.kind === 'dead') return; 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 // Reduce health
const newHealth = target.health.sub(damage); const newHealth = target.health.sub(damage);
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive; const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
target.#state = new CharacterState( return new Character(
target.name, new CharacterState(target.name, newHealth, newStatus, target.level, target.factions),
newHealth,
newStatus,
target.level,
target.factions,
); );
} }
} }

View File

@ -11,10 +11,6 @@ export class Health {
this.#value = n; this.#value = n;
} }
static maxHealthForLevel(level: number): number {
return level >= 6 ? 1500 : 1000;
}
static create(n: number): Health { static create(n: number): Health {
if (n < 0) { if (n < 0) {
throw new Error(`Health cannot be negative, got ${n}`); throw new Error(`Health cannot be negative, got ${n}`);

View File

@ -18,8 +18,8 @@ describe('DamageAndHealth', () => {
health, health,
}); });
const expected = Math.max(0, health - damage); const expected = Math.max(0, health - damage);
attacker.dealDamage(target, damage); const result = attacker.dealDamage(target, damage);
return target.health.value === expected; return result.health.value === expected;
}, },
), ),
); );
@ -39,8 +39,8 @@ describe('DamageAndHealth', () => {
level: Level.create(1), level: Level.create(1),
health, health,
}); });
attacker.dealDamage(target, damage); const result = attacker.dealDamage(target, damage);
return target.health.value >= 0; return result.health.value >= 0;
}, },
), ),
); );
@ -60,12 +60,12 @@ describe('DamageAndHealth', () => {
level: Level.create(1), level: Level.create(1),
health, health,
}); });
attacker.dealDamage(target, damage); const result = attacker.dealDamage(target, damage);
const expected = Math.max(0, health - damage); const expected = Math.max(0, health - damage);
if (expected === 0) { if (expected === 0) {
return target.status.kind === 'dead'; return result.status.kind === 'dead';
} }
return target.status.kind === 'alive'; return result.status.kind === 'alive';
}, },
), ),
); );
@ -73,7 +73,7 @@ describe('DamageAndHealth', () => {
}); });
describe('SelfDamageForbidden', () => { describe('SelfDamageForbidden', () => {
it('property: a character cannot deal damage to itself — health unchanged', () => { it('property: a character cannot deal damage to itself — returns same reference, state unchanged', () => {
fc.assert( fc.assert(
fc.property( fc.property(
fc.integer({ min: 0, max: 1000 }), fc.integer({ min: 0, max: 1000 }),
@ -82,8 +82,13 @@ describe('DamageAndHealth', () => {
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health }); const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
const healthBefore = c.health.value; const healthBefore = c.health.value;
const statusBefore = c.status.kind; const statusBefore = c.status.kind;
c.dealDamage(c, damage); const result = c.dealDamage(c, damage);
return c.health.value === healthBefore && c.status.kind === statusBefore; // Should return the same reference
return (
result === c &&
result.health.value === healthBefore &&
result.status.kind === statusBefore
);
}, },
), ),
); );
@ -91,18 +96,40 @@ describe('DamageAndHealth', () => {
}); });
describe('DeadCannotTakeDamage', () => { describe('DeadCannotTakeDamage', () => {
it('property: dead characters cannot take damage — state unchanged', () => { it('property: dead characters cannot take damage — returns same reference, state unchanged', () => {
fc.assert( fc.assert(
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => { fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) }); const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'target', level: Level.create(1) }); const target = Character.create({ name: 'target', level: Level.create(1) });
// Kill the target first // Kill the target first — capture the returned (dead) character
attacker.dealDamage(target, 10000); const deadTarget = attacker.dealDamage(target, 10000);
const healthBefore = target.health.value; const healthBefore = deadTarget.health.value;
const statusBefore = target.status.kind; const statusBefore = deadTarget.status.kind;
// Then try to deal more damage // Then try to deal more damage to the dead character
attacker.dealDamage(target, damage); const result = attacker.dealDamage(deadTarget, damage);
return target.health.value === healthBefore && target.status.kind === statusBefore; return (
result === deadTarget &&
result.health.value === healthBefore &&
result.status.kind === statusBefore
);
}),
);
});
});
describe('NegativeDamageForbidden', () => {
it('property: negative damage throws an error', () => {
fc.assert(
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'target', level: Level.create(1) });
let threw = false;
try {
attacker.dealDamage(target, negativeDamage);
} catch {
threw = true;
}
return threw;
}), }),
); );
}); });