From 02037b291706495cdd558d3fd512cfe3831f68e7 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 14:41:17 +0200 Subject: [PATCH] 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 --- .../character-stats.service.spec.ts | 46 +++++++++++++++++-- .../src/characters/character-stats.service.ts | 13 +++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/api/src/characters/character-stats.service.spec.ts b/apps/api/src/characters/character-stats.service.spec.ts index 45f8417..aa3ff55 100644 --- a/apps/api/src/characters/character-stats.service.spec.ts +++ b/apps/api/src/characters/character-stats.service.spec.ts @@ -4,6 +4,7 @@ import { CharacterStatsService } from './character-stats.service'; import { Character } from './entities/character.entity'; import { EquipmentSlot } from '../items/equipment-slot.enum'; import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { CharacterVitalsService } from './character-vitals.service'; type EquippedFixture = { slot: EquipmentSlot; @@ -34,12 +35,16 @@ function character(overrides: Partial = {}): Character { baseHp: 100, baseAttack: 6, currentHp: 90, + hpRegenSince: null, ...overrides, } as Character; } 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 () => { 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); }); - 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 stats = await service.calculate(character({ currentHp: 42 }), scope); + const stats = await service.calculate( + character({ currentHp: 42, hpRegenSince: null }), + scope, + ); 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); + }); }); diff --git a/apps/api/src/characters/character-stats.service.ts b/apps/api/src/characters/character-stats.service.ts index 5d0e0a1..b6dc859 100644 --- a/apps/api/src/characters/character-stats.service.ts +++ b/apps/api/src/characters/character-stats.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { CharacterEquipment } from '../equipment/entities/character-equipment.entity'; 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'; export interface EffectiveCharacterStats { @@ -11,6 +13,8 @@ export interface EffectiveCharacterStats { weaponDamage: number; armor: number; combatPower: number; + hpRegenPerSecond: number; + hpRegenSince: Date | null; } type RepositoryScope = Pick; @@ -21,7 +25,10 @@ type RepositoryScope = Pick; */ @Injectable() export class CharacterStatsService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly characterVitals: CharacterVitalsService, + ) {} async calculate( character: Character, @@ -54,11 +61,13 @@ export class CharacterStatsService { return { maxHp, - currentHp: character.currentHp, + currentHp: this.characterVitals.effectiveHp(character, maxHp), attack, weaponDamage, armor, combatPower: maxHp / 10 + attack * 2 + weaponDamage * 2 + armor * 1.5, + hpRegenPerSecond: HP_REGEN_PER_SECOND, + hpRegenSince: character.hpRegenSince, }; } }