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 { CombatRewardService } from '../rewards/combat-reward.service'; import type { CombatRewardDto } from '../rewards/combat-reward.service'; 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[]; rewards: CombatRewardDto | null; } @Injectable() export class CombatService { constructor( private readonly dataSource: DataSource, private readonly travelService: TravelService, private readonly combatEngine: CombatEngineService, private readonly characterCombatStats: CharacterCombatStatsService, private readonly combatRewards: CombatRewardService, ) {} 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, [], null); }); } 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, rewards] = await Promise.all([ this.loadCharacter(combat.characterId), this.loadMonster(combat.monsterDefinitionId), this.loadEvents(combat.id), this.combatRewards.loadRewards(combat.id), ]); return this.toCombatDto(combat, character.name, monster, events, rewards); } async getActiveCombat(characterId: string): Promise { const combats = this.dataSource.getRepository(Combat); const combat = await combats.findOne({ where: { characterId, status: CombatStatus.ACTIVE }, }); if (!combat) { return null; } 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, null); } async performAction( characterId: string, combatId: string, action: CombatAction, ): Promise { return this.dataSource.transaction(async (manager) => { const characters = manager.getRepository(Character); const combats = manager.getRepository(Combat); const combatEvents = manager.getRepository(CombatEvent); // Lock the character before the combat row here, matching the order // startCombat already uses (character, then combat). grantVictoryRewards // locks the character again later in this same transaction, which is a // no-op re-lock — but locking it first here keeps both code paths // consistent and avoids a lock-order inversion that could deadlock two // concurrent requests against the same character. Do not reorder this. await this.lockCharacter(characters, characterId); 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); } // The engine decided the outcome; rewards are resolved here, outside it // (spec §30). Running inside this transaction means a reward failure // rolls the whole round back rather than leaving a half-granted victory. const rewards = combat.status === CombatStatus.WON ? await this.combatRewards.grantVictoryRewards(manager, combat) : null; 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, rewards); }); } 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[], rewards: CombatRewardDto | null, ): 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, })), rewards, }; } }