rpg-combat-pi-01/AGENTS.md

5.2 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. Add only code necessary to make the property pass, no more:

// 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 primitivesHealth, Damage, Level instead of number
  • 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 Write the minimum code necessary to make the current property pass.

Before writing a method, ask:

  1. Does a story property require this? If no → don't write it.
  2. Does this method touch a concept from a different story? If yes → it's scope creep.
  3. Am I implementing something because it feels useful, not because a property forces it? If yes → stop.

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