Files
ashen-realms/docs/superpowers/plans/2026-08-21-persistent-hp-and-regeneration.md
2026-08-21 14:12:38 +02:00

1959 lines
65 KiB
Markdown

# 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 `-- <pattern>`). 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<void> {
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<void> {
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<Character, 'currentHp' | 'hpRegenSince'>, 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> = {}): 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<Character, 'currentHp' | 'hpRegenSince'>,
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> = {}): 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<DataSource, 'getRepository'>;
/**
* 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<EffectiveCharacterStats> {
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> = {}): 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> = {}): 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<State> = {}) {
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> = {}): 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<Record<string, string>> = {
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<CharacterResponse | null>`. 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<CharacterResponse | null>(null);
private readonly displayedCharacterState = signal<CharacterResponse | null>(null);
```
```ts
readonly character = this.characterState.asReadonly();
readonly displayedCharacter = this.displayedCharacterState.asReadonly();
```
Add a new private field beside `countdownTimer`:
```ts
private countdownTimer: ReturnType<typeof setInterval> | undefined;
private regenTimer: ReturnType<typeof setInterval> | 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<CharacterResponse | null>;
let displayedCharacter: WritableSignal<CharacterResponse | null>;
let currentLocation: WritableSignal<null>;
let selectedConnection: WritableSignal<null>;
beforeEach(async () => {
character = signal<CharacterResponse | null>(null);
displayedCharacter = signal<CharacterResponse | null>(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
<app-top-bar [character]="worldStore.character()" />
```
to:
```html
<app-top-bar [character]="worldStore.displayedCharacter()" />
```
- [ ] **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.