refactor: replace number health with Health value object in MagicalObject

- MagicalObject: internal #health and #maxHealth now Health type
- HealingObject: constructor, create, and heal use Health value object
- MagicalWeapon: constructor, create, and use use Health value object
- DamageDealer and Healer interfaces: health: number -> health: Health
- magical-objects.spec.ts: all assertions use .health.value
- Run npm run checks: 0 errors, 70 tests passing
This commit is contained in:
Willem van den Ende 2026-06-14 13:35:44 +01:00
parent 0540e5ff5b
commit ba0903714c
13 changed files with 51679 additions and 11982 deletions

View File

@ -1,18 +1,19 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => { pi.on('tool_call', async (event, ctx) => {
if (!isToolCallEventType("bash", event)) return; if (!isToolCallEventType('bash', event)) return;
const cmd = event.input.command || ""; const cmd = event.input.command || '';
const isYxCommand = cmd.includes("yx "); const isYxCommand = cmd.includes('yx ');
// Allow yaks in interactive mode, block in print mode (sub-agents) // Allow yaks in interactive mode, block in print mode (sub-agents)
if (isYxCommand && ctx.mode === "print") { if (isYxCommand && ctx.mode === 'print') {
return { return {
block: true, block: true,
reason: "yx commands are disabled in print mode. Sub-agents must focus on domain work only.", reason:
'yx commands are disabled in print mode. Sub-agents must focus on domain work only.',
}; };
} }
}); });

View File

@ -18,6 +18,7 @@ Coordinate sub-agent work using the `yx` CLI. The main agent creates tasks and d
## Running Sub-Agents ## 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: 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) - The yak's `.context.md` (task description)
- `AGENTS.md` (project conventions) - `AGENTS.md` (project conventions)
- Domain files to work on - Domain files to work on
@ -43,6 +44,7 @@ cat .yaks/<task-id>/.state
``` ```
States: States:
- `pending` — not yet started - `pending` — not yet started
- `in-progress` — being worked on - `in-progress` — being worked on
- `done` — completed - `done` — completed
@ -54,6 +56,7 @@ yx done <task-id>
``` ```
Always verify before marking done: Always verify before marking done:
```bash ```bash
# 1. Check state # 1. Check state
cat .yaks/<task-id>/.state cat .yaks/<task-id>/.state
@ -68,6 +71,7 @@ npm run checks
## Sub-Agent Communication ## Sub-Agent Communication
Sub-agents can read yak state files directly (no yx CLI needed): Sub-agents can read yak state files directly (no yx CLI needed):
```bash ```bash
cat .yaks/<task-id>/.name # task name cat .yaks/<task-id>/.name # task name
cat .yaks/<task-id>/.state # current state cat .yaks/<task-id>/.state # current state

View File

@ -52,7 +52,7 @@ The TypeScript implementation embraces functional patterns:
All five user stories are implemented and verified: All five user stories are implemented and verified:
| Story | Topic | Status | | Story | Topic | Status |
|-------|-------|--------| | ----- | --------------------------- | ------- |
| 1 | Character Creation & Damage | ✅ Done | | 1 | Character Creation & Damage | ✅ Done |
| 2 | Levels | ✅ Done | | 2 | Levels | ✅ Done |
| 3 | Factions | ✅ Done | | 3 | Factions | ✅ Done |

View File

@ -4,9 +4,7 @@ import { join, parse } from 'node:path';
const VALUE_OBJECTS_DIR = join(import.meta.dirname, '..', 'src', 'value-objects'); const VALUE_OBJECTS_DIR = join(import.meta.dirname, '..', 'src', 'value-objects');
function discoverValueObjects() { function discoverValueObjects() {
const files = readdirSync(VALUE_OBJECTS_DIR).filter( const files = readdirSync(VALUE_OBJECTS_DIR).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
(f) => f.endsWith('.ts') && f !== 'index.ts',
);
return files.map((f) => parse(f).name); return files.map((f) => parse(f).name);
} }

View File

