feat(api): mirror HP onto the character each round and resume regen at combat end

This commit is contained in:
Bastian Wagner
2026-08-21 15:08:04 +02:00
parent ac1329be46
commit b070bf2b0d
2 changed files with 51 additions and 4 deletions

View File

@@ -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 () => { it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] }); const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state); 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 state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); 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); const retry = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(retry.id).not.toBe(combatId); expect(retry.id).not.toBe(combatId);

View File

@@ -225,7 +225,7 @@ export class CombatService {
// no-op re-lock — but locking it first here keeps both code paths // no-op re-lock — but locking it first here keeps both code paths
// consistent and avoids a lock-order inversion that could deadlock two // consistent and avoids a lock-order inversion that could deadlock two
// concurrent requests against the same character. Do not reorder this. // 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({ const combat = await combats.findOne({
where: { id: combatId, characterId }, where: { id: combatId, characterId },
@@ -253,12 +253,16 @@ export class CombatService {
combat.monsterState = result.state.monster.stats; combat.monsterState = result.state.monster.stats;
if (combat.status !== CombatStatus.ACTIVE) { if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date(); combat.completedAt = new Date();
this.characterVitals.resume(character, combat.playerCurrentHp);
await this.settleEncounter( await this.settleEncounter(
manager.getRepository(HuntEncounter), manager.getRepository(HuntEncounter),
combat.huntEncounterId, combat.huntEncounterId,
combat.status, combat.status,
); );
} else {
this.characterVitals.pause(character, combat.playerCurrentHp);
} }
await characters.save(character);
await combats.save(combat); await combats.save(combat);
const startingSequence = await combatEvents.count({ const startingSequence = await combatEvents.count({
@@ -286,7 +290,7 @@ export class CombatService {
? await this.combatRewards.grantVictoryRewards(manager, combat) ? await this.combatRewards.grantVictoryRewards(manager, combat)
: null; : null;
const [character, monster, events] = await Promise.all([ const [reloadedCharacter, monster, events] = await Promise.all([
this.loadCharacter( this.loadCharacter(
combat.characterId, combat.characterId,
manager.getRepository(Character), manager.getRepository(Character),
@@ -298,7 +302,7 @@ export class CombatService {
this.loadEvents(combat.id, combatEvents), this.loadEvents(combat.id, combatEvents),
]); ]);
return this.toCombatDto(combat, character.name, monster, events, rewards); return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards);
}); });
} }