feat(combat): add CombatService orchestration and persistence
Wires the pure combat engine, character combat stats, and domain errors into a transactional service that validates the HuntEncounter boundary, snapshots stats into a new Combat row, and persists engine results.
This commit is contained in:
569
apps/api/src/combat/combat.service.spec.ts
Normal file
569
apps/api/src/combat/combat.service.spec.ts
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
||||||
|
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
|
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { TravelService } from '../travel/travel.service';
|
||||||
|
import { CombatAction } from './combat-action.enum';
|
||||||
|
import { CombatEngineService } from './combat-engine.service';
|
||||||
|
import { CombatDomainError } from './combat.errors';
|
||||||
|
import { CombatService } from './combat.service';
|
||||||
|
import { CombatStatus } from './combat-status.enum';
|
||||||
|
import { CombatEvent } from './entities/combat-event.entity';
|
||||||
|
import { Combat } from './entities/combat.entity';
|
||||||
|
|
||||||
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||||
|
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
|
||||||
|
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
|
||||||
|
const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
|
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
interface FakeState {
|
||||||
|
characters: Character[];
|
||||||
|
hunts: Hunt[];
|
||||||
|
huntEncounters: HuntEncounter[];
|
||||||
|
monsters: MonsterDefinition[];
|
||||||
|
combats: Combat[];
|
||||||
|
combatEvents: CombatEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRepository<T extends { id: string }> {
|
||||||
|
constructor(
|
||||||
|
private readonly state: FakeState,
|
||||||
|
private readonly target: EntityTarget<T>,
|
||||||
|
private readonly inTransaction: boolean,
|
||||||
|
private readonly dataSource: FakeDataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findOne(options: {
|
||||||
|
where: Partial<T>;
|
||||||
|
lock?: { mode: string };
|
||||||
|
}): Promise<T | null> {
|
||||||
|
if (options.lock) {
|
||||||
|
if (!this.inTransaction) {
|
||||||
|
throw new Error('Pessimistic locks require a transaction');
|
||||||
|
}
|
||||||
|
this.dataSource.locks.push({
|
||||||
|
target: this.target,
|
||||||
|
mode: options.lock.mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
this.rows().find((row) => this.matches(row, options.where)) ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findOneBy(where: Partial<T>): Promise<T | null> {
|
||||||
|
return Promise.resolve(
|
||||||
|
this.rows().find((row) => this.matches(row, where)) ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
find(options: {
|
||||||
|
where: Partial<T>;
|
||||||
|
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
|
||||||
|
}): Promise<T[]> {
|
||||||
|
const matched = this.rows().filter((row) =>
|
||||||
|
this.matches(row, options.where),
|
||||||
|
);
|
||||||
|
const orderKey = options.order
|
||||||
|
? (Object.keys(options.order)[0] as keyof T)
|
||||||
|
: undefined;
|
||||||
|
if (orderKey) {
|
||||||
|
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
|
||||||
|
matched.sort((a, b) => {
|
||||||
|
if (a[orderKey] === b[orderKey]) return 0;
|
||||||
|
return a[orderKey] > b[orderKey] ? direction : -direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
count(options: { where: Partial<T> }): Promise<number> {
|
||||||
|
return Promise.resolve(
|
||||||
|
this.rows().filter((row) => this.matches(row, options.where)).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(values: Partial<T>): T {
|
||||||
|
return { ...values } as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(entity: T): Promise<T> {
|
||||||
|
if (!entity.id) {
|
||||||
|
entity.id = this.dataSource.nextId(this.targetName());
|
||||||
|
}
|
||||||
|
const rows = this.rows();
|
||||||
|
const index = rows.findIndex((row) => row.id === entity.id);
|
||||||
|
if (index === -1) {
|
||||||
|
rows.push(entity);
|
||||||
|
} else {
|
||||||
|
rows[index] = entity;
|
||||||
|
}
|
||||||
|
return Promise.resolve(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rows(): T[] {
|
||||||
|
if (this.target === Character) return this.state.characters as T[];
|
||||||
|
if (this.target === Hunt) return this.state.hunts as T[];
|
||||||
|
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
|
||||||
|
if (this.target === MonsterDefinition) return this.state.monsters as T[];
|
||||||
|
if (this.target === Combat) return this.state.combats as T[];
|
||||||
|
if (this.target === CombatEvent) return this.state.combatEvents as T[];
|
||||||
|
throw new Error(`Unsupported repository ${this.targetName()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(row: T, where: Partial<T>): boolean {
|
||||||
|
return Object.entries(where).every(
|
||||||
|
([key, value]) => row[key as keyof T] === value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private targetName(): string {
|
||||||
|
return typeof this.target === 'function'
|
||||||
|
? this.target.name
|
||||||
|
: 'EntitySchema';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeEntityManager {
|
||||||
|
constructor(
|
||||||
|
private readonly state: FakeState,
|
||||||
|
private readonly dataSource: FakeDataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||||
|
return new FakeRepository(this.state, target, true, this.dataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDataSource {
|
||||||
|
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
|
||||||
|
private readonly idCounters = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(public state: FakeState) {}
|
||||||
|
|
||||||
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
||||||
|
return new FakeRepository(this.state, target, false, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(
|
||||||
|
work: (manager: EntityManager) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const draft = structuredClone(this.state);
|
||||||
|
const result = await work(
|
||||||
|
new FakeEntityManager(draft, this) as unknown as EntityManager,
|
||||||
|
);
|
||||||
|
this.state = draft;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextId(targetName: string): string {
|
||||||
|
const next = (this.idCounters.get(targetName) ?? 0) + 1;
|
||||||
|
this.idCounters.set(targetName, next);
|
||||||
|
return `${targetName.toLowerCase()}-generated-${next}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function character(overrides: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: CHARACTER_ID,
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
level: 1,
|
||||||
|
experience: 0,
|
||||||
|
baseHp: 100,
|
||||||
|
baseAttack: 6,
|
||||||
|
currentHp: 100,
|
||||||
|
currentLocationId: 'location-1',
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
} as Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monster(
|
||||||
|
overrides: Partial<MonsterDefinition> = {},
|
||||||
|
): MonsterDefinition {
|
||||||
|
return {
|
||||||
|
id: MONSTER_ID,
|
||||||
|
key: 'ash-rat',
|
||||||
|
name: 'Aschenratte',
|
||||||
|
level: 1,
|
||||||
|
maxHp: 45,
|
||||||
|
attack: 5,
|
||||||
|
armor: 0,
|
||||||
|
experienceReward: 8,
|
||||||
|
silverMin: 4,
|
||||||
|
silverMax: 7,
|
||||||
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hunt(overrides: Partial<Hunt> = {}): Hunt {
|
||||||
|
return {
|
||||||
|
id: HUNT_ID,
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
locationId: 'location-1',
|
||||||
|
status: HuntStatus.ACTIVE,
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
} as Hunt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
|
||||||
|
return {
|
||||||
|
id: ENCOUNTER_ID,
|
||||||
|
huntId: HUNT_ID,
|
||||||
|
monsterDefinitionId: MONSTER_ID,
|
||||||
|
position: 0,
|
||||||
|
consumedAt: null,
|
||||||
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
} as HuntEncounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(overrides: Partial<FakeState> = {}): FakeState {
|
||||||
|
return {
|
||||||
|
characters: [character()],
|
||||||
|
hunts: [hunt()],
|
||||||
|
huntEncounters: [huntEncounter()],
|
||||||
|
monsters: [monster()],
|
||||||
|
combats: [],
|
||||||
|
combatEvents: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTravelService(
|
||||||
|
status: 'IDLE' | 'TRAVELLING' = 'IDLE',
|
||||||
|
): TravelService {
|
||||||
|
return {
|
||||||
|
completeTravelIfDue: jest.fn().mockResolvedValue({ status }),
|
||||||
|
} as unknown as TravelService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
options: { state?: FakeState; travelService?: TravelService } = {},
|
||||||
|
) {
|
||||||
|
const state = options.state ?? createState();
|
||||||
|
const dataSource = new FakeDataSource(state);
|
||||||
|
const travelService = options.travelService ?? fakeTravelService();
|
||||||
|
const combatEngine = new CombatEngineService();
|
||||||
|
const characterCombatStats = new CharacterCombatStatsService();
|
||||||
|
const service = new CombatService(
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
travelService,
|
||||||
|
combatEngine,
|
||||||
|
characterCombatStats,
|
||||||
|
);
|
||||||
|
return { dataSource, service, travelService };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCombatDomainError(
|
||||||
|
promise: Promise<unknown>,
|
||||||
|
code: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(CombatDomainError);
|
||||||
|
if (!(error instanceof CombatDomainError)) {
|
||||||
|
throw new Error('Expected CombatDomainError');
|
||||||
|
}
|
||||||
|
expect(error.code).toBe(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CombatService', () => {
|
||||||
|
describe('startCombat', () => {
|
||||||
|
it('starts an ACTIVE combat with snapshotted stats and full HP', async () => {
|
||||||
|
const { dataSource, service } = createService();
|
||||||
|
|
||||||
|
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||||
|
|
||||||
|
expect(combat.status).toBe('ACTIVE');
|
||||||
|
expect(combat.round).toBe(1);
|
||||||
|
expect(combat.player).toEqual({
|
||||||
|
name: 'Aric Duskwalker',
|
||||||
|
maxHp: 100,
|
||||||
|
currentHp: 100,
|
||||||
|
});
|
||||||
|
expect(combat.monster).toEqual({
|
||||||
|
key: 'ash-rat',
|
||||||
|
name: 'Aschenratte',
|
||||||
|
level: 1,
|
||||||
|
maxHp: 45,
|
||||||
|
currentHp: 45,
|
||||||
|
artworkPath: '/images/monsters/ash-rat.png',
|
||||||
|
});
|
||||||
|
expect(combat.events).toEqual([]);
|
||||||
|
expect(dataSource.state.combats).toHaveLength(1);
|
||||||
|
expect(dataSource.state.combats[0]).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
huntEncounterId: ENCOUNTER_ID,
|
||||||
|
monsterDefinitionId: MONSTER_ID,
|
||||||
|
status: CombatStatus.ACTIVE,
|
||||||
|
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||||
|
monsterState: { attack: 5, armor: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the encounter as consumed', async () => {
|
||||||
|
const { dataSource, service } = createService();
|
||||||
|
|
||||||
|
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||||
|
|
||||||
|
expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown encounter id', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.startCombat(CHARACTER_ID, 'unknown-id'),
|
||||||
|
'HUNT_ENCOUNTER_NOT_FOUND',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-consumed encounter, and does not create a second combat', async () => {
|
||||||
|
const state = createState({
|
||||||
|
huntEncounters: [
|
||||||
|
huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { dataSource, service } = createService({ state });
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||||
|
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
|
||||||
|
);
|
||||||
|
expect(dataSource.state.combats).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an encounter belonging to a different character', async () => {
|
||||||
|
const state = createState({
|
||||||
|
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
||||||
|
});
|
||||||
|
const { service } = createService({ state });
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID),
|
||||||
|
'INVALID_HUNT_ENCOUNTER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an encounter whose hunt is no longer ACTIVE', async () => {
|
||||||
|
const state = createState({
|
||||||
|
hunts: [hunt({ status: HuntStatus.SUPERSEDED })],
|
||||||
|
});
|
||||||
|
const { service } = createService({ state });
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||||
|
'INVALID_HUNT_ENCOUNTER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects starting combat while the character is travelling', async () => {
|
||||||
|
const { service } = createService({
|
||||||
|
travelService: fakeTravelService('TRAVELLING'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||||
|
'CHARACTER_TRAVELLING',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects starting a second combat while one is already ACTIVE', async () => {
|
||||||
|
const state = createState({
|
||||||
|
combats: [
|
||||||
|
{
|
||||||
|
id: 'combat-existing',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
huntEncounterId: 'other-encounter',
|
||||||
|
monsterDefinitionId: MONSTER_ID,
|
||||||
|
status: CombatStatus.ACTIVE,
|
||||||
|
round: 1,
|
||||||
|
playerMaxHp: 100,
|
||||||
|
playerCurrentHp: 100,
|
||||||
|
monsterMaxHp: 45,
|
||||||
|
monsterCurrentHp: 45,
|
||||||
|
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
|
||||||
|
monsterState: { attack: 5, armor: 0 },
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
completedAt: null,
|
||||||
|
} as Combat,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { service } = createService({ state });
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
|
||||||
|
'COMBAT_ALREADY_ACTIVE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the character and the active-combat lookup', async () => {
|
||||||
|
const { dataSource, service } = createService();
|
||||||
|
|
||||||
|
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
|
||||||
|
|
||||||
|
expect(dataSource.locks).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ target: Character, mode: 'pessimistic_write' },
|
||||||
|
{ target: Combat, mode: 'pessimistic_write' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('performAction', () => {
|
||||||
|
async function startedCombat(state = createState()) {
|
||||||
|
const context = createService({ state });
|
||||||
|
const combat = await context.service.startCombat(
|
||||||
|
CHARACTER_ID,
|
||||||
|
ENCOUNTER_ID,
|
||||||
|
);
|
||||||
|
return { ...context, combatId: combat.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('resolves ATTACK, persists HP/round changes, and returns them', async () => {
|
||||||
|
const { dataSource, service, combatId } = await startedCombat();
|
||||||
|
|
||||||
|
const result = await service.performAction(
|
||||||
|
CHARACTER_ID,
|
||||||
|
combatId,
|
||||||
|
CombatAction.ATTACK,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('ACTIVE');
|
||||||
|
expect(result.round).toBe(2);
|
||||||
|
expect(result.monster.currentHp).toBe(45 - 14);
|
||||||
|
expect(result.player.currentHp).toBe(100 - 5);
|
||||||
|
expect(dataSource.state.combats[0].round).toBe(2);
|
||||||
|
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31);
|
||||||
|
expect(dataSource.state.combats[0].playerCurrentHp).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists ordered, sequential CombatEvents across multiple rounds', async () => {
|
||||||
|
const { dataSource, service, combatId } = await startedCombat();
|
||||||
|
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
const events = dataSource.state.combatEvents
|
||||||
|
.filter((event) => event.combatId === combatId)
|
||||||
|
.sort((a, b) => a.sequence - b.sequence);
|
||||||
|
expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
|
||||||
|
expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]);
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
source: 'PLAYER',
|
||||||
|
target: 'MONSTER',
|
||||||
|
type: 'DAMAGE',
|
||||||
|
amount: 14,
|
||||||
|
});
|
||||||
|
expect(events[1]).toMatchObject({
|
||||||
|
source: 'MONSTER',
|
||||||
|
target: 'PLAYER',
|
||||||
|
type: 'DAMAGE',
|
||||||
|
amount: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
|
||||||
|
const state = createState({ monsters: [monster({ maxHp: 10 })] });
|
||||||
|
const { dataSource, service, combatId } = await startedCombat(state);
|
||||||
|
|
||||||
|
const result = await service.performAction(
|
||||||
|
CHARACTER_ID,
|
||||||
|
combatId,
|
||||||
|
CombatAction.ATTACK,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('WON');
|
||||||
|
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
|
||||||
|
'COMBAT_ALREADY_FINISHED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects actions on an unknown combat id', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.performAction(
|
||||||
|
CHARACTER_ID,
|
||||||
|
'unknown-combat',
|
||||||
|
CombatAction.ATTACK,
|
||||||
|
),
|
||||||
|
'COMBAT_NOT_FOUND',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects actions from a character who does not own the combat', async () => {
|
||||||
|
const { service, combatId } = await startedCombat();
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.performAction(
|
||||||
|
OTHER_CHARACTER_ID,
|
||||||
|
combatId,
|
||||||
|
CombatAction.ATTACK,
|
||||||
|
),
|
||||||
|
'COMBAT_NOT_FOUND',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the combat row for the duration of the action', async () => {
|
||||||
|
const { dataSource, service, combatId } = await startedCombat();
|
||||||
|
|
||||||
|
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
|
||||||
|
|
||||||
|
expect(dataSource.locks).toEqual(
|
||||||
|
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCombat', () => {
|
||||||
|
it('returns the persisted state and ordered events after a refresh', async () => {
|
||||||
|
const context = createService();
|
||||||
|
const started = await context.service.startCombat(
|
||||||
|
CHARACTER_ID,
|
||||||
|
ENCOUNTER_ID,
|
||||||
|
);
|
||||||
|
await context.service.performAction(
|
||||||
|
CHARACTER_ID,
|
||||||
|
started.id,
|
||||||
|
CombatAction.ATTACK,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reloaded = await context.service.getCombat(
|
||||||
|
CHARACTER_ID,
|
||||||
|
started.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reloaded.round).toBe(2);
|
||||||
|
expect(reloaded.monster.currentHp).toBe(31);
|
||||||
|
expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown combat id', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expectCombatDomainError(
|
||||||
|
service.getCombat(CHARACTER_ID, 'unknown'),
|
||||||
|
'COMBAT_NOT_FOUND',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
339
apps/api/src/combat/combat.service.ts
Normal file
339
apps/api/src/combat/combat.service.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { Hunt } from '../hunting/entities/hunt.entity';
|
||||||
|
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
|
||||||
|
import { HuntStatus } from '../hunting/hunt-status.enum';
|
||||||
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
||||||
|
import { TravelService } from '../travel/travel.service';
|
||||||
|
import { TravelStatus } from '../travel/travel-status.enum';
|
||||||
|
import { CombatAction } from './combat-action.enum';
|
||||||
|
import { CombatEngineService } from './combat-engine.service';
|
||||||
|
import { CombatEngineState } from './combat-engine.types';
|
||||||
|
import {
|
||||||
|
characterNotFound,
|
||||||
|
characterTravelling,
|
||||||
|
combatAlreadyActive,
|
||||||
|
combatAlreadyFinished,
|
||||||
|
combatNotFound,
|
||||||
|
combatStateInvalid,
|
||||||
|
huntEncounterAlreadyConsumed,
|
||||||
|
huntEncounterNotFound,
|
||||||
|
invalidHuntEncounter,
|
||||||
|
} from './combat.errors';
|
||||||
|
import { CombatStatus } from './combat-status.enum';
|
||||||
|
import { CombatEvent } from './entities/combat-event.entity';
|
||||||
|
import { Combat } from './entities/combat.entity';
|
||||||
|
|
||||||
|
export interface CombatPlayerDto {
|
||||||
|
name: string;
|
||||||
|
maxHp: number;
|
||||||
|
currentHp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatMonsterDto {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
maxHp: number;
|
||||||
|
currentHp: number;
|
||||||
|
artworkPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatEventDto {
|
||||||
|
round: number;
|
||||||
|
sequence: number;
|
||||||
|
type: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatDto {
|
||||||
|
id: string;
|
||||||
|
status: CombatStatus;
|
||||||
|
round: number;
|
||||||
|
player: CombatPlayerDto;
|
||||||
|
monster: CombatMonsterDto;
|
||||||
|
events: CombatEventDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CombatService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly travelService: TravelService,
|
||||||
|
private readonly combatEngine: CombatEngineService,
|
||||||
|
private readonly characterCombatStats: CharacterCombatStatsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async startCombat(
|
||||||
|
characterId: string,
|
||||||
|
encounterId: string,
|
||||||
|
): Promise<CombatDto> {
|
||||||
|
const travel = await this.travelService.completeTravelIfDue(characterId);
|
||||||
|
if (travel.status === TravelStatus.TRAVELLING) {
|
||||||
|
throw characterTravelling();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const characters = manager.getRepository(Character);
|
||||||
|
const encounters = manager.getRepository(HuntEncounter);
|
||||||
|
const hunts = manager.getRepository(Hunt);
|
||||||
|
const monsters = manager.getRepository(MonsterDefinition);
|
||||||
|
const combats = manager.getRepository(Combat);
|
||||||
|
|
||||||
|
const character = await this.lockCharacter(characters, characterId);
|
||||||
|
|
||||||
|
const encounter = await encounters.findOne({
|
||||||
|
where: { id: encounterId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!encounter) {
|
||||||
|
throw huntEncounterNotFound();
|
||||||
|
}
|
||||||
|
if (encounter.consumedAt) {
|
||||||
|
throw huntEncounterAlreadyConsumed();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hunt = await hunts.findOneBy({ id: encounter.huntId });
|
||||||
|
if (
|
||||||
|
!hunt ||
|
||||||
|
hunt.characterId !== characterId ||
|
||||||
|
hunt.status !== HuntStatus.ACTIVE
|
||||||
|
) {
|
||||||
|
throw invalidHuntEncounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingActiveCombat = await combats.findOne({
|
||||||
|
where: { characterId, status: CombatStatus.ACTIVE },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (existingActiveCombat) {
|
||||||
|
throw combatAlreadyActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
const monster = await monsters.findOneBy({
|
||||||
|
id: encounter.monsterDefinitionId,
|
||||||
|
});
|
||||||
|
if (!monster) {
|
||||||
|
throw invalidHuntEncounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerStats = this.characterCombatStats.getStats(character);
|
||||||
|
|
||||||
|
const combat = combats.create({
|
||||||
|
characterId,
|
||||||
|
huntEncounterId: encounter.id,
|
||||||
|
monsterDefinitionId: monster.id,
|
||||||
|
status: CombatStatus.ACTIVE,
|
||||||
|
round: 1,
|
||||||
|
playerMaxHp: playerStats.maxHp,
|
||||||
|
playerCurrentHp: playerStats.maxHp,
|
||||||
|
monsterMaxHp: monster.maxHp,
|
||||||
|
monsterCurrentHp: monster.maxHp,
|
||||||
|
playerState: {
|
||||||
|
attack: playerStats.attack,
|
||||||
|
weaponDamage: playerStats.weaponDamage,
|
||||||
|
armor: playerStats.armor,
|
||||||
|
},
|
||||||
|
monsterState: { attack: monster.attack, armor: monster.armor },
|
||||||
|
completedAt: null,
|
||||||
|
});
|
||||||
|
await combats.save(combat);
|
||||||
|
|
||||||
|
encounter.consumedAt = new Date();
|
||||||
|
await encounters.save(encounter);
|
||||||
|
|
||||||
|
return this.toCombatDto(combat, character.name, monster, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCombat(characterId: string, combatId: string): Promise<CombatDto> {
|
||||||
|
const combats = this.dataSource.getRepository(Combat);
|
||||||
|
const combat = await combats.findOne({
|
||||||
|
where: { id: combatId, characterId },
|
||||||
|
});
|
||||||
|
if (!combat) {
|
||||||
|
throw combatNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [character, monster, events] = await Promise.all([
|
||||||
|
this.loadCharacter(combat.characterId),
|
||||||
|
this.loadMonster(combat.monsterDefinitionId),
|
||||||
|
this.loadEvents(combat.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.toCombatDto(combat, character.name, monster, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
async performAction(
|
||||||
|
characterId: string,
|
||||||
|
combatId: string,
|
||||||
|
action: CombatAction,
|
||||||
|
): Promise<CombatDto> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const combats = manager.getRepository(Combat);
|
||||||
|
const combatEvents = manager.getRepository(CombatEvent);
|
||||||
|
|
||||||
|
const combat = await combats.findOne({
|
||||||
|
where: { id: combatId, characterId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!combat) {
|
||||||
|
throw combatNotFound();
|
||||||
|
}
|
||||||
|
if (combat.status !== CombatStatus.ACTIVE) {
|
||||||
|
throw combatAlreadyFinished();
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionRound = combat.round;
|
||||||
|
const engineState = this.toEngineState(combat);
|
||||||
|
const result = this.combatEngine.resolveAction(engineState, { action });
|
||||||
|
|
||||||
|
combat.round = result.state.round;
|
||||||
|
combat.status = result.state.status;
|
||||||
|
combat.playerCurrentHp = result.state.player.currentHp;
|
||||||
|
combat.monsterCurrentHp = result.state.monster.currentHp;
|
||||||
|
if (combat.status !== CombatStatus.ACTIVE) {
|
||||||
|
combat.completedAt = new Date();
|
||||||
|
}
|
||||||
|
await combats.save(combat);
|
||||||
|
|
||||||
|
const startingSequence = await combatEvents.count({
|
||||||
|
where: { combatId: combat.id },
|
||||||
|
});
|
||||||
|
for (let index = 0; index < result.events.length; index += 1) {
|
||||||
|
const event = result.events[index];
|
||||||
|
const entity = combatEvents.create({
|
||||||
|
combatId: combat.id,
|
||||||
|
round: actionRound,
|
||||||
|
sequence: startingSequence + index + 1,
|
||||||
|
type: event.type,
|
||||||
|
source: event.source,
|
||||||
|
target: event.target,
|
||||||
|
amount: event.amount ?? null,
|
||||||
|
});
|
||||||
|
await combatEvents.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [character, monster, events] = await Promise.all([
|
||||||
|
this.loadCharacter(
|
||||||
|
combat.characterId,
|
||||||
|
manager.getRepository(Character),
|
||||||
|
),
|
||||||
|
this.loadMonster(
|
||||||
|
combat.monsterDefinitionId,
|
||||||
|
manager.getRepository(MonsterDefinition),
|
||||||
|
),
|
||||||
|
this.loadEvents(combat.id, combatEvents),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.toCombatDto(combat, character.name, monster, events);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockCharacter(
|
||||||
|
characters: Repository<Character>,
|
||||||
|
characterId: string,
|
||||||
|
): Promise<Character> {
|
||||||
|
const character = await characters.findOne({
|
||||||
|
where: { id: characterId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
throw characterNotFound();
|
||||||
|
}
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCharacter(
|
||||||
|
characterId: string,
|
||||||
|
repo?: Repository<Character>,
|
||||||
|
): Promise<Character> {
|
||||||
|
const characters = repo ?? this.dataSource.getRepository(Character);
|
||||||
|
const character = await characters.findOneBy({ id: characterId });
|
||||||
|
if (!character) {
|
||||||
|
// combats.character_id is a RESTRICT FK; a persisted combat's
|
||||||
|
// character is guaranteed to exist.
|
||||||
|
throw combatStateInvalid();
|
||||||
|
}
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadMonster(
|
||||||
|
monsterId: string,
|
||||||
|
repo?: Repository<MonsterDefinition>,
|
||||||
|
): Promise<MonsterDefinition> {
|
||||||
|
const monsters = repo ?? this.dataSource.getRepository(MonsterDefinition);
|
||||||
|
const monster = await monsters.findOneBy({ id: monsterId });
|
||||||
|
if (!monster) {
|
||||||
|
// combats.monster_definition_id is a RESTRICT FK; guaranteed to exist.
|
||||||
|
throw combatStateInvalid();
|
||||||
|
}
|
||||||
|
return monster;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadEvents(
|
||||||
|
combatId: string,
|
||||||
|
repo?: Repository<CombatEvent>,
|
||||||
|
): Promise<CombatEvent[]> {
|
||||||
|
const combatEvents = repo ?? this.dataSource.getRepository(CombatEvent);
|
||||||
|
return combatEvents.find({
|
||||||
|
where: { combatId },
|
||||||
|
order: { sequence: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEngineState(combat: Combat): CombatEngineState {
|
||||||
|
return {
|
||||||
|
status: combat.status,
|
||||||
|
round: combat.round,
|
||||||
|
player: {
|
||||||
|
currentHp: combat.playerCurrentHp,
|
||||||
|
maxHp: combat.playerMaxHp,
|
||||||
|
stats: combat.playerState,
|
||||||
|
},
|
||||||
|
monster: {
|
||||||
|
currentHp: combat.monsterCurrentHp,
|
||||||
|
maxHp: combat.monsterMaxHp,
|
||||||
|
stats: combat.monsterState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCombatDto(
|
||||||
|
combat: Combat,
|
||||||
|
playerName: string,
|
||||||
|
monster: MonsterDefinition,
|
||||||
|
events: CombatEvent[],
|
||||||
|
): CombatDto {
|
||||||
|
return {
|
||||||
|
id: combat.id,
|
||||||
|
status: combat.status,
|
||||||
|
round: combat.round,
|
||||||
|
player: {
|
||||||
|
name: playerName,
|
||||||
|
maxHp: combat.playerMaxHp,
|
||||||
|
currentHp: combat.playerCurrentHp,
|
||||||
|
},
|
||||||
|
monster: {
|
||||||
|
key: monster.key,
|
||||||
|
name: monster.name,
|
||||||
|
level: monster.level,
|
||||||
|
maxHp: combat.monsterMaxHp,
|
||||||
|
currentHp: combat.monsterCurrentHp,
|
||||||
|
artworkPath: monster.artworkPath,
|
||||||
|
},
|
||||||
|
events: events.map((event) => ({
|
||||||
|
round: event.round,
|
||||||
|
sequence: event.sequence,
|
||||||
|
type: event.type,
|
||||||
|
source: event.source,
|
||||||
|
target: event.target,
|
||||||
|
amount: event.amount ?? undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user