feat(api): CharacterStatsService reports effective (regenerated) HP
- Update CharacterStatsService constructor to accept CharacterVitalsService - Compute currentHp via CharacterVitalsService.effectiveHp() instead of raw pass-through - Add hpRegenPerSecond and hpRegenSince fields to EffectiveCharacterStats return type - Update spec with new test cases for regeneration calculation and field pass-through - Equipment and combat tests now fail as expected (separate tasks will fix constructor calls) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { CharacterStatsService } from './character-stats.service';
|
|||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
|
|
||||||
type EquippedFixture = {
|
type EquippedFixture = {
|
||||||
slot: EquipmentSlot;
|
slot: EquipmentSlot;
|
||||||
@@ -34,12 +35,16 @@ function character(overrides: Partial<Character> = {}): Character {
|
|||||||
baseHp: 100,
|
baseHp: 100,
|
||||||
baseAttack: 6,
|
baseAttack: 6,
|
||||||
currentHp: 90,
|
currentHp: 90,
|
||||||
|
hpRegenSince: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
} as Character;
|
} as Character;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CharacterStatsService', () => {
|
describe('CharacterStatsService', () => {
|
||||||
const service = new CharacterStatsService({} as DataSource);
|
const characterVitals = new CharacterVitalsService({
|
||||||
|
now: () => new Date('2026-08-21T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const service = new CharacterStatsService({} as DataSource, characterVitals);
|
||||||
|
|
||||||
it('derives stats from the starting weapon alone', async () => {
|
it('derives stats from the starting weapon alone', async () => {
|
||||||
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]);
|
const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]);
|
||||||
@@ -105,11 +110,46 @@ describe('CharacterStatsService', () => {
|
|||||||
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
|
expect(stats.combatPower).toBe(105 / 10 + 7 * 2 + 11 * 2 + 3 * 1.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes currentHp through unchanged from the character', async () => {
|
it('returns the raw current HP unchanged while regeneration is paused', async () => {
|
||||||
const scope = fakeScope([]);
|
const scope = fakeScope([]);
|
||||||
|
|
||||||
const stats = await service.calculate(character({ currentHp: 42 }), scope);
|
const stats = await service.calculate(
|
||||||
|
character({ currentHp: 42, hpRegenSince: null }),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
expect(stats.currentHp).toBe(42);
|
expect(stats.currentHp).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds elapsed regeneration, clamped to maxHp, when a regen anchor is set', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
|
||||||
|
const regenerating = await service.calculate(
|
||||||
|
character({
|
||||||
|
currentHp: 40,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:59:30.000Z'),
|
||||||
|
}),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
expect(regenerating.currentHp).toBe(70);
|
||||||
|
|
||||||
|
const clamped = await service.calculate(
|
||||||
|
character({
|
||||||
|
currentHp: 40,
|
||||||
|
hpRegenSince: new Date('2026-08-21T11:40:00.000Z'),
|
||||||
|
}),
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
expect(clamped.currentHp).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the regeneration rate and anchor alongside the effective stats', async () => {
|
||||||
|
const scope = fakeScope([]);
|
||||||
|
const anchor = new Date('2026-08-21T11:59:30.000Z');
|
||||||
|
|
||||||
|
const stats = await service.calculate(character({ hpRegenSince: anchor }), scope);
|
||||||
|
|
||||||
|
expect(stats.hpRegenPerSecond).toBe(1);
|
||||||
|
expect(stats.hpRegenSince).toEqual(anchor);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
import { CharacterEquipment } from '../equipment/entities/character-equipment.entity';
|
||||||
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
import { EquipmentSlot } from '../items/equipment-slot.enum';
|
||||||
|
import { HP_REGEN_PER_SECOND } from './character-vitals.constants';
|
||||||
|
import { CharacterVitalsService } from './character-vitals.service';
|
||||||
import { Character } from './entities/character.entity';
|
import { Character } from './entities/character.entity';
|
||||||
|
|
||||||
export interface EffectiveCharacterStats {
|
export interface EffectiveCharacterStats {
|
||||||
@@ -11,6 +13,8 @@ export interface EffectiveCharacterStats {
|
|||||||
weaponDamage: number;
|
weaponDamage: number;
|
||||||
armor: number;
|
armor: number;
|
||||||
combatPower: number;
|
combatPower: number;
|
||||||
|
hpRegenPerSecond: number;
|
||||||
|
hpRegenSince: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
||||||
@@ -21,7 +25,10 @@ type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CharacterStatsService {
|
export class CharacterStatsService {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly characterVitals: CharacterVitalsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async calculate(
|
async calculate(
|
||||||
character: Character,
|
character: Character,
|
||||||
@@ -54,11 +61,13 @@ export class CharacterStatsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
maxHp,
|
maxHp,
|
||||||
currentHp: character.currentHp,
|
currentHp: this.characterVitals.effectiveHp(character, maxHp),
|
||||||
attack,
|
attack,
|
||||||
weaponDamage,
|
weaponDamage,
|
||||||
armor,
|
armor,
|
||||||
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5,
|
||||||
|
hpRegenPerSecond: HP_REGEN_PER_SECOND,
|
||||||
|
hpRegenSince: character.hpRegenSince,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user