feat(api): mirror HP onto the character each round and resume regen at combat end
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user