Files
ashen-realms/apps/api/src/combat/combat.service.ts
Bastian Wagner 40e830a321 fix(combat): lock character before combat row and harden reward-transaction test
Fix a lock-order inversion Task 8 introduced: performAction locked the
combat row first and, inside grantVictoryRewards, the character row
second -- the opposite order to startCombat (character, then combat),
creating a deadlock cycle for two concurrent requests on the same
character. performAction now locks the character first via the
existing lockCharacter helper, matching startCombat; the later re-lock
inside grantVictoryRewards is a no-op within the same transaction.

Also strengthen the test that guards the transaction contract for
grantVictoryRewards: expect.anything() would have passed even if the
data source were handed over instead of the transaction manager, since
CombatRewardService has no runtime guard against that substitution.
The test now asserts on the captured argument's identity. Verified
this is load-bearing by temporarily passing the data source in place
of the manager and confirming the test fails.

Finally, make the rollback test's unchanged-XP/silver assertions real:
the fake grantVictoryRewards now writes through the transaction
manager before throwing, so the assertions prove the rollback
discarded those writes instead of passing vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:06:50 +02:00

382 lines
12 KiB
TypeScript

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<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, [], null);
});
}
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, 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<CombatDto | null> {
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<CombatDto> {
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<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[],
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,
};
}
}