Files
ashen-realms/docs/superpowers/specs/2026-08-21-persistent-hp-and-regeneration-design.md
2026-08-21 13:56:43 +02:00

175 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.