Add design doc for persistent character HP and out-of-combat regeneration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-21 13:56:43 +02:00
parent dd90e05446
commit a75a670509

View File

@@ -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.