Compare commits
19
Commits
f6605bbbfd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21bd58be33 | ||
|
|
31984bbd9d | ||
|
|
39839dc594 | ||
|
|
692bd7305b | ||
|
|
1c9d4ad66b | ||
|
|
fb61fb85ed | ||
|
|
ba0903714c | ||
|
|
0540e5ff5b | ||
|
|
dea77d463f | ||
|
|
23edbc6e36 | ||
|
|
b628cc639f | ||
|
|
fe984a1c86 | ||
|
|
bc64293ba4 | ||
|
|
fc260dc97c | ||
|
|
3965aaf33b | ||
|
|
0805623b68 | ||
|
|
f29e3c456f | ||
|
|
0c09b08009 | ||
|
|
350e8073e9 |
+2
-1
@@ -8,4 +8,5 @@ coverage/
|
||||
**/.idea
|
||||
**/*.received.*
|
||||
**/DS_Store/*
|
||||
**/.DS_Store
|
||||
**/.DS_Store.yaks
|
||||
.yaks
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(_, _)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 1→2: 1000, Level 2→3: 3000, Level 3→4: 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
@@ -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
+13112
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10235
-1379
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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user