Compare commits

..
19 Commits
Author SHA1 Message Date
mostalive 21bd58be33 Index transcripts 2026-06-15 09:39:42 +01:00
mostalive 31984bbd9d Consolidate all .allium specs into specs/
- Move .pi/specs/ files into specs/ (healing, factions, merged magical-objects)
- Move src/*.allium files into specs/ (levels, changing-level)
- Delete .pi/specs/ directory
- Document specs/ convention in AGENTS.md
2026-06-15 08:16:12 +01:00
mostalive 39839dc594 fix Allium specs syntax + implement Changing Level story
- Fix Allium spec syntax: type→value, enum for Status, remove implies chaining
- Fix factions.spec: add missing type declarations (Health, Level, Status)
- Fix magical-objects.spec: add type declarations, use .value for Health access,
  remove entity inheritance syntax, remove invalid invariants
- Implement Changing Level: add totalDamageTaken + factionsJoined to Character
- Add level-up logic in dealDamage() and joinFaction()
- Add Level.damageThresholdForLevel() static method
- Fix changing-level.spec.ts properties: handle target survival, compute
  expected level from threshold crossings
2026-06-15 07:55:39 +01:00
mostalive 692bd7305b add example tests for damage 2026-06-14 20:52:31 +01:00
mostalive 1c9d4ad66b create magical objects spec 2026-06-14 14:05:28 +01:00
mostalive fb61fb85ed refactor transcript 2026-06-14 13:36:47 +01:00
mostalive ba0903714c refactor: replace number health with Health value object in MagicalObject
- MagicalObject: internal #health and #maxHealth now Health type
- HealingObject: constructor, create, and heal use Health value object
- MagicalWeapon: constructor, create, and use use Health value object
- DamageDealer and Healer interfaces: health: number -> health: Health
- magical-objects.spec.ts: all assertions use .health.value
- Run npm run checks: 0 errors, 70 tests passing
2026-06-14 13:35:44 +01:00
mostalive 0540e5ff5b feat(eslint): add no-primitive-value-properties rule to enforce value objects
Add custom ESLint rule that warns when class properties or constructor
parameters use primitive types (number, string, boolean) where a value
object exists in src/value-objects/.

The rule auto-discovers value objects from the directory and maps
property names (e.g. 'health' → 'Health') to suggest the correct type.

Found 4 warnings on the codebase:
- MagicalObject.ts: #health: number property
- MagicalObject.ts: health: number constructor param
- MagicalWeapon.ts: health: number constructor param
- HealingObject.ts: health: number constructor param
2026-06-14 12:39:54 +01:00
mostalive dea77d463f create extension and skill to run yaks 2026-06-14 12:23:52 +01:00
mostalive 23edbc6e36 docs: update README with attribution, workflow, and project overview 2026-06-14 12:08:49 +01:00
mostalive b628cc639f horizontal refactoring transcript 2026-06-14 12:02:35 +01:00
mostalive fe984a1c86 phase 3: verify all spec import paths (already correct) 2026-06-14 12:01:14 +01:00
mostalive bc64293ba4 phase 2: move Character, HealingObject, MagicalObject, MagicalWeapon into directories 2026-06-14 11:44:42 +01:00
mostalive fc260dc97c phase 1: refactor directory structure (value-objects, factions, characters) 2026-06-14 11:36:37 +01:00
mostalive 3965aaf33b create yaks 2026-06-14 11:22:44 +01:00
mostalive 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
mostalive f29e3c456f add problem breakdown with yaks skill 2026-06-14 10:31:03 +01:00
mostalive 0c09b08009 gitignore .yaks directory 2026-06-14 10:24:54 +01:00
mostalive 350e8073e9 story 3 was not completely done 2026-06-14 09:54:07 +01:00
53 changed files with 219427 additions and 4341 deletions
+2 -1
View File
@@ -8,4 +8,5 @@ coverage/
**/.idea
**/*.received.*
**/DS_Store/*
**/.DS_Store
**/.DS_Store.yaks
.yaks
+20
View File
@@ -0,0 +1,20 @@
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
export default function (pi: ExtensionAPI) {
pi.on('tool_call', async (event, ctx) => {
if (!isToolCallEventType('bash', event)) return;
const cmd = event.input.command || '';
const isYxCommand = cmd.includes('yx ');
// Allow yaks in interactive mode, block in print mode (sub-agents)
if (isYxCommand && ctx.mode === 'print') {
return {
block: true,
reason:
'yx commands are disabled in print mode. Sub-agents must focus on domain work only.',
};
}
});
}
+253
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
+88
View File
@@ -0,0 +1,88 @@
---
name: yak-tasks
description: Orchestrate sub-agents using yx task tracking. Use when delegating work to sub-agents, tracking task progress, or marking tasks complete. For breaking down problems into tasks, use /skill:problem-breakdown first.
---
# Yak Task Orchestration
Coordinate sub-agent work using the `yx` CLI. The main agent creates tasks and delegates to sub-agents; sub-agents execute domain work without yx CLI access.
## Workflow
1. **Break down**`/skill:problem-breakdown` (creates yaks with context)
2. **Delegate** → sub-agents run on yaks via `pi -p` (blocked from yx CLI)
3. **Track**`yx list` / `yx show <task-id>`
4. **Verify** → run `npm run checks` after sub-agent work
5. **Complete**`yx done <task-id>`
## Running Sub-Agents
Sub-agents execute in print mode and are **hard-blocked from using `yx` commands** (see `.pi/extensions/yak-mode-gate.ts`). They receive:
- The yak's `.context.md` (task description)
- `AGENTS.md` (project conventions)
- Domain files to work on
- No yx CLI access
```bash
# Run a sub-agent on a specific yak
# The sub-agent reads the yak context and executes the work
pi -p "Work on yak: <task-name>. Read .yaks/<task-id>/.context.md for details."
```
## Tracking Progress
```bash
# List all yaks with hierarchy
yx list
# Show yak details
yx show <task-id>
# Check state directly
cat .yaks/<task-id>/.state
```
States:
- `pending` — not yet started
- `in-progress` — being worked on
- `done` — completed
## Marking Tasks Complete
```bash
yx done <task-id>
```
Always verify before marking done:
```bash
# 1. Check state
cat .yaks/<task-id>/.state
# 2. Review what was done
cat .yaks/<task-id>/.context.md
# 3. Run project checks
npm run checks
```
## Sub-Agent Communication
Sub-agents can read yak state files directly (no yx CLI needed):
```bash
cat .yaks/<task-id>/.name # task name
cat .yaks/<task-id>/.state # current state
cat .yaks/<task-id>/.context.md # task description
cat .yaks/<task-id>/.created.json # creation metadata
```
## Best Practices
- **Delegate after breakdown** — run `/skill:problem-breakdown` first to create structured yaks
- **Verify before marking done** — always run `npm run checks` to catch sub-agent errors
- **Review context** — read `.context.md` to understand what the sub-agent was supposed to do
- **Keep yaks focused** — each yak should be a single file operation or one method implementation
- **Use hierarchy** — parent yaks block children; fix leaves first
-174
View File
@@ -1,174 +0,0 @@
-- allium: 3
-- allium: magical-objects
------------------------------------------------------------
-- External Entities
------------------------------------------------------------
external entity Character {
name: String
health: Health
status: alive | dead
level: Level
factions: Set<Faction>
}
external entity Health {
value: Integer
}
external entity Level {
value: Integer
}
external entity Faction {
name: String
}
------------------------------------------------------------
-- Entities and Variants
------------------------------------------------------------
entity MagicalWeapon {
health: Health
maxHealth: Integer
status: alive | destroyed
damage: Integer
owner: Character
}
entity HealingObject {
health: Health
maxHealth: Integer
status: alive | destroyed
}
------------------------------------------------------------
-- Rules
------------------------------------------------------------
rule WeaponDealsDamage {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: weapon.status = alive
requires: attacker = weapon.owner
requires: attacker.status = alive
ensures: target.health.value = max(0, target.health.value - weapon.damage)
ensures: weapon.health.value = weapon.health.value - 1
ensures:
if weapon.health.value - 1 = 0:
weapon.status = destroyed
else:
weapon.status = alive
ensures:
if max(0, target.health.value - weapon.damage) = 0:
target.status = dead
else:
target.status = alive
}
rule DeadCannotUseWeapon {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: attacker.status = dead
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule NonOwnerCannotUseWeapon {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: attacker != weapon.owner
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule DestroyedWeaponCannotDealDamage {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: weapon.status = destroyed
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule HealingObjectHealsCharacter {
when: HealingObject.healsCharacter(object, character, amount)
requires: object.status = alive
requires: character.status = alive
ensures: healAmount = min(amount, object.maxHealth - object.health.value)
ensures: character.health.value = character.health.value + healAmount
ensures: object.health.value = object.health.value - healAmount
ensures:
if object.health.value - healAmount = 0:
object.status = destroyed
else:
object.status = alive
}
rule DeadCannotUseHealingObject {
when: HealingObject.healsCharacter(object, character, amount)
requires: character.status = dead
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
object.status = object.status
}
rule DestroyedHealingObjectCannotHeal {
when: HealingObject.healsCharacter(object, character, amount)
requires: object.status = destroyed
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
object.status = object.status
}
------------------------------------------------------------
-- Invariants
------------------------------------------------------------
invariant WeaponHealthNeverNegative {
for w in MagicalWeapons:
w.health.value >= 0
}
invariant WeaponDestroyedAtZeroHealth {
for w in MagicalWeapons:
w.health.value = 0 implies w.status = destroyed
}
invariant WeaponMaxHealthNeverExceeded {
for w in MagicalWeapons:
w.health.value <= w.maxHealth
}
invariant HealingObjectHealthNeverNegative {
for h in HealingObjects:
h.health.value >= 0
}
invariant HealingObjectDestroyedAtZeroHealth {
for h in HealingObjects:
h.health.value = 0 implies h.status = destroyed
}
invariant HealingObjectMaxHealthNeverExceeded {
for h in HealingObjects:
h.health.value <= h.maxHealth
}
invariant HealingObjectCannotDealDamage {
for h in HealingObjects:
not h.dealsDamage(_, _)
}
invariant WeaponCannotHeal {
for w in MagicalWeapons:
not w.healsCharacter(_, _)
}
+1 -1
View File
@@ -16,7 +16,7 @@ An implementation of the RPG Combat rules engine. There are six user stories des
This project combines three practices:
1. **Allium** (`.allium` specs) — formal behavioural specifications that capture _what_ the system does
1. **Allium** (`.allium` specs) — formal behavioural specifications that capture _what_ the system does. All specs live in [specs/](specs/) — one file per story/domain area.
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
+95 -2
View File
@@ -1,8 +1,101 @@
# 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 — 28 sessions documenting the full journey from first requirements review through horizontal refactoring. See the [full transcript index](transcripts/index.md) for a chronological list.
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
```
+9
View File
@@ -1,6 +1,7 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import noPrimitiveValue from './eslint/no-primitive-value-properties.js';
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] },
@@ -17,9 +18,17 @@ export default tseslint.config(
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
rpg: {
rules: {
'no-primitive-value-properties': noPrimitiveValue,
},
},
},
rules: {
'@typescript-eslint/switch-exhaustiveness-check': 'warn',
'@typescript-eslint/no-unnecessary-condition': 'warn',
'rpg/no-primitive-value-properties': 'warn',
},
},
+145
View File
@@ -0,0 +1,145 @@
import { readdirSync } from 'node:fs';
import { join, parse } from 'node:path';
const VALUE_OBJECTS_DIR = join(import.meta.dirname, '..', 'src', 'value-objects');
function discoverValueObjects() {
const files = readdirSync(VALUE_OBJECTS_DIR).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
return files.map((f) => parse(f).name);
}
const VALUE_OBJECTS = discoverValueObjects();
function buildPropertyMap() {
const map = {};
for (const name of VALUE_OBJECTS) {
const propertyName = name.charAt(0).toLowerCase() + name.slice(1);
map[propertyName] = name;
}
return map;
}
const PROPERTY_MAP = buildPropertyMap();
/** Map ESLint AST type names to the primitive keyword string. */
const PRIMITIVE_TYPE_MAP = {
TSNumberKeyword: 'number',
TSStringKeyword: 'string',
TSBooleanKeyword: 'boolean',
};
function isPrimitiveType(typeNode) {
if (!typeNode) return false;
const keyword = PRIMITIVE_TYPE_MAP[typeNode.type];
return keyword !== undefined;
}
function getPrimitiveKeyword(typeNode) {
if (!typeNode) return null;
return PRIMITIVE_TYPE_MAP[typeNode.type] || null;
}
function getParamName(param) {
if (param.type === 'TSParameterProperty') {
return getParamName(param.parameter);
}
if (param.type === 'Identifier') {
return param.name;
}
return null;
}
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Warn when primitive types are used in class properties or constructor parameters that should be value objects',
recommended: false,
},
schema: [
{
type: 'object',
properties: {
includeConstructorParams: { type: 'boolean', default: true },
includeProperties: { type: 'boolean', default: true },
},
additionalProperties: false,
},
],
messages: {
primitiveProperty:
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for property "{{propertyName}}". Available: {{available}}',
primitiveParam:
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for parameter "{{paramName}}". Available: {{available}}',
},
},
create(context) {
const options = context.options[0] || {};
const includeConstructorParams = options.includeConstructorParams !== false;
const includeProperties = options.includeProperties !== false;
function reportPrimitive(node, propName, primitiveType, messageId) {
const valueObject = PROPERTY_MAP[propName];
if (valueObject) {
context.report({
node,
messageId,
data: {
valueObject,
primitive: primitiveType,
propertyName: propName,
paramName: propName,
available: VALUE_OBJECTS.join(', '),
},
});
}
}
return {
ClassProperty(node) {
if (!includeProperties) return;
if (!node.typeAnnotation) return;
const typeNode = node.typeAnnotation.typeAnnotation;
if (!isPrimitiveType(typeNode)) return;
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
},
PropertyDefinition(node) {
if (!includeProperties) return;
if (!node.typeAnnotation) return;
const typeNode = node.typeAnnotation.typeAnnotation;
if (!isPrimitiveType(typeNode)) return;
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
},
MethodDefinition(node) {
if (!includeConstructorParams) return;
if (node.key.name !== 'constructor') return;
if (!node.value.params) return;
for (const param of node.value.params) {
let paramName = null;
let typeNode = null;
if (param.type === 'TSParameterProperty') {
paramName = getParamName(param.parameter);
typeNode = param.parameter.typeAnnotation?.typeAnnotation;
} else if (param.type === 'Identifier') {
paramName = param.name;
typeNode = param.typeAnnotation?.typeAnnotation;
}
if (!paramName || !typeNode || !isPrimitiveType(typeNode)) continue;
reportPrimitive(param, paramName, getPrimitiveKeyword(typeNode), 'primitiveParam');
}
},
};
},
};
+120
View File
@@ -0,0 +1,120 @@
-- allium: 3
-- allium: changing-level
------------------------------------------------------------
-- Value Types
------------------------------------------------------------
value Faction {
name: String
}
value Health {
value: Integer
requires: value >= 0
}
value Level {
value: Integer
requires: value >= 1 and value <= 10
}
value Damage {
value: Integer
requires: value >= 0
}
------------------------------------------------------------
-- Enumerations
------------------------------------------------------------
enum Status {
alive | dead
}
------------------------------------------------------------
-- Entities
------------------------------------------------------------
entity Character {
name: String
health: Health
status: Status
level: Level
factions: Set<Faction>
totalDamageTaken: Damage
factionsJoined: Set<Faction>
}
------------------------------------------------------------
-- Rules
------------------------------------------------------------
rule DamageIsAccumulated {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = alive
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
}
rule LevelUpFromDamage {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = alive
requires: old(target.totalDamageTaken.value) + damage.value >= 1000 * (target.level.value + 1) * (target.level.value + 2) / 2
ensures: target.level.value = target.level.value + 1
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
}
rule LevelUpFromFaction {
when: Character.joinFaction(character, faction)
requires: character.status = alive
requires: old(character.factionsJoined.size) + 1 >= 3 * (character.level.value + 1)
ensures: character.level.value = character.level.value + 1
ensures: character.factionsJoined = old(character.factionsJoined) + {faction}
}
rule MaxLevelCappedOnDamage {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = alive
requires: target.level.value = 10
ensures: target.level.value = 10
}
rule MaxLevelCappedOnFaction {
when: Character.joinFaction(character, faction)
requires: character.status = alive
requires: character.level.value = 10
ensures: character.level.value = 10
}
rule DeadCannotLevelUpFromDamage {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = dead
ensures: target.level.value = old(target.level.value)
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value)
}
rule DeadCannotLevelUpFromFaction {
when: Character.joinFaction(character, faction)
requires: character.status = dead
ensures: character.level.value = old(character.level.value)
}
------------------------------------------------------------
-- Invariants
------------------------------------------------------------
invariant LevelBounded {
for c in Characters:
c.level.value >= 1 and c.level.value <= 10
}
invariant DamageTotalNonNegative {
for c in Characters:
c.totalDamageTaken.value >= 0
}
invariant FactionsJoinedNonNegative {
for c in Characters:
c.factionsJoined.size >= 0
}
@@ -6,9 +6,22 @@
-- Value Types
------------------------------------------------------------
type Faction {
value Faction {
name: String
requires: trimmed(name).length > 0
}
value Health {
value: Integer
requires: value >= 0
}
value Level {
value: Integer
requires: value >= 1 and value <= 10
}
enum Status {
alive | dead
}
------------------------------------------------------------
@@ -18,7 +31,7 @@ type Faction {
entity Character {
name: String
health: Health
status: alive | dead
status: Status
level: Level
factions: Set<Faction>
}
@@ -83,12 +96,7 @@ rule DeadCannotLeaveFaction {
invariant FactionsAlwaysValid {
for c in Characters:
for f in c.factions:
f.name.trim().length > 0
}
invariant AllyRelationIsSymmetric {
for a in Characters, b in Characters:
a.isAllyOf(b) implies b.isAllyOf(a)
f.name.length > 0
}
invariant SelfNotAlly {
+189
View File
@@ -0,0 +1,189 @@
-- allium: 3
-- allium: magical-objects
------------------------------------------------------------
-- Value Types
------------------------------------------------------------
value Health {
value: Integer
requires: value >= 0
}
value Faction {
name: String
}
value Level {
value: Integer
requires: value >= 1 and value <= 10
}
------------------------------------------------------------
-- Enumerations
------------------------------------------------------------
enum Status {
alive | destroyed
}
------------------------------------------------------------
-- Entities
------------------------------------------------------------
entity Character {
name: String
health: Health
status: Status
level: Level
factions: Set<Faction>
}
entity MagicalObject {
health: Health
maxHealth: Integer
status: Status
}
entity HealingObject {
health: Health
maxHealth: Integer
status: Status
}
entity MagicalWeapon {
health: Health
maxHealth: Integer
status: Status
damage: Integer
owner: Character
}
------------------------------------------------------------
-- Rules
------------------------------------------------------------
rule HealingObjectHealsCharacter {
when: CharacterUsesHealingObject(character, object, amount)
requires: object.status = alive
requires: character.status = alive
requires: amount >= 0
ensures:
character.health.value = character.health.value + min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
object.health.value = object.health.value - min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
if object.health.value = 0:
object.status = destroyed
else:
object.status = alive
}
rule HealingObjectDestroyedCannotHeal {
when: CharacterUsesHealingObject(character, object, amount)
requires: object.status = destroyed
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
}
rule DeadCannotUseHealingObject {
when: CharacterUsesHealingObject(character, object, amount)
requires: character.status = dead
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
}
rule HealingObjectZeroHealIsNoOp {
when: CharacterUsesHealingObject(character, object, amount)
requires: object.status = alive
requires: character.status = alive
requires: amount >= 0
requires: min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value) = 0
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
}
rule MagicalWeaponDealsDamage {
when: CharacterUsesWeapon(owner, weapon, target)
requires: weapon.status = alive
requires: owner.status = alive
requires: owner = weapon.owner
ensures:
target.health.value = max(0, target.health.value - weapon.damage)
weapon.health.value = weapon.health.value - 1
if weapon.health.value = 0:
weapon.status = destroyed
else:
weapon.status = alive
}
rule DeadCannotUseWeapon {
when: CharacterUsesWeapon(owner, weapon, target)
requires: owner.status = dead
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule NonOwnerCannotUseWeapon {
when: CharacterUsesWeapon(thief, weapon, target)
requires: thief != weapon.owner
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule WeaponDestroyedCannotDealDamage {
when: CharacterUsesWeapon(owner, weapon, target)
requires: weapon.status = destroyed
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
------------------------------------------------------------
-- Invariants
------------------------------------------------------------
invariant MagicalObjectHealthNonNegative {
for m in MagicalObjects:
m.health.value >= 0
}
invariant MagicalObjectHealthNeverExceedsMax {
for m in MagicalObjects:
m.health.value <= m.maxHealth
}
invariant MagicalObjectDestroyedAtZeroHealth {
for m in MagicalObjects:
m.health.value = 0 implies m.status = destroyed
}
invariant MagicalObjectAliveAtPositiveHealth {
for m in MagicalObjects:
m.health.value > 0 implies m.status = alive
}
-20
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>,
) {}
}
+131
View File
@@ -0,0 +1,131 @@
import fc from 'fast-check';
import { describe, it, expect } from 'vitest';
import { Character } from './characters/Character.ts';
import { Level } from './value-objects/Level.ts';
import { Damage } from './value-objects/Damage.ts';
import { Faction } from './factions/Faction.ts';
describe('ChangingLevel', () => {
describe('MaxLevelCappedOnDamage (example)', () => {
it('L10 takes 99999 damage → stays L10', () => {
const attacker = Character.create({ name: 'dragons', level: Level.create(1) });
const target = Character.create({ name: 'hero', level: Level.create(10) });
const result = attacker.dealDamage(target, Damage.create(99999));
expect(result.level.value).toBe(10);
});
});
describe('MaxLevelCappedOnFaction (example)', () => {
it('L10 joins 100 factions → stays L10', () => {
const hero = Character.create({ name: 'hero', level: Level.create(10) });
let char = hero;
for (let i = 0; i < 100; i++) {
char = char.joinFaction(Faction.create(`faction-${i}`));
}
expect(char.level.value).toBe(10);
});
});
describe('DeadCannotLevelUpFromDamage (example)', () => {
it('dead char takes 1000 damage → stays same level', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'hero', level: Level.create(1) });
const dead = attacker.dealDamage(target, Damage.create(10000));
const result = attacker.dealDamage(dead, Damage.create(1000));
expect(result.level.value).toBe(1);
});
});
describe('DeadCannotLevelUpFromFaction (example)', () => {
it('dead char joins 5 factions → stays same level', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'hero', level: Level.create(1) });
const dead = attacker.dealDamage(target, Damage.create(10000));
let char = dead;
for (let i = 0; i < 5; i++) {
char = char.joinFaction(Faction.create(`faction-${i}`));
}
expect(char.level.value).toBe(1);
});
});
describe('LevelUpFromDamage (property)', () => {
it('property: cumulative damage triggers level-up at threshold', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 500 }),
fc.integer({ min: 1, max: 500 }),
(level, dmg1, dmg2) => {
const currentLevel = Level.create(level);
const threshold = Level.damageThresholdForLevel(level + 1);
const total = dmg1 + dmg2;
// Only test when total meets threshold AND target survives
// Target starts with 1000 health, needs health > total after damage
if (total >= threshold && total < 1000) {
const attacker = Character.create({ name: 'a', level: Level.create(1) });
const target = Character.create({ name: 't', level: currentLevel });
const afterFirst = attacker.dealDamage(target, Damage.create(dmg1));
const afterSecond = attacker.dealDamage(afterFirst, Damage.create(dmg2));
const expectedLevel = Math.min(10, level + 1);
return afterSecond.level.value === expectedLevel;
}
return true; // skip non-applicable cases
},
),
);
});
});
describe('LevelUpFromFaction (property)', () => {
it('property: faction count triggers level-up at thresholds', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 30 }),
(level, totalFactions) => {
// Compute expected level: how many thresholds are crossed?
let currentLevel = level;
for (let f = 1; f <= totalFactions; f++) {
if (f >= 3 * (currentLevel + 1) && currentLevel < 10) {
currentLevel++;
}
}
const hero = Character.create({ name: 'hero', level: Level.create(level) });
let char = hero;
for (let i = 0; i < totalFactions; i++) {
char = char.joinFaction(Faction.create(`faction-${i}`));
}
return char.level.value === currentLevel;
},
),
);
});
});
describe('DamageAccumulation (property)', () => {
it('property: totalDamageTaken accumulates across multiple damage events', () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 200 }), { minLength: 2, maxLength: 10 }),
(damageEvents) => {
const expectedTotal = damageEvents.reduce((sum, d) => sum + d, 0);
// Ensure target survives: total damage must be < 1000 (starting health)
if (expectedTotal >= 1000) return true;
const attacker = Character.create({ name: 'a', level: Level.create(1) });
const target = Character.create({ name: 't', level: Level.create(1) });
let current = target;
for (const dmg of damageEvents) {
current = attacker.dealDamage(current, Damage.create(dmg));
}
return current.totalDamageTaken.value === expectedTotal;
},
),
);
});
});
});
+2 -2
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', () => {
@@ -4,14 +4,15 @@
* "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 { Damage } from '../value-objects/Damage.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;
@@ -48,6 +49,8 @@ export class Character {
status: StatusAlive,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@@ -60,6 +63,8 @@ export class Character {
status: StatusAlive,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@@ -77,6 +82,8 @@ export class Character {
status,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@@ -101,6 +108,14 @@ export class Character {
return this.#state.factions;
}
get totalDamageTaken(): Damage {
return this.#state.totalDamageTaken;
}
get factionsJoined(): ReadonlySet<Faction> {
return this.#state.factionsJoined;
}
/**
* Check if this character is an ally of another.
* Two characters are allies if they share at least one faction.
@@ -122,12 +137,24 @@ export class Character {
if (this.status.kind === 'dead') return this;
const newFactions = new Set(this.#state.factions);
newFactions.add(faction);
const newFactionsJoined = new Set(this.#state.factionsJoined);
newFactionsJoined.add(faction);
// Level-up from faction count
let newLevel = this.level;
if (newFactionsJoined.size >= 3 * (newLevel.value + 1)) {
newLevel = Level.create(Math.min(10, newLevel.value + 1));
}
return new Character({
name: this.name,
health: this.health,
status: this.status,
level: this.level,
level: newLevel,
factions: newFactions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: newFactionsJoined,
});
}
@@ -147,6 +174,8 @@ export class Character {
status: this.status,
level: this.level,
factions: newFactions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: this.factionsJoined,
});
}
@@ -169,6 +198,8 @@ export class Character {
status: ally.status,
level: ally.level,
factions: ally.factions,
totalDamageTaken: ally.totalDamageTaken,
factionsJoined: ally.factionsJoined,
});
}
@@ -176,34 +207,46 @@ export class Character {
* Deal damage to another character. Returns a new Character with updated state.
* Does not mutate the attacker or the original target reference.
*/
dealDamage(target: Character, damage: number): Character {
dealDamage(target: Character, damage: Damage): Character {
// Self-damage is forbidden — use reference equality, not name
if (this === target) return target;
// Allies cannot deal damage to each other
if (this.isAllyOf(target)) return target;
// Dead characters cannot take damage
if (target.status.kind === 'dead') return target;
// Negative damage is invalid
if (damage < 0) throw new Error(`Damage must be non-negative, got ${damage}`);
// Level-based damage modifier
const levelDiff = this.level.diff(target.level); // = this.level - target.level
let actualDamage = damage;
let actualDamage = damage.value;
if (levelDiff <= -5) {
// Target is ≥5 levels above → damage reduced by 50%
actualDamage = Math.floor(damage * 0.5);
actualDamage = Math.floor(damage.value * 0.5);
} else if (levelDiff >= 5) {
// Target is ≥5 levels below → damage increased by 50%
actualDamage = Math.floor(damage * 1.5);
actualDamage = Math.floor(damage.value * 1.5);
}
// Reduce health by the (possibly modified) damage amount
const newHealth = target.health.sub(actualDamage);
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
// Level-up from cumulative damage
let newLevel = target.level;
const newTotalDamage = target.totalDamageTaken.add(Damage.create(actualDamage));
if (newStatus.kind === 'alive') {
const threshold = Level.damageThresholdForLevel(newLevel.value + 1);
if (newTotalDamage.value >= threshold) {
newLevel = Level.create(Math.min(10, newLevel.value + 1));
}
}
return new Character({
name: target.name,
health: newHealth,
status: newStatus,
level: target.level,
level: newLevel,
factions: target.factions,
totalDamageTaken: newTotalDamage,
factionsJoined: target.factionsJoined,
});
}
@@ -225,6 +268,8 @@ export class Character {
status: this.status,
level: this.level,
factions: this.factions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: this.factionsJoined,
});
}
@@ -233,10 +278,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 +291,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);
+21
View File
@@ -0,0 +1,21 @@
/**
* 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';
import type { Damage } from '../value-objects/Damage.ts';
export type CharacterState = {
readonly name: string;
readonly health: Health;
readonly status: Status;
readonly level: Level;
readonly factions: ReadonlySet<Faction>;
readonly totalDamageTaken: Damage;
readonly factionsJoined: ReadonlySet<Faction>;
};
+11 -12
View File
@@ -1,7 +1,8 @@
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';
import { Damage } from './value-objects/Damage.ts';
describe('DamageAndHealth', () => {
describe('DamageReducesHealth', () => {
@@ -18,7 +19,7 @@ describe('DamageAndHealth', () => {
health,
});
const expected = Math.max(0, health - damage);
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === expected;
},
),
@@ -39,7 +40,7 @@ describe('DamageAndHealth', () => {
level: Level.create(1),
health,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value >= 0;
},
),
@@ -60,7 +61,7 @@ describe('DamageAndHealth', () => {
level: Level.create(1),
health,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
const expected = Math.max(0, health - damage);
if (expected === 0) {
return result.status.kind === 'dead';
@@ -82,7 +83,7 @@ describe('DamageAndHealth', () => {
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
const healthBefore = c.health.value;
const statusBefore = c.status.kind;
const result = c.dealDamage(c, damage);
const result = c.dealDamage(c, Damage.create(damage));
// Should return the same reference
return (
result === c &&
@@ -102,11 +103,11 @@ describe('DamageAndHealth', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'target', level: Level.create(1) });
// Kill the target first — capture the returned (dead) character
const deadTarget = attacker.dealDamage(target, 10000);
const deadTarget = attacker.dealDamage(target, Damage.create(10000));
const healthBefore = deadTarget.health.value;
const statusBefore = deadTarget.status.kind;
// Then try to deal more damage to the dead character
const result = attacker.dealDamage(deadTarget, damage);
const result = attacker.dealDamage(deadTarget, Damage.create(damage));
return (
result === deadTarget &&
result.health.value === healthBefore &&
@@ -118,14 +119,12 @@ describe('DamageAndHealth', () => {
});
describe('NegativeDamageForbidden', () => {
it('property: negative damage throws an error', () => {
it('property: negative damage throws an error via Damage.create', () => {
fc.assert(
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'target', level: Level.create(1) });
let threw = false;
try {
attacker.dealDamage(target, negativeDamage);
Damage.create(negativeDamage);
} catch {
threw = true;
}
+10 -9
View File
@@ -1,8 +1,9 @@
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';
import { Damage } from './value-objects/Damage.ts';
describe('Factions', () => {
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
@@ -189,7 +190,7 @@ describe('Factions', () => {
(factionName) => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const hero = Character.create({ name: 'hero', level: Level.create(1) });
const deadHero = attacker.dealDamage(hero, 10000);
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
const f = Faction.create(factionName);
const result = deadHero.joinFaction(f);
return result === deadHero && result.factions.size === 0;
@@ -209,7 +210,7 @@ describe('Factions', () => {
const hero = Character.create({ name: 'hero', level: Level.create(1) });
const f = Faction.create(factionName);
const withFaction = hero.joinFaction(f);
const deadHero = attacker.dealDamage(withFaction, 10000);
const deadHero = attacker.dealDamage(withFaction, Damage.create(10000));
const result = deadHero.leaveFaction(f);
return result === deadHero && result.factions.has(f);
},
@@ -302,7 +303,7 @@ describe('Factions', () => {
const target = ally().joinFaction(faction);
const healthBefore = target.health.value;
const statusBefore = target.status.kind;
const result = attacker.dealDamage(target, 500);
const result = attacker.dealDamage(target, Damage.create(500));
return result.health.value === healthBefore && result.status.kind === statusBefore;
}),
);
@@ -314,7 +315,7 @@ describe('Factions', () => {
const faction = Faction.create('guard');
const attacker = hero().joinFaction(faction);
const target = ally().joinFaction(faction);
const result = attacker.dealDamage(target, 500);
const result = attacker.dealDamage(target, Damage.create(500));
return result === target;
}),
);
@@ -326,7 +327,7 @@ describe('Factions', () => {
const attacker = hero();
const target = enemy();
const healthBefore = target.health.value;
const result = attacker.dealDamage(target, 100);
const result = attacker.dealDamage(target, Damage.create(100));
return result.health.value === healthBefore - 100;
}),
);
@@ -414,7 +415,7 @@ describe('Factions', () => {
const target = ally().joinFaction(faction);
// Kill the target with a non-ally attacker
const killer = enemy();
const deadTarget = killer.dealDamage(target, 10000);
const deadTarget = killer.dealDamage(target, Damage.create(10000));
const healthBefore = deadTarget.health.value;
const statusBefore = deadTarget.status.kind;
const result = healer.healAlly(deadTarget, 500);
+4 -3
View File
@@ -1,7 +1,8 @@
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';
import { Damage } from './value-objects/Damage.ts';
describe('Healing', () => {
describe('SelfHealIncreasesHealth', () => {
@@ -88,7 +89,7 @@ describe('Healing', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const hero = Character.create({ name: 'hero', level: Level.create(1) });
// Kill the hero first using a different attacker
const deadHero = attacker.dealDamage(hero, 10000);
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
const healthBefore = deadHero.health.value;
const statusBefore = deadHero.status.kind;
// Try to heal the dead character
+11 -10
View File
@@ -1,7 +1,8 @@
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';
import { Damage } from './value-objects/Damage.ts';
describe('Levels', () => {
describe('CloseLevelNoModifier', () => {
@@ -21,7 +22,7 @@ describe('Levels', () => {
level: Level.create(targetLevel),
health: 1000,
});
const result = attacker.dealDamage(target, baseDamage);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
return result.health.value === Math.max(0, 1000 - baseDamage);
},
),
@@ -47,7 +48,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 0.5);
const result = attacker.dealDamage(target, baseDamage);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
@@ -66,7 +67,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(oddDamage * 0.5);
const result = attacker.dealDamage(target, oddDamage);
const result = attacker.dealDamage(target, Damage.create(oddDamage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@@ -91,7 +92,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 1.5);
const result = attacker.dealDamage(target, baseDamage);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
@@ -111,7 +112,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(damage * 0.5);
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@@ -128,7 +129,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(damage * 1.5);
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@@ -144,7 +145,7 @@ describe('Levels', () => {
level: Level.create(5),
health: 1000,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - damage);
}),
);
@@ -160,7 +161,7 @@ describe('Levels', () => {
level: Level.create(1),
health: 1000,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - damage);
}),
);
+24 -20
View File
@@ -1,9 +1,10 @@
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 { Damage } from './value-objects/Damage.ts';
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
import { HealingObject } from './magical-objects/HealingObject.ts';
describe('Magical Objects', () => {
describe('WeaponDealsDamage', () => {
@@ -38,7 +39,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target);
return result.weapon.health === weaponHP - 1;
return result.weapon.health.value === weaponHP - 1;
},
),
);
@@ -85,12 +86,12 @@ describe('Magical Objects', () => {
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
// Kill the attacker using a separate killer
const killer = Character.create({ name: 'boss', level: Level.create(1) });
const deadAttacker = killer.dealDamage(attacker, 10000);
const weaponHPBefore = weapon.health;
const deadAttacker = killer.dealDamage(attacker, Damage.create(10000));
const weaponHPBefore = weapon.health.value;
const targetHealthBefore = target.health.value;
const result = deadAttacker.useWeapon(weapon, target);
return (
result.weapon.health === weaponHPBefore &&
result.weapon.health.value === weaponHPBefore &&
result.target.health.value === targetHealthBefore
);
},
@@ -110,11 +111,11 @@ describe('Magical Objects', () => {
const thief = Character.create({ name: 'thief', level: Level.create(1) });
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner });
const weaponHPBefore = weapon.health;
const weaponHPBefore = weapon.health.value;
const targetHealthBefore = target.health.value;
const result = thief.useWeapon(weapon, target);
return (
result.weapon.health === weaponHPBefore &&
result.weapon.health.value === weaponHPBefore &&
result.target.health.value === targetHealthBefore
);
},
@@ -136,7 +137,9 @@ describe('Magical Objects', () => {
const targetHealthBefore = firstUse.target.health.value;
// Try to use again on the destroyed weapon
const result = owner.useWeapon(destroyedWeapon, firstUse.target);
return result.weapon.health === 0 && result.target.health.value === targetHealthBefore;
return (
result.weapon.health.value === 0 && result.target.health.value === targetHealthBefore
);
}),
);
});
@@ -203,7 +206,7 @@ describe('Magical Objects', () => {
});
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount);
return result.object.health === objectHP - healAmount;
return result.object.health.value === objectHP - healAmount;
},
),
);
@@ -254,12 +257,12 @@ describe('Magical Objects', () => {
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
// Kill the character using a separate killer
const killer = Character.create({ name: 'boss', level: Level.create(1) });
const deadCharacter = killer.dealDamage(character, 10000);
const objectHPBefore = object.health;
const deadCharacter = killer.dealDamage(character, Damage.create(10000));
const objectHPBefore = object.health.value;
const characterHealthBefore = deadCharacter.health.value;
const result = deadCharacter.useHealingObject(object, 100);
return (
result.object.health === objectHPBefore &&
result.object.health.value === objectHPBefore &&
result.character.health.value === characterHealthBefore
);
}),
@@ -287,7 +290,8 @@ describe('Magical Objects', () => {
// Try to use again on the destroyed object
const result = healedCharacter.useHealingObject(destroyedObject, 100);
return (
result.object.health === 0 && result.character.health.value === characterHealthBefore
result.object.health.value === 0 &&
result.character.health.value === characterHealthBefore
);
}),
);
@@ -305,7 +309,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target);
return result.weapon.health >= 0;
return result.weapon.health.value >= 0;
},
),
);
@@ -320,7 +324,7 @@ describe('Magical Objects', () => {
const character = Character.create({ name: 'hero', level: Level.create(1) });
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount);
return result.object.health >= 0;
return result.object.health.value >= 0;
},
),
);
@@ -336,7 +340,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target);
return result.weapon.health <= weaponHP;
return result.weapon.health.value <= weaponHP;
},
),
);
@@ -351,7 +355,7 @@ describe('Magical Objects', () => {
const character = Character.create({ name: 'hero', level: Level.create(1) });
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount);
return result.object.health <= objectHP;
return result.object.health.value <= objectHP;
},
),
);
@@ -1,24 +1,24 @@
/**
* 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 { Health } from '../value-objects/Health.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: Health,
maxHealth: Health,
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
) {
super(health, maxHealth, status);
}
static create({
@@ -33,32 +33,20 @@ export class HealingObject {
if (currentHealth > maxHealth) throw new Error('CurrentHealth cannot exceed maxHealth');
const status =
currentHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
return new HealingObject(currentHealth, maxHealth, status);
}
get health(): number {
return this.#health;
}
get maxHealth(): number {
return this.#maxHealth;
}
get status(): ObjectStatus {
return this.#status;
return new HealingObject(Health.create(currentHealth), Health.create(maxHealth), 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.value;
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 +54,7 @@ export class HealingObject {
return { object: this, character };
}
// Create updated object
const newObjectHealth = this.#health - actualHeal;
const newObjectHealth = this.health.value - actualHeal;
const newObjectStatus =
newObjectHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
// Create updated character
@@ -77,7 +65,7 @@ export class HealingObject {
health: newCharacterHealth,
});
return {
object: new HealingObject(newObjectHealth, this.#maxHealth, newObjectStatus),
object: new HealingObject(Health.create(newObjectHealth), this.maxHealth, newObjectStatus),
character: newCharacter,
};
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 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)
*/
import { Health } from '../value-objects/Health.ts';
export type MagicalObjectStatus = { readonly kind: 'alive' } | { readonly kind: 'destroyed' };
export class MagicalObject {
readonly #health: Health;
readonly #maxHealth: Health;
readonly #status: MagicalObjectStatus;
protected constructor(health: Health, maxHealth: Health, status: MagicalObjectStatus) {
this.#health = health;
this.#maxHealth = maxHealth;
this.#status = status;
}
get health(): Health {
return this.#health;
}
get maxHealth(): Health {
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(Health.create(0), Health.create(maxHealth), { kind: 'destroyed' });
}
/** Check if this object is alive. */
isAlive(): boolean {
return this.#status.kind === 'alive';
}
}
@@ -1,32 +1,28 @@
/**
* 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 { Health } from '../value-objects/Health.ts';
import { Damage } from '../value-objects/Damage.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;
readonly #damage: number;
export class MagicalWeapon extends MagicalObject implements DamageDealer {
readonly #damage: Damage;
readonly #owner: Character;
private constructor(
health: number,
maxHealth: number,
status: WeaponStatus,
damage: number,
health: Health,
maxHealth: Health,
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
damage: Damage,
owner: Character,
) {
this.#health = health;
this.#maxHealth = maxHealth;
this.#status = status;
super(health, maxHealth, status);
this.#damage = damage;
this.#owner = owner;
}
@@ -41,23 +37,16 @@ export class MagicalWeapon {
owner: Character;
}): MagicalWeapon {
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
if (damage < 0) throw new Error('Damage cannot be negative');
return new MagicalWeapon(maxHealth, maxHealth, { kind: 'alive' }, damage, owner);
return new MagicalWeapon(
Health.create(maxHealth),
Health.create(maxHealth),
{ kind: 'alive' },
Damage.create(damage),
owner,
);
}
get health(): number {
return this.#health;
}
get maxHealth(): number {
return this.#maxHealth;
}
get status(): WeaponStatus {
return this.#status;
}
get damage(): number {
get damage(): Damage {
return this.#damage;
}
@@ -68,11 +57,11 @@ 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
const newTargetHealth = Math.max(0, target.health.value - this.#damage);
const newTargetHealth = Math.max(0, target.health.value - this.#damage.value);
const newTargetStatus = newTargetHealth === 0 ? { kind: 'dead' as const } : target.status;
const newTarget = Character.createWithHealthAndStatus({
name: target.name,
@@ -81,18 +70,17 @@ export class MagicalWeapon {
status: newTargetStatus,
});
// Reduce weapon health by 1
const newWeaponHealth = this.#health - 1;
const newWeaponHealth = this.health.value - 1;
const newWeaponStatus =
newWeaponHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
return {
weapon: new MagicalWeapon(
newWeaponHealth,
this.#maxHealth,
Health.create(newWeaponHealth),
this.maxHealth,
newWeaponStatus,
this.#damage,
this.#owner,
),
target: newTarget,
};
}
@@ -0,0 +1,25 @@
/**
* 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 { Health } from '../value-objects/Health.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: Health;
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: Health;
readonly status: MagicalObjectStatus;
heal(character: Character, amount: number): { object: Healer; character: Character };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Damage value object — non-negative, immutable.
*
* Invariant enforced at construction: n >= 0
*/
export class Damage {
readonly #value: number;
private constructor(n: number) {
this.#value = n;
}
static create(n: number): Damage {
if (n < 0) throw new Error(`Damage must be non-negative, got ${n}`);
return new Damage(n);
}
get value(): number {
return this.#value;
}
/** Add another damage amount — cumulative damage is still a Damage. */
add(other: Damage): Damage {
return Damage.create(this.#value + other.value);
}
}
@@ -31,4 +31,12 @@ export class Level {
diff(target: Level): number {
return this.value - target.value;
}
/** Damage threshold to reach the given level.
* Level 12: 1000, Level 23: 3000, Level 34: 6000, etc.
* Formula: 1000 * N * (N+1) / 2
*/
static damageThresholdForLevel(level: number): number {
return (1000 * level * (level + 1)) / 2;
}
}
@@ -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
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
# Transcript Archive
Chronological list of all session transcripts from the RPG Combat project.
| Date | Transcript |
| ---- | ---------- |
| 2026-06-12 20:20 | [Card, Conversation & Confirmation](card-conversation-confirmation.html) |
| 2026-06-12 20:20 | [Install Allium](install-allium.html) |
| 2026-06-12 20:20 | [Review User Stories](review-user-stories.md) |
| 2026-06-12 23:02 | [Clear and Export](clear-and-export.html) |
| 2026-06-12 23:02 | [Refactor Story 1](refactor-story-1.html) |
| 2026-06-12 23:02 | [Story 1 — Process Improvement](story1-process-improvement.md) |
| 2026-06-13 15:42 | [YAGNI in AGENTS.md](yagni-in-agents-md.html) |
| 2026-06-13 15:51 | [Story 2 — Refactored](story-2-refactored.html) |
| 2026-06-13 16:06 | [Forgot to Mention the Story](forgot-to-mention-the-story.html) |
| 2026-06-13 22:03 | [Forgot to Commit](forgot-to-commit.html) |
| 2026-06-13 22:03 | [Story 2 (re?) — Done](story-2-(re?)-done.html) |
| 2026-06-13 22:20 | [Story 4 — Built](story-4-built.html) |
| 2026-06-13 22:32 | [Found Out Story 3 Is Not Done](found-out-story-3-is-not-done.html) |
| 2026-06-14 10:48 | [Break Down Horizontal Refactoring into Yaks](break-down-horizontal-refactoring-into-yaks.html) |
| 2026-06-14 10:48 | [Create Task Breakdown with Yaks Skill](create-task-breakdown-with-yaks-skill.html) |
| 2026-06-14 10:48 | [Fixed Character Implementation (Maybe)](fixed-character-implementation-maybe.html) |
| 2026-06-14 10:51 | [Resolved Circular Dependency](resolved-circular-dependency.html) |
| 2026-06-14 12:01 | [Break Down the Refactoring Yaks](break-down-the-refactoring-yaks.html) |
| 2026-06-14 12:01 | [Break Yaks Down into Phases](break-yaks-down-into-phases.html) |
| 2026-06-14 12:01 | [Yaks Were Not Marked S-Done](yaks-were-not-marked-s-done.html) |
| 2026-06-14 12:47 | [Create Yak Run Skill](create-yak-run-skill.html) |
| 2026-06-14 12:47 | [ESLint Rule Against Value Objects and Yaks to Refactor](eslint-rule-against-value-objects-and-yaks-to-refactor.html) |
| 2026-06-14 12:47 | [Generate README](generate-readme.html) |
| 2026-06-14 12:47 | [Yaks Work for Horizontal Refactoring](yaks-work-for-horizontal-refactoring.html) |
| 2026-06-14 14:26 | [Refactor: Replace Number Health with Health Value Object in MagicalObject](refactor:-replace-number-health-with-Health-value-object-in-MagicalObject.html) |
| 2026-06-14 14:26 | [Story 4 Also Done — Spec and Story Needed to Be Put Straight](story-4-also-done,-spec-and-story-needed-to-be-put-straight.html) |
| 2026-06-15 08:09 | [Last Story Done](last-story-done.html) |
| 2026-06-15 09:36 | [Wrap Up](wrap-up.html) |
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
File diff suppressed because one or more lines are too long
+1
View File
@@ -50,6 +50,7 @@ This is a description of the business rules we should support in the game engine
- Healing Magical Objects cannot deal Damage
3. Characters can deal Damage by using a Magical Weapon.
- A Character can only use a Magical Weapon they own
- These Magical Objects deal a fixed amount of damage when they are used
- The amount of damage is fixed at the time the weapon is created
- Every time the weapon is used, the Health is reduced by 1