feat: story 2 — Characters can Deal Damage to Characters
- Allium spec: damage-and-health.allium - 5 fast-check properties: damage reduces health, health non-negative, death at zero, self-damage forbidden, dead cannot take damage - Character.createWithHealth factory for testing with arbitrary health - Character.dealDamage(target, damage) method
This commit is contained in:
parent
75c7c63dda
commit
fc2cf73b34
49
specs/damage-and-health.allium
Normal file
49
specs/damage-and-health.allium
Normal file
@ -0,0 +1,49 @@
|
||||
-- allium: 3
|
||||
|
||||
-- allium: damage-and-health
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Rules
|
||||
------------------------------------------------------------
|
||||
|
||||
rule DamageReducesHealth {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: attacker.name != target.name
|
||||
requires: target.status = alive
|
||||
ensures: target.health.value = target.health.value - damage
|
||||
ensures:
|
||||
if target.health.value - damage <= 0:
|
||||
target.status = dead
|
||||
else:
|
||||
target.status = alive
|
||||
}
|
||||
|
||||
rule SelfDamageForbidden {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: attacker.name = target.name
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
target.status = target.status
|
||||
}
|
||||
|
||||
rule DeadCannotTakeDamage {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: target.status = dead
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
target.status = dead
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Invariants
|
||||
------------------------------------------------------------
|
||||
|
||||
invariant HealthNonNegative {
|
||||
for c in Characters:
|
||||
c.health.value >= 0
|
||||
}
|
||||
|
||||
invariant DeathAtZeroHealth {
|
||||
for c in Characters:
|
||||
c.health.value = 0 implies c.status = dead
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Character entity — immutable, value-object-driven.
|
||||
* Character entity — value-object-driven.
|
||||
*
|
||||
* "I can't believe it's not Haskell": no mutation, invariants at boundaries.
|
||||
* "I can't believe it's not Haskell": invariants at boundaries.
|
||||
* State is encapsulated in a CharacterState record type.
|
||||
*/
|
||||
import { Health } from './Health.ts';
|
||||
import type { Level } from './Level.ts';
|
||||
import type { Status } from './Status.ts';
|
||||
import { StatusAlive } from './Status.ts';
|
||||
import { StatusAlive, StatusDead } from './Status.ts';
|
||||
import { CharacterState } from './CharacterState.ts';
|
||||
import type { Faction } from './Faction.ts';
|
||||
|
||||
@ -16,8 +16,18 @@ export interface CharacterCtor {
|
||||
level: Level;
|
||||
}
|
||||
|
||||
export interface CharacterCtorWithHealth {
|
||||
name: string;
|
||||
level: Level;
|
||||
health: number;
|
||||
}
|
||||
|
||||
export class Character {
|
||||
private constructor(readonly state: CharacterState) {}
|
||||
#state: CharacterState;
|
||||
|
||||
private constructor(state: CharacterState) {
|
||||
this.#state = state;
|
||||
}
|
||||
|
||||
/** Create a new character with default health (1000) and alive status. */
|
||||
static create({ name, level }: CharacterCtor): Character {
|
||||
@ -25,23 +35,47 @@ export class Character {
|
||||
return new Character(state);
|
||||
}
|
||||
|
||||
/** Create a character with a specific health value (for testing). */
|
||||
static createWithHealth({ name, level, health }: CharacterCtorWithHealth): Character {
|
||||
const state = new CharacterState(name, Health.create(health), StatusAlive, level, new Set());
|
||||
return new Character(state);
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.state.name;
|
||||
return this.#state.name;
|
||||
}
|
||||
|
||||
get health(): Health {
|
||||
return this.state.health;
|
||||
return this.#state.health;
|
||||
}
|
||||
|
||||
get status(): Status {
|
||||
return this.state.status;
|
||||
return this.#state.status;
|
||||
}
|
||||
|
||||
get level(): Level {
|
||||
return this.state.level;
|
||||
return this.#state.level;
|
||||
}
|
||||
|
||||
get factions(): ReadonlySet<Faction> {
|
||||
return this.state.factions;
|
||||
return this.#state.factions;
|
||||
}
|
||||
|
||||
/** Deal damage to another character. Mutates target in place. */
|
||||
dealDamage(target: Character, damage: number): void {
|
||||
// Self-damage is forbidden
|
||||
if (this.name === target.name) return;
|
||||
// Dead characters cannot take damage
|
||||
if (target.status.kind === 'dead') return;
|
||||
// Reduce health
|
||||
const newHealth = target.health.sub(damage);
|
||||
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
||||
target.#state = new CharacterState(
|
||||
target.name,
|
||||
newHealth,
|
||||
newStatus,
|
||||
target.level,
|
||||
target.factions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
110
src/damage-and-health.spec.ts
Normal file
110
src/damage-and-health.spec.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Level } from './Level.ts';
|
||||
|
||||
describe('DamageAndHealth', () => {
|
||||
describe('DamageReducesHealth', () => {
|
||||
it('property: health is reduced by the damage amount', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
fc.integer({ min: 0, max: 10000 }),
|
||||
(health, damage) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.createWithHealth({
|
||||
name: 'target',
|
||||
level: Level.create(1),
|
||||
health,
|
||||
});
|
||||
const expected = Math.max(0, health - damage);
|
||||
attacker.dealDamage(target, damage);
|
||||
return target.health.value === expected;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthNonNegative', () => {
|
||||
it('property: health never goes below zero after damage', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
fc.integer({ min: 0, max: 10000 }),
|
||||
(health, damage) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.createWithHealth({
|
||||
name: 'target',
|
||||
level: Level.create(1),
|
||||
health,
|
||||
});
|
||||
attacker.dealDamage(target, damage);
|
||||
return target.health.value >= 0;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeathAtZeroHealth', () => {
|
||||
it('property: character dies when health reaches zero', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
fc.integer({ min: 0, max: 10000 }),
|
||||
(health, damage) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.createWithHealth({
|
||||
name: 'target',
|
||||
level: Level.create(1),
|
||||
health,
|
||||
});
|
||||
attacker.dealDamage(target, damage);
|
||||
const expected = Math.max(0, health - damage);
|
||||
if (expected === 0) {
|
||||
return target.status.kind === 'dead';
|
||||
}
|
||||
return target.status.kind === 'alive';
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SelfDamageForbidden', () => {
|
||||
it('property: a character cannot deal damage to itself — health unchanged', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
fc.integer({ min: 0, max: 10000 }),
|
||||
(health, damage) => {
|
||||
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
||||
const healthBefore = c.health.value;
|
||||
const statusBefore = c.status.kind;
|
||||
c.dealDamage(c, damage);
|
||||
return c.health.value === healthBefore && c.status.kind === statusBefore;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeadCannotTakeDamage', () => {
|
||||
it('property: dead characters cannot take damage — state unchanged', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.create({ name: 'target', level: Level.create(1) });
|
||||
// Kill the target first
|
||||
attacker.dealDamage(target, 10000);
|
||||
const healthBefore = target.health.value;
|
||||
const statusBefore = target.status.kind;
|
||||
// Then try to deal more damage
|
||||
attacker.dealDamage(target, damage);
|
||||
return target.health.value === healthBefore && target.status.kind === statusBefore;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user