@ -38,7 +38,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target); const result = attacker.useWeapon(weapon, target);
return result.weapon.health === weaponHP - 1; return result.weapon.health.value === weaponHP - 1;
}, },
), ),
); );
@ -86,11 +86,11 @@ describe('Magical Objects', () => {
// Kill the attacker using a separate killer // Kill the attacker using a separate killer
const killer = Character.create({ name: 'boss', level: Level.create(1) }); const killer = Character.create({ name: 'boss', level: Level.create(1) });
const deadAttacker = killer.dealDamage(attacker, 10000); const deadAttacker = killer.dealDamage(attacker, 10000);
const weaponHPBefore = weapon.health; const weaponHPBefore = weapon.health.value;
const targetHealthBefore = target.health.value; const targetHealthBefore = target.health.value;
const result = deadAttacker.useWeapon(weapon, target); const result = deadAttacker.useWeapon(weapon, target);
return ( return (
result.weapon.health === weaponHPBefore && result.weapon.health.value === weaponHPBefore &&
result.target.health.value === targetHealthBefore result.target.health.value === targetHealthBefore
); );
}, },
@ -110,11 +110,11 @@ describe('Magical Objects', () => {
const thief = Character.create({ name: 'thief', level: Level.create(1) }); const thief = Character.create({ name: 'thief', level: Level.create(1) });
const target = Character.create({ name: 'goblin', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner });
const weaponHPBefore = weapon.health; const weaponHPBefore = weapon.health.value;
const targetHealthBefore = target.health.value; const targetHealthBefore = target.health.value;
const result = thief.useWeapon(weapon, target); const result = thief.useWeapon(weapon, target);
return ( return (
result.weapon.health === weaponHPBefore && result.weapon.health.value === weaponHPBefore &&
result.target.health.value === targetHealthBefore result.target.health.value === targetHealthBefore
); );
}, },
@ -136,7 +136,9 @@ describe('Magical Objects', () => {
const targetHealthBefore = firstUse.target.health.value; const targetHealthBefore = firstUse.target.health.value;
// Try to use again on the destroyed weapon // Try to use again on the destroyed weapon
const result = owner.useWeapon(destroyedWeapon, firstUse.target); 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 +205,7 @@ describe('Magical Objects', () => {
}); });
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount); const result = character.useHealingObject(object, healAmount);
return result.object.health === objectHP - healAmount; return result.object.health.value === objectHP - healAmount;
}, },
), ),
); );
@ -255,11 +257,11 @@ describe('Magical Objects', () => {
// Kill the character using a separate killer // Kill the character using a separate killer
const killer = Character.create({ name: 'boss', level: Level.create(1) }); const killer = Character.create({ name: 'boss', level: Level.create(1) });
const deadCharacter = killer.dealDamage(character, 10000); const deadCharacter = killer.dealDamage(character, 10000);
const objectHPBefore = object.health; const objectHPBefore = object.health.value;
const characterHealthBefore = deadCharacter.health.value; const characterHealthBefore = deadCharacter.health.value;
const result = deadCharacter.useHealingObject(object, 100); const result = deadCharacter.useHealingObject(object, 100);
return ( return (
result.object.health === objectHPBefore && result.object.health.value === objectHPBefore &&
result.character.health.value === characterHealthBefore result.character.health.value === characterHealthBefore
); );
}), }),
@ -287,7 +289,8 @@ describe('Magical Objects', () => {
// Try to use again on the destroyed object // Try to use again on the destroyed object
const result = healedCharacter.useHealingObject(destroyedObject, 100); const result = healedCharacter.useHealingObject(destroyedObject, 100);
return ( return (
result.object.health === 0 && result.character.health.value === characterHealthBefore result.object.health.value === 0 &&
result.character.health.value === characterHealthBefore
); );
}), }),
); );
@ -305,7 +308,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target); const result = attacker.useWeapon(weapon, target);
return result.weapon.health >= 0; return result.weapon.health.value >= 0;
}, },
), ),
); );
@ -320,7 +323,7 @@ describe('Magical Objects', () => {
const character = Character.create({ name: 'hero', level: Level.create(1) }); const character = Character.create({ name: 'hero', level: Level.create(1) });
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount); const result = character.useHealingObject(object, healAmount);
return result.object.health >= 0; return result.object.health.value >= 0;
}, },
), ),
); );
@ -336,7 +339,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target); const result = attacker.useWeapon(weapon, target);
return result.weapon.health <= weaponHP; return result.weapon.health.value <= weaponHP;
}, },
), ),
); );
@ -351,7 +354,7 @@ describe('Magical Objects', () => {
const character = Character.create({ name: 'hero', level: Level.create(1) }); const character = Character.create({ name: 'hero', level: Level.create(1) });
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount); const result = character.useHealingObject(object, healAmount);
return result.object.health <= objectHP; return result.object.health.value <= objectHP;
}, },
), ),
); );

