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