From 71f58bc1b6a4ea3b4ee6ef0970b81440be41a23a Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 14:32:18 +0200 Subject: [PATCH] feat(api): add CharacterVitalsService for anchored HP regeneration Co-Authored-By: Claude Sonnet 5 --- .../character-vitals.service.spec.ts | 136 ++++++++++++++++++ .../characters/character-vitals.service.ts | 46 ++++++ 2 files changed, 182 insertions(+) create mode 100644 apps/api/src/characters/character-vitals.service.spec.ts create mode 100644 apps/api/src/characters/character-vitals.service.ts diff --git a/apps/api/src/characters/character-vitals.service.spec.ts b/apps/api/src/characters/character-vitals.service.spec.ts new file mode 100644 index 0000000..06d697c --- /dev/null +++ b/apps/api/src/characters/character-vitals.service.spec.ts @@ -0,0 +1,136 @@ +import { Clock } from '../shared/clock'; +import { CharacterVitalsService } from './character-vitals.service'; +import { Character } from './entities/character.entity'; + +function fakeClock(initialIso: string): { + clock: Clock; + advanceSeconds: (seconds: number) => void; +} { + let current = Date.parse(initialIso); + return { + clock: { now: () => new Date(current) }, + advanceSeconds: (seconds: number) => { + current += seconds * 1000; + }, + }; +} + +function character(overrides: Partial = {}): Character { + return { + id: 'character-1', + currentHp: 50, + hpRegenSince: null, + ...overrides, + } as Character; +} + +describe('CharacterVitalsService', () => { + describe('effectiveHp', () => { + it('returns the raw current HP when regeneration is paused', () => { + const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + + const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100); + + expect(hp).toBe(37); + }); + + it('adds one HP per elapsed second since the anchor', () => { + const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + const anchor = new Date('2026-08-21T12:00:00.000Z'); + const target = character({ currentHp: 40, hpRegenSince: anchor }); + + advanceSeconds(25); + + expect(service.effectiveHp(target, 100)).toBe(65); + }); + + it('floors partial seconds instead of rounding up', () => { + const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + const anchor = new Date('2026-08-21T12:00:00.000Z'); + const target = character({ currentHp: 40, hpRegenSince: anchor }); + + advanceSeconds(1.9); + + expect(service.effectiveHp(target, 100)).toBe(41); + }); + + it('clamps regeneration at maxHp', () => { + const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + const anchor = new Date('2026-08-21T12:00:00.000Z'); + const target = character({ currentHp: 90, hpRegenSince: anchor }); + + advanceSeconds(50); + + expect(service.effectiveHp(target, 100)).toBe(100); + }); + + it('never lets HP fall if the clock moves backwards', () => { + const clock: Clock = { now: () => new Date('2026-08-21T11:59:00.000Z') }; + const service = new CharacterVitalsService(clock); + const anchor = new Date('2026-08-21T12:00:00.000Z'); + const target = character({ currentHp: 40, hpRegenSince: anchor }); + + expect(service.effectiveHp(target, 100)).toBe(40); + }); + }); + + describe('pause', () => { + it('freezes current HP at the given value and clears the anchor', () => { + const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + const target = character({ + currentHp: 100, + hpRegenSince: new Date('2026-08-21T11:00:00.000Z'), + }); + + service.pause(target, 62); + + expect(target.currentHp).toBe(62); + expect(target.hpRegenSince).toBeNull(); + }); + }); + + describe('resume', () => { + it('sets current HP and anchors regeneration at now', () => { + const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + const target = character({ currentHp: 0, hpRegenSince: null }); + + service.resume(target, 15); + + expect(target.currentHp).toBe(15); + expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:00.000Z')); + }); + }); + + describe('settle', () => { + it('re-anchors at the current effective value without changing it', () => { + const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + const anchor = new Date('2026-08-21T12:00:00.000Z'); + const target = character({ currentHp: 40, hpRegenSince: anchor }); + advanceSeconds(10); + + service.settle(target, 100); + + expect(target.currentHp).toBe(50); + expect(target.hpRegenSince).toEqual(new Date('2026-08-21T12:00:10.000Z')); + }); + + it('does not gift overflow past the pre-change maxHp when re-anchoring', () => { + const { clock, advanceSeconds } = fakeClock('2026-08-21T12:00:00.000Z'); + const service = new CharacterVitalsService(clock); + const anchor = new Date('2026-08-21T12:00:00.000Z'); + const target = character({ currentHp: 100, hpRegenSince: anchor }); + advanceSeconds(600); + + service.settle(target, 100); + + expect(target.currentHp).toBe(100); + }); + }); +}); diff --git a/apps/api/src/characters/character-vitals.service.ts b/apps/api/src/characters/character-vitals.service.ts new file mode 100644 index 0000000..ee45abc --- /dev/null +++ b/apps/api/src/characters/character-vitals.service.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CLOCK } from '../shared/clock'; +import type { Clock } from '../shared/clock'; +import { HP_REGEN_PER_SECOND } from './character-vitals.constants'; +import { Character } from './entities/character.entity'; + +/** + * The only place that turns (current_hp, hp_regen_since) into an effective + * HP value, or moves that pair. `current_hp` is exact only while the anchor + * is null; everything else must go through here (persistent-hp-and- + * regeneration design, R3). + */ +@Injectable() +export class CharacterVitalsService { + constructor(@Inject(CLOCK) private readonly clock: Clock) {} + + effectiveHp( + character: Pick, + maxHp: number, + ): number { + if (character.hpRegenSince === null) { + return Math.min(maxHp, character.currentHp); + } + + const elapsedSeconds = Math.max( + 0, + (this.clock.now().getTime() - character.hpRegenSince.getTime()) / 1000, + ); + const regenerated = Math.floor(elapsedSeconds * HP_REGEN_PER_SECOND); + return Math.min(maxHp, character.currentHp + regenerated); + } + + pause(character: Character, value: number): void { + character.currentHp = value; + character.hpRegenSince = null; + } + + resume(character: Character, value: number): void { + character.currentHp = value; + character.hpRegenSince = this.clock.now(); + } + + settle(character: Character, maxHp: number): void { + this.resume(character, this.effectiveHp(character, maxHp)); + } +}