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:
parent
a9c20a5f1b
commit
4cdb048dfc
@ -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
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -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;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user