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 { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterStatsService } from '../characters/character-stats.service'; import { CharacterStatsService } from '../characters/character-stats.service';
import { CharacterVitalsService } from '../characters/character-vitals.service';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity'; import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
@@ -179,6 +180,7 @@ function character(overrides: Partial<Character> = {}): Character {
baseHp: 100, baseHp: 100,
baseAttack: 6, baseAttack: 6,
currentHp: 100, currentHp: 100,
hpRegenSince: null,
currentLocationId: 'location-1', currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'), createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: 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 travelService = options.travelService ?? fakeTravelService();
const combatEngine = new CombatEngineService(); const combatEngine = new CombatEngineService();
const characterCombatStats = fakeCharacterStats(); const characterCombatStats = fakeCharacterStats();
const characterVitals = new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
});
const service = new CombatService( const service = new CombatService(
dataSource as unknown as DataSource, dataSource as unknown as DataSource,
travelService, travelService,
combatEngine, combatEngine,
characterCombatStats, characterCombatStats,
characterVitals,
fakeRewardService(), fakeRewardService(),
); );
return { dataSource, service, travelService }; 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 () => { it('marks the encounter as IN_PROGRESS', async () => {
const { dataSource, service } = createService(); 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 () => { it('ends the combat as LOST, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({ const state = createState({
characters: [character({ baseHp: 1 })], characters: [character({ baseHp: 1, currentHp: 1 })],
}); });
const { dataSource, service, combatId } = await startedCombat(state); 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 () => { 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); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); 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 () => { 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); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
@@ -781,7 +830,7 @@ describe('CombatService', () => {
it('keeps returning LOST after the combat has ended', async () => { it('keeps returning LOST after the combat has ended', async () => {
const state = createState({ const state = createState({
characters: [character({ baseHp: 1 })], characters: [character({ baseHp: 1, currentHp: 1 })],
}); });
const context = createService({ state }); const context = createService({ state });
const started = await context.service.startCombat( const started = await context.service.startCombat(
@@ -839,6 +888,9 @@ describe('CombatService', () => {
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
fakeCharacterStats(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards, rewards,
); );
@@ -887,6 +939,9 @@ describe('CombatService', () => {
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
fakeCharacterStats(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards, rewards,
); );
@@ -944,6 +999,9 @@ describe('CombatService', () => {
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
fakeCharacterStats(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
rewards, rewards,
); );
@@ -981,6 +1039,9 @@ describe('CombatService', () => {
fakeTravelService(), fakeTravelService(),
new CombatEngineService(), new CombatEngineService(),
fakeCharacterStats(), fakeCharacterStats(),
new CharacterVitalsService({
now: () => new Date('2026-08-18T09:00:00.000Z'),
}),
fakeRewardService({ fakeRewardService({
// Genuinely write XP/silver through the transaction's manager // Genuinely write XP/silver through the transaction's manager
// before failing, so the assertions below prove the rollback // before failing, so the assertions below prove the rollback

View File

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