diff --git a/docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md b/docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md new file mode 100644 index 0000000..a7e775b --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md @@ -0,0 +1,174 @@ +# 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.