feat(api): carry HP into combat and gate starting a fight on it

This commit is contained in:
Bastian Wagner
2026-08-21 14:56:05 +02:00
parent 432483e958
commit ac1329be46
2 changed files with 74 additions and 5 deletions

View File

@@ -1,5 +1,6 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
@@ -179,6 +180,7 @@ function character(overrides: Partial<Character> = {}): Character {
baseHp: 100,
baseAttack: 6,
currentHp: 100,
hpRegenSince: null,
currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
@@ -285,11 +287,15 @@ function createService(
const travelService = options.travelService ?? fakeTravelService();
const combatEngine = new CombatEngineService();
const characterCombatStats = fakeCharacterStats();
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
});
const service = new CombatService(
dataSource as unknown as DataSource,
travelService,
combatEngine,
characterCombatStats,
characterVitals,
fakeRewardService(),
);
return { dataSource, service, travelService };
@@ -349,6 +355,49 @@ describe('CombatService', () => {
});
});
it('seeds player HP from the character, carrying HP from a previous fight rather than starting full', async () => {
const state = createState({ characters: [character({ currentHp: 63 })] });
const { dataSource, service } = createService({ state });
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(combat.player.currentHp).toBe(63);
expect(dataSource.state.combats[0].playerCurrentHp).toBe(63);
});
it('pauses regeneration on the character once a combat starts', async () => {
const state = createState({
characters: [
character({ currentHp: 63, hpRegenSince: new Date('2026-08-18T08:00:00.000Z') }),
],
});
const { dataSource, service } = createService({ state });
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.state.characters[0].currentHp).toBe(63);
expect(dataSource.state.characters[0].hpRegenSince).toBeNull();
});
it('rejects starting a combat when the character has 0 effective HP', async () => {
const state = createState({ characters: [character({ currentHp: 0 })] });
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'CHARACTER_TOO_WOUNDED',
);
});
it('allows starting a combat at exactly 1 effective HP', async () => {
const state = createState({ characters: [character({ currentHp: 1 })] });
const { service } = createService({ state });
await expect(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
).resolves.toMatchObject({ status: 'ACTIVE' });
});
it('marks the encounter as IN_PROGRESS', async () => {
const { dataSource, service } = createService();
@@ -550,7 +599,7 @@ describe('CombatService', () => {
it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({
characters: [character({ baseHp: 1 })],
characters: [character({ baseHp: 1, currentHp: 1 })],
});
const { dataSource, service, combatId } = await startedCombat(state);
@@ -581,7 +630,7 @@ describe('CombatService', () => {
});
it('frees the encounter for another attempt when the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
@@ -602,7 +651,7 @@ describe('CombatService', () => {
});
it('lets a lost encounter be fought again as a fresh combat', async () => {
const state = createState({ characters: [character({ baseHp: 1 })] });
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] });
const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
@@ -781,7 +830,7 @@ describe('CombatService', () => {
it('keeps returning LOST after the combat has ended', async () => {
const state = createState({
characters: [character({ baseHp: 1 })],
characters: [character({ baseHp: 1, currentHp: 1 })],
});
const context = createService({ state });
const started = await context.service.startCombat(
@@ -839,6 +888,9 @@ describe('CombatService', () => {
fakeTravelService(),
new CombatEngineService(),
fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards,
);
@@ -887,6 +939,9 @@ describe('CombatService', () => {
fakeTravelService(),
new CombatEngineService(),
fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards,
);
@@ -944,6 +999,9 @@ describe('CombatService', () => {
fakeTravelService(),
new CombatEngineService(),
fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards,
);
@@ -981,6 +1039,9 @@ describe('CombatService', () => {
fakeTravelService(),
new CombatEngineService(),
fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
fakeRewardService({
// Genuinely write XP/silver through the transaction's manager
// before failing, so the assertions below prove the rollback

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
@@ -16,6 +17,7 @@ import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState, CombatIntent } from './combat-engine.types';
import {
characterNotFound,
characterTooWounded,
characterTravelling,
combatAlreadyActive,
combatAlreadyFinished,
@@ -78,6 +80,7 @@ export class CombatService {
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterStats: CharacterStatsService,
private readonly characterVitals: CharacterVitalsService,
private readonly combatRewards: CombatRewardService,
) {}
@@ -135,6 +138,11 @@ export class CombatService {
}
const playerStats = await this.characterStats.calculate(character, manager);
if (playerStats.currentHp < 1) {
throw characterTooWounded();
}
this.characterVitals.pause(character, playerStats.currentHp);
await characters.save(character);
const combat = combats.create({
characterId,
@@ -143,7 +151,7 @@ export class CombatService {
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: playerStats.maxHp,
playerCurrentHp: playerStats.maxHp,
playerCurrentHp: playerStats.currentHp,
monsterMaxHp: monster.maxHp,
monsterCurrentHp: monster.maxHp,
playerState: {