Add the user-story-conversation Pi skill following Ron Jeffries' Card, Conversation, Confirmation mantra enhanced with Matt Wynne's Example Mapping. The skill guides users through six steps: 1. Card — user story 2. Conversation — Example Mapping (Rules, Examples, Questions, Answers) 3. Allium Spec — formal behavioural specification 4. fast-check Properties — executable verification 5. TypeScript ADTs — value objects and discriminated unions 6. Confirmation — verify all properties pass Also update AGENTS.md with workflow guidance and add the new skill to the Allium routing table. Add fast-check as a dependency.
141 lines
4.8 KiB
Markdown
141 lines
4.8 KiB
Markdown
**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](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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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** — `Health`, `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
|
|
|
|
### Example: Damage Property
|
|
|
|
From user story: *"When damage received exceeds current Health, Health becomes 0 and the character dies"*
|
|
|
|
```typescript
|
|
// 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
|