# Persistent Character HP & Out-of-Combat Regeneration Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Carry a character's remaining HP from one combat into the next, regenerate 1 HP/second while out of combat, and never regenerate while a combat is running. **Architecture:** `characters.current_hp` becomes "HP as of `hp_regen_since`" instead of "current HP". A new `CharacterVitalsService` is the only code allowed to turn that pair into an effective HP value (pure function, clamped to max HP) or to move it (`pause`/`resume`/`settle`). `CharacterStatsService` — already the single authoritative source of effective stats — calls it internally, so every existing reader of `currentHp` becomes correct automatically. `CombatService` calls the mutators directly at combat start, every round, and at combat end. `EquipmentService` calls `settle` before an equipment change can move max HP. The web client counts up locally from the server-supplied anchor purely for display; the server never trusts the client's math. **Tech Stack:** NestJS + TypeORM (Postgres) on the API, Angular + Signals + Vitest on the web client, Jest on the API. **Spec:** `docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md` ## Global Constraints - Regeneration rate is `HP_REGEN_PER_SECOND = 1`, defined once in `apps/api/src/characters/character-vitals.constants.ts`. No other file may hardcode this number. - No code outside `CharacterVitalsService` reads `Character.currentHp` or `Character.hpRegenSince` to compute an effective HP value. Everything else goes through `CharacterStatsService.calculate()` (server) or the client's mirrored formula in `WorldStore` (display only). - Starting a combat requires effective HP `>= 1`; there is no separate "defeated" flag or cooldown timer (design R2). - A combat's `playerMaxHp` is still frozen at combat start, unchanged by this plan (pre-existing behaviour). - Migration files are timestamp-prefixed in `apps/api/src/database/migrations/`; this plan's migration is `1792000000000-AddHpRegeneration.ts` (skips `1791000000000`, reserved by the not-yet-implemented Renown plan). Migration specs assert TypeORM entity metadata only — this repo's tests never open a live database connection. - API tests run with `jest`, from `apps/api` (`npm run test --workspace=@ashen-realms/api`, or scope with `-- `). Web tests run with `vitest`, from `apps/web` (`npm run test --workspace=@ashen-realms/web`, or with a path filter). --- ### Task 1: Move the `Clock` abstraction out of the travel module **Files:** - Create: `apps/api/src/shared/clock.ts` - Delete: `apps/api/src/travel/clock.ts` - Modify: `apps/api/src/travel/travel.module.ts` - Modify: `apps/api/src/travel/travel.service.ts` - Modify: `apps/api/src/travel/travel.service.spec.ts` **Interfaces:** - Produces: `CLOCK` (injection token, `Symbol`), `Clock` interface (`{ now(): Date }`), `systemClock: Clock` — all now importable from `../shared/clock` (or `./clock` from within `shared/`). Every later task that needs a clock imports from here. This is a pure move: the character vitals service (Task 4) needs a clock too, and two feature modules must not borrow one from a third's folder. - [ ] **Step 1: Create `apps/api/src/shared/clock.ts` with the moved content** ```ts export const CLOCK = Symbol('CLOCK'); export interface Clock { now(): Date; } export const systemClock: Clock = { now: () => new Date(), }; ``` - [ ] **Step 2: Delete `apps/api/src/travel/clock.ts`** - [ ] **Step 3: Update the import in `apps/api/src/travel/travel.module.ts`** Change: ```ts import { CLOCK, systemClock } from './clock'; ``` to: ```ts import { CLOCK, systemClock } from '../shared/clock'; ``` - [ ] **Step 4: Update the imports in `apps/api/src/travel/travel.service.ts`** Change: ```ts import { CLOCK } from './clock'; import type { Clock } from './clock'; ``` to: ```ts import { CLOCK } from '../shared/clock'; import type { Clock } from '../shared/clock'; ``` - [ ] **Step 5: Update the import in `apps/api/src/travel/travel.service.spec.ts`** Change: ```ts import { Clock } from './clock'; ``` to: ```ts import { Clock } from '../shared/clock'; ``` - [ ] **Step 6: Run the travel suite to confirm nothing broke** Run: `npm run test --workspace=@ashen-realms/api -- travel` Expected: PASS (same test count as before the move) - [ ] **Step 7: Commit** ```bash git add apps/api/src/shared/clock.ts apps/api/src/travel/clock.ts apps/api/src/travel/travel.module.ts apps/api/src/travel/travel.service.ts apps/api/src/travel/travel.service.spec.ts git commit -m "refactor(api): move Clock abstraction from travel to shared" ``` --- ### Task 2: Add `hp_regen_since` to the `characters` table **Files:** - Modify: `apps/api/src/characters/entities/character.entity.ts` - Create: `apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts` - Create: `apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts` **Interfaces:** - Produces: `Character.hpRegenSince: Date | null`. Task 4 (`CharacterVitalsService`) reads and writes this field on `Character` instances it is given. - [ ] **Step 1: Write the failing migration spec** ```ts // apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts import 'reflect-metadata'; import { getMetadataArgsStorage } from 'typeorm'; import { Character } from '../../characters/entities/character.entity'; describe('characters.hp_regen_since schema', () => { it('stores the regeneration anchor as a nullable timestamptz', () => { const metadata = getMetadataArgsStorage(); const column = metadata.columns.find( (candidate) => candidate.target === Character && candidate.propertyName === 'hpRegenSince', ); expect(column).toBeDefined(); expect(column?.options.type).toBe('timestamptz'); expect(column?.options.nullable).toBe(true); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `npm run test --workspace=@ashen-realms/api -- add-hp-regeneration.migration` Expected: FAIL — `column` is `undefined` because the entity has no such property yet. - [ ] **Step 3: Add the column to the entity** In `apps/api/src/characters/entities/character.entity.ts`, add after the `currentHp` column: ```ts // `current_hp` is only exact while this is null (regeneration paused, e.g. // mid-combat). Otherwise it's the HP as of this timestamp -- read it // through CharacterVitalsService.effectiveHp(), never directly. @Column({ name: 'hp_regen_since', type: 'timestamptz', nullable: true }) hpRegenSince!: Date | null; ``` - [ ] **Step 4: Run the migration spec again to verify it passes** Run: `npm run test --workspace=@ashen-realms/api -- add-hp-regeneration.migration` Expected: PASS - [ ] **Step 5: Write the migration** ```ts // apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddHpRegeneration1792000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE "characters" ADD COLUMN "hp_regen_since" TIMESTAMP WITH TIME ZONE', ); // Existing characters start regenerating immediately from their current // HP. A character whose fight is still ACTIVE keeps regeneration paused // until that fight resolves, matching the "no combat-time regen" rule // (persistent-hp-and-regeneration design, R4) -- this migration must not // gift them free healing mid-fight. await queryRunner.query('UPDATE "characters" SET "hp_regen_since" = now()'); await queryRunner.query(`UPDATE "characters" AS "character" SET "hp_regen_since" = NULL FROM "combats" AS "combat" WHERE "combat"."character_id" = "character"."id" AND "combat"."status" = 'ACTIVE'`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"'); } } ``` - [ ] **Step 6: Run the full API test suite to confirm nothing else references the old shape** Run: `npm run test --workspace=@ashen-realms/api` Expected: Some failures are expected here (`character-stats.service.spec.ts`, `characters.service.spec.ts`, `equipment.service.spec.ts`, `combat.service.spec.ts`, `combat-equipment-integration.spec.ts` all construct `Character` fixtures directly and will be fixed in later tasks). Confirm the only new failures are compile/runtime errors mentioning these known fixtures, not something unrelated. - [ ] **Step 7: Commit** ```bash git add apps/api/src/characters/entities/character.entity.ts apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts git commit -m "feat(api): add hp_regen_since column for persistent HP regeneration" ``` --- ### Task 3: Add the regeneration-rate constant **Files:** - Create: `apps/api/src/characters/character-vitals.constants.ts` **Interfaces:** - Produces: `HP_REGEN_PER_SECOND: number`. Consumed by `CharacterVitalsService` (Task 4) and `CharacterStatsService` (Task 6). - [ ] **Step 1: Create the constants file** ```ts // apps/api/src/characters/character-vitals.constants.ts export const HP_REGEN_PER_SECOND = 1; ``` - [ ] **Step 2: Commit** ```bash git add apps/api/src/characters/character-vitals.constants.ts git commit -m "feat(api): add HP regeneration rate constant" ``` --- ### Task 4: `CharacterVitalsService` (TDD) **Files:** - Create: `apps/api/src/characters/character-vitals.service.ts` - Create: `apps/api/src/characters/character-vitals.service.spec.ts` **Interfaces:** - Consumes: `Clock`, `CLOCK` from `../shared/clock` (Task 1); `HP_REGEN_PER_SECOND` from `./character-vitals.constants` (Task 3); `Character` from `./entities/character.entity` (Task 2). - Produces: `class CharacterVitalsService` with: - `effectiveHp(character: Pick, maxHp: number): number` - `pause(character: Character, value: number): void` - `resume(character: Character, value: number): void` - `settle(character: Character, maxHp: number): void` Consumed by `CharacterStatsService` (Task 6), `CombatService` (Tasks 9-10), `EquipmentService` (Task 12). - [ ] **Step 1: Write the failing test file** ```ts // apps/api/src/characters/character-vitals.service.spec.ts import { Clock } from '../shared/clock'; import { CharacterVitalsService } from './character-vitals.service'; import { Character } from './entities/character.entity'; function fakeClock(initialIso: string): { clock: Clock; advanceSeconds: (seconds: number) => void; } { let current = Date.parse(initialIso); return { clock: { now: () => new Date(current) }, advanceSeconds: (seconds: number) => { current += seconds * 1000; }, }; } function character(overrides: Partial = {}): Character { return { id: 'character-1', currentHp: 50, hpRegenSince: null, ...overrides, } as Character; } describe('CharacterVitalsService', () => { describe('effectiveHp', () => { it('returns the raw current HP when regeneration is paused', () => { const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100); expect(hp).toBe(37); }); it('adds one HP per elapsed second since the anchor', () => { const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const anchor = new Date('2026-08-21T12:00:00.000Z'); const target = character({ currentHp: 40, hpRegenSince: anchor }); advanceSeconds(25); expect(service.effectiveHp(target, 100)).toBe(65); }); it('floors partial seconds instead of rounding up', () => { const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const anchor = new Date('2026-08-21T12:00:00.000Z'); const target = character({ currentHp: 40, hpRegenSince: anchor }); advanceSeconds(1.9); expect(service.effectiveHp(target, 100)).toBe(41); }); it('clamps regeneration at maxHp', () => { const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const anchor = new Date('2026-08-21T12:00:00.000Z'); const target = character({ currentHp: 90, hpRegenSince: anchor }); advanceSeconds(50); expect(service.effectiveHp(target, 100)).toBe(100); }); it('never lets HP fall if the clock moves backwards', () => { const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') }; const service = new CharacterVitalsService(clock); const anchor = new Date('2026-08-21T12:00:00.000Z'); const target = character({ currentHp: 40, hpRegenSince: anchor }); expect(service.effectiveHp(target, 100)).toBe(40); }); }); describe('pause', () => { it('freezes current HP at the given value and clears the anchor', () => { const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const target = character({ currentHp: 100, hpRegenSince: new Date('2026-08-21T11:00:00.000Z'), }); service.pause(target, 62); expect(target.currentHp).toBe(62); expect(target.hpRegenSince).toBeNull(); }); }); describe('resume', () => { it('sets current HP and anchors regeneration at now', () => { const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const target = character({ currentHp: 0, hpRegenSince: null }); service.resume(target, 15); expect(target.currentHp).toBe(15); expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z')); }); }); describe('settle', () => { it('re-anchors at the current effective value without changing it', () => { const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const anchor = new Date('2026-08-21T12:00:00.000Z'); const target = character({ currentHp: 40, hpRegenSince: anchor }); advanceSeconds(10); service.settle(target, 100); expect(target.currentHp).toBe(50); expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z')); }); it('does not gift overflow past the pre-change maxHp when re-anchoring', () => { const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); const service = new CharacterVitalsService(clock); const anchor = new Date('2026-08-21T12:00:00.000Z'); const target = character({ currentHp: 100, hpRegenSince: anchor }); advanceSeconds(600); service.settle(target, 100); expect(target.currentHp).toBe(100); }); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `npm run test --workspace=@ashen-realms/api -- character-vitals.service` Expected: FAIL — `Cannot find module './character-vitals.service'` - [ ] **Step 3: Write the implementation** ```ts // apps/api/src/characters/character-vitals.service.ts import { Inject, Injectable } from '@nestjs/common'; import { CLOCK } from '../shared/clock'; import type { Clock } from '../shared/clock'; import { HP_REGEN_PER_SECOND } from './character-vitals.constants'; import { Character } from './entities/character.entity'; /** * The only place that turns (current_hp, hp_regen_since) into an effective * HP value, or moves that pair. `current_hp` is exact only while the anchor * is null; everything else must go through here (persistent-hp-and- * regeneration design, R3). */ @Injectable() export class CharacterVitalsService { constructor(@Inject(CLOCK) private readonly clock: Clock) {} effectiveHp( character: Pick, maxHp: number, ): number { if (character.hpRegenSince === null) { return Math.min(maxHp, character.currentHp); } const elapsedSeconds = Math.max( 0, (this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000, ); const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND); return Math.min(maxHp, character.currentHp + regenerated); } pause(character: Character, value: number): void { character.currentHp = value; character.hpRegenSince = null; } resume(character: Character, value: number): void { character.currentHp = value; character.hpRegenSince = this.clock.now(); } settle(character: Character, maxHp: number): void { this.resume(character, this.effectiveHp(character, maxHp)); } } ``` - [ ] **Step 4: Run the test again to verify it passes** Run: `npm run test --workspace=@ashen-realms/api -- character-vitals.service` Expected: PASS - [ ] **Step 5: Commit** ```bash git add apps/api/src/characters/character-vitals.service.ts apps/api/src/characters/character-vitals.service.spec.ts git commit -m "feat(api): add CharacterVitalsService for anchored HP regeneration" ``` --- ### Task 5: Wire `CharacterVitalsService` into `CharactersModule` **Files:** - Modify: `apps/api/src/characters/characters.module.ts` **Interfaces:** - Produces: `CharacterVitalsService` and `CharacterStatsService` both exported from `CharactersModule`. `CombatModule` and `EquipmentModule` already import `CharactersModule`, so they need no import changes to receive `CharacterVitalsService` via constructor injection in later tasks. - [ ] **Step 1: Update the module** ```ts // apps/api/src/characters/characters.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CLOCK, systemClock } from '../shared/clock'; import { CharacterStatsService } from './character-stats.service'; import { CharacterVitalsService } from './character-vitals.service'; import { CharactersController } from './characters.controller'; import { CharactersService } from './characters.service'; import { Character } from './entities/character.entity'; @Module({ imports: [TypeOrmModule.forFeature([Character])], controllers: [CharactersController], providers: [ CharactersService, CharacterStatsService, CharacterVitalsService, { provide: CLOCK, useValue: systemClock }, ], exports: [CharacterStatsService, CharacterVitalsService], }) export class CharactersModule {} ``` - [ ] **Step 2: Commit** ```bash git add apps/api/src/characters/characters.module.ts git commit -m "feat(api): provide CharacterVitalsService from CharactersModule" ``` (This task has no independent test — `npm run build:api` or the Nest app bootstrapping in the E2E harness would catch a wiring mistake; later tasks' unit tests exercise the service directly.) --- ### Task 6: `CharacterStatsService` returns effective HP **Files:** - Modify: `apps/api/src/characters/character-stats.service.ts` - Modify: `apps/api/src/characters/character-stats.service.spec.ts` **Interfaces:** - Consumes: `CharacterVitalsService.effectiveHp` (Task 4), `HP_REGEN_PER_SECOND` (Task 3). - Produces: `EffectiveCharacterStats` gains `hpRegenPerSecond: number` and `hpRegenSince: Date | null`; `currentHp` is now the effective value, not the raw column. `CharacterStatsService`'s constructor becomes `(dataSource: DataSource, characterVitals: CharacterVitalsService)` — every direct instantiation elsewhere must add the second argument (Tasks 7, 9, 12, 13). - [ ] **Step 1: Update the fixture and the existing pass-through test to be explicit about the paused case** In `apps/api/src/characters/character-stats.service.spec.ts`, update the `character()` fixture to set `hpRegenSince` explicitly: ```ts function character(overrides: Partial = {}): Character { return { id: 'character-1', baseHp: 100, baseAttack: 6, currentHp: 90, hpRegenSince: null, ...overrides, } as Character; } ``` Replace the `describe('CharacterStatsService', ...)` block's `const service = ...` line and the last test with: ```ts describe('CharacterStatsService', () => { const characterVitals = new CharacterVitalsService({ now: () => new Date('2026-08-21T12:00:00.000Z'), }); const service = new CharacterStatsService({} as DataSource, characterVitals); ``` and change the final test from: ```ts it('passes currentHp through unchanged from the character', async () => { const scope = fakeScope([]); const stats = await service.calculate(character({ currentHp: 42 }), scope); expect(stats.currentHp).toBe(42); }); }); ``` to: ```ts it('returns the raw current HP unchanged while regeneration is paused', async () => { const scope = fakeScope([]); const stats = await service.calculate( character({ currentHp: 42, hpRegenSince: null }), scope, ); expect(stats.currentHp).toBe(42); }); it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => { const scope = fakeScope([]); const regenerating = await service.calculate( character({ currentHp: 40, hpRegenSince: new Date('2026-08-21T11:59:30.000Z'), }), scope, ); expect(regenerating.currentHp).toBe(70); const clamped = await service.calculate( character({ currentHp: 40, hpRegenSince: new Date('2026-08-21T11:40:00.000Z'), }), scope, ); expect(clamped.currentHp).toBe(100); }); it('reports the regeneration rate and anchor alongside the effective stats', async () => { const scope = fakeScope([]); const anchor = new Date('2026-08-21T11:59:30.000Z'); const stats = await service.calculate(character({ hpRegenSince: anchor }), scope); expect(stats.hpRegenPerSecond).toBe(1); expect(stats.hpRegenSince).toEqual(anchor); }); }); ``` Add the import at the top of the file: ```ts import { CharacterVitalsService } from './character-vitals.service'; ``` - [ ] **Step 2: Run the suite to verify the new/changed tests fail** Run: `npm run test --workspace=@ashen-realms/api -- character-stats.service` Expected: FAIL — `CharacterStatsService` constructor doesn't accept a second argument yet, and `hpRegenPerSecond`/`hpRegenSince` are undefined on the result. - [ ] **Step 3: Update the implementation** ```ts // apps/api/src/characters/character-stats.service.ts import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; import { EquipmentSlot } from '../items/equipment-slot.enum'; import { HP_REGEN_PER_SECOND } from './character-vitals.constants'; import { CharacterVitalsService } from './character-vitals.service'; import { Character } from './entities/character.entity'; export interface EffectiveCharacterStats { maxHp: number; currentHp: number; attack: number; weaponDamage: number; armor: number; combatPower: number; hpRegenPerSecond: number; hpRegenSince: Date | null; } type RepositoryScope = Pick; /** * Single authoritative source of effective character stats (spec §18). * Replaces the Slice 0.3 `CharacterCombatStatsService` shortcut. */ @Injectable() export class CharacterStatsService { constructor( private readonly dataSource: DataSource, private readonly characterVitals: CharacterVitalsService, ) {} async calculate( character: Character, scope?: RepositoryScope, ): Promise { const db = scope ?? this.dataSource; const equipped = await db.getRepository(CharacterEquipment).find({ where: { characterId: character.id }, relations: { characterItem: { itemDefinition: true } }, }); let weaponDamage = 0; let bonusHp = 0; let bonusAttack = 0; let bonusArmor = 0; for (const slot of equipped) { const definition = slot.characterItem.itemDefinition; if (slot.slot === EquipmentSlot.WEAPON) { weaponDamage = definition.weaponDamage; } bonusHp += definition.bonusHp; bonusAttack += definition.bonusAttack; bonusArmor += definition.bonusArmor; } const maxHp = character.baseHp + bonusHp; const attack = character.baseAttack + bonusAttack; const armor = bonusArmor; return { maxHp, currentHp: this.characterVitals.effectiveHp(character, maxHp), attack, weaponDamage, armor, combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5, hpRegenPerSecond: HP_REGEN_PER_SECOND, hpRegenSince: character.hpRegenSince, }; } } ``` - [ ] **Step 4: Run the suite to verify it passes** Run: `npm run test --workspace=@ashen-realms/api -- character-stats.service` Expected: PASS - [ ] **Step 5: Commit** ```bash git add apps/api/src/characters/character-stats.service.ts apps/api/src/characters/character-stats.service.spec.ts git commit -m "feat(api): CharacterStatsService reports effective (regenerated) HP" ``` --- ### Task 7: `CharactersService` exposes effective HP and regen fields **Files:** - Modify: `apps/api/src/characters/characters.service.ts` - Modify: `apps/api/src/characters/characters.service.spec.ts` **Interfaces:** - Consumes: `EffectiveCharacterStats.currentHp/hpRegenPerSecond/hpRegenSince` (Task 6). - Produces: `getDemoCharacter()`'s return type gains `hpRegenPerSecond: number` and `hpRegenSince: string | null`. This is the shape `GET /api/characters/me` returns to the web client (Task 15 updates the matching frontend type). - [ ] **Step 1: Update the test fixtures and expectations** In `apps/api/src/characters/characters.service.spec.ts`, update `fakeCharacterStats`: ```ts function fakeCharacterStats( overrides: Partial<{ maxHp: number; attack: number }> = {}, ): CharacterStatsService { return { calculate: jest.fn().mockResolvedValue({ maxHp: overrides.maxHp ?? 100, currentHp: 100, attack: overrides.attack ?? 6, weaponDamage: 8, armor: 0, combatPower: 0, hpRegenPerSecond: 1, hpRegenSince: new Date('2026-08-18T09:00:00.000Z'), }), } as unknown as CharacterStatsService; } ``` Update the first test's expected result (the `.resolves.toEqual({...})` block) to add the two new fields: ```ts await expect(service.getDemoCharacter()).resolves.toEqual({ id: DEMO_CHARACTER_ID, name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, currentHp: 100, maxHp: 115, attack: 7, hpRegenPerSecond: 1, hpRegenSince: '2026-08-18T09:00:00.000Z', currentLocation: { id: SOUTH_GATE_ID, key: 'south-gate', name: 'Südtor von Graufurt', }, }); ``` - [ ] **Step 2: Run the suite to verify the updated test fails** Run: `npm run test --workspace=@ashen-realms/api -- characters.service` Expected: FAIL — actual result is missing `hpRegenPerSecond`/`hpRegenSince`. - [ ] **Step 3: Update the implementation** In `apps/api/src/characters/characters.service.ts`, replace the return block: ```ts return { id: character.id, name: character.name, level: character.level, experience: character.experience, silver: character.silver, currentHp: stats.currentHp, maxHp: stats.maxHp, attack: stats.attack, hpRegenPerSecond: stats.hpRegenPerSecond, hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null, currentLocation: { id: character.currentLocation.id, key: character.currentLocation.key, name: character.currentLocation.name, }, }; ``` Note this also fixes a latent bug: the field used to read `character.currentHp` (the raw column) directly instead of `stats.currentHp` (the calculated value) — harmless before this plan since they were always equal, but wrong now that `character.currentHp` can be a stale anchor value. - [ ] **Step 4: Run the suite to verify it passes** Run: `npm run test --workspace=@ashen-realms/api -- characters.service` Expected: PASS - [ ] **Step 5: Commit** ```bash git add apps/api/src/characters/characters.service.ts apps/api/src/characters/characters.service.spec.ts git commit -m "feat(api): expose effective HP and regen anchor from GET /characters/me" ``` --- ### Task 8: Add the `CHARACTER_TOO_WOUNDED` combat error **Files:** - Modify: `apps/api/src/combat/combat.errors.ts` **Interfaces:** - Produces: `characterTooWounded(): CombatDomainError` with `code: 'CHARACTER_TOO_WOUNDED'`, `409 Conflict`. Consumed by `CombatService.startCombat` (Task 9). - [ ] **Step 1: Add the error code and factory function** In `apps/api/src/combat/combat.errors.ts`, add `'CHARACTER_TOO_WOUNDED'` to the `CombatErrorCode` union: ```ts export type CombatErrorCode = | 'HUNT_ENCOUNTER_NOT_FOUND' | 'HUNT_ENCOUNTER_ALREADY_CONSUMED' | 'INVALID_HUNT_ENCOUNTER' | 'CHARACTER_TRAVELLING' | 'CHARACTER_TOO_WOUNDED' | 'COMBAT_ALREADY_ACTIVE' | 'COMBAT_NOT_FOUND' | 'COMBAT_ALREADY_FINISHED' | 'COMBAT_STATE_INVALID' | 'COMBAT_NO_POTIONS_REMAINING'; ``` Add the factory function, next to `characterTravelling()`: ```ts export function characterTooWounded(): CombatDomainError { return new CombatDomainError( 'CHARACTER_TOO_WOUNDED', HttpStatus.CONFLICT, 'The character is too wounded to fight.', ); } ``` - [ ] **Step 2: Compile check (no dedicated test file for this error module; Task 9 exercises it end to end)** Run: `npm run build:api` Expected: no new type errors - [ ] **Step 3: Commit** ```bash git add apps/api/src/combat/combat.errors.ts git commit -m "feat(api): add CHARACTER_TOO_WOUNDED combat error" ``` --- ### Task 9: `CombatService.startCombat` seeds from carried-over HP and gates on it **Files:** - Modify: `apps/api/src/combat/combat.service.ts` - Modify: `apps/api/src/combat/combat.service.spec.ts` **Interfaces:** - Consumes: `CharacterVitalsService.pause` (Task 4), `characterTooWounded` (Task 8). - Produces: `CombatService`'s constructor becomes `(dataSource, travelService, combatEngine, characterStats, characterVitals, combatRewards)` — the new `characterVitals` parameter sits between `characterStats` and `combatRewards`. `combat.playerCurrentHp` at combat start is now the character's effective HP, not `playerMaxHp`. Consumed by Task 10 (same file/class) and Task 13 (integration spec). - [ ] **Step 1: Update the fixture, error-path tests, and add new tests** In `apps/api/src/combat/combat.service.spec.ts`, add the import: ```ts import { CharacterVitalsService } from '../characters/character-vitals.service'; ``` Update the `character()` fixture to set `hpRegenSince` explicitly: ```ts function character(overrides: Partial = {}): Character { return { id: CHARACTER_ID, name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, hpRegenSince: null, currentLocationId: 'location-1', createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as Character; } ``` Update `createService` to build and pass a `CharacterVitalsService`: ```ts function createService( options: { state?: FakeState; travelService?: TravelService } = {}, ) { const state = options.state ?? createState(); const dataSource = new FakeDataSource(state); const travelService = options.travelService ?? fakeTravelService(); const combatEngine = new CombatEngineService(); const characterCombatStats = fakeCharacterStats(); const characterVitals = new CharacterVitalsService({ now: () => new Date('2026-08-18T09:00:00.000Z'), }); const service = new CombatService( dataSource as unknown as DataSource, travelService, combatEngine, characterCombatStats, characterVitals, fakeRewardService(), ); return { dataSource, service, travelService }; } ``` Update the two existing tests that rely on a near-dead player (they previously worked because `playerMaxHp` was seeded and `baseHp: 1` made that tiny; now the seed comes from `currentHp`, so it must be set explicitly too): ```ts it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => { const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })], }); ``` and ```ts it('frees the encounter for another attempt when the fight is lost', async () => { const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); ``` Add these new tests inside `describe('startCombat', ...)`, after the existing "starts an ACTIVE combat..." test: ```ts it('seeds player HP from the character, carrying HP from a previous fight rather than starting full', async () => { const state = createState({ characters: [character({ currentHp: 63 })] }); const { dataSource, service } = createService({ state }); const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); expect(combat.player.currentHp).toBe(63); expect(dataSource.state.combats[0].playerCurrentHp).toBe(63); }); it('pauses regeneration on the character once a combat starts', async () => { const state = createState({ characters: [ character({ currentHp: 63, hpRegenSince: new Date('2026-08-18T08:00:00.000Z') }), ], }); const { dataSource, service } = createService({ state }); await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); expect(dataSource.state.characters[0].currentHp).toBe(63); expect(dataSource.state.characters[0].hpRegenSince).toBeNull(); }); it('rejects starting a combat when the character has 0 effective HP', async () => { const state = createState({ characters: [character({ currentHp: 0 })] }); const { service } = createService({ state }); await expectCombatDomainError( service.startCombat(CHARACTER_ID, ENCOUNTER_ID), 'CHARACTER_TOO_WOUNDED', ); }); it('allows starting a combat at exactly 1 effective HP', async () => { const state = createState({ characters: [character({ currentHp: 1 })] }); const { service } = createService({ state }); await expect( service.startCombat(CHARACTER_ID, ENCOUNTER_ID), ).resolves.toMatchObject({ status: 'ACTIVE' }); }); ``` - [ ] **Step 2: Run the suite to verify the new/changed tests fail** Run: `npm run test --workspace=@ashen-realms/api -- combat.service` Expected: FAIL — `CombatService` doesn't accept a `characterVitals` constructor argument yet, and still seeds from `maxHp`. - [ ] **Step 3: Update the implementation** In `apps/api/src/combat/combat.service.ts`, add imports: ```ts import { CharacterVitalsService } from '../characters/character-vitals.service'; ``` and add `characterTooWounded` to the existing `combat.errors` import list (alphabetical, matching the existing style): ```ts import { characterNotFound, characterTooWounded, characterTravelling, combatAlreadyActive, combatAlreadyFinished, combatNoPotionsRemaining, combatNotFound, combatStateInvalid, huntEncounterAlreadyConsumed, huntEncounterNotFound, invalidHuntEncounter, } from './combat.errors'; ``` Update the constructor: ```ts constructor( private readonly dataSource: DataSource, private readonly travelService: TravelService, private readonly combatEngine: CombatEngineService, private readonly characterStats: CharacterStatsService, private readonly characterVitals: CharacterVitalsService, private readonly combatRewards: CombatRewardService, ) {} ``` In `startCombat`, replace: ```ts const playerStats = await this.characterStats.calculate(character, manager); const combat = combats.create({ characterId, huntEncounterId: encounter.id, monsterDefinitionId: monster.id, status: CombatStatus.ACTIVE, round: 1, playerMaxHp: playerStats.maxHp, playerCurrentHp: playerStats.maxHp, ``` with: ```ts const playerStats = await this.characterStats.calculate(character, manager); if (playerStats.currentHp < 1) { throw characterTooWounded(); } this.characterVitals.pause(character, playerStats.currentHp); await characters.save(character); const combat = combats.create({ characterId, huntEncounterId: encounter.id, monsterDefinitionId: monster.id, status: CombatStatus.ACTIVE, round: 1, playerMaxHp: playerStats.maxHp, playerCurrentHp: playerStats.currentHp, ``` - [ ] **Step 4: Run the suite to verify it passes** Run: `npm run test --workspace=@ashen-realms/api -- combat.service` Expected: PASS - [ ] **Step 5: Commit** ```bash git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts git commit -m "feat(api): carry HP into combat and gate starting a fight on it" ``` --- ### Task 10: `CombatService.performAction` mirrors HP each round and resumes regen at combat end **Files:** - Modify: `apps/api/src/combat/combat.service.ts` - Modify: `apps/api/src/combat/combat.service.spec.ts` **Interfaces:** - Consumes: `CharacterVitalsService.pause`/`resume` (Task 4). - Produces: after this task, `Character.currentHp`/`hpRegenSince` are kept in sync with `Combat.playerCurrentHp` on every round, and regeneration restarts (from wherever the fight ended, including 0 on a loss) the moment a combat finishes. - [ ] **Step 1: Add the failing tests** In `apps/api/src/combat/combat.service.spec.ts`, inside `describe('performAction', ...)`, add after "persists ordered, sequential CombatEvents across multiple rounds": ```ts it('mirrors the player HP onto the character each round while the fight continues', async () => { const { dataSource, service, combatId } = await startedCombat(); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); expect(dataSource.state.characters[0].currentHp).toBe(95); expect(dataSource.state.characters[0].hpRegenSince).toBeNull(); }); it('restarts regeneration on the character once the fight is won', async () => { const state = createState({ monsters: [monster({ maxHp: 10 })] }); const { dataSource, service, combatId } = await startedCombat(state); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); expect(dataSource.state.characters[0].currentHp).toBe( dataSource.state.combats[0].playerCurrentHp, ); expect(dataSource.state.characters[0].hpRegenSince).toEqual( new Date('2026-08-18T09:00:00.000Z'), ); }); it('restarts regeneration from 0 HP once the fight is lost', async () => { const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); const { dataSource, service, combatId } = await startedCombat(state); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); expect(dataSource.state.characters[0].currentHp).toBe(0); expect(dataSource.state.characters[0].hpRegenSince).toEqual( new Date('2026-08-18T09:00:00.000Z'), ); }); ``` - [ ] **Step 2: Run the suite to verify the new tests fail** Run: `npm run test --workspace=@ashen-realms/api -- combat.service` Expected: FAIL — the character row is never updated by `performAction` yet. - [ ] **Step 3: Update the implementation** In `apps/api/src/combat/combat.service.ts`, `performAction` currently discards the locked character: ```ts await this.lockCharacter(characters, characterId); ``` Change it to keep the reference: ```ts const character = await this.lockCharacter(characters, characterId); ``` Then replace the block that mutates and saves `combat`: ```ts combat.round = result.state.round; combat.status = result.state.status; combat.playerCurrentHp = result.state.player.currentHp; combat.monsterCurrentHp = result.state.monster.currentHp; combat.playerState = result.state.player.stats as CombatPlayerState; combat.monsterState = result.state.monster.stats; if (combat.status !== CombatStatus.ACTIVE) { combat.completedAt = new Date(); await this.settleEncounter( manager.getRepository(HuntEncounter), combat.huntEncounterId, combat.status, ); } await combats.save(combat); ``` with: ```ts combat.round = result.state.round; combat.status = result.state.status; combat.playerCurrentHp = result.state.player.currentHp; combat.monsterCurrentHp = result.state.monster.currentHp; combat.playerState = result.state.player.stats as CombatPlayerState; combat.monsterState = result.state.monster.stats; if (combat.status !== CombatStatus.ACTIVE) { combat.completedAt = new Date(); this.characterVitals.resume(character, combat.playerCurrentHp); await this.settleEncounter( manager.getRepository(HuntEncounter), combat.huntEncounterId, combat.status, ); } else { this.characterVitals.pause(character, combat.playerCurrentHp); } await characters.save(character); await combats.save(combat); ``` - [ ] **Step 4: Run the suite to verify it passes** Run: `npm run test --workspace=@ashen-realms/api -- combat.service` Expected: PASS - [ ] **Step 5: Run the whole combat directory once more as a final check** Run: `npm run test --workspace=@ashen-realms/api -- combat/` Expected: PASS (this also re-runs `combat-equipment-integration.spec.ts` and `combat-engine.service.spec.ts`; the integration spec is expected to still fail until Task 12/13 update its harness — confirm the only failures left are in that one file) - [ ] **Step 6: Commit** ```bash git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts git commit -m "feat(api): mirror HP onto the character each round and resume regen at combat end" ``` --- ### Task 11: `EquipmentService.equip` re-anchors HP before a max-HP change **Files:** - Modify: `apps/api/src/equipment/equipment.service.ts` - Modify: `apps/api/src/equipment/equipment.service.spec.ts` **Interfaces:** - Consumes: `CharacterVitalsService.settle` (Task 4). - Produces: `EquipmentService`'s constructor becomes `(dataSource, characterStats, characterVitals)`. - [ ] **Step 1: Update the fixture and harness, add the failing test** In `apps/api/src/equipment/equipment.service.spec.ts`, add the import: ```ts import { CharacterVitalsService } from '../characters/character-vitals.service'; ``` Update the `character()` fixture: ```ts function character(overrides: Partial = {}): Character { return { id: CHARACTER_ID, name: 'Aric Duskwalker', level: 1, baseHp: 100, baseAttack: 6, currentHp: 100, hpRegenSince: null, ...overrides, } as Character; } ``` Update `createHarness`: ```ts function createHarness(state: Partial = {}) { const fullState: State = { characters: [character()], itemDefinitions: [], characterItems: [], characterEquipment: [], combats: [], ...state, }; const dataSource = new FakeDataSource(fullState); const characterVitals = new CharacterVitalsService({ now: () => new Date('2026-08-18T09:00:00.000Z'), }); const characterStats = new CharacterStatsService( dataSource as unknown as DataSource, characterVitals, ); const service = new EquipmentService( dataSource as unknown as DataSource, characterStats, characterVitals, ); return { state: fullState, service }; } ``` Add a new test inside `describe('equip', ...)`: ```ts it('re-anchors HP regeneration so a later max-HP increase does not gift accumulated overflow', async () => { const bonusHpHelm = itemDefinition({ id: 'def-bonus-hp-helm', key: 'bonus-hp-helm', name: 'Gepolsterter Helm', equipmentSlot: EquipmentSlot.HEAD, bonusHp: 20, weaponDamage: 0, }); const { state, service } = createHarness({ characters: [character({ currentHp: 100, hpRegenSince: null })], itemDefinitions: [bonusHpHelm], characterItems: [ { id: BANDIT_HOOD_ITEM_ID, characterId: CHARACTER_ID, itemDefinitionId: bonusHpHelm.id, quantity: 1, } as CharacterItem, ], }); await service.equip(CHARACTER_ID, BANDIT_HOOD_ITEM_ID); expect(state.characters[0].currentHp).toBe(100); }); ``` - [ ] **Step 2: Run the suite to verify it fails** Run: `npm run test --workspace=@ashen-realms/api -- equipment.service` Expected: FAIL — `EquipmentService` doesn't accept a third constructor argument yet. - [ ] **Step 3: Update the implementation** In `apps/api/src/equipment/equipment.service.ts`, add the import: ```ts import { CharacterVitalsService } from '../characters/character-vitals.service'; ``` Update the constructor: ```ts constructor( private readonly dataSource: DataSource, private readonly characterStats: CharacterStatsService, private readonly characterVitals: CharacterVitalsService, ) {} ``` In `equip()`, insert the re-anchor after the level-requirement check and before the equipment-slot write: ```ts const definition = characterItem.itemDefinition; if (!definition.equipmentSlot) { throw itemNotEquippable(); } if (definition.requiredLevel > character.level) { throw itemLevelRequirementNotMet(); } const statsBeforeChange = await this.characterStats.calculate(character, manager); this.characterVitals.settle(character, statsBeforeChange.maxHp); await characters.save(character); const existing = await equipmentRepo.findOne({ ``` - [ ] **Step 4: Run the suite to verify it passes** Run: `npm run test --workspace=@ashen-realms/api -- equipment.service` Expected: PASS - [ ] **Step 5: Commit** ```bash git add apps/api/src/equipment/equipment.service.ts apps/api/src/equipment/equipment.service.spec.ts git commit -m "feat(api): re-anchor HP regeneration before an equipment-driven max-HP change" ``` --- ### Task 12: Update the combat/equipment integration spec for the new constructors **Files:** - Modify: `apps/api/src/combat/combat-equipment-integration.spec.ts` **Interfaces:** - Consumes: `CharacterVitalsService` (Task 4), the updated `CombatService`/`EquipmentService`/`CharacterStatsService` constructors (Tasks 6, 9, 11). This file constructs real (non-mocked) `CombatService`, `EquipmentService`, and `CharacterStatsService` instances sharing one `dataSource`, so it needs the same wiring update, plus the `hpRegenSince` fixture default. - [ ] **Step 1: Update the fixture and harness** Add the import: ```ts import { CharacterVitalsService } from '../characters/character-vitals.service'; ``` Update the `character()` fixture: ```ts function character(overrides: Partial = {}): Character { return { id: CHARACTER_ID, name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, hpRegenSince: null, currentLocationId: 'location-1', createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), ...overrides, } as Character; } ``` Find the harness construction block (the one building `characterStats`, `equipmentService`, and `combatService` together) and change it to: ```ts const dataSource = new FakeDataSource(state); const characterVitals = new CharacterVitalsService({ now: () => new Date('2026-08-18T09:00:00.000Z'), }); const characterStats = new CharacterStatsService( dataSource as unknown as DataSource, characterVitals, ); const equipmentService = new EquipmentService( dataSource as unknown as DataSource, characterStats, characterVitals, ); const combatService = new CombatService( dataSource as unknown as DataSource, fakeTravelService(), new CombatEngineService(), characterStats, characterVitals, fakeRewardService(), ); return { state, equipmentService, combatService }; ``` - [ ] **Step 2: Run the suite** Run: `npm run test --workspace=@ashen-realms/api -- combat-equipment-integration` Expected: PASS. (This fight plays out over several rounds to a `WON` result with the player well above 0 HP — verified by hand against `combat-damage.ts`'s formula before writing this task — so the second fight's `startCombat` call is never blocked by the new `CHARACTER_TOO_WOUNDED` gate. If this assumption turns out wrong and the test fails on that gate instead, the fix is to increase the player's `currentHp` or the monster's HP pool in the harness's `character()`/`monster()` fixtures, not to change production code.) - [ ] **Step 3: Commit** ```bash git add apps/api/src/combat/combat-equipment-integration.spec.ts git commit -m "test(api): update combat/equipment integration harness for HP vitals wiring" ``` --- ### Task 13: Seed script anchors the demo character's regeneration **Files:** - Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts` **Interfaces:** - None new — this only affects the dev-seed data path, not any service's public interface. - [ ] **Step 1: Update the insert** In `apps/api/src/database/seeds/vertical-slice.seed.ts`, find the demo character insert: ```ts if (!existing) { await characterRepository.insert({ id: DEMO_CHARACTER_ID, name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, currentLocationId: southGateId, }); } ``` and add `hpRegenSince`: ```ts if (!existing) { await characterRepository.insert({ id: DEMO_CHARACTER_ID, name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, baseHp: 100, baseAttack: 6, currentHp: 100, hpRegenSince: new Date(), currentLocationId: southGateId, }); } ``` - [ ] **Step 2: Compile check (no dedicated seed test in this repo)** Run: `npm run build:api` Expected: no new type errors - [ ] **Step 3: Commit** ```bash git add apps/api/src/database/seeds/vertical-slice.seed.ts git commit -m "chore(api): anchor the seeded demo character's HP regeneration" ``` --- ### Task 14: Add the API error code and regen fields to the web `CharacterResponse` model **Files:** - Modify: `apps/web/src/app/core/api/game-api.models.ts` - Modify: `apps/web/src/app/features/inventory/inventory-page.component.spec.ts` - Modify: `apps/web/src/app/features/combat/combat.store.ts` - Modify: `apps/web/src/app/features/combat/combat.store.spec.ts` **Interfaces:** - Produces: `CharacterResponse` gains `hpRegenPerSecond: number` and `hpRegenSince: string | null`, matching `GET /api/characters/me`'s new shape (Task 7). `COMBAT_ERROR_MESSAGES` gains a `CHARACTER_TOO_WOUNDED` entry (Task 8). Consumed by `WorldStore` (Task 15) and `app.spec.ts`/`world.store.spec.ts` fixtures (Task 15, 16). - [ ] **Step 1: Update the model** In `apps/web/src/app/core/api/game-api.models.ts`: ```ts export interface CharacterResponse { id: string; name: string; level: number; experience: number; silver: number; currentHp: number; maxHp: number; attack: number; hpRegenPerSecond: number; hpRegenSince: string | null; currentLocation: LocationSummary; } ``` - [ ] **Step 2: Fix the now-incomplete fixture in `inventory-page.component.spec.ts`** This fixture is passed directly to the component under test (not through `WorldStore`), so it only needs to satisfy the type: ```ts const character: CharacterResponse = { id: 'character-1', name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, currentHp: 100, maxHp: 100, attack: 6, hpRegenPerSecond: 1, hpRegenSince: null, currentLocation: { id: 'loc-1', key: 'south-gate', name: 'Südtor' }, }; ``` - [ ] **Step 3: Run the inventory suite to confirm it still compiles and passes** Run: `npm run test --workspace=@ashen-realms/web -- inventory-page` Expected: PASS - [ ] **Step 4: Add the failing error-mapping test for `combat.store.spec.ts`** Add this test near the existing "clears any previous combat and reports the mapped error when starting fails" test: ```ts it('maps CHARACTER_TOO_WOUNDED to its German message', async () => { api.startCombat.mockReturnValue( throwError( () => new HttpErrorResponse({ status: 409, error: { statusCode: 409, code: 'CHARACTER_TOO_WOUNDED', message: 'Too wounded.' }, }), ), ); await store.startCombat('encounter-1'); expect(store.error()).toBe('Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.'); }); ``` - [ ] **Step 5: Run it to verify it fails** Run: `npm run test --workspace=@ashen-realms/web -- combat.store` Expected: FAIL — the message falls back to the generic error text because the code is unmapped. - [ ] **Step 6: Add the mapping** In `apps/web/src/app/features/combat/combat.store.ts`: ```ts const COMBAT_ERROR_MESSAGES: Readonly> = { HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.', HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.', INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.', CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.', CHARACTER_TOO_WOUNDED: 'Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.', COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.', COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.', COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.', COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.', }; ``` - [ ] **Step 7: Run it to verify it passes** Run: `npm run test --workspace=@ashen-realms/web -- combat.store` Expected: PASS - [ ] **Step 8: Commit** ```bash git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/inventory/inventory-page.component.spec.ts apps/web/src/app/features/combat/combat.store.ts apps/web/src/app/features/combat/combat.store.spec.ts git commit -m "feat(web): model HP regen fields and map CHARACTER_TOO_WOUNDED" ``` --- ### Task 15: `WorldStore` counts up displayed HP locally between server syncs **Files:** - Modify: `apps/web/src/app/features/world/world.store.ts` - Modify: `apps/web/src/app/features/world/world.store.spec.ts` **Interfaces:** - Consumes: `CharacterResponse.hpRegenPerSecond`/`hpRegenSince` (Task 14). - Produces: `WorldStore.displayedCharacter: Signal`. Consumed by `app-shell.component.html` (Task 16). - [ ] **Step 1: Update the shared fixture and add the failing tests** In `apps/web/src/app/features/world/world.store.spec.ts`, update the top-level `character` fixture: ```ts const character: CharacterResponse = { id: 'character-id', name: 'Aric Duskwalker', level: 1, experience: 0, silver: 0, currentHp: 100, maxHp: 100, attack: 6, hpRegenPerSecond: 1, hpRegenSince: null, currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, }; ``` Add a new `describe` block at the end of the file, before the closing of the outer `describe('WorldStore', ...)`: ```ts describe('HP regeneration display', () => { it('counts displayedCharacter up once per second while an anchor is set', async () => { const wounded: CharacterResponse = { ...character, currentHp: 40, maxHp: 100, hpRegenSince: '2026-08-18T10:00:00.000Z', }; api.getCharacter.mockReturnValue(of(wounded)); await store.load(); expect(store.displayedCharacter()?.currentHp).toBe(40); await vi.advanceTimersByTimeAsync(3_000); expect(store.displayedCharacter()?.currentHp).toBe(43); }); it('stops at maxHp instead of counting past it', async () => { const almostHealed: CharacterResponse = { ...character, currentHp: 99, maxHp: 100, hpRegenSince: '2026-08-18T10:00:00.000Z', }; api.getCharacter.mockReturnValue(of(almostHealed)); await store.load(); await vi.advanceTimersByTimeAsync(5_000); expect(store.displayedCharacter()?.currentHp).toBe(100); }); it('does not tick while regeneration is paused', async () => { const paused: CharacterResponse = { ...character, currentHp: 40, maxHp: 100, hpRegenSince: null, }; api.getCharacter.mockReturnValue(of(paused)); await store.load(); await vi.advanceTimersByTimeAsync(5_000); expect(store.displayedCharacter()?.currentHp).toBe(40); }); it('resyncs the ticker to a freshly loaded anchor on refreshCharacter', async () => { api.getCharacter.mockReturnValue(of(character)); await store.load(); const stillWounded: CharacterResponse = { ...character, currentHp: 10, maxHp: 100, hpRegenSince: '2026-08-18T10:00:00.000Z', }; api.getCharacter.mockReturnValue(of(stillWounded)); await store.refreshCharacter(); await vi.advanceTimersByTimeAsync(4_000); expect(store.displayedCharacter()?.currentHp).toBe(14); }); }); ``` - [ ] **Step 2: Run the suite to verify the new tests fail and existing ones still pass** Run: `npm run test --workspace=@ashen-realms/web -- world.store` Expected: The four new tests FAIL (`displayedCharacter` doesn't exist yet); every pre-existing test in the file still PASSes. - [ ] **Step 3: Update the implementation** In `apps/web/src/app/features/world/world.store.ts`, add a new private state signal and its public accessor near the top: ```ts private readonly characterState = signal(null); private readonly displayedCharacterState = signal(null); ``` ```ts readonly character = this.characterState.asReadonly(); readonly displayedCharacter = this.displayedCharacterState.asReadonly(); ``` Add a new private field beside `countdownTimer`: ```ts private countdownTimer: ReturnType | undefined; private regenTimer: ReturnType | undefined; ``` Replace the three call sites that currently do `this.characterState.set(character);` — inside `load()`, inside `refreshCharacter()`, and inside `reloadAuthoritativeState()` — with `this.applyCharacter(character);`. Do not change anything else in those three methods. Add the new private methods, near `startCountdown`/`stopCountdown`: ```ts private applyCharacter(character: CharacterResponse): void { this.characterState.set(character); this.stopRegenTicker(); this.refreshDisplayedCharacter(character); if (character.hpRegenSince !== null) { this.regenTimer = setInterval(() => this.refreshDisplayedCharacter(character), 1_000); } } private refreshDisplayedCharacter(character: CharacterResponse): void { if (this.destroyed) { return; } const currentHp = this.computeDisplayedHp(character); this.displayedCharacterState.set({ ...character, currentHp }); if (currentHp >= character.maxHp) { this.stopRegenTicker(); } } private computeDisplayedHp(character: CharacterResponse): number { if (character.hpRegenSince === null) { return character.currentHp; } const elapsedSeconds = Math.max(0, (Date.now() - Date.parse(character.hpRegenSince)) / 1000); const regenerated = Math.floor(elapsedSeconds * character.hpRegenPerSecond); return Math.min(character.maxHp, character.currentHp + regenerated); } private stopRegenTicker(): void { if (this.regenTimer !== undefined) { clearInterval(this.regenTimer); this.regenTimer = undefined; } } ``` Update `ngOnDestroy` to also stop the new timer: ```ts ngOnDestroy(): void { this.destroyed = true; this.stopCountdown(); this.stopRegenTicker(); this.clearTravelRetry(); } ``` - [ ] **Step 4: Run the suite to verify it passes** Run: `npm run test --workspace=@ashen-realms/web -- world.store` Expected: PASS (all tests, old and new) - [ ] **Step 5: Commit** ```bash git add apps/web/src/app/features/world/world.store.ts apps/web/src/app/features/world/world.store.spec.ts git commit -m "feat(web): count displayed HP up locally between server syncs" ``` --- ### Task 16: HUD reads the locally-ticking HP value **Files:** - Modify: `apps/web/src/app/layout/app-shell/app-shell.component.html` - Modify: `apps/web/src/app/app.spec.ts` **Interfaces:** - Consumes: `WorldStore.displayedCharacter` (Task 15). - [ ] **Step 1: Update the failing test fixture** In `apps/web/src/app/app.spec.ts`, the fake `WorldStore` provided in `beforeEach` only supplies `{ character, currentLocation, selectedConnection }`. Add a `displayedCharacter` signal alongside `character`: ```ts let character: WritableSignal; let displayedCharacter: WritableSignal; let currentLocation: WritableSignal; let selectedConnection: WritableSignal; beforeEach(async () => { character = signal(null); displayedCharacter = signal(null); currentLocation = signal(null); selectedConnection = signal(null); await TestBed.configureTestingModule({ imports: [AppShellComponent], providers: [ provideRouter([ { path: 'location', children: [] }, { path: 'world', children: [] }, { path: 'hunt', children: [] }, { path: 'inventory', children: [] }, ]), { provide: WorldStore, useValue: { character, displayedCharacter, currentLocation, selectedConnection }, }, ], }).compileComponents(); }); ``` In the `'renders loaded character values supplied by the WorldStore'` test, the component is about to switch from reading `character()` to reading `displayedCharacter()`. Set `character` to a decoy value the HUD must NOT show, and `displayedCharacter` to the value it must show — this way the test actually fails before Task 16 Step 3's binding change, instead of passing for the wrong reason: ```ts it('renders loaded character values supplied by the WorldStore', () => { const decoy: CharacterResponse = { id: 'stale-id', name: 'Stale Decoy', level: 1, experience: 0, silver: 0, currentHp: 1, maxHp: 1, attack: 1, hpRegenPerSecond: 1, hpRegenSince: null, currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' }, }; const value: CharacterResponse = { id: 'character-id', name: 'Mara Ashfall', level: 7, experience: 320, silver: 150, currentHp: 52, maxHp: 80, attack: 12, hpRegenPerSecond: 1, hpRegenSince: null, currentLocation: { id: 'location-id', key: 'south-gate', name: 'Südtor von Graufurt' }, }; character.set(decoy); displayedCharacter.set(value); const fixture = TestBed.createComponent(AppShellComponent); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain( 'Mara Ashfall', ); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('Stufe 7'); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('52 / 80'); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('150'); expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320'); }); ``` - [ ] **Step 2: Run the suite to verify it fails** Run: `npm run test --workspace=@ashen-realms/web -- app.spec` Expected: FAIL — the template still binds `worldStore.character()`, so it renders "Stale Decoy" / "Stufe 1" / "1 / 1" instead of the expected values. - [ ] **Step 3: Update the binding** In `apps/web/src/app/layout/app-shell/app-shell.component.html`, change: ```html ``` to: ```html ``` - [ ] **Step 4: Run the full web suite to catch anything else touching AppShellComponent or TopBarComponent** Run: `npm run test --workspace=@ashen-realms/web` Expected: PASS - [ ] **Step 5: Commit** ```bash git add apps/web/src/app/layout/app-shell/app-shell.component.html apps/web/src/app/app.spec.ts git commit -m "feat(web): bind the HUD health bar to the locally-ticking HP value" ``` --- ### Task 17: Full-suite verification **Files:** none (verification only) - [ ] **Step 1: Run the full API test suite** Run: `npm run test --workspace=@ashen-realms/api` Expected: PASS, zero failures - [ ] **Step 2: Run the full web test suite** Run: `npm run test --workspace=@ashen-realms/web` Expected: PASS, zero failures - [ ] **Step 3: Build both apps to catch any type errors the test suites didn't** Run: `npm run build` Expected: PASS, no compile errors - [ ] **Step 4: Re-read the design doc's edge-case table (§10) against the finished code** Confirm by inspection: max-HP rise re-anchors (Task 11), max-HP fall has no unequip path to exercise it (documented gap, not a bug), backwards clock cannot reduce HP (Task 4's test), abandoned mid-fight combat leaves the anchor null (Task 9/10 never call `resume` except at combat end), equipment mid-combat cannot happen (`characterInCombat()` guard, pre-existing), potion healing is unaffected (still clamped to the frozen `combat.playerMaxHp`, untouched by this plan). No commit for this task — it is a checkpoint, not a change.