feat(api): add CharacterVitalsService for anchored HP regeneration

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-21 14:32:18 +02:00
parent ea26e4b844
commit 71f58bc1b6
2 changed files with 182 additions and 0 deletions

View File

@@ -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> = {}): 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);
});
});
});