From b070bf2b0db48c8dba2af7905769af39ba6572d0 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 15:08:04 +0200 Subject: [PATCH] feat(api): mirror HP onto the character each round and resume regen at combat end --- apps/api/src/combat/combat.service.spec.ts | 45 +++++++++++++++++++++- apps/api/src/combat/combat.service.ts | 10 +++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 45287e4..222a215 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -579,6 +579,41 @@ describe('CombatService', () => { }); }); + it('mirrors the player HP onto the character each round while the fight continues', async () => { + const { dataSource, service, combatId } = await startedCombat(); + + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + expect(dataSource.state.characters[0].currentHp).toBe(95); + expect(dataSource.state.characters[0].hpRegenSince).toBeNull(); + }); + + it('restarts regeneration on the character once the fight is won', async () => { + const state = createState({ monsters: [monster({ maxHp: 10 })] }); + const { dataSource, service, combatId } = await startedCombat(state); + + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + expect(dataSource.state.characters[0].currentHp).toBe( + dataSource.state.combats[0].playerCurrentHp, + ); + expect(dataSource.state.characters[0].hpRegenSince).toEqual( + new Date('2026-08-18T09:00:00.000Z'), + ); + }); + + it('restarts regeneration from 0 HP once the fight is lost', async () => { + const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); + const { dataSource, service, combatId } = await startedCombat(state); + + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + expect(dataSource.state.characters[0].currentHp).toBe(0); + expect(dataSource.state.characters[0].hpRegenSince).toEqual( + new Date('2026-08-18T09:00:00.000Z'), + ); + }); + it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => { const state = createState({ monsters: [monster({ maxHp: 10 })] }); const { dataSource, service, combatId } = await startedCombat(state); @@ -650,11 +685,19 @@ describe('CombatService', () => { ); }); - it('lets a lost encounter be fought again as a fresh combat', async () => { + it('lets a lost encounter be fought again once the character has recovered HP', async () => { const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); const { dataSource, service, combatId } = await startedCombat(state); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + // The loss now mirrors 0 HP onto the character (this task); simulate + // that regeneration has since restored it before retrying. This test + // is about the encounter itself being retryable once the character + // can fight again, not about regen math (covered by + // CharacterVitalsService's own tests). + dataSource.state.characters[0].currentHp = 1; + dataSource.state.characters[0].hpRegenSince = null; + const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); expect(retry.id).not.toBe(combatId); diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 826da50..b75c64a 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -225,7 +225,7 @@ export class CombatService { // no-op re-lock — but locking it first here keeps both code paths // consistent and avoids a lock-order inversion that could deadlock two // concurrent requests against the same character. Do not reorder this. - await this.lockCharacter(characters, characterId); + const character = await this.lockCharacter(characters, characterId); const combat = await combats.findOne({ where: { id: combatId, characterId }, @@ -253,12 +253,16 @@ export class CombatService { combat.monsterState = result.state.monster.stats; if (combat.status !== CombatStatus.ACTIVE) { combat.completedAt = new Date(); + this.characterVitals.resume(character, combat.playerCurrentHp); await this.settleEncounter( manager.getRepository(HuntEncounter), combat.huntEncounterId, combat.status, ); + } else { + this.characterVitals.pause(character, combat.playerCurrentHp); } + await characters.save(character); await combats.save(combat); const startingSequence = await combatEvents.count({ @@ -286,7 +290,7 @@ export class CombatService { ? await this.combatRewards.grantVictoryRewards(manager, combat) : null; - const [character, monster, events] = await Promise.all([ + const [reloadedCharacter, monster, events] = await Promise.all([ this.loadCharacter( combat.characterId, manager.getRepository(Character), @@ -298,7 +302,7 @@ export class CombatService { this.loadEvents(combat.id, combatEvents), ]); - return this.toCombatDto(combat, character.name, monster, events, rewards); + return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards); }); }