rpg-combat-pi-01/AGENTS.md

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-check
  • npm run lint:fix: runs eslint with autofix
  • npm run format:fix: runs prettier with autofix
  • npm run typecheck: runs tsc without emit
  • npm run checks: runs the pre-commit gate (format:fix, lint:fix, typecheck, test)

Allium + fast-check Workflow

This project combines three practices:

  1. Allium (.allium specs) — formal behavioural specifications that capture what the system does
  2. fast-check — property-based testing that verifies those properties hold across thousands of random inputs
  3. "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 number or string is 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:

  1. Does a story property require this? If no → don't write it.
  2. Does this touch a concept from a different story? If yes → it's scope creep.
  3. 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