feat(api): add CharacterVitalsService for anchored HP regeneration
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal file
136
apps/api/src/characters/character-vitals.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/api/src/characters/character-vitals.service.ts
Normal file
46
apps/api/src/characters/character-vitals.service.ts
Normal file
@@ -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<Character, 'currentHp' | 'hpRegenSince'>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user