diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts new file mode 100644 index 0000000..539a5ff --- /dev/null +++ b/apps/api/src/combat/combat.service.spec.ts @@ -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 { + constructor( + private readonly state: FakeState, + private readonly target: EntityTarget, + private readonly inTransaction: boolean, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { + where: Partial; + lock?: { mode: string }; + }): Promise { + 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): Promise { + return Promise.resolve( + this.rows().find((row) => this.matches(row, where)) ?? null, + ); + } + + find(options: { + where: Partial; + order?: Partial>; + }): Promise { + 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 }): Promise { + return Promise.resolve( + this.rows().filter((row) => this.matches(row, options.where)).length, + ); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + 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): 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(target: EntityTarget) { + return new FakeRepository(this.state, target, true, this.dataSource); + } +} + +class FakeDataSource { + readonly locks: Array<{ target: EntityTarget; mode: string }> = []; + private readonly idCounters = new Map(); + + constructor(public state: FakeState) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, false, this); + } + + async transaction( + work: (manager: EntityManager) => Promise, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + code: string, +): Promise { + 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', + ); + }); + }); +}); diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts new file mode 100644 index 0000000..e85434d --- /dev/null +++ b/apps/api/src/combat/combat.service.ts @@ -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 { + 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 { + 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 { + 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, + characterId: string, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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, + })), + }; + } +}