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