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:
Willem van den Ende 2026-06-13 15:28:34 +01:00
parent 75c7c63dda
commit fc2cf73b34
3 changed files with 202 additions and 9 deletions

View 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
}

View File

@ -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,
);
}
}

View 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;
}),
);
});
});
});