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