6.5 KiB
ALWAYS start replies with ⚔️.
What this project is
An implementation of the RPG Combat rules engine. There are six user stories described in user-stories.md. We use a spec-first, property-based testing approach.
Build and Test Scripts
npm test: runs unit tests using vitest + fast-checknpm run lint:fix: runs eslint with autofixnpm run format:fix: runs prettier with autofixnpm run typecheck: runs tsc without emitnpm run checks: runs the pre-commit gate (format:fix, lint:fix, typecheck, test)
Allium + fast-check Workflow
This project combines three practices:
- Allium (
.alliumspecs) — formal behavioural specifications that capture what the system does - fast-check — property-based testing that verifies those properties hold across thousands of random inputs
- "I can't believe it's not Haskell" — TypeScript with ADTs, value objects, and immutability
Step 1: Spec with Allium
Use the Allium skills to formalize user stories into .allium specs:
/skill:elicit— explore requirements with stakeholders/skill:distill— extract specs from existing code/skill:tend— evolve specs as understanding deepens
The spec captures invariants (always-true properties) and rules (state transitions). These become the source of truth for your properties.
Step 2: Properties with fast-check
Translate Allium invariants and rules into fast-check properties. Each invariant becomes a property:
import fc from 'fast-check';
import { Character } from './domain';
// Invariant: "A character's health is never negative"
fc.property(fc.integer({ min: 0, max: 1000 }), (initialHealth) => {
const c = new Character({ name: 'hero', health: initialHealth });
return c.health >= 0;
});
// Property: "Dealing damage reduces health, capped at 0"
fc.property(
fc.record({
attacker: fc.character(),
target: fc.character(),
damage: fc.integer({ min: 1, max: 5000 }),
}),
({ attacker, target, damage }) => {
const a = new Character({ name: attacker, health: 1000 });
const t = new Character({ name: target, health: 1000 });
a.dealDamage(t, damage);
return t.health === Math.max(0, 1000 - damage);
},
);
Step 3: "I can't believe it's not Haskell"
Use TypeScript's type system to encode domain constraints. The example below shows the complete domain shape — not what to implement per story. Implement only what a property forces you to write.
// ADTs via discriminated unions
type Status = { kind: 'alive' } | { kind: 'dead' };
// Value objects with invariants enforced at construction
class Health {
private constructor(private readonly value: number) {}
static create(n: number): Health {
if (n < 0) throw new Error('Health cannot be negative');
return new Health(n);
}
get value() {
return this.value;
}
add(n: number) {
return Health.create(this.value + n);
}
sub(n: number) {
return Health.create(Math.max(0, this.value - n));
}
}
// Immutable entities
class Character {
constructor(
readonly name: string,
readonly health: Health,
readonly status: Status,
readonly level: Level,
readonly factions: ReadonlySet<Faction>,
) {}
dealDamage(target: Character, amount: number): void {
// ... pure logic, no mutation
}
}
Key principles:
- ADTs over classes — use discriminated unions for state/variants
- Value objects over primitives — when a property reveals that a bare
numberorstringis insufficient, introduce a value object. Don't anticipate — react. - Immutability — no
this.health = ..., return new instances - Invariants at boundaries — constructors enforce invariants, not getters/setters
- Pure functions — domain logic has no side effects, testable in isolation
YAGNI discipline: Write only the minimum code necessary to make the current property pass.
Before writing a method or class, ask:
- Does a story property require this? If no → don't write it.
- Does this touch a concept from a different story? If yes → it's scope creep.
- Am I implementing this because it feels useful, not because a property forces it? If yes → stop.
The litmus test: If you can't point to a failing property that demands this code, don't write it. Future stories will reveal what abstractions are actually needed — and they'll look different than you expect.
What to skip per story (common traps)
| Story | Don't implement | Belongs to |
|---|---|---|
| 1 (Creation) | isAllyOf, isAlive, isDead |
Stories 3+ |
| 1 (Creation) | Health.add(), Health.isMax() |
Stories 3/4 |
| 1 (Creation) | Level.next(), Level.diff() |
Story 5 |
| 2 (Damage) | Damage value object |
Only if a property demands it |
| 2 (Damage) | Level modifier (±50%) | Story 3 |
| 2 (Damage) | Faction/ally checks | Story 3 |
| 3 (Levels) | MagicalObjects | Story 4 |
| 3 (Levels) | Level-up tracking | Story 5 |
| 4 (Objects) | joinFaction / leaveFaction |
Story 3 |
Example: Damage Property
From user story: "When damage received exceeds current Health, Health becomes 0 and the character dies"
// Allium invariant (in .allium spec)
// invariant HealthNonNegative { for c in Characters: c.health >= 0 }
// invariant DeathAtZeroHealth { for c in Characters: c.health = 0 implies c.status = dead }
// fast-check property
fc.property(
fc.integer({ min: 0, max: 10000 }),
fc.integer({ min: 0, max: 10000 }),
(health, damage) => {
const c = new Character({ name: 'goblin', health: Health.create(health) });
c.takeDamage(Damage.create(damage));
return c.health.value === Math.max(0, health - damage);
},
).check(/* ... */);
Skill Invocation
Allium skills are available in this project:
/skill:allium— entry point and language reference/skill:elicit— explore requirements/skill:distill— extract specs from code/skill:propagate— generate test obligations from specs/skill:tend— evolve specs/skill:weed— check spec-code alignment
Domain workflow skill:
/skill:user-story-conversation— Card, Conversation, Confirmation workflow with Example Mapping, Allium specs, and fast-check properties