Compare commits

..

10 Commits

Author SHA1 Message Date
23edbc6e36 docs: update README with attribution, workflow, and project overview 2026-06-14 12:08:49 +01:00
b628cc639f horizontal refactoring transcript 2026-06-14 12:02:35 +01:00
fe984a1c86 phase 3: verify all spec import paths (already correct) 2026-06-14 12:01:14 +01:00
bc64293ba4 phase 2: move Character, HealingObject, MagicalObject, MagicalWeapon into directories 2026-06-14 11:44:42 +01:00
fc260dc97c phase 1: refactor directory structure (value-objects, factions, characters) 2026-06-14 11:36:37 +01:00
3965aaf33b create yaks 2026-06-14 11:22:44 +01:00
0805623b68 refactor: break circular dependency with DamageDealer and Healer interfaces
- Create src/magical-objects/magical-object-types.ts with DamageDealer
  and Healer interfaces
- MagicalWeapon implements DamageDealer
- HealingObject implements Healer
- Character depends on interfaces instead of concrete classes
2026-06-14 10:49:53 +01:00
f29e3c456f add problem breakdown with yaks skill 2026-06-14 10:31:03 +01:00
0c09b08009 gitignore .yaks directory 2026-06-14 10:24:54 +01:00
350e8073e9 story 3 was not completely done 2026-06-14 09:54:07 +01:00
31 changed files with 135584 additions and 4098 deletions

3
.gitignore vendored
View File

@ -8,4 +8,5 @@ coverage/
**/.idea
**/*.received.*
**/DS_Store/*
**/.DS_Store
**/.DS_Store.yaks
.yaks

View 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

View File

@ -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
```

View File

@ -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>,
) {}
}

View File

@ -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', () => {

View File

@ -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);

View 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>;
};

View File

@ -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', () => {

View File

@ -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) });

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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,
};
}

View 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';
}
}

View File

@ -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,
};
}

View 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 };
}

View File

@ -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';
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long