diff --git a/docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md b/docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md new file mode 100644 index 0000000..9bd6f61 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md @@ -0,0 +1,1958 @@ +# 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.