Compare commits
10 Commits
f6605bbbfd
...
23edbc6e36
| Author | SHA1 | Date | |
|---|---|---|---|
| 23edbc6e36 | |||
| b628cc639f | |||
| fe984a1c86 | |||
| bc64293ba4 | |||
| fc260dc97c | |||
| 3965aaf33b | |||
| 0805623b68 | |||
| f29e3c456f | |||
| 0c09b08009 | |||
| 350e8073e9 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,4 +8,5 @@ coverage/
|
||||
**/.idea
|
||||
**/*.received.*
|
||||
**/DS_Store/*
|
||||
**/.DS_Store
|
||||
**/.DS_Store.yaks
|
||||
.yaks
|
||||
|
||||
253
.pi/skills/problem-breakdown/SKILL.md
Normal file
253
.pi/skills/problem-breakdown/SKILL.md
Normal file
@ -0,0 +1,253 @@
|
||||
---
|
||||
name: problem-breakdown
|
||||
description: 'Break down a problem into small, independently executable steps using yx. Use after user-story-conversation to create a test execution list, or during horizontal refactoring to plan file moves and transformations. Each step becomes a yak with yx add and includes execution context so another agent can execute it independently.'
|
||||
disable-model-invocation: true
|
||||
license: MIT
|
||||
metadata:
|
||||
tool: yx
|
||||
---
|
||||
|
||||
# Problem Breakdown
|
||||
|
||||
Break a problem into small, independently executable steps using the `yx` CLI. Each step becomes a yak with execution context, enabling another agent (or future you) to execute it independently.
|
||||
|
||||
## When to Use
|
||||
|
||||
1. **After user-story-conversation** — Convert the output (Allium spec + fast-check properties) into a concrete test execution list: which files to create/change, what tests to write, where to place them.
|
||||
2. **Horizontal refactoring** — Plan cross-cutting changes like moving value objects into a `value-objects/` directory, extracting interfaces, or restructuring modules.
|
||||
3. **Feature scaffolding** — Break a feature into file creation, implementation, wiring, and test steps.
|
||||
|
||||
## The Method
|
||||
|
||||
### Step 1: Identify the work items
|
||||
|
||||
From the problem description, extract discrete, independently executable units of work. Each work item should satisfy:
|
||||
|
||||
- **Self-contained** — can be executed without waiting for another yak to finish
|
||||
- **Small** — one file, one method, one move, one test
|
||||
- **Verifiable** — has a clear pass/fail condition (compiles, tests green, linter clean)
|
||||
- **Ordered** — parents block children (use `yx add --under`)
|
||||
|
||||
### Step 2: Create yaks with `yx add`
|
||||
|
||||
For each work item:
|
||||
|
||||
```bash
|
||||
yx add "create src/domain/health.value-objects.ts"
|
||||
yx add "implement Health.create() with invariant n >= 0" --under "create src/domain/health.value-objects.ts"
|
||||
yx add "write fast-check property: Health.create rejects negative numbers" --under "implement Health.create() with invariant n >= 0"
|
||||
```
|
||||
|
||||
Use `--under` to express dependency hierarchy. Children block their parent.
|
||||
|
||||
### Step 3: Add execution context with `yx context`
|
||||
|
||||
For each yak, add enough detail for another agent to execute it independently:
|
||||
|
||||
```bash
|
||||
echo "Create src/domain/health.value-objects.ts with a Health value object class.
|
||||
- Private constructor taking number
|
||||
- Static create(n: number): Health — throws if n < 0
|
||||
- get value(): number
|
||||
- sub(amount: number): Health — returns Health.create(max(0, this.value - amount))
|
||||
- add(amount: number): Health — returns Health.create(this.value + amount)
|
||||
- No dependency on other domain entities yet" | yx context "implement Health.create() with invariant n >= 0"
|
||||
```
|
||||
|
||||
### Step 4: Execute
|
||||
|
||||
The executor agent reads each yak's context, executes the step, and marks it done:
|
||||
|
||||
```bash
|
||||
yx start "implement Health.create() with invariant n >= 0"
|
||||
# ... execute ...
|
||||
yx done "implement Health.create() with invariant n >= 0"
|
||||
```
|
||||
|
||||
## Output Patterns
|
||||
|
||||
### Pattern A: Test Execution List (after user-story-conversation)
|
||||
|
||||
After a user-story-conversation produces an Allium spec and fast-check properties, break them into file-level execution steps. Before writing any test yak, run the **Test Strategy Decision** below.
|
||||
|
||||
#### Test Strategy Decision
|
||||
|
||||
For each rule/invariant from the spec, decide whether to write a **property-based test** or an **example-based test**. Discuss with the user:
|
||||
|
||||
| Signal | Choose | Rationale |
|
||||
| --------------------------------------------------- | -------------- | ------------------------------------------- |
|
||||
| `fc.property` would be trivially short (< 5 lines) | Example-based | Property overhead not worth it |
|
||||
| Invariant is a simple arithmetic relationship | Example-based | One or two examples cover all cases |
|
||||
| State transition has a small, finite input space | Example-based | Exhaustive examples are feasible |
|
||||
| Invariant involves collections, sequences, or math | Property-based | Need random inputs to find edge cases |
|
||||
| Rule has complex guards (requires + ensures chains) | Property-based | Random inputs surface hidden preconditions |
|
||||
| User says "just show it works" | Example-based | Confidence test, not a robustness guarantee |
|
||||
| User says "prove it always holds" | Property-based | That's what properties are for |
|
||||
|
||||
**Default:** start with example-based tests. Escalate to property-based only when the user or the spec demands broader coverage. This keeps the yak list smaller and faster to execute.
|
||||
|
||||
After deciding, create the test yaks with the chosen approach in context.
|
||||
|
||||
```
|
||||
Feature: Characters Deal Damage
|
||||
├── create src/domain/status.value-objects.ts ← ADT for alive/dead
|
||||
│ └── write Status discriminated union ← {kind: 'alive'} | {kind: 'dead'}
|
||||
├── create src/domain/health.value-objects.ts ← Health value object
|
||||
│ ├── implement Health.create() with invariant ← throws if n < 0
|
||||
│ └── implement Health.sub() ← capped at 0
|
||||
├── create src/domain/character.entity.ts ← Character entity
|
||||
│ ├── implement Character constructor ← name, health, status
|
||||
│ └── implement Character.dealDamage() ← with self-damage guard
|
||||
├── write tests/health.spec.ts ← example + PBT tests
|
||||
│ ├── example: 500 - 200 = 300 ← Health.sub arithmetic (simple, example-based)
|
||||
│ ├── property: Health.sub never goes below zero ← fc.property (invariant, property-based)
|
||||
│ └── example: 100 - 200 = 0 ← Health.sub boundary (simple, example-based)
|
||||
├── write tests/character.spec.ts ← example + PBT tests
|
||||
│ ├── example: 1000 health, 200 damage → 800 ← dealDamage happy path (example-based)
|
||||
│ ├── property: dealDamage reduces target health ← fc.property (invariant, property-based)
|
||||
│ └── example: self-damage is forbidden ← dealDamage guard (example-based)
|
||||
└── run npm test ← verify all pass
|
||||
```
|
||||
|
||||
### Pattern B: Horizontal Refactoring
|
||||
|
||||
For cross-cutting structural changes:
|
||||
|
||||
```
|
||||
Refactor: Move value objects to value-objects/
|
||||
├── create src/domain/value-objects/ ← new directory
|
||||
│ └── write barrel index.ts ← re-export all value objects
|
||||
├── move src/domain/health.ts → src/domain/value-objects/health.ts
|
||||
│ └── update all imports to point to value-objects/health
|
||||
├── move src/domain/damage.ts → src/domain/value-objects/damage.ts
|
||||
│ └── update all imports to point to value-objects/damage
|
||||
├── move src/domain/level.ts → src/domain/value-objects/level.ts
|
||||
│ └── update all imports to point to value-objects/level
|
||||
├── update src/domain/index.ts ← update barrel exports
|
||||
└── run npm run checks ← format, lint, typecheck, test
|
||||
```
|
||||
|
||||
## Context Template
|
||||
|
||||
Each yak's context should contain:
|
||||
|
||||
```
|
||||
### Location
|
||||
File: src/path/to/file.ts
|
||||
Line: ~line numbers (if modifying existing)
|
||||
|
||||
### What to create/modify
|
||||
Clear description of the change.
|
||||
|
||||
### Implementation details
|
||||
- Key signatures
|
||||
- Invariants to enforce
|
||||
- Dependencies (what already exists)
|
||||
- What NOT to implement (scope guard)
|
||||
|
||||
### Verification
|
||||
- npm run typecheck passes
|
||||
- npm test passes (specific test file)
|
||||
- npm run lint:fix clean
|
||||
|
||||
### References
|
||||
- Allium spec: .allium/path/allium-file.allium
|
||||
- Related yak: "name of parent yak"
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One yak per file operation** — create, move, or modify a single file
|
||||
2. **Context is king** — if another agent can't execute it from the context alone, add more detail
|
||||
3. **Scope guards** — explicitly state what NOT to implement in each yak's context (prevents scope creep)
|
||||
4. **Dependencies via `--under`** — use the hierarchy, not just flat list
|
||||
5. **Verification yak** — always add a final yak to run the full check suite
|
||||
6. **No implementation details in yak names** — yak names should be action-oriented summaries; details go in context
|
||||
7. **Keep yaks small** — if a yak's context is more than 30 lines, split it
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
| Skill | When to run problem-breakdown after |
|
||||
| ----------------------- | ------------------------------------------------------------------------ |
|
||||
| user-story-conversation | After Allium spec + properties are produced |
|
||||
| distill | After spec extraction, before test generation |
|
||||
| tend | After spec changes, to plan implementation updates |
|
||||
| propagate | When propagate produces obligations, to break them into file-level steps |
|
||||
| weed | After divergence is found, to plan alignment fixes |
|
||||
|
||||
## Example: Full Breakdown
|
||||
|
||||
After a user-story-conversation on "Characters can Deal Damage":
|
||||
|
||||
```bash
|
||||
# Phase 1: Create value objects
|
||||
yx add "create src/domain/status.value-objects.ts"
|
||||
echo "Create src/domain/status.value-objects.ts
|
||||
- Discriminated union: type Status = { kind: 'alive' } | { kind: 'dead' }
|
||||
- No methods, just the type
|
||||
- Export as default" | yx context "create src/domain/status.value-objects.ts"
|
||||
|
||||
# Phase 2: Health value object
|
||||
yx add "create src/domain/health.value-objects.ts" --under "create src/domain/status.value-objects.ts"
|
||||
echo "Create src/domain/health.value-objects.ts
|
||||
- class Health with private constructor
|
||||
- static create(n: number): Health — throw if n < 0
|
||||
- get value(): number
|
||||
- sub(amount: number): Health — Health.create(max(0, this.value - amount))
|
||||
- add(amount: number): Health — Health.create(this.value + amount)
|
||||
- NO: maxForLevel, NO: isMax, NO: isZero — those belong to later stories
|
||||
- NO: dependency on Character or Level" | yx context "create src/domain/health.value-objects.ts"
|
||||
|
||||
# Phase 3: Character entity
|
||||
yx add "create src/domain/character.entity.ts" --under "create src/domain/health.value-objects.ts"
|
||||
echo "Create src/domain/character.entity.ts
|
||||
- class Character with readonly name, health, status
|
||||
- constructor(name: string, health: Health, status: Status)
|
||||
- dealDamage(target: Character, damage: number): void — pure logic, no mutation
|
||||
- self-damage guard (this.name === target.name → return)
|
||||
- health reduced by damage amount (calls target.health.sub)
|
||||
- NO: factions, NO: level, NO: magicalObjects — those belong to later stories
|
||||
- NO: isAllyOf, NO: isDead — those belong to later stories" | yx context "create src/domain/character.entity.ts"
|
||||
|
||||
# Phase 4: Test Strategy Decision
|
||||
|
||||
Before writing test yaks, decide property vs example for each test item:
|
||||
|
||||
| Spec item | Decision | Why |
|
||||
| ---------------------------- | --------------- | -------------------------------------- |
|
||||
| Health.create rejects neg. | Example-based | One negative input is sufficient |
|
||||
| Health.sub never below zero | Property-based | Invariant over arbitrary input range |
|
||||
| Health.sub correct arithmetic| Example-based | Simple arithmetic, examples cover all |
|
||||
| dealDamage reduces health | Property-based | Invariant: result = max(0, h - d) |
|
||||
| Self-damage forbidden | Example-based | One case (same name) proves the rule |
|
||||
|
||||
# Phase 4: Tests
|
||||
yx add "write tests/health.spec.ts" --under "create src/domain/health.value-objects.ts"
|
||||
echo "Write tests/health.spec.ts
|
||||
- Import Health from src/domain/health.value-objects.ts
|
||||
- Example: Health.create rejects negative (it('rejects -1', () => { expect(() => Health.create(-1)).toThrow() }))
|
||||
- Property: Health.sub never below zero (fc.property(fc.integer({min:0,max:10000}), fc.integer({min:0,max:10000}), (h, d) => { const c = new Character({ health: Health.create(h) }); c.takeDamage(d); return c.health >= 0; }))
|
||||
- Example: Health.sub 500 - 200 = 300
|
||||
- Example: Health.sub 100 - 200 = 0
|
||||
- Use vitest describe/it blocks with fc.assert()" | yx context "write tests/health.spec.ts"
|
||||
|
||||
yx add "write tests/character.spec.ts" --under "create src/domain/character.entity.ts"
|
||||
echo "Write tests/character.spec.ts
|
||||
- Import Character, Health, Status from domain
|
||||
- Example: dealDamage 1000 health, 200 damage → 800 (it('reduces health', () => { ... }))
|
||||
- Property: dealDamage reduces target health (fc.property(fc.integer({min:0,max:10000}), fc.integer({min:0,max:10000}), (h, d) => { ... return target.health === Math.max(0, h - d) }))
|
||||
- Example: self-damage forbidden (it('does nothing when target is self', () => { ... }))
|
||||
- Use vitest describe/it blocks with fc.assert()" | yx context "write tests/character.spec.ts"
|
||||
|
||||
# Phase 5: Verify
|
||||
yx add "run npm test and npm run typecheck" --under "write tests/health.spec.ts"
|
||||
yx add "run npm run checks" --under "run npm test and npm run typecheck"
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Start from the spec** — the Allium spec's entities and rules map directly to yak groups
|
||||
- **Group by file** — create yaks for file creation first, then implementation, then tests
|
||||
- **Add scope guards** — explicitly state what NOT to implement to prevent scope creep
|
||||
- **Use yx tags** — tag yaks with `test`, `domain`, `refactor` for filtering: `yx tag "write tests/health.spec.ts" test`
|
||||
- **Review before executing** — run `yx list` to verify the hierarchy makes sense before starting work
|
||||
95
README.md
95
README.md
@ -1,8 +1,99 @@
|
||||
# RPG Combat
|
||||
|
||||
Use this starting template for your implementation of the game rules. Requires Node.js and npm. Install dependencies, then run the tests:
|
||||
> A challenge set by [Emily Bache](https://github.com/emilybache). The kata was invented by Daniel Ojeda Loisel and the description is adapted from Steve Smith's version.
|
||||
|
||||
## What This Is
|
||||
|
||||
RPG Combat is a small rules engine for a tabletop-style RPG. Characters fight, level up, join factions, and wield magical objects — all governed by a precise set of business rules. The challenge is to implement those rules correctly, and this project takes a different path than most: instead of writing code first and tests later, we start with **formal specifications** and let properties drive every line of implementation.
|
||||
|
||||
## Original Source
|
||||
|
||||
The user stories and rules come from [this kata description](https://www.sammancoaching.org/kata_descriptions/rpg_combat.html), originally created by Daniel Ojeda Loisel and adapted from Steve Smith's version.
|
||||
|
||||
```
|
||||
user-stories.md ← the requirements (what the system should do)
|
||||
.specs/ ← Allium formal specifications (the formal model)
|
||||
src/ ← TypeScript implementation (ADTs, value objects, immutability)
|
||||
*.spec.ts ← fast-check property tests (executable verification)
|
||||
```
|
||||
|
||||
## How We Work
|
||||
|
||||
The workflow follows three intertwined practices:
|
||||
|
||||
### 1. Spec-First with Allium
|
||||
|
||||
Before writing any code, requirements are formalized into [Allium](https://github.com/juxt/allium) — a logic-based specification language. Each user story becomes a `.allium` file with invariants (always-true properties) and rules (state transitions). This is the source of truth.
|
||||
|
||||
### 2. Property-Based Testing with fast-check
|
||||
|
||||
Allium invariants are translated into fast-check properties. Instead of hand-crafting individual test cases, we define **properties** that must hold for thousands of random inputs:
|
||||
|
||||
```typescript
|
||||
// "Health is never negative" — verified across 1000 random damage values
|
||||
fc.property(fc.integer({ min: 0, max: 10000 }), (damage) => {
|
||||
const c = new Character({ name: 'goblin', health: 1000 });
|
||||
c.takeDamage(damage);
|
||||
return c.health >= 0;
|
||||
});
|
||||
```
|
||||
|
||||
### 3. "I Can't Believe It's Not Haskell"
|
||||
|
||||
The TypeScript implementation embraces functional patterns:
|
||||
|
||||
- **ADTs** (algebraic data types) via discriminated unions for states
|
||||
- **Value objects** (Health, Level, Status) with invariants enforced at construction
|
||||
- **Immutability** — no mutation, pure functions, new instances returned
|
||||
- **YAGNI discipline** — write only what a failing property demands
|
||||
|
||||
## What We've Done
|
||||
|
||||
All five user stories are implemented and verified:
|
||||
|
||||
| Story | Topic | Status |
|
||||
|-------|-------|--------|
|
||||
| 1 | Character Creation & Damage | ✅ Done |
|
||||
| 2 | Levels | ✅ Done |
|
||||
| 3 | Factions | ✅ Done |
|
||||
| 4 | Magical Objects | ✅ Done |
|
||||
| 5 | Changing Level | ✅ Done |
|
||||
|
||||
**70 tests passing** across 6 spec files.
|
||||
|
||||
## The Journey: Skills & Tools
|
||||
|
||||
The real value of this project isn't the code — it's the process. Here's what was built along the way:
|
||||
|
||||
### Built-in Allium Skills
|
||||
|
||||
Six Allium skills guide the workflow from requirements to verified code:
|
||||
|
||||
- **`/skill:elicit`** — explore and clarify requirements with stakeholders
|
||||
- **`/skill:distill`** — extract specifications from existing code
|
||||
- **`/skill:propagate`** — generate test obligations from specs
|
||||
- **`/skill:tend`** — evolve specs as understanding deepens
|
||||
- **`/skill:weed`** — check spec-code alignment
|
||||
- **`/skill:user-story-conversation`** — Card, Conversation, Confirmation workflow with Example Mapping, Allium specs, and fast-check properties
|
||||
|
||||
### Custom Extensions
|
||||
|
||||
Two custom extensions were developed for this project:
|
||||
|
||||
- **`clear-export`** — exports the current pi session to an HTML transcript in `transcripts/` and starts a fresh session. This creates a permanent record of each decision, iteration, and insight.
|
||||
- **`problem-breakdown`** — breaks problems into small, independently executable steps using the `yx` CLI. Each step becomes a "yak" with full execution context, enabling parallel or sequential execution by any agent.
|
||||
|
||||
### The Transcript Archive
|
||||
|
||||
Every session is exported as an HTML transcript in the `transcripts/` directory — over 20 sessions documenting the full journey from first requirements review through horizontal refactoring. These are the project's most valuable artifacts.
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm test # 70 properties across 6 spec files
|
||||
npm run lint:fix
|
||||
npm run format:fix
|
||||
npm run typecheck
|
||||
npm run checks # pre-commit gate: format + lint + typecheck + test
|
||||
```
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
/**
|
||||
* CharacterState — immutable record of all character state at a point in time.
|
||||
*
|
||||
* Groups the five character properties into a single value object,
|
||||
* keeping the Character constructor at one parameter (max-params: 4).
|
||||
*/
|
||||
import type { Health } from './Health.ts';
|
||||
import type { Level } from './Level.ts';
|
||||
import type { Status } from './Status.ts';
|
||||
import type { Faction } from './Faction.ts';
|
||||
|
||||
export class CharacterState {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly health: Health,
|
||||
readonly status: Status,
|
||||
readonly level: Level,
|
||||
readonly factions: ReadonlySet<Faction>,
|
||||
) {}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Level } from './Level.ts';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
|
||||
describe('CharacterCreation', () => {
|
||||
describe('initial health', () => {
|
||||
|
||||
@ -4,14 +4,14 @@
|
||||
* "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 { Level } from './Level.ts';
|
||||
import type { Status } from './Status.ts';
|
||||
import { StatusAlive, StatusDead } from './Status.ts';
|
||||
import { Health } from '../value-objects/Health.ts';
|
||||
import { Level } from '../value-objects/Level.ts';
|
||||
import type { Status } from '../value-objects/Status.ts';
|
||||
import { StatusAlive, StatusDead } from '../value-objects/Status.ts';
|
||||
import type { CharacterState } from './CharacterState.ts';
|
||||
import type { Faction } from './Faction.ts';
|
||||
import type { MagicalWeapon } from './MagicalWeapon.ts';
|
||||
import type { HealingObject } from './HealingObject.ts';
|
||||
import type { Faction } from '../factions/Faction.ts';
|
||||
import type { DamageDealer } from '../magical-objects/magical-object-types.ts';
|
||||
import type { Healer } from '../magical-objects/magical-object-types.ts';
|
||||
|
||||
export interface CharacterCtor {
|
||||
name: string;
|
||||
@ -233,10 +233,7 @@ export class Character {
|
||||
* Dead characters cannot use weapons. Only the owner can use a weapon.
|
||||
* Returns updated weapon and target.
|
||||
*/
|
||||
useWeapon(
|
||||
weapon: MagicalWeapon,
|
||||
target: Character,
|
||||
): { weapon: MagicalWeapon; target: Character } {
|
||||
useWeapon(weapon: DamageDealer, target: Character): { weapon: DamageDealer; target: Character } {
|
||||
// Dead characters cannot use weapons
|
||||
if (this.status.kind === 'dead') return { weapon, target };
|
||||
// Only the owner can use the weapon
|
||||
@ -249,10 +246,7 @@ export class Character {
|
||||
* Dead characters cannot use healing objects.
|
||||
* Returns updated object and character.
|
||||
*/
|
||||
useHealingObject(
|
||||
object: HealingObject,
|
||||
amount: number,
|
||||
): { object: HealingObject; character: Character } {
|
||||
useHealingObject(object: Healer, amount: number): { object: Healer; character: Character } {
|
||||
// Dead characters cannot use healing objects
|
||||
if (this.status.kind === 'dead') return { object, character: this };
|
||||
return object.heal(this, amount);
|
||||
18
src/characters/CharacterState.ts
Normal file
18
src/characters/CharacterState.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* CharacterState — immutable record of all character state at a point in time.
|
||||
*
|
||||
* Groups the five character properties into a single value type,
|
||||
* keeping the Character constructor at one parameter.
|
||||
*/
|
||||
import type { Health } from '../value-objects/Health.ts';
|
||||
import type { Level } from '../value-objects/Level.ts';
|
||||
import type { Status } from '../value-objects/Status.ts';
|
||||
import type { Faction } from '../factions/Faction.ts';
|
||||
|
||||
export type CharacterState = {
|
||||
readonly name: string;
|
||||
readonly health: Health;
|
||||
readonly status: Status;
|
||||
readonly level: Level;
|
||||
readonly factions: ReadonlySet<Faction>;
|
||||
};
|
||||
@ -1,7 +1,7 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Level } from './Level.ts';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
|
||||
describe('DamageAndHealth', () => {
|
||||
describe('DamageReducesHealth', () => {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Faction } from './Faction.ts';
|
||||
import { Level } from './Level.ts';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Faction } from './factions/Faction.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
|
||||
describe('Factions', () => {
|
||||
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Level } from './Level.ts';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
|
||||
describe('Healing', () => {
|
||||
describe('SelfHealIncreasesHealth', () => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Level } from './Level.ts';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
|
||||
describe('Levels', () => {
|
||||
describe('CloseLevelNoModifier', () => {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Level } from './Level.ts';
|
||||
import { MagicalWeapon } from './MagicalWeapon.ts';
|
||||
import { HealingObject } from './HealingObject.ts';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
|
||||
import { HealingObject } from './magical-objects/HealingObject.ts';
|
||||
|
||||
describe('Magical Objects', () => {
|
||||
describe('WeaponDealsDamage', () => {
|
||||
|
||||
@ -1,24 +1,23 @@
|
||||
/**
|
||||
* Healing Object — a Magical Object that gives health to Characters.
|
||||
*
|
||||
* Inherits health/status management from MagicalObject.
|
||||
* Invariants enforced at construction:
|
||||
* - Health is non-negative
|
||||
* - Health never exceeds maxHealth
|
||||
* - CurrentHealth never exceeds maxHealth
|
||||
*/
|
||||
|
||||
import { Character } from './Character.ts';
|
||||
import { Character } from '../characters/Character.ts';
|
||||
import { Level } from '../value-objects/Level.ts';
|
||||
import { MagicalObject } from './MagicalObject.ts';
|
||||
import type { Healer } from './magical-object-types.ts';
|
||||
|
||||
export type ObjectStatus = { kind: 'alive' } | { kind: 'destroyed' };
|
||||
|
||||
export class HealingObject {
|
||||
readonly #health: number;
|
||||
readonly #maxHealth: number;
|
||||
readonly #status: ObjectStatus;
|
||||
|
||||
private constructor(health: number, maxHealth: number, status: ObjectStatus) {
|
||||
this.#health = health;
|
||||
this.#maxHealth = maxHealth;
|
||||
this.#status = status;
|
||||
export class HealingObject extends MagicalObject implements Healer {
|
||||
private constructor(
|
||||
health: number,
|
||||
maxHealth: number,
|
||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||
) {
|
||||
super(health, maxHealth, status);
|
||||
}
|
||||
|
||||
static create({
|
||||
@ -36,29 +35,17 @@ export class HealingObject {
|
||||
return new HealingObject(currentHealth, maxHealth, status);
|
||||
}
|
||||
|
||||
get health(): number {
|
||||
return this.#health;
|
||||
}
|
||||
|
||||
get maxHealth(): number {
|
||||
return this.#maxHealth;
|
||||
}
|
||||
|
||||
get status(): ObjectStatus {
|
||||
return this.#status;
|
||||
}
|
||||
|
||||
/** Use this object to heal a character. Returns updated object and character. */
|
||||
heal(character: Character, amount: number): { object: HealingObject; character: Character } {
|
||||
// Destroyed objects can't heal
|
||||
if (this.#status.kind === 'destroyed') {
|
||||
if (this.status.kind === 'destroyed') {
|
||||
return { object: this, character };
|
||||
}
|
||||
// Negative amount is invalid
|
||||
if (amount < 0) throw new Error('Heal amount must be non-negative');
|
||||
// Calculate actual heal amount: min of requested, object remaining, character headroom
|
||||
const objectRemaining = this.#health;
|
||||
const characterMax = character.level.value >= 6 ? 1500 : 1000;
|
||||
const objectRemaining = this.health;
|
||||
const characterMax = Level.maxHealthForLevel(character.level.value);
|
||||
const characterHeadroom = characterMax - character.health.value;
|
||||
const actualHeal = Math.min(amount, objectRemaining, characterHeadroom);
|
||||
// If actualHeal is 0, nothing changes
|
||||
@ -66,7 +53,7 @@ export class HealingObject {
|
||||
return { object: this, character };
|
||||
}
|
||||
// Create updated object
|
||||
const newObjectHealth = this.#health - actualHeal;
|
||||
const newObjectHealth = this.health - actualHeal;
|
||||
const newObjectStatus =
|
||||
newObjectHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||
// Create updated character
|
||||
@ -77,7 +64,7 @@ export class HealingObject {
|
||||
health: newCharacterHealth,
|
||||
});
|
||||
return {
|
||||
object: new HealingObject(newObjectHealth, this.#maxHealth, newObjectStatus),
|
||||
object: new HealingObject(newObjectHealth, this.maxHealth, newObjectStatus),
|
||||
character: newCharacter,
|
||||
};
|
||||
}
|
||||
45
src/magical-objects/MagicalObject.ts
Normal file
45
src/magical-objects/MagicalObject.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* MagicalObject — shared base for all magical items in the game.
|
||||
*
|
||||
* Invariants enforced at construction:
|
||||
* - Health is non-negative
|
||||
* - Health never exceeds maxHealth
|
||||
* - Status derived from health (0 = destroyed, > 0 = alive)
|
||||
*/
|
||||
|
||||
export type MagicalObjectStatus = { readonly kind: 'alive' } | { readonly kind: 'destroyed' };
|
||||
|
||||
export class MagicalObject {
|
||||
readonly #health: number;
|
||||
readonly #maxHealth: number;
|
||||
readonly #status: MagicalObjectStatus;
|
||||
|
||||
protected constructor(health: number, maxHealth: number, status: MagicalObjectStatus) {
|
||||
this.#health = health;
|
||||
this.#maxHealth = maxHealth;
|
||||
this.#status = status;
|
||||
}
|
||||
|
||||
get health(): number {
|
||||
return this.#health;
|
||||
}
|
||||
|
||||
get maxHealth(): number {
|
||||
return this.#maxHealth;
|
||||
}
|
||||
|
||||
get status(): MagicalObjectStatus {
|
||||
return this.#status;
|
||||
}
|
||||
|
||||
/** Create a destroyed object (health = 0). */
|
||||
static createDestroyed(maxHealth: number): MagicalObject {
|
||||
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
||||
return new MagicalObject(0, maxHealth, { kind: 'destroyed' });
|
||||
}
|
||||
|
||||
/** Check if this object is alive. */
|
||||
isAlive(): boolean {
|
||||
return this.#status.kind === 'alive';
|
||||
}
|
||||
}
|
||||
@ -1,32 +1,26 @@
|
||||
/**
|
||||
* Magical Weapon — a Magical Object that deals fixed damage.
|
||||
*
|
||||
* Inherits health/status management from MagicalObject.
|
||||
* Invariants enforced at construction:
|
||||
* - Health is non-negative
|
||||
* - Health never exceeds maxHealth
|
||||
* - Damage is non-negative
|
||||
*/
|
||||
import { Character } from './Character.ts';
|
||||
import { Character } from '../characters/Character.ts';
|
||||
import { MagicalObject } from './MagicalObject.ts';
|
||||
import type { DamageDealer } from './magical-object-types.ts';
|
||||
|
||||
export type WeaponStatus = { kind: 'alive' } | { kind: 'destroyed' };
|
||||
|
||||
export class MagicalWeapon {
|
||||
readonly #health: number;
|
||||
readonly #maxHealth: number;
|
||||
readonly #status: WeaponStatus;
|
||||
export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||
readonly #damage: number;
|
||||
readonly #owner: Character;
|
||||
|
||||
private constructor(
|
||||
health: number,
|
||||
maxHealth: number,
|
||||
status: WeaponStatus,
|
||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||
damage: number,
|
||||
owner: Character,
|
||||
) {
|
||||
this.#health = health;
|
||||
this.#maxHealth = maxHealth;
|
||||
this.#status = status;
|
||||
super(health, maxHealth, status);
|
||||
this.#damage = damage;
|
||||
this.#owner = owner;
|
||||
}
|
||||
@ -45,18 +39,6 @@ export class MagicalWeapon {
|
||||
return new MagicalWeapon(maxHealth, maxHealth, { kind: 'alive' }, damage, owner);
|
||||
}
|
||||
|
||||
get health(): number {
|
||||
return this.#health;
|
||||
}
|
||||
|
||||
get maxHealth(): number {
|
||||
return this.#maxHealth;
|
||||
}
|
||||
|
||||
get status(): WeaponStatus {
|
||||
return this.#status;
|
||||
}
|
||||
|
||||
get damage(): number {
|
||||
return this.#damage;
|
||||
}
|
||||
@ -68,7 +50,7 @@ export class MagicalWeapon {
|
||||
/** Use this weapon to deal damage. Returns updated weapon and target. */
|
||||
use(target: Character): { weapon: MagicalWeapon; target: Character } {
|
||||
// Destroyed weapons can't be used
|
||||
if (this.#status.kind === 'destroyed') {
|
||||
if (this.status.kind === 'destroyed') {
|
||||
return { weapon: this, target };
|
||||
}
|
||||
// Deal fixed damage
|
||||
@ -81,18 +63,17 @@ export class MagicalWeapon {
|
||||
status: newTargetStatus,
|
||||
});
|
||||
// Reduce weapon health by 1
|
||||
const newWeaponHealth = this.#health - 1;
|
||||
const newWeaponHealth = this.health - 1;
|
||||
const newWeaponStatus =
|
||||
newWeaponHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||
return {
|
||||
weapon: new MagicalWeapon(
|
||||
newWeaponHealth,
|
||||
this.#maxHealth,
|
||||
this.maxHealth,
|
||||
newWeaponStatus,
|
||||
this.#damage,
|
||||
this.#owner,
|
||||
),
|
||||
|
||||
target: newTarget,
|
||||
};
|
||||
}
|
||||
24
src/magical-objects/magical-object-types.ts
Normal file
24
src/magical-objects/magical-object-types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Magical object type interfaces — break the circular dependency between
|
||||
* Character.ts and the magical object implementations.
|
||||
*
|
||||
* These interfaces describe magical objects from Character's point of view,
|
||||
* so Character can depend on abstractions rather than concrete classes.
|
||||
*/
|
||||
import type { Character } from '../characters/Character.ts';
|
||||
import type { MagicalObjectStatus } from './MagicalObject.ts';
|
||||
|
||||
/** A magical object that deals damage — from Character's point of view */
|
||||
export interface DamageDealer {
|
||||
readonly owner: Character;
|
||||
readonly health: number;
|
||||
readonly status: MagicalObjectStatus;
|
||||
use(target: Character): { weapon: DamageDealer; target: Character };
|
||||
}
|
||||
|
||||
/** A magical object that heals — from Character's point of view */
|
||||
export interface Healer {
|
||||
readonly health: number;
|
||||
readonly status: MagicalObjectStatus;
|
||||
heal(character: Character, amount: number): { object: Healer; character: Character };
|
||||
}
|
||||
@ -7,11 +7,3 @@ export type Status = { readonly kind: 'alive' } | { readonly kind: 'dead' };
|
||||
|
||||
export const StatusAlive: Status = { kind: 'alive' };
|
||||
export const StatusDead: Status = { kind: 'dead' };
|
||||
|
||||
export function isAlive(s: Status): s is { kind: 'alive' } {
|
||||
return s.kind === 'alive';
|
||||
}
|
||||
|
||||
export function isDead(s: Status): s is { kind: 'dead' } {
|
||||
return s.kind === 'dead';
|
||||
}
|
||||
13112
transcripts/break-down-horizontal-refactoring-into-yaks.html
Normal file
13112
transcripts/break-down-horizontal-refactoring-into-yaks.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/break-down-the-refactoring-yaks.html
Normal file
13112
transcripts/break-down-the-refactoring-yaks.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/break-yaks-down-into-phases.html
Normal file
13112
transcripts/break-yaks-down-into-phases.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/create-task-breakdown-with-yaks-skill.html
Normal file
13112
transcripts/create-task-breakdown-with-yaks-skill.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/fixed-character-implementation-maybe.html
Normal file
13112
transcripts/fixed-character-implementation-maybe.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/found-out-story-3-is-not-done.html
Normal file
13112
transcripts/found-out-story-3-is-not-done.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/resolved-circular-dependency.html
Normal file
13112
transcripts/resolved-circular-dependency.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
13112
transcripts/story-4-built.html
Normal file
13112
transcripts/story-4-built.html
Normal file
File diff suppressed because one or more lines are too long
13112
transcripts/yaks-were-not-marked-s-done.html
Normal file
13112
transcripts/yaks-were-not-marked-s-done.html
Normal file
File diff suppressed because one or more lines are too long
4256
transcripts/yaks-work-for-horizontal-refactoring.html
Normal file
4256
transcripts/yaks-work-for-horizontal-refactoring.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user