View File

@ -7,14 +7,15 @@
*/ */
import { Character } from '../characters/Character.ts'; import { Character } from '../characters/Character.ts';
import { Health } from '../value-objects/Health.ts';
import { Level } from '../value-objects/Level.ts'; import { Level } from '../value-objects/Level.ts';
import { MagicalObject } from './MagicalObject.ts'; import { MagicalObject } from './MagicalObject.ts';
import type { Healer } from './magical-object-types.ts'; import type { Healer } from './magical-object-types.ts';
export class HealingObject extends MagicalObject implements Healer { export class HealingObject extends MagicalObject implements Healer {
private constructor( private constructor(
health: number, health: Health,
maxHealth: number, maxHealth: Health,
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' }, status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
) { ) {
super(health, maxHealth, status); super(health, maxHealth, status);
@ -32,7 +33,7 @@ export class HealingObject extends MagicalObject implements Healer {
if (currentHealth > maxHealth) throw new Error('CurrentHealth cannot exceed maxHealth'); if (currentHealth > maxHealth) throw new Error('CurrentHealth cannot exceed maxHealth');
const status = const status =
currentHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const }; currentHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
return new HealingObject(currentHealth, maxHealth, status); return new HealingObject(Health.create(currentHealth), Health.create(maxHealth), status);
} }
/** Use this object to heal a character. Returns updated object and character. */ /** Use this object to heal a character. Returns updated object and character. */
@ -44,7 +45,7 @@ export class HealingObject extends MagicalObject implements Healer {
// Negative amount is invalid // Negative amount is invalid
if (amount < 0) throw new Error('Heal amount must be non-negative'); if (amount < 0) throw new Error('Heal amount must be non-negative');
// Calculate actual heal amount: min of requested, object remaining, character headroom // Calculate actual heal amount: min of requested, object remaining, character headroom
const objectRemaining = this.health; const objectRemaining = this.health.value;
const characterMax = Level.maxHealthForLevel(character.level.value); const characterMax = Level.maxHealthForLevel(character.level.value);
const characterHeadroom = characterMax - character.health.value; const characterHeadroom = characterMax - character.health.value;
const actualHeal = Math.min(amount, objectRemaining, characterHeadroom); const actualHeal = Math.min(amount, objectRemaining, characterHeadroom);
@ -53,7 +54,7 @@ export class HealingObject extends MagicalObject implements Healer {
return { object: this, character }; return { object: this, character };
} }
// Create updated object // Create updated object
const newObjectHealth = this.health - actualHeal; const newObjectHealth = this.health.value - actualHeal;
const newObjectStatus = const newObjectStatus =
newObjectHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const }; newObjectHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
// Create updated character // Create updated character
@ -64,7 +65,7 @@ export class HealingObject extends MagicalObject implements Healer {
health: newCharacterHealth, health: newCharacterHealth,
}); });
return { return {
object: new HealingObject(newObjectHealth, this.maxHealth, newObjectStatus), object: new HealingObject(Health.create(newObjectHealth), this.maxHealth, newObjectStatus),
character: newCharacter, character: newCharacter,
}; };
} }

View File

@ -7,24 +7,26 @@
* - Status derived from health (0 = destroyed, > 0 = alive) * - 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 type MagicalObjectStatus = { readonly kind: 'alive' } | { readonly kind: 'destroyed' };
export class MagicalObject { export class MagicalObject {
readonly #health: number; readonly #health: Health;
readonly #maxHealth: number; readonly #maxHealth: Health;
readonly #status: MagicalObjectStatus; readonly #status: MagicalObjectStatus;
protected constructor(health: number, maxHealth: number, status: MagicalObjectStatus) { protected constructor(health: Health, maxHealth: Health, status: MagicalObjectStatus) {
this.#health = health; this.#health = health;
this.#maxHealth = maxHealth; this.#maxHealth = maxHealth;
this.#status = status; this.#status = status;
} }
get health(): number { get health(): Health {
return this.#health; return this.#health;
} }
get maxHealth(): number { get maxHealth(): Health {
return this.#maxHealth; return this.#maxHealth;
} }
@ -35,7 +37,7 @@ export class MagicalObject {
/** Create a destroyed object (health = 0). */ /** Create a destroyed object (health = 0). */
static createDestroyed(maxHealth: number): MagicalObject { static createDestroyed(maxHealth: number): MagicalObject {
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative'); if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
return new MagicalObject(0, maxHealth, { kind: 'destroyed' }); return new MagicalObject(Health.create(0), Health.create(maxHealth), { kind: 'destroyed' });
} }
/** Check if this object is alive. */ /** Check if this object is alive. */

View File

@ -6,6 +6,7 @@
* - Damage is non-negative * - Damage is non-negative
*/ */
import { Character } from '../characters/Character.ts'; import { Character } from '../characters/Character.ts';
import { Health } from '../value-objects/Health.ts';
import { MagicalObject } from './MagicalObject.ts'; import { MagicalObject } from './MagicalObject.ts';
import type { DamageDealer } from './magical-object-types.ts'; import type { DamageDealer } from './magical-object-types.ts';
@ -14,8 +15,8 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
readonly #owner: Character; readonly #owner: Character;
private constructor( private constructor(
health: number, health: Health,
maxHealth: number, maxHealth: Health,
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' }, status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
damage: number, damage: number,
owner: Character, owner: Character,
@ -36,7 +37,13 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
}): MagicalWeapon { }): MagicalWeapon {
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative'); if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
if (damage < 0) throw new Error('Damage 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,
owner,
);
} }
get damage(): number { get damage(): number {
@ -63,12 +70,12 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
status: newTargetStatus, status: newTargetStatus,
}); });
// Reduce weapon health by 1 // Reduce weapon health by 1
const newWeaponHealth = this.health - 1; const newWeaponHealth = this.health.value - 1;
const newWeaponStatus = const newWeaponStatus =
newWeaponHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const }; newWeaponHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
return { return {
weapon: new MagicalWeapon( weapon: new MagicalWeapon(
newWeaponHealth, Health.create(newWeaponHealth),
this.maxHealth, this.maxHealth,
newWeaponStatus, newWeaponStatus,
this.#damage, this.#damage,

View File

@ -6,19 +6,20 @@
* so Character can depend on abstractions rather than concrete classes. * so Character can depend on abstractions rather than concrete classes.
*/ */
import type { Character } from '../characters/Character.ts'; import type { Character } from '../characters/Character.ts';
import type { Health } from '../value-objects/Health.ts';
import type { MagicalObjectStatus } from './MagicalObject.ts'; import type { MagicalObjectStatus } from './MagicalObject.ts';
/** A magical object that deals damage — from Character's point of view */ /** A magical object that deals damage — from Character's point of view */
export interface DamageDealer { export interface DamageDealer {
readonly owner: Character; readonly owner: Character;
readonly health: number; readonly health: Health;
readonly status: MagicalObjectStatus; readonly status: MagicalObjectStatus;
use(target: Character): { weapon: DamageDealer; target: Character }; use(target: Character): { weapon: DamageDealer; target: Character };
} }
/** A magical object that heals — from Character's point of view */ /** A magical object that heals — from Character's point of view */
export interface Healer { export interface Healer {
readonly health: number; readonly health: Health;
readonly status: MagicalObjectStatus; readonly status: MagicalObjectStatus;
heal(character: Character, amount: number): { object: Healer; character: Character }; 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