Compare commits

...

2 Commits

Author SHA1 Message Date
ba0903714c refactor: replace number health with Health value object in MagicalObject
- MagicalObject: internal #health and #maxHealth now Health type
- HealingObject: constructor, create, and heal use Health value object
- MagicalWeapon: constructor, create, and use use Health value object
- DamageDealer and Healer interfaces: health: number -> health: Health
- magical-objects.spec.ts: all assertions use .health.value
- Run npm run checks: 0 errors, 70 tests passing
2026-06-14 13:35:44 +01:00
0540e5ff5b feat(eslint): add no-primitive-value-properties rule to enforce value objects
Add custom ESLint rule that warns when class properties or constructor
parameters use primitive types (number, string, boolean) where a value
object exists in src/value-objects/.

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

Found 4 warnings on the codebase:
- MagicalObject.ts: #health: number property
- MagicalObject.ts: health: number constructor param
- MagicalWeapon.ts: health: number constructor param
- HealingObject.ts: health: number constructor param
2026-06-14 12:39:54 +01:00
14 changed files with 51832 additions and 11979 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

@ -51,13 +51,13 @@ 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 |
| 4 | Magical Objects | ✅ Done |
| 5 | Changing Level | ✅ Done |
| 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.

View File

@ -1,6 +1,7 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import noPrimitiveValue from './eslint/no-primitive-value-properties.js';
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] },
@ -17,9 +18,17 @@ export default tseslint.config(
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
rpg: {
rules: {
'no-primitive-value-properties': noPrimitiveValue,
},
},
},
rules: {
'@typescript-eslint/switch-exhaustiveness-check': 'warn',
'@typescript-eslint/no-unnecessary-condition': 'warn',
'rpg/no-primitive-value-properties': 'warn',
},
},

View File

@ -0,0 +1,145 @@
import { readdirSync } from 'node:fs';
import { join, parse } from 'node:path';
const VALUE_OBJECTS_DIR = join(import.meta.dirname, '..', 'src', 'value-objects');
function discoverValueObjects() {
const files = readdirSync(VALUE_OBJECTS_DIR).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
return files.map((f) => parse(f).name);
}
const VALUE_OBJECTS = discoverValueObjects();
function buildPropertyMap() {
const map = {};
for (const name of VALUE_OBJECTS) {
const propertyName = name.charAt(0).toLowerCase() + name.slice(1);
map[propertyName] = name;
}
return map;
}
const PROPERTY_MAP = buildPropertyMap();
/** Map ESLint AST type names to the primitive keyword string. */
const PRIMITIVE_TYPE_MAP = {
TSNumberKeyword: 'number',
TSStringKeyword: 'string',
TSBooleanKeyword: 'boolean',
};
function isPrimitiveType(typeNode) {
if (!typeNode) return false;
const keyword = PRIMITIVE_TYPE_MAP[typeNode.type];
return keyword !== undefined;
}
function getPrimitiveKeyword(typeNode) {
if (!typeNode) return null;
return PRIMITIVE_TYPE_MAP[typeNode.type] || null;
}
function getParamName(param) {
if (param.type === 'TSParameterProperty') {
return getParamName(param.parameter);
}
if (param.type === 'Identifier') {
return param.name;
}
return null;
}
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Warn when primitive types are used in class properties or constructor parameters that should be value objects',
recommended: false,
},
schema: [
{
type: 'object',
properties: {
includeConstructorParams: { type: 'boolean', default: true },
includeProperties: { type: 'boolean', default: true },
},
additionalProperties: false,
},
],
messages: {
primitiveProperty:
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for property "{{propertyName}}". Available: {{available}}',
primitiveParam:
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for parameter "{{paramName}}". Available: {{available}}',
},
},
create(context) {
const options = context.options[0] || {};
const includeConstructorParams = options.includeConstructorParams !== false;
const includeProperties = options.includeProperties !== false;
function reportPrimitive(node, propName, primitiveType, messageId) {
const valueObject = PROPERTY_MAP[propName];
if (valueObject) {
context.report({
node,
messageId,
data: {
valueObject,
primitive: primitiveType,
propertyName: propName,
paramName: propName,
available: VALUE_OBJECTS.join(', '),
},
});
}
}
return {
ClassProperty(node) {
if (!includeProperties) return;
if (!node.typeAnnotation) return;
const typeNode = node.typeAnnotation.typeAnnotation;
if (!isPrimitiveType(typeNode)) return;
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
},
PropertyDefinition(node) {
if (!includeProperties) return;
if (!node.typeAnnotation) return;
const typeNode = node.typeAnnotation.typeAnnotation;
if (!isPrimitiveType(typeNode)) return;
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
},
MethodDefinition(node) {
if (!includeConstructorParams) return;
if (node.key.name !== 'constructor') return;
if (!node.value.params) return;
for (const param of node.value.params) {
let paramName = null;
let typeNode = null;
if (param.type === 'TSParameterProperty') {
paramName = getParamName(param.parameter);
typeNode = param.parameter.typeAnnotation?.typeAnnotation;
} else if (param.type === 'Identifier') {
paramName = param.name;
typeNode = param.typeAnnotation?.typeAnnotation;
}
if (!paramName || !typeNode || !isPrimitiveType(typeNode)) continue;
reportPrimitive(param, paramName, getPrimitiveKeyword(typeNode), 'primitiveParam');
}
},
};
},
};

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