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

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 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
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 on Character instances 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 by CharacterVitalsService (Task 4) and CharacterStatsService (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, 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

// 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: 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

// 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: 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:

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 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:

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(): 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:

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 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:

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/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":

    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 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:

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: 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:

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 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:

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.