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