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:
Bastian Wagner
2026-08-19 16:01:17 +02:00
parent 90f6a8cd78
commit 498349b5eb
2 changed files with 908 additions and 0 deletions

View 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',
);
});
});
});

View 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,
})),
};
}
}