Compare commits

..
9 Commits
Author SHA1 Message Date
mostalive 21bd58be33 Index transcripts 2026-06-15 09:39:42 +01:00
mostalive 31984bbd9d Consolidate all .allium specs into specs/
- Move .pi/specs/ files into specs/ (healing, factions, merged magical-objects)
- Move src/*.allium files into specs/ (levels, changing-level)
- Delete .pi/specs/ directory
- Document specs/ convention in AGENTS.md
2026-06-15 08:16:12 +01:00
mostalive 39839dc594 fix Allium specs syntax + implement Changing Level story
- Fix Allium spec syntax: type→value, enum for Status, remove implies chaining
- Fix factions.spec: add missing type declarations (Health, Level, Status)
- Fix magical-objects.spec: add type declarations, use .value for Health access,
  remove entity inheritance syntax, remove invalid invariants
- Implement Changing Level: add totalDamageTaken + factionsJoined to Character
- Add level-up logic in dealDamage() and joinFaction()
- Add Level.damageThresholdForLevel() static method
- Fix changing-level.spec.ts properties: handle target survival, compute
  expected level from threshold crossings
2026-06-15 07:55:39 +01:00
mostalive 692bd7305b add example tests for damage 2026-06-14 20:52:31 +01:00
mostalive 1c9d4ad66b create magical objects spec 2026-06-14 14:05:28 +01:00
mostalive fb61fb85ed refactor transcript 2026-06-14 13:36:47 +01:00
mostalive 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
mostalive 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
mostalive dea77d463f create extension and skill to run yaks 2026-06-14 12:23:52 +01:00
36 changed files with 87842 additions and 4242 deletions
+20
View File
@@ -0,0 +1,20 @@
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
export default function (pi: ExtensionAPI) {
pi.on('tool_call', async (event, ctx) => {
if (!isToolCallEventType('bash', event)) return;
const cmd = event.input.command || '';
const isYxCommand = cmd.includes('yx ');
// Allow yaks in interactive mode, block in print mode (sub-agents)
if (isYxCommand && ctx.mode === 'print') {
return {
block: true,
reason:
'yx commands are disabled in print mode. Sub-agents must focus on domain work only.',
};
}
});
}
+88
View File
@@ -0,0 +1,88 @@
---
name: yak-tasks
description: Orchestrate sub-agents using yx task tracking. Use when delegating work to sub-agents, tracking task progress, or marking tasks complete. For breaking down problems into tasks, use /skill:problem-breakdown first.
---
# Yak Task Orchestration
Coordinate sub-agent work using the `yx` CLI. The main agent creates tasks and delegates to sub-agents; sub-agents execute domain work without yx CLI access.
## Workflow
1. **Break down**`/skill:problem-breakdown` (creates yaks with context)
2. **Delegate** → sub-agents run on yaks via `pi -p` (blocked from yx CLI)
3. **Track**`yx list` / `yx show <task-id>`
4. **Verify** → run `npm run checks` after sub-agent work
5. **Complete**`yx done <task-id>`
## Running Sub-Agents
Sub-agents execute in print mode and are **hard-blocked from using `yx` commands** (see `.pi/extensions/yak-mode-gate.ts`). They receive:
- The yak's `.context.md` (task description)
- `AGENTS.md` (project conventions)
- Domain files to work on
- No yx CLI access
```bash
# Run a sub-agent on a specific yak
# The sub-agent reads the yak context and executes the work
pi -p "Work on yak: <task-name>. Read .yaks/<task-id>/.context.md for details."
```
## Tracking Progress
```bash
# List all yaks with hierarchy
yx list
# Show yak details
yx show <task-id>
# Check state directly
cat .yaks/<task-id>/.state
```
States:
- `pending` — not yet started
- `in-progress` — being worked on
- `done` — completed
## Marking Tasks Complete
```bash
yx done <task-id>
```
Always verify before marking done:
```bash
# 1. Check state
cat .yaks/<task-id>/.state
# 2. Review what was done
cat .yaks/<task-id>/.context.md
# 3. Run project checks
npm run checks
```
## Sub-Agent Communication
Sub-agents can read yak state files directly (no yx CLI needed):
```bash
cat .yaks/<task-id>/.name # task name
cat .yaks/<task-id>/.state # current state
cat .yaks/<task-id>/.context.md # task description
cat .yaks/<task-id>/.created.json # creation metadata
```
## Best Practices
- **Delegate after breakdown** — run `/skill:problem-breakdown` first to create structured yaks
- **Verify before marking done** — always run `npm run checks` to catch sub-agent errors
- **Review context** — read `.context.md` to understand what the sub-agent was supposed to do
- **Keep yaks focused** — each yak should be a single file operation or one method implementation
- **Use hierarchy** — parent yaks block children; fix leaves first
-174
View File
@@ -1,174 +0,0 @@
-- allium: 3
-- allium: magical-objects
------------------------------------------------------------
-- External Entities
------------------------------------------------------------
external entity Character {
name: String
health: Health
status: alive | dead
level: Level
factions: Set<Faction>
}
external entity Health {
value: Integer
}
external entity Level {
value: Integer
}
external entity Faction {
name: String
}
------------------------------------------------------------
-- Entities and Variants
------------------------------------------------------------
entity MagicalWeapon {
health: Health
maxHealth: Integer
status: alive | destroyed
damage: Integer
owner: Character
}
entity HealingObject {
health: Health
maxHealth: Integer
status: alive | destroyed
}
------------------------------------------------------------
-- Rules
------------------------------------------------------------
rule WeaponDealsDamage {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: weapon.status = alive
requires: attacker = weapon.owner
requires: attacker.status = alive
ensures: target.health.value = max(0, target.health.value - weapon.damage)
ensures: weapon.health.value = weapon.health.value - 1
ensures:
if weapon.health.value - 1 = 0:
weapon.status = destroyed
else:
weapon.status = alive
ensures:
if max(0, target.health.value - weapon.damage) = 0:
target.status = dead
else:
target.status = alive
}
rule DeadCannotUseWeapon {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: attacker.status = dead
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule NonOwnerCannotUseWeapon {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: attacker != weapon.owner
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule DestroyedWeaponCannotDealDamage {
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
requires: weapon.status = destroyed
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule HealingObjectHealsCharacter {
when: HealingObject.healsCharacter(object, character, amount)
requires: object.status = alive
requires: character.status = alive
ensures: healAmount = min(amount, object.maxHealth - object.health.value)
ensures: character.health.value = character.health.value + healAmount
ensures: object.health.value = object.health.value - healAmount
ensures:
if object.health.value - healAmount = 0:
object.status = destroyed
else:
object.status = alive
}
rule DeadCannotUseHealingObject {
when: HealingObject.healsCharacter(object, character, amount)
requires: character.status = dead
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
object.status = object.status
}
rule DestroyedHealingObjectCannotHeal {
when: HealingObject.healsCharacter(object, character, amount)
requires: object.status = destroyed
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
object.status = object.status
}
------------------------------------------------------------
-- Invariants
------------------------------------------------------------
invariant WeaponHealthNeverNegative {
for w in MagicalWeapons:
w.health.value >= 0
}
invariant WeaponDestroyedAtZeroHealth {
for w in MagicalWeapons:
w.health.value = 0 implies w.status = destroyed
}
invariant WeaponMaxHealthNeverExceeded {
for w in MagicalWeapons:
w.health.value <= w.maxHealth
}
invariant HealingObjectHealthNeverNegative {
for h in HealingObjects:
h.health.value >= 0
}
invariant HealingObjectDestroyedAtZeroHealth {
for h in HealingObjects:
h.health.value = 0 implies h.status = destroyed
}
invariant HealingObjectMaxHealthNeverExceeded {
for h in HealingObjects:
h.health.value <= h.maxHealth
}
invariant HealingObjectCannotDealDamage {
for h in HealingObjects:
not h.dealsDamage(_, _)
}
invariant WeaponCannotHeal {
for w in MagicalWeapons:
not w.healsCharacter(_, _)
}
+1 -1
View File
@@ -16,7 +16,7 @@ An implementation of the RPG Combat rules engine. There are six user stories des
This project combines three practices:
1. **Allium** (`.allium` specs) — formal behavioural specifications that capture _what_ the system does
1. **Allium** (`.allium` specs) — formal behavioural specifications that capture _what_ the system does. All specs live in [specs/](specs/) — one file per story/domain area.
2. **fast-check** — property-based testing that verifies those properties hold across thousands of random inputs
3. **"I can't believe it's not Haskell"** — TypeScript with ADTs, value objects, and immutability
+10 -8
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.
@@ -85,7 +85,9 @@ Two custom extensions were developed for this project:
### 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.
Every session is exported as an HTML transcript in the `transcripts/` directory — 28 sessions documenting the full journey from first requirements review through horizontal refactoring. See the [full transcript index](transcripts/index.md) for a chronological list.
These are the project's most valuable artifacts.
## Build & Test
+9
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',
},
},
+145
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');
}
},
};
},
};
+120
View File
@@ -0,0 +1,120 @@
-- allium: 3
-- allium: changing-level
------------------------------------------------------------
-- Value Types
------------------------------------------------------------
value Faction {
name: String
}
value Health {
value: Integer
requires: value >= 0
}
value Level {
value: Integer
requires: value >= 1 and value <= 10
}
value Damage {
value: Integer
requires: value >= 0
}
------------------------------------------------------------
-- Enumerations
------------------------------------------------------------
enum Status {
alive | dead
}
------------------------------------------------------------
-- Entities
------------------------------------------------------------
entity Character {
name: String
health: Health
status: Status
level: Level
factions: Set<Faction>
totalDamageTaken: Damage
factionsJoined: Set<Faction>
}
------------------------------------------------------------
-- Rules
------------------------------------------------------------
rule DamageIsAccumulated {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = alive
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
}
rule LevelUpFromDamage {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = alive
requires: old(target.totalDamageTaken.value) + damage.value >= 1000 * (target.level.value + 1) * (target.level.value + 2) / 2
ensures: target.level.value = target.level.value + 1
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
}
rule LevelUpFromFaction {
when: Character.joinFaction(character, faction)
requires: character.status = alive
requires: old(character.factionsJoined.size) + 1 >= 3 * (character.level.value + 1)
ensures: character.level.value = character.level.value + 1
ensures: character.factionsJoined = old(character.factionsJoined) + {faction}
}
rule MaxLevelCappedOnDamage {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = alive
requires: target.level.value = 10
ensures: target.level.value = 10
}
rule MaxLevelCappedOnFaction {
when: Character.joinFaction(character, faction)
requires: character.status = alive
requires: character.level.value = 10
ensures: character.level.value = 10
}
rule DeadCannotLevelUpFromDamage {
when: Character.dealDamage(attacker, target, damage)
requires: target.status = dead
ensures: target.level.value = old(target.level.value)
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value)
}
rule DeadCannotLevelUpFromFaction {
when: Character.joinFaction(character, faction)
requires: character.status = dead
ensures: character.level.value = old(character.level.value)
}
------------------------------------------------------------
-- Invariants
------------------------------------------------------------
invariant LevelBounded {
for c in Characters:
c.level.value >= 1 and c.level.value <= 10
}
invariant DamageTotalNonNegative {
for c in Characters:
c.totalDamageTaken.value >= 0
}
invariant FactionsJoinedNonNegative {
for c in Characters:
c.factionsJoined.size >= 0
}
@@ -6,9 +6,22 @@
-- Value Types
------------------------------------------------------------
type Faction {
value Faction {
name: String
requires: trimmed(name).length > 0
}
value Health {
value: Integer
requires: value >= 0
}
value Level {
value: Integer
requires: value >= 1 and value <= 10
}
enum Status {
alive | dead
}
------------------------------------------------------------
@@ -18,7 +31,7 @@ type Faction {
entity Character {
name: String
health: Health
status: alive | dead
status: Status
level: Level
factions: Set<Faction>
}
@@ -83,12 +96,7 @@ rule DeadCannotLeaveFaction {
invariant FactionsAlwaysValid {
for c in Characters:
for f in c.factions:
f.name.trim().length > 0
}
invariant AllyRelationIsSymmetric {
for a in Characters, b in Characters:
a.isAllyOf(b) implies b.isAllyOf(a)
f.name.length > 0
}
invariant SelfNotAlly {
+189
View File
@@ -0,0 +1,189 @@
-- allium: 3
-- allium: magical-objects
------------------------------------------------------------
-- Value Types
------------------------------------------------------------
value Health {
value: Integer
requires: value >= 0
}
value Faction {
name: String
}
value Level {
value: Integer
requires: value >= 1 and value <= 10
}
------------------------------------------------------------
-- Enumerations
------------------------------------------------------------
enum Status {
alive | destroyed
}
------------------------------------------------------------
-- Entities
------------------------------------------------------------
entity Character {
name: String
health: Health
status: Status
level: Level
factions: Set<Faction>
}
entity MagicalObject {
health: Health
maxHealth: Integer
status: Status
}
entity HealingObject {
health: Health
maxHealth: Integer
status: Status
}
entity MagicalWeapon {
health: Health
maxHealth: Integer
status: Status
damage: Integer
owner: Character
}
------------------------------------------------------------
-- Rules
------------------------------------------------------------
rule HealingObjectHealsCharacter {
when: CharacterUsesHealingObject(character, object, amount)
requires: object.status = alive
requires: character.status = alive
requires: amount >= 0
ensures:
character.health.value = character.health.value + min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
object.health.value = object.health.value - min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
if object.health.value = 0:
object.status = destroyed
else:
object.status = alive
}
rule HealingObjectDestroyedCannotHeal {
when: CharacterUsesHealingObject(character, object, amount)
requires: object.status = destroyed
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
}
rule DeadCannotUseHealingObject {
when: CharacterUsesHealingObject(character, object, amount)
requires: character.status = dead
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
}
rule HealingObjectZeroHealIsNoOp {
when: CharacterUsesHealingObject(character, object, amount)
requires: object.status = alive
requires: character.status = alive
requires: amount >= 0
requires: min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value) = 0
ensures:
character.health.value = character.health.value
object.health.value = object.health.value
}
rule MagicalWeaponDealsDamage {
when: CharacterUsesWeapon(owner, weapon, target)
requires: weapon.status = alive
requires: owner.status = alive
requires: owner = weapon.owner
ensures:
target.health.value = max(0, target.health.value - weapon.damage)
weapon.health.value = weapon.health.value - 1
if weapon.health.value = 0:
weapon.status = destroyed
else:
weapon.status = alive
}
rule DeadCannotUseWeapon {
when: CharacterUsesWeapon(owner, weapon, target)
requires: owner.status = dead
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule NonOwnerCannotUseWeapon {
when: CharacterUsesWeapon(thief, weapon, target)
requires: thief != weapon.owner
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
rule WeaponDestroyedCannotDealDamage {
when: CharacterUsesWeapon(owner, weapon, target)
requires: weapon.status = destroyed
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
weapon.status = weapon.status
target.status = target.status
}
------------------------------------------------------------
-- Invariants
------------------------------------------------------------
invariant MagicalObjectHealthNonNegative {
for m in MagicalObjects:
m.health.value >= 0
}
invariant MagicalObjectHealthNeverExceedsMax {
for m in MagicalObjects:
m.health.value <= m.maxHealth
}
invariant MagicalObjectDestroyedAtZeroHealth {
for m in MagicalObjects:
m.health.value = 0 implies m.status = destroyed
}
invariant MagicalObjectAliveAtPositiveHealth {
for m in MagicalObjects:
m.health.value > 0 implies m.status = alive
}
+131
View File
@@ -0,0 +1,131 @@
import fc from 'fast-check';
import { describe, it, expect } from 'vitest';
import { Character } from './characters/Character.ts';
import { Level } from './value-objects/Level.ts';
import { Damage } from './value-objects/Damage.ts';
import { Faction } from './factions/Faction.ts';
describe('ChangingLevel', () => {
describe('MaxLevelCappedOnDamage (example)', () => {
it('L10 takes 99999 damage → stays L10', () => {
const attacker = Character.create({ name: 'dragons', level: Level.create(1) });
const target = Character.create({ name: 'hero', level: Level.create(10) });
const result = attacker.dealDamage(target, Damage.create(99999));
expect(result.level.value).toBe(10);
});
});
describe('MaxLevelCappedOnFaction (example)', () => {
it('L10 joins 100 factions → stays L10', () => {
const hero = Character.create({ name: 'hero', level: Level.create(10) });
let char = hero;
for (let i = 0; i < 100; i++) {
char = char.joinFaction(Faction.create(`faction-${i}`));
}
expect(char.level.value).toBe(10);
});
});
describe('DeadCannotLevelUpFromDamage (example)', () => {
it('dead char takes 1000 damage → stays same level', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'hero', level: Level.create(1) });
const dead = attacker.dealDamage(target, Damage.create(10000));
const result = attacker.dealDamage(dead, Damage.create(1000));
expect(result.level.value).toBe(1);
});
});
describe('DeadCannotLevelUpFromFaction (example)', () => {
it('dead char joins 5 factions → stays same level', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'hero', level: Level.create(1) });
const dead = attacker.dealDamage(target, Damage.create(10000));
let char = dead;
for (let i = 0; i < 5; i++) {
char = char.joinFaction(Faction.create(`faction-${i}`));
}
expect(char.level.value).toBe(1);
});
});
describe('LevelUpFromDamage (property)', () => {
it('property: cumulative damage triggers level-up at threshold', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 500 }),
fc.integer({ min: 1, max: 500 }),
(level, dmg1, dmg2) => {
const currentLevel = Level.create(level);
const threshold = Level.damageThresholdForLevel(level + 1);
const total = dmg1 + dmg2;
// Only test when total meets threshold AND target survives
// Target starts with 1000 health, needs health > total after damage
if (total >= threshold && total < 1000) {
const attacker = Character.create({ name: 'a', level: Level.create(1) });
const target = Character.create({ name: 't', level: currentLevel });
const afterFirst = attacker.dealDamage(target, Damage.create(dmg1));
const afterSecond = attacker.dealDamage(afterFirst, Damage.create(dmg2));
const expectedLevel = Math.min(10, level + 1);
return afterSecond.level.value === expectedLevel;
}
return true; // skip non-applicable cases
},
),
);
});
});
describe('LevelUpFromFaction (property)', () => {
it('property: faction count triggers level-up at thresholds', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 9 }),
fc.integer({ min: 1, max: 30 }),
(level, totalFactions) => {
// Compute expected level: how many thresholds are crossed?
let currentLevel = level;
for (let f = 1; f <= totalFactions; f++) {
if (f >= 3 * (currentLevel + 1) && currentLevel < 10) {
currentLevel++;
}
}
const hero = Character.create({ name: 'hero', level: Level.create(level) });
let char = hero;
for (let i = 0; i < totalFactions; i++) {
char = char.joinFaction(Faction.create(`faction-${i}`));
}
return char.level.value === currentLevel;
},
),
);
});
});
describe('DamageAccumulation (property)', () => {
it('property: totalDamageTaken accumulates across multiple damage events', () => {
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 200 }), { minLength: 2, maxLength: 10 }),
(damageEvents) => {
const expectedTotal = damageEvents.reduce((sum, d) => sum + d, 0);
// Ensure target survives: total damage must be < 1000 (starting health)
if (expectedTotal >= 1000) return true;
const attacker = Character.create({ name: 'a', level: Level.create(1) });
const target = Character.create({ name: 't', level: Level.create(1) });
let current = target;
for (const dmg of damageEvents) {
current = attacker.dealDamage(current, Damage.create(dmg));
}
return current.totalDamageTaken.value === expectedTotal;
},
),
);
});
});
});
+53 -8
View File
@@ -6,6 +6,7 @@
*/
import { Health } from '../value-objects/Health.ts';
import { Level } from '../value-objects/Level.ts';
import { Damage } from '../value-objects/Damage.ts';
import type { Status } from '../value-objects/Status.ts';
import { StatusAlive, StatusDead } from '../value-objects/Status.ts';
import type { CharacterState } from './CharacterState.ts';
@@ -48,6 +49,8 @@ export class Character {
status: StatusAlive,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@@ -60,6 +63,8 @@ export class Character {
status: StatusAlive,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@@ -77,6 +82,8 @@ export class Character {
status,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@@ -101,6 +108,14 @@ export class Character {
return this.#state.factions;
}
get totalDamageTaken(): Damage {
return this.#state.totalDamageTaken;
}
get factionsJoined(): ReadonlySet<Faction> {
return this.#state.factionsJoined;
}
/**
* Check if this character is an ally of another.
* Two characters are allies if they share at least one faction.
@@ -122,12 +137,24 @@ export class Character {
if (this.status.kind === 'dead') return this;
const newFactions = new Set(this.#state.factions);
newFactions.add(faction);
const newFactionsJoined = new Set(this.#state.factionsJoined);
newFactionsJoined.add(faction);
// Level-up from faction count
let newLevel = this.level;
if (newFactionsJoined.size >= 3 * (newLevel.value + 1)) {
newLevel = Level.create(Math.min(10, newLevel.value + 1));
}
return new Character({
name: this.name,
health: this.health,
status: this.status,
level: this.level,
level: newLevel,
factions: newFactions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: newFactionsJoined,
});
}
@@ -147,6 +174,8 @@ export class Character {
status: this.status,
level: this.level,
factions: newFactions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: this.factionsJoined,
});
}
@@ -169,6 +198,8 @@ export class Character {
status: ally.status,
level: ally.level,
factions: ally.factions,
totalDamageTaken: ally.totalDamageTaken,
factionsJoined: ally.factionsJoined,
});
}
@@ -176,34 +207,46 @@ export class Character {
* Deal damage to another character. Returns a new Character with updated state.
* Does not mutate the attacker or the original target reference.
*/
dealDamage(target: Character, damage: number): Character {
dealDamage(target: Character, damage: Damage): Character {
// Self-damage is forbidden — use reference equality, not name
if (this === target) return target;
// Allies cannot deal damage to each other
if (this.isAllyOf(target)) return target;
// Dead characters cannot take damage
if (target.status.kind === 'dead') return target;
// Negative damage is invalid
if (damage < 0) throw new Error(`Damage must be non-negative, got ${damage}`);
// Level-based damage modifier
const levelDiff = this.level.diff(target.level); // = this.level - target.level
let actualDamage = damage;
let actualDamage = damage.value;
if (levelDiff <= -5) {
// Target is ≥5 levels above → damage reduced by 50%
actualDamage = Math.floor(damage * 0.5);
actualDamage = Math.floor(damage.value * 0.5);
} else if (levelDiff >= 5) {
// Target is ≥5 levels below → damage increased by 50%
actualDamage = Math.floor(damage * 1.5);
actualDamage = Math.floor(damage.value * 1.5);
}
// Reduce health by the (possibly modified) damage amount
const newHealth = target.health.sub(actualDamage);
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
// Level-up from cumulative damage
let newLevel = target.level;
const newTotalDamage = target.totalDamageTaken.add(Damage.create(actualDamage));
if (newStatus.kind === 'alive') {
const threshold = Level.damageThresholdForLevel(newLevel.value + 1);
if (newTotalDamage.value >= threshold) {
newLevel = Level.create(Math.min(10, newLevel.value + 1));
}
}
return new Character({
name: target.name,
health: newHealth,
status: newStatus,
level: target.level,
level: newLevel,
factions: target.factions,
totalDamageTaken: newTotalDamage,
factionsJoined: target.factionsJoined,
});
}
@@ -225,6 +268,8 @@ export class Character {
status: this.status,
level: this.level,
factions: this.factions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: this.factionsJoined,
});
}
+3
View File
@@ -8,6 +8,7 @@ import type { Health } from '../value-objects/Health.ts';
import type { Level } from '../value-objects/Level.ts';
import type { Status } from '../value-objects/Status.ts';
import type { Faction } from '../factions/Faction.ts';
import type { Damage } from '../value-objects/Damage.ts';
export type CharacterState = {
readonly name: string;
@@ -15,4 +16,6 @@ export type CharacterState = {
readonly status: Status;
readonly level: Level;
readonly factions: ReadonlySet<Faction>;
readonly totalDamageTaken: Damage;
readonly factionsJoined: ReadonlySet<Faction>;
};
+9 -10
View File
@@ -2,6 +2,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 { Damage } from './value-objects/Damage.ts';
describe('DamageAndHealth', () => {
describe('DamageReducesHealth', () => {
@@ -18,7 +19,7 @@ describe('DamageAndHealth', () => {
health,
});
const expected = Math.max(0, health - damage);
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === expected;
},
),
@@ -39,7 +40,7 @@ describe('DamageAndHealth', () => {
level: Level.create(1),
health,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value >= 0;
},
),
@@ -60,7 +61,7 @@ describe('DamageAndHealth', () => {
level: Level.create(1),
health,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
const expected = Math.max(0, health - damage);
if (expected === 0) {
return result.status.kind === 'dead';
@@ -82,7 +83,7 @@ describe('DamageAndHealth', () => {
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
const healthBefore = c.health.value;
const statusBefore = c.status.kind;
const result = c.dealDamage(c, damage);
const result = c.dealDamage(c, Damage.create(damage));
// Should return the same reference
return (
result === c &&
@@ -102,11 +103,11 @@ describe('DamageAndHealth', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'target', level: Level.create(1) });
// Kill the target first — capture the returned (dead) character
const deadTarget = attacker.dealDamage(target, 10000);
const deadTarget = attacker.dealDamage(target, Damage.create(10000));
const healthBefore = deadTarget.health.value;
const statusBefore = deadTarget.status.kind;
// Then try to deal more damage to the dead character
const result = attacker.dealDamage(deadTarget, damage);
const result = attacker.dealDamage(deadTarget, Damage.create(damage));
return (
result === deadTarget &&
result.health.value === healthBefore &&
@@ -118,14 +119,12 @@ describe('DamageAndHealth', () => {
});
describe('NegativeDamageForbidden', () => {
it('property: negative damage throws an error', () => {
it('property: negative damage throws an error via Damage.create', () => {
fc.assert(
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const target = Character.create({ name: 'target', level: Level.create(1) });
let threw = false;
try {
attacker.dealDamage(target, negativeDamage);
Damage.create(negativeDamage);
} catch {
threw = true;
}
+7 -6
View File
@@ -3,6 +3,7 @@ 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 { Damage } from './value-objects/Damage.ts';
describe('Factions', () => {
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
@@ -189,7 +190,7 @@ describe('Factions', () => {
(factionName) => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const hero = Character.create({ name: 'hero', level: Level.create(1) });
const deadHero = attacker.dealDamage(hero, 10000);
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
const f = Faction.create(factionName);
const result = deadHero.joinFaction(f);
return result === deadHero && result.factions.size === 0;
@@ -209,7 +210,7 @@ describe('Factions', () => {
const hero = Character.create({ name: 'hero', level: Level.create(1) });
const f = Faction.create(factionName);
const withFaction = hero.joinFaction(f);
const deadHero = attacker.dealDamage(withFaction, 10000);
const deadHero = attacker.dealDamage(withFaction, Damage.create(10000));
const result = deadHero.leaveFaction(f);
return result === deadHero && result.factions.has(f);
},
@@ -302,7 +303,7 @@ describe('Factions', () => {
const target = ally().joinFaction(faction);
const healthBefore = target.health.value;
const statusBefore = target.status.kind;
const result = attacker.dealDamage(target, 500);
const result = attacker.dealDamage(target, Damage.create(500));
return result.health.value === healthBefore && result.status.kind === statusBefore;
}),
);
@@ -314,7 +315,7 @@ describe('Factions', () => {
const faction = Faction.create('guard');
const attacker = hero().joinFaction(faction);
const target = ally().joinFaction(faction);
const result = attacker.dealDamage(target, 500);
const result = attacker.dealDamage(target, Damage.create(500));
return result === target;
}),
);
@@ -326,7 +327,7 @@ describe('Factions', () => {
const attacker = hero();
const target = enemy();
const healthBefore = target.health.value;
const result = attacker.dealDamage(target, 100);
const result = attacker.dealDamage(target, Damage.create(100));
return result.health.value === healthBefore - 100;
}),
);
@@ -414,7 +415,7 @@ describe('Factions', () => {
const target = ally().joinFaction(faction);
// Kill the target with a non-ally attacker
const killer = enemy();
const deadTarget = killer.dealDamage(target, 10000);
const deadTarget = killer.dealDamage(target, Damage.create(10000));
const healthBefore = deadTarget.health.value;
const statusBefore = deadTarget.status.kind;
const result = healer.healAlly(deadTarget, 500);
+2 -1
View File
@@ -2,6 +2,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 { Damage } from './value-objects/Damage.ts';
describe('Healing', () => {
describe('SelfHealIncreasesHealth', () => {
@@ -88,7 +89,7 @@ describe('Healing', () => {
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
const hero = Character.create({ name: 'hero', level: Level.create(1) });
// Kill the hero first using a different attacker
const deadHero = attacker.dealDamage(hero, 10000);
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
const healthBefore = deadHero.health.value;
const statusBefore = deadHero.status.kind;
// Try to heal the dead character
+9 -8
View File
@@ -2,6 +2,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 { Damage } from './value-objects/Damage.ts';
describe('Levels', () => {
describe('CloseLevelNoModifier', () => {
@@ -21,7 +22,7 @@ describe('Levels', () => {
level: Level.create(targetLevel),
health: 1000,
});
const result = attacker.dealDamage(target, baseDamage);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
return result.health.value === Math.max(0, 1000 - baseDamage);
},
),
@@ -47,7 +48,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 0.5);
const result = attacker.dealDamage(target, baseDamage);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
@@ -66,7 +67,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(oddDamage * 0.5);
const result = attacker.dealDamage(target, oddDamage);
const result = attacker.dealDamage(target, Damage.create(oddDamage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@@ -91,7 +92,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 1.5);
const result = attacker.dealDamage(target, baseDamage);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
@@ -111,7 +112,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(damage * 0.5);
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@@ -128,7 +129,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(damage * 1.5);
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@@ -144,7 +145,7 @@ describe('Levels', () => {
level: Level.create(5),
health: 1000,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - damage);
}),
);
@@ -160,7 +161,7 @@ describe('Levels', () => {
level: Level.create(1),
health: 1000,
});
const result = attacker.dealDamage(target, damage);
const result = attacker.dealDamage(target, Damage.create(damage));
return result.health.value === Math.max(0, 1000 - damage);
}),
);
+20 -16
View File
@@ -2,6 +2,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 { Damage } from './value-objects/Damage.ts';
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
import { HealingObject } from './magical-objects/HealingObject.ts';
@@ -38,7 +39,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target);
return result.weapon.health === weaponHP - 1;
return result.weapon.health.value === weaponHP - 1;
},
),
);
@@ -85,12 +86,12 @@ describe('Magical Objects', () => {
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
// Kill the attacker using a separate killer
const killer = Character.create({ name: 'boss', level: Level.create(1) });
const deadAttacker = killer.dealDamage(attacker, 10000);
const weaponHPBefore = weapon.health;
const deadAttacker = killer.dealDamage(attacker, Damage.create(10000));
const weaponHPBefore = weapon.health.value;
const targetHealthBefore = target.health.value;
const result = deadAttacker.useWeapon(weapon, target);
return (
result.weapon.health === weaponHPBefore &&
result.weapon.health.value === weaponHPBefore &&
result.target.health.value === targetHealthBefore
);
},
@@ -110,11 +111,11 @@ describe('Magical Objects', () => {
const thief = Character.create({ name: 'thief', level: Level.create(1) });
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner });
const weaponHPBefore = weapon.health;
const weaponHPBefore = weapon.health.value;
const targetHealthBefore = target.health.value;
const result = thief.useWeapon(weapon, target);
return (
result.weapon.health === weaponHPBefore &&
result.weapon.health.value === weaponHPBefore &&
result.target.health.value === targetHealthBefore
);
},
@@ -136,7 +137,9 @@ describe('Magical Objects', () => {
const targetHealthBefore = firstUse.target.health.value;
// Try to use again on the destroyed weapon
const result = owner.useWeapon(destroyedWeapon, firstUse.target);
return result.weapon.health === 0 && result.target.health.value === targetHealthBefore;
return (
result.weapon.health.value === 0 && result.target.health.value === targetHealthBefore
);
}),
);
});
@@ -203,7 +206,7 @@ describe('Magical Objects', () => {
});
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount);
return result.object.health === objectHP - healAmount;
return result.object.health.value === objectHP - healAmount;
},
),
);
@@ -254,12 +257,12 @@ describe('Magical Objects', () => {
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
// Kill the character using a separate killer
const killer = Character.create({ name: 'boss', level: Level.create(1) });
const deadCharacter = killer.dealDamage(character, 10000);
const objectHPBefore = object.health;
const deadCharacter = killer.dealDamage(character, Damage.create(10000));
const objectHPBefore = object.health.value;
const characterHealthBefore = deadCharacter.health.value;
const result = deadCharacter.useHealingObject(object, 100);
return (
result.object.health === objectHPBefore &&
result.object.health.value === objectHPBefore &&
result.character.health.value === characterHealthBefore
);
}),
@@ -287,7 +290,8 @@ describe('Magical Objects', () => {
// Try to use again on the destroyed object
const result = healedCharacter.useHealingObject(destroyedObject, 100);
return (
result.object.health === 0 && result.character.health.value === characterHealthBefore
result.object.health.value === 0 &&
result.character.health.value === characterHealthBefore
);
}),
);
@@ -305,7 +309,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target);
return result.weapon.health >= 0;
return result.weapon.health.value >= 0;
},
),
);
@@ -320,7 +324,7 @@ describe('Magical Objects', () => {
const character = Character.create({ name: 'hero', level: Level.create(1) });
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount);
return result.object.health >= 0;
return result.object.health.value >= 0;
},
),
);
@@ -336,7 +340,7 @@ describe('Magical Objects', () => {
const target = Character.create({ name: 'goblin', level: Level.create(1) });
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
const result = attacker.useWeapon(weapon, target);
return result.weapon.health <= weaponHP;
return result.weapon.health.value <= weaponHP;
},
),
);
@@ -351,7 +355,7 @@ describe('Magical Objects', () => {
const character = Character.create({ name: 'hero', level: Level.create(1) });
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
const result = character.useHealingObject(object, healAmount);
return result.object.health <= objectHP;
return result.object.health.value <= objectHP;
},
),
);
+7 -6
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,
};
}
+8 -6
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. */
+17 -10
View File
@@ -6,18 +6,20 @@
* - Damage is non-negative
*/
import { Character } from '../characters/Character.ts';
import { Health } from '../value-objects/Health.ts';
import { Damage } from '../value-objects/Damage.ts';
import { MagicalObject } from './MagicalObject.ts';
import type { DamageDealer } from './magical-object-types.ts';
export class MagicalWeapon extends MagicalObject implements DamageDealer {
readonly #damage: number;
readonly #damage: Damage;
readonly #owner: Character;
private constructor(
health: number,
maxHealth: number,
health: Health,
maxHealth: Health,
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
damage: number,
damage: Damage,
owner: Character,
) {
super(health, maxHealth, status);
@@ -35,11 +37,16 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
owner: Character;
}): MagicalWeapon {
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
if (damage < 0) throw new Error('Damage cannot be negative');
return new MagicalWeapon(maxHealth, maxHealth, { kind: 'alive' }, damage, owner);
return new MagicalWeapon(
Health.create(maxHealth),
Health.create(maxHealth),
{ kind: 'alive' },
Damage.create(damage),
owner,
);
}
get damage(): number {
get damage(): Damage {
return this.#damage;
}
@@ -54,7 +61,7 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
return { weapon: this, target };
}
// Deal fixed damage
const newTargetHealth = Math.max(0, target.health.value - this.#damage);
const newTargetHealth = Math.max(0, target.health.value - this.#damage.value);
const newTargetStatus = newTargetHealth === 0 ? { kind: 'dead' as const } : target.status;
const newTarget = Character.createWithHealthAndStatus({
name: target.name,
@@ -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,
+3 -2
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 };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Damage value object — non-negative, immutable.
*
* Invariant enforced at construction: n >= 0
*/
export class Damage {
readonly #value: number;
private constructor(n: number) {
this.#value = n;
}
static create(n: number): Damage {
if (n < 0) throw new Error(`Damage must be non-negative, got ${n}`);
return new Damage(n);
}
get value(): number {
return this.#value;
}
/** Add another damage amount — cumulative damage is still a Damage. */
add(other: Damage): Damage {
return Damage.create(this.#value + other.value);
}
}
+8
View File
@@ -31,4 +31,12 @@ export class Level {
diff(target: Level): number {
return this.value - target.value;
}
/** Damage threshold to reach the given level.
* Level 1→2: 1000, Level 2→3: 3000, Level 3→4: 6000, etc.
* Formula: 1000 * N * (N+1) / 2
*/
static damageThresholdForLevel(level: number): number {
return (1000 * level * (level + 1)) / 2;
}
}
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
+34
View File
@@ -0,0 +1,34 @@
# Transcript Archive
Chronological list of all session transcripts from the RPG Combat project.
| Date | Transcript |
| ---- | ---------- |
| 2026-06-12 20:20 | [Card, Conversation & Confirmation](card-conversation-confirmation.html) |
| 2026-06-12 20:20 | [Install Allium](install-allium.html) |
| 2026-06-12 20:20 | [Review User Stories](review-user-stories.md) |
| 2026-06-12 23:02 | [Clear and Export](clear-and-export.html) |
| 2026-06-12 23:02 | [Refactor Story 1](refactor-story-1.html) |
| 2026-06-12 23:02 | [Story 1 — Process Improvement](story1-process-improvement.md) |
| 2026-06-13 15:42 | [YAGNI in AGENTS.md](yagni-in-agents-md.html) |
| 2026-06-13 15:51 | [Story 2 — Refactored](story-2-refactored.html) |
| 2026-06-13 16:06 | [Forgot to Mention the Story](forgot-to-mention-the-story.html) |
| 2026-06-13 22:03 | [Forgot to Commit](forgot-to-commit.html) |
| 2026-06-13 22:03 | [Story 2 (re?) — Done](story-2-(re?)-done.html) |
| 2026-06-13 22:20 | [Story 4 — Built](story-4-built.html) |
| 2026-06-13 22:32 | [Found Out Story 3 Is Not Done](found-out-story-3-is-not-done.html) |
| 2026-06-14 10:48 | [Break Down Horizontal Refactoring into Yaks](break-down-horizontal-refactoring-into-yaks.html) |
| 2026-06-14 10:48 | [Create Task Breakdown with Yaks Skill](create-task-breakdown-with-yaks-skill.html) |
| 2026-06-14 10:48 | [Fixed Character Implementation (Maybe)](fixed-character-implementation-maybe.html) |
| 2026-06-14 10:51 | [Resolved Circular Dependency](resolved-circular-dependency.html) |
| 2026-06-14 12:01 | [Break Down the Refactoring Yaks](break-down-the-refactoring-yaks.html) |
| 2026-06-14 12:01 | [Break Yaks Down into Phases](break-yaks-down-into-phases.html) |
| 2026-06-14 12:01 | [Yaks Were Not Marked S-Done](yaks-were-not-marked-s-done.html) |
| 2026-06-14 12:47 | [Create Yak Run Skill](create-yak-run-skill.html) |
| 2026-06-14 12:47 | [ESLint Rule Against Value Objects and Yaks to Refactor](eslint-rule-against-value-objects-and-yaks-to-refactor.html) |
| 2026-06-14 12:47 | [Generate README](generate-readme.html) |
| 2026-06-14 12:47 | [Yaks Work for Horizontal Refactoring](yaks-work-for-horizontal-refactoring.html) |
| 2026-06-14 14:26 | [Refactor: Replace Number Health with Health Value Object in MagicalObject](refactor:-replace-number-health-with-Health-value-object-in-MagicalObject.html) |
| 2026-06-14 14:26 | [Story 4 Also Done — Spec and Story Needed to Be Put Straight](story-4-also-done,-spec-and-story-needed-to-be-put-straight.html) |
| 2026-06-15 08:09 | [Last Story Done](last-story-done.html) |
| 2026-06-15 09:36 | [Wrap Up](wrap-up.html) |
File diff suppressed because one or more lines are too long
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
+1
View File
@@ -50,6 +50,7 @@ This is a description of the business rules we should support in the game engine
- Healing Magical Objects cannot deal Damage
3. Characters can deal Damage by using a Magical Weapon.
- A Character can only use a Magical Weapon they own
- These Magical Objects deal a fixed amount of damage when they are used
- The amount of damage is fixed at the time the weapon is created
- Every time the weapon is used, the Health is reduced by 1