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

14 KiB
Raw Blame History

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):

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 NULLmin(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:

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.