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 { isToolCallEventType } from "@earendil-works/pi-coding-agent";
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;
pi.on('tool_call', async (event, ctx) => {
if (!isToolCallEventType('bash', event)) return;
const cmd = event.input.command || "";
const isYxCommand = cmd.includes("yx ");
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") {
if (isYxCommand && ctx.mode === 'print') {
return {
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
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
@ -43,6 +44,7 @@ cat .yaks/<task-id>/.state
```
States:
- `pending` — not yet started
- `in-progress` — being worked on
- `done` — completed
@ -54,6 +56,7 @@ yx done <task-id>
```
Always verify before marking done:
```bash
# 1. Check state
cat .yaks/<task-id>/.state
@ -68,6 +71,7 @@ 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

View File

@ -52,7 +52,7 @@ The TypeScript implementation embraces functional patterns:
All five user stories are implemented and verified:
| Story | Topic | Status |
|-------|-------|--------|
| ----- | --------------------------- | ------- |
| 1 | Character Creation & Damage | ✅ Done |
| 2 | Levels | ✅ 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');
function discoverValueObjects() {
const files = readdirSync(VALUE_OBJECTS_DIR).filter(
(f) => f.endsWith('.ts') && f !== 'index.ts',
);
const files = readdirSync(VALUE_OBJECTS_DIR).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
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 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;
},
),
);
@ -86,11 +86,11 @@ describe('Magical Objects', () => {
// 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 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 +110,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 +136,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 +205,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;
},
),
);
@ -255,11 +257,11 @@ describe('Magical Objects', () => {
// 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 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 +289,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 +308,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 +323,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 +339,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 +354,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;
},
),
);

View File

@ -7,14 +7,15 @@
*/
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 class HealingObject extends MagicalObject implements Healer {
private constructor(
health: number,
maxHealth: number,
health: Health,
maxHealth: Health,
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
) {
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');
const status =
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. */
@ -44,7 +45,7 @@ export class HealingObject extends MagicalObject implements Healer {
// 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 objectRemaining = this.health.value;
const characterMax = Level.maxHealthForLevel(character.level.value);
const characterHeadroom = characterMax - character.health.value;
const actualHeal = Math.min(amount, objectRemaining, characterHeadroom);
@ -53,7 +54,7 @@ export class HealingObject extends MagicalObject implements Healer {
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
@ -64,7 +65,7 @@ export class HealingObject extends MagicalObject implements Healer {
health: newCharacterHealth,
});
return {
object: new HealingObject(newObjectHealth, this.maxHealth, newObjectStatus),
object: new HealingObject(Health.create(newObjectHealth), this.maxHealth, newObjectStatus),
character: newCharacter,
};
}

View File

@ -7,24 +7,26 @@
* - 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: number;
readonly #maxHealth: number;
readonly #health: Health;
readonly #maxHealth: Health;
readonly #status: MagicalObjectStatus;
protected constructor(health: number, maxHealth: number, status: MagicalObjectStatus) {
protected constructor(health: Health, maxHealth: Health, status: MagicalObjectStatus) {
this.#health = health;
this.#maxHealth = maxHealth;
this.#status = status;
}
get health(): number {
get health(): Health {
return this.#health;
}
get maxHealth(): number {
get maxHealth(): Health {
return this.#maxHealth;
}
@ -35,7 +37,7 @@ export class MagicalObject {
/** 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' });
return new MagicalObject(Health.create(0), Health.create(maxHealth), { kind: 'destroyed' });
}
/** Check if this object is alive. */

View File

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

View File

@ -6,19 +6,20 @@
* 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: number;
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: number;
readonly health: Health;
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