import { Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; import { CharacterStatsService } from '../characters/character-stats.service'; import { CharacterVitalsService } from '../characters/character-vitals.service'; import { Character } from '../characters/entities/character.entity'; import { Hunt } from '../hunting/entities/hunt.entity'; import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity'; import { HuntEncounterStatus } from '../hunting/hunt-encounter-status.enum'; 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 { ActiveStatusEffect, CombatEngineState, CombatIntent, } from './combat-engine.types'; import { characterNotFound, characterTooWounded, characterTravelling, combatAlreadyActive, combatAlreadyFinished, combatNoPotionsRemaining, combatNotFound, combatStateInvalid, huntEncounterAlreadyConsumed, huntEncounterNotFound, invalidHuntEncounter, } from './combat.errors'; import { CombatStatus } from './combat-status.enum'; import { CombatEvent } from './entities/combat-event.entity'; import { Combat, CombatMonsterState, CombatPlayerState, } from './entities/combat.entity'; import { StatusEffectType } from './status-effect.enum'; // Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the // persistent consumable inventory. const STARTING_POTION_COUNT = 2; export interface CombatStatusEffectDto { type: StatusEffectType; remainingRounds: number; damagePerRound: number; } export interface CombatPlayerDto { name: string; maxHp: number; currentHp: number; potionsRemaining: number; potionsMax: number; statusEffects: CombatStatusEffectDto[]; } export interface CombatMonsterDto { key: string; name: string; level: number; maxHp: number; currentHp: number; artworkPath: string; pendingIntent: CombatIntent | null; } export interface CombatEventDto { round: number; sequence: number; type: string; source: string; target: string; amount?: number; statusEffect?: StatusEffectType; } 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 characterStats: CharacterStatsService, private readonly characterVitals: CharacterVitalsService, 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.status !== HuntEncounterStatus.AVAILABLE) { 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 = await this.characterStats.calculate( character, manager, ); if (playerStats.currentHp < 1) { throw characterTooWounded(); } this.characterVitals.pause(character, playerStats.currentHp); await characters.save(character); const combat = combats.create({ characterId, huntEncounterId: encounter.id, monsterDefinitionId: monster.id, status: CombatStatus.ACTIVE, round: 1, playerMaxHp: playerStats.maxHp, playerCurrentHp: playerStats.currentHp, monsterMaxHp: monster.maxHp, monsterCurrentHp: monster.maxHp, playerState: { attack: playerStats.attack, weaponDamage: playerStats.weaponDamage, armor: playerStats.armor, potionsRemaining: STARTING_POTION_COUNT, }, monsterState: { attack: monster.attack, armor: monster.armor, abilities: monster.abilities ?? {}, }, completedAt: null, }); await combats.save(combat); encounter.status = HuntEncounterStatus.IN_PROGRESS; 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). Keeping both code // paths on the same order avoids a lock-order inversion that could // deadlock two concurrent requests against the same character. Do not // reorder this. const character = 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(); } if ( action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0 ) { throw combatNoPotionsRemaining(); } 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; combat.playerState = result.state.player.stats as CombatPlayerState; combat.monsterState = result.state.monster.stats as CombatMonsterState; if (combat.status !== CombatStatus.ACTIVE) { combat.completedAt = new Date(); this.characterVitals.resume(character, combat.playerCurrentHp); await this.settleEncounter( manager.getRepository(HuntEncounter), combat.huntEncounterId, combat.status, ); } else { this.characterVitals.pause(character, combat.playerCurrentHp); } await characters.save(character); 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, statusEffect: event.statusEffect ?? 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 [reloadedCharacter, 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, reloadedCharacter.name, monster, events, rewards, ); }); } /** * Records the fight's outcome on the encounter that spawned it. A win * retires the encounter; a loss hands it back so the player can try again. */ private async settleEncounter( encounters: Repository, encounterId: string, outcome: CombatStatus, ): Promise { const encounter = await encounters.findOneBy({ id: encounterId }); if (!encounter) { // combats.hunt_encounter_id is a RESTRICT FK; guaranteed to exist. throw combatStateInvalid(); } encounter.status = outcome === CombatStatus.WON ? HuntEncounterStatus.DEFEATED : HuntEncounterStatus.AVAILABLE; await encounters.save(encounter); } 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, potionsRemaining: combat.playerState.potionsRemaining, potionsMax: STARTING_POTION_COUNT, statusEffects: this.toStatusEffectDtos( combat.playerState.statusEffects, ), }, monster: { key: monster.key, name: monster.name, level: monster.level, maxHp: combat.monsterMaxHp, currentHp: combat.monsterCurrentHp, artworkPath: monster.artworkPath, pendingIntent: combat.monsterState.pendingAction ?? null, }, events: events.map((event) => ({ round: event.round, sequence: event.sequence, type: event.type, source: event.source, target: event.target, amount: event.amount ?? undefined, statusEffect: event.statusEffect ?? undefined, })), rewards, }; } private toStatusEffectDtos( effects: ActiveStatusEffect[] | undefined, ): CombatStatusEffectDto[] { return (effects ?? []).map((effect) => ({ type: effect.type, remainingRounds: effect.remainingRounds, damagePerRound: effect.damagePerRound, })); } }