# Persistent Character HP & Out-of-Combat Regeneration: Design **Status:** Approved section by section in brainstorming on 2026-08-21. Every open question the request left is recorded below as a ruling with its cost if wrong. ## 1. Problem The player starts every combat at full HP. Finishing a fight on 10 HP and immediately entering the next one hands the player a free heal, so damage taken has no cost beyond the current round and there is no reason to ever retreat. Requested behaviour: 1. HP surviving a combat carries over to the character. 2. The character regenerates 1 HP per second outside combat. 3. The HP the character walks in with is the HP the next combat starts with. 4. No automatic regeneration while a combat is running. ## 2. Scope Architectural. It introduces a resource mechanic shared by the characters, combat, and equipment modules, adds a column and a service, changes the meaning of an existing column, and changes two API payloads plus one frontend store. ## 3. Current-state findings that shape this design (From a codebase survey on `master` at `dd90e05`.) - `characters.current_hp` **already exists** (`character.entity.ts:35-36`), is already surfaced by `GET /api/characters/me` (`characters.service.ts:34`), and is already rendered by the HUD health bar (`top-bar.component.html`). Nothing writes it after seeding. - `CombatService.startCombat` seeds the fight with `playerCurrentHp: playerStats.maxHp` (`combat.service.ts:146`). This one line is the actual bug. - Combat never writes back: `performAction` mutates `combat.playerCurrentHp` (`combat.service.ts:242`) and saves the combat row, but the character row is only locked, never updated. - `CharacterStatsService` is documented as the "single authoritative source of effective character stats (spec §18)" and already passes `currentHp` through (`character-stats.service.ts:57`). Its three consumers are `CharactersService`, `EquipmentService`, and `CombatService`. - A `Clock` abstraction with a `CLOCK` injection token already exists, but lives in the travel module (`travel/clock.ts`) and is provided only by `TravelModule`. - `TravelService.completeTravelIfDue` establishes the house lazy-settle-on-read pattern: open a transaction, lock the character, apply elapsed time, write back. - `EquipmentService.equip` is the **only** way equipment changes today — there is no unequip endpoint, and it already refuses to run during an active combat (`characterInCombat()`, `equipment.service.ts:83-88`). - `WorldStore` already owns the character signal that feeds the top bar (`app-shell.component.html:2`), already runs a one-second `countdownTimer` for travel, and already cleans it up in `ngOnDestroy`. - Migration convention: timestamp-prefixed files in `apps/api/src/database/migrations/`, each with a sibling `*.migration.spec.ts` that asserts TypeORM entity metadata only and never touches a live database. Latest on disk is `1790000000000-ExtendCombatEventTypes.ts`; the not-yet-implemented Renown plan reserves `1791000000000`. - No design document in `docs/` specifies any HP regeneration rule. This mechanic is genuinely new. ## 4. Rulings **R1 — Defeat leaves the character at 0 HP and regenerates up from there.** No revive, no partial restore. Losing costs real time, which is what makes retreating a decision. *Cost if wrong:* a single line in the combat-end path. **R2 — Starting a combat requires ≥ 1 effective HP; there is no separate defeat lockout.** One rule the player can learn, no "was defeated" flag on the character, no second timer. After a loss the player waits roughly one second. *Cost if wrong:* raising the threshold is a constant change plus one test; it needs no schema change. **R3 — Regeneration is a derived value anchored to a timestamp, not a scheduled job and not a settle-on-read write.** `characters.current_hp` becomes "HP as of `hp_regen_since`". Effective HP is a pure function of `(current_hp, hp_regen_since, maxHp, now)`. Rejected alternatives: - *A cron tick incrementing every character each second* — one write per character per second, and every server restart or outage silently swallows the elapsed time. Only testable against real timers. - *Settle-on-read after the `completeTravelIfDue` pattern* — turns every `GET` into a locking write transaction. Since the client ticks locally, the API has to return the anchor timestamp regardless, which makes the write pure redundancy and opens a lost-update window between concurrent reads. The derived model additionally gets max-HP clamping for free (the `min(maxHp, …)` happens at read time, so an equipment change never has to touch HP) and produces exactly the four values the client needs to count up locally. *Cost if wrong:* switching to settle-on-read later reuses the same column and the same arithmetic; only the call sites change. **R4 — Regeneration is paused during combat by setting the anchor to `NULL`, and time spent in combat is not credited afterwards.** This is what "no regeneration during combat" means: a paused clock, not a deferred one. `NULL` encodes "frozen at `current_hp`" and needs no extra column. *Cost if wrong:* crediting combat time instead would mean not nulling the anchor at all — a deletion, not a rewrite. **R5 — Every combat round mirrors the player's HP onto the character, rather than only writing back once at combat end.** `performAction` already holds a pessimistic write lock on the character row, so this is one extra field on a row that is being saved anyway. It keeps the HUD truthful mid-fight and removes "combat end" as a special case — the end differs only in that it restarts the anchor. *Cost if wrong:* dropping the mirror leaves the write-back at combat end; the code path stays identical. **R6 — `EquipmentService.equip` re-anchors HP, to stop accumulated overflow from being cashed in.** The anchor clamps on read, not on write. A character idling ten minutes at 100/100 (anchor: 100 HP, 600 s ago) who then equips +20 max HP would evaluate to `min(120, 100 + 600) = 120` and receive the new points instantly. `settle()` before the equipment change collapses the anchor to the clamped value at `now()`. This is the only call site: there is no unequip endpoint, and `equip` is already blocked during combat — so `settle()` here always runs against a live (non-`NULL`) anchor. *Cost if wrong:* removing the call restores the old behaviour; it is one line. **R7 — The regeneration rate is one exported constant, not a column and not per-character.** `HP_REGEN_PER_SECOND = 1` in `characters/character-vitals.constants.ts`, matching how `STARTING_POTION_COUNT` lives beside its service. *Cost if wrong:* making it character-derived later changes the service signature and one migration; no consumer outside the vitals service reads it. **R8 — The client counts up locally from the server's anchor; the server stays authoritative.** The API returns `hpRegenPerSecond` and `hpRegenSince`; `WorldStore` recomputes the displayed value every second using the same formula, clamped to `maxHp`. Every real request overwrites the local value. No polling loop. *Cost if wrong:* falling back to "update only on navigation" is deleting the timer. ## 5. Data model Migration `1792000000000-AddHpRegeneration.ts` (skipping `1791000000000`, which the pending Renown plan reserves): ```sql ALTER TABLE characters ADD COLUMN hp_regen_since timestamptz NULL; ``` Backfill in the same migration: - `hp_regen_since = now()` for every character, then - `hp_regen_since = NULL` for characters holding a combat with `status = 'ACTIVE'`, so the migration does not gift an in-flight fight. `down()` drops the column. **Column contract**, documented on the entity: `current_hp` is the HP value as of `hp_regen_since`. When `hp_regen_since` is `NULL`, regeneration is paused and `current_hp` is exact. **No code outside `CharacterVitalsService` may read `current_hp` directly.** ## 6. Components ### 6.1 `shared/clock.ts` (moved) `travel/clock.ts` moves to `shared/clock.ts` verbatim. Two modules must not borrow a clock from the travel module. `TravelModule` keeps providing it; `CharactersModule` provides it too. Import paths in `travel.module.ts` and `travel.service.ts` change; nothing else does. ### 6.2 `CharacterVitalsService` (new) `apps/api/src/characters/character-vitals.service.ts`. The only place the anchor arithmetic exists. Injects `CLOCK`. No method opens a transaction — each operates on an already-loaded `Character` instance, leaving locking and saving to the caller. | Method | Behaviour | |---|---| | `effectiveHp(character, maxHp): number` | Pure. Anchor `NULL` ⇒ `min(maxHp, current_hp)`. Otherwise `min(maxHp, current_hp + floor(elapsedSeconds × HP_REGEN_PER_SECOND))`, with `elapsedSeconds` floored at 0 so a backwards-moving clock can never reduce HP. | | `pause(character, value)` | `current_hp = value`, `hp_regen_since = null`. | | `resume(character, value)` | `current_hp = value`, `hp_regen_since = clock.now()`. | | `settle(character, maxHp)` | `resume(character, effectiveHp(character, maxHp))`. Re-anchors without changing the effective value. | ### 6.3 `CharacterStatsService` (changed) `currentHp` in `EffectiveCharacterStats` becomes the effective value rather than the raw column, via an injected `CharacterVitalsService`. This keeps the documented "single authoritative source" claim true and makes all three existing consumers correct without touching them individually. ### 6.4 Module wiring `CharactersModule` adds `CharacterVitalsService` and `{ provide: CLOCK, useValue: systemClock }` to `providers`, and `CharacterVitalsService` to `exports`. `CombatModule` and `EquipmentModule` already import `CharactersModule`, so they need no new imports — only the constructor injection. ## 7. Data flow | Event | Effect | |---|---| | `GET /api/characters/me` | Read-only. Response gains `hpRegenPerSecond: number` and `hpRegenSince: string \| null` (ISO 8601). `currentHp` is the effective value. | | `POST /api/combats` | Compute effective HP. If `< 1`, throw `CHARACTER_TOO_WOUNDED`. Otherwise `pause(character, effectiveHp)`, save the character, and seed `combat.playerCurrentHp` with that value instead of `playerStats.maxHp`. `playerMaxHp` still comes from `playerStats.maxHp`. | | `POST /api/combats/:id/actions`, fight continues | After the engine resolves the round: `pause(character, combat.playerCurrentHp)`, save the character in the same transaction. | | `POST /api/combats/:id/actions`, fight ends (`WON`/`LOST`) | `resume(character, combat.playerCurrentHp)` instead of `pause`. The anchor restarts; a loss regenerates from 0. | | `POST /api/equipment` | `settle(character, maxHpBeforeChange)` before writing the equipment row. | The character row is already locked in all three write paths, so no new locking or lock ordering is introduced. ## 8. Frontend - `CharacterResponse` (`game-api.models.ts`) gains `hpRegenPerSecond` and `hpRegenSince`. - `WorldStore` gains a second one-second timer beside `countdownTimer`, cleaned up in the same `ngOnDestroy`, and a `displayedCharacter` signal that applies the section 6.2 formula to the last server snapshot. The timer only runs while `hpRegenSince !== null`. `characterState` keeps the raw server value; every real request resyncs it. - `app-shell.component.html` binds the top bar to `worldStore.displayedCharacter()`. - `TopBarComponent` is unchanged — it still reads one input. - Error messages for `CHARACTER_TOO_WOUNDED` go into both `COMBAT_ERROR_MESSAGES` (`combat.store.ts`) and `HUNT_ERROR_MESSAGES` (`hunting.store.ts`), because the fight is started from the hunt screen: *"Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast."* ## 9. Error handling One new code in `combat.errors.ts`, following the existing factory-function pattern: ```ts export function characterTooWounded(): CombatDomainError { return new CombatDomainError( 'CHARACTER_TOO_WOUNDED', HttpStatus.CONFLICT, 'The character is too wounded to fight.', ); } ``` added to the `CombatErrorCode` union. `409 Conflict` matches `CHARACTER_TRAVELLING`, the other "right request, wrong moment" case. ## 10. Edge cases | Case | Handling | |---|---| | Max HP rises (equipping) | R6's `settle()` re-anchors first, so only time elapsed *after* the change counts toward the new ceiling. | | Max HP falls | No unequip endpoint exists today, but `min(maxHp, …)` clamps on every read, so a future one needs no HP-specific code beyond the existing `settle()` call. | | Clock moves backwards | Elapsed seconds floor at 0; HP can never decrease through regeneration. | | Combat abandoned mid-fight | The anchor stays `NULL`, so the character does not regenerate. This follows directly from R4. It is not a dead end: `App.ngOnInit` already resumes an open combat on the next page load. | | Equipment changed mid-combat | Cannot happen — `EquipmentService.equip` already throws `characterInCombat()`. | | Potion healing | Unchanged. The engine still clamps to `combat.playerMaxHp`, which is frozen at combat start. | ## 11. Testing TDD throughout, in plan order. - **`character-vitals.service.spec.ts`** (the core): anchor arithmetic against a fake clock, clamping at `maxHp`, `NULL` anchor returns the frozen value, backwards clock yields no loss, `pause`/`resume`/`settle` write the expected pair of fields. - **`character-stats.service.spec.ts`**: the existing `passes currentHp through unchanged from the character` case is replaced — `currentHp` is now the effective value. - **`combat.service.spec.ts`**: start seeds from character HP rather than max HP; start at 0 effective HP throws `CHARACTER_TOO_WOUNDED`; start at exactly 1 HP succeeds; a continuing round mirrors HP onto the character with a `NULL` anchor; a win and a loss both restart the anchor at `now()`. - **`equipment.service.spec.ts`**: equipping re-anchors, so a long-idle character at full HP does not instantly gain the item's bonus HP. - **`add-hp-regeneration.migration.spec.ts`**: entity-metadata assertions matching the eight existing migration specs, plus the backfill rule for a character with an active combat. - **`world.store.spec.ts`**: local count-up under Jest fake timers, stop at `maxHp`, no ticking while the anchor is `null`, timer cleared on destroy. - **E2E**: finish a combat with damage taken → `GET /characters/me` reports the reduced HP → after advancing the injected clock, it reports a higher value.