feat(api): carry HP into combat and gate starting a fight on it
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user