diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 74b254a..45287e4 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -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 { 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 diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 224e714..826da50 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -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: {