14 KiB
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:
- HP surviving a combat carries over to the character.
- The character regenerates 1 HP per second outside combat.
- The HP the character walks in with is the HP the next combat starts with.
- 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_hpalready exists (character.entity.ts:35-36), is already surfaced byGET /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.startCombatseeds the fight withplayerCurrentHp: playerStats.maxHp(combat.service.ts:146). This one line is the actual bug.- Combat never writes back:
performActionmutatescombat.playerCurrentHp(combat.service.ts:242) and saves the combat row, but the character row is only locked, never updated. CharacterStatsServiceis documented as the "single authoritative source of effective character stats (spec §18)" and already passescurrentHpthrough (character-stats.service.ts:57). Its three consumers areCharactersService,EquipmentService, andCombatService.- A
Clockabstraction with aCLOCKinjection token already exists, but lives in the travel module (travel/clock.ts) and is provided only byTravelModule. TravelService.completeTravelIfDueestablishes the house lazy-settle-on-read pattern: open a transaction, lock the character, apply elapsed time, write back.EquipmentService.equipis 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).WorldStorealready owns the character signal that feeds the top bar (app-shell.component.html:2), already runs a one-secondcountdownTimerfor travel, and already cleans it up inngOnDestroy.- Migration convention: timestamp-prefixed files in
apps/api/src/database/migrations/, each with a sibling*.migration.spec.tsthat asserts TypeORM entity metadata only and never touches a live database. Latest on disk is1790000000000-ExtendCombatEventTypes.ts; the not-yet-implemented Renown plan reserves1791000000000. - 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
completeTravelIfDuepattern — turns everyGETinto 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, thenhp_regen_since = NULLfor characters holding a combat withstatus = '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) gainshpRegenPerSecondandhpRegenSince.WorldStoregains a second one-second timer besidecountdownTimer, cleaned up in the samengOnDestroy, and adisplayedCharactersignal that applies the section 6.2 formula to the last server snapshot. The timer only runs whilehpRegenSince !== null.characterStatekeeps the raw server value; every real request resyncs it.app-shell.component.htmlbinds the top bar toworldStore.displayedCharacter().TopBarComponentis unchanged — it still reads one input.- Error messages for
CHARACTER_TOO_WOUNDEDgo into bothCOMBAT_ERROR_MESSAGES(combat.store.ts) andHUNT_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 atmaxHp,NULLanchor returns the frozen value, backwards clock yields no loss,pause/resume/settlewrite the expected pair of fields.character-stats.service.spec.ts: the existingpasses currentHp through unchanged from the charactercase is replaced —currentHpis now the effective value.combat.service.spec.ts: start seeds from character HP rather than max HP; start at 0 effective HP throwsCHARACTER_TOO_WOUNDED; start at exactly 1 HP succeeds; a continuing round mirrors HP onto the character with aNULLanchor; a win and a loss both restart the anchor atnow().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 atmaxHp, no ticking while the anchor isnull, timer cleared on destroy.- E2E: finish a combat with damage taken →
GET /characters/mereports the reduced HP → after advancing the injected clock, it reports a higher value.