65 KiB
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 inapps/api/src/characters/character-vitals.constants.ts. No other file may hardcode this number. - No code outside
CharacterVitalsServicereadsCharacter.currentHporCharacter.hpRegenSinceto compute an effective HP value. Everything else goes throughCharacterStatsService.calculate()(server) or the client's mirrored formula inWorldStore(display only). - Starting a combat requires effective HP
>= 1; there is no separate "defeated" flag or cooldown timer (design R2). - A combat's
playerMaxHpis 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 is1792000000000-AddHpRegeneration.ts(skips1791000000000, 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, fromapps/api(npm run test --workspace=@ashen-realms/api, or scope with-- <pattern>). Web tests run withvitest, fromapps/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),Clockinterface ({ now(): Date }),systemClock: Clock— all now importable from../shared/clock(or./clockfrom withinshared/). 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.tswith the moved content
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:
import { CLOCK, systemClock } from './clock';
to:
import { CLOCK, systemClock } from '../shared/clock';
- Step 4: Update the imports in
apps/api/src/travel/travel.service.ts
Change:
import { CLOCK } from './clock';
import type { Clock } from './clock';
to:
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:
import { Clock } from './clock';
to:
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
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 onCharacterinstances it is given. -
Step 1: Write the failing migration spec
// 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:
// `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
// 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
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 byCharacterVitalsService(Task 4) andCharacterStatsService(Task 6). -
Step 1: Create the constants file
// apps/api/src/characters/character-vitals.constants.ts
export const HP_REGEN_PER_SECOND = 1;
- Step 2: Commit
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,CLOCKfrom../shared/clock(Task 1);HP_REGEN_PER_SECONDfrom./character-vitals.constants(Task 3);Characterfrom./entities/character.entity(Task 2). -
Produces:
class CharacterVitalsServicewith:effectiveHp(character: Pick<Character, 'currentHp' | 'hpRegenSince'>, maxHp: number): numberpause(character: Character, value: number): voidresume(character: Character, value: number): voidsettle(character: Character, maxHp: number): void
Consumed by
CharacterStatsService(Task 6),CombatService(Tasks 9-10),EquipmentService(Task 12). -
Step 1: Write the failing test file
// 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
// 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
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:
CharacterVitalsServiceandCharacterStatsServiceboth exported fromCharactersModule.CombatModuleandEquipmentModulealready importCharactersModule, so they need no import changes to receiveCharacterVitalsServicevia constructor injection in later tasks. -
Step 1: Update the module
// 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
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:
EffectiveCharacterStatsgainshpRegenPerSecond: numberandhpRegenSince: Date | null;currentHpis 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:
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:
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:
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:
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:
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
// 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
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 gainshpRegenPerSecond: numberandhpRegenSince: string | null. This is the shapeGET /api/characters/mereturns 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:
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:
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:
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
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(): CombatDomainErrorwithcode: 'CHARACTER_TOO_WOUNDED',409 Conflict. Consumed byCombatService.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:
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():
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
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 newcharacterVitalsparameter sits betweencharacterStatsandcombatRewards.combat.playerCurrentHpat combat start is now the character's effective HP, notplayerMaxHp. 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:
import { CharacterVitalsService } from '../characters/character-vitals.service';
Update the character() fixture to set hpRegenSince explicitly:
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:
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):
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
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:
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:
import { CharacterVitalsService } from '../characters/character-vitals.service';
and add characterTooWounded to the existing combat.errors import list (alphabetical, matching the existing style):
import {
characterNotFound,
characterTooWounded,
characterTravelling,
combatAlreadyActive,
combatAlreadyFinished,
combatNoPotionsRemaining,
combatNotFound,
combatStateInvalid,
huntEncounterAlreadyConsumed,
huntEncounterNotFound,
invalidHuntEncounter,
} from './combat.errors';
Update the constructor:
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:
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:
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
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/hpRegenSinceare kept in sync withCombat.playerCurrentHpon 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":
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:
await this.lockCharacter(characters, characterId);
Change it to keep the reference:
const character = await this.lockCharacter(characters, characterId);
Then replace the block that mutates and saves combat:
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:
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
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:
import { CharacterVitalsService } from '../characters/character-vitals.service';
Update the character() fixture:
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:
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', ...):
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:
import { CharacterVitalsService } from '../characters/character-vitals.service';
Update the constructor:
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:
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
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 updatedCombatService/EquipmentService/CharacterStatsServiceconstructors (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:
import { CharacterVitalsService } from '../characters/character-vitals.service';
Update the character() fixture:
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:
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
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:
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:
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
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:
CharacterResponsegainshpRegenPerSecond: numberandhpRegenSince: string | null, matchingGET /api/characters/me's new shape (Task 7).COMBAT_ERROR_MESSAGESgains aCHARACTER_TOO_WOUNDEDentry (Task 8). Consumed byWorldStore(Task 15) andapp.spec.ts/world.store.spec.tsfixtures (Task 15, 16). -
Step 1: Update the model
In apps/web/src/app/core/api/game-api.models.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:
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:
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:
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
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 byapp-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:
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', ...):
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:
private readonly characterState = signal<CharacterResponse | null>(null);
private readonly displayedCharacterState = signal<CharacterResponse | null>(null);
readonly character = this.characterState.asReadonly();
readonly displayedCharacter = this.displayedCharacterState.asReadonly();
Add a new private field beside countdownTimer:
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:
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:
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
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:
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:
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:
<app-top-bar [character]="worldStore.character()" />
to:
<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
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.