Merge branch 'master' into worktree-encounter-status

This commit is contained in:
Bastian Wagner
2026-08-20 10:34:52 +02:00
82 changed files with 12212 additions and 37 deletions

View File

@@ -7,6 +7,8 @@ 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';
@@ -58,6 +60,7 @@ export interface CombatDto {
player: CombatPlayerDto;
monster: CombatMonsterDto;
events: CombatEventDto[];
rewards: CombatRewardDto | null;
}
@Injectable()
@@ -67,6 +70,7 @@ export class CombatService {
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService,
private readonly combatRewards: CombatRewardService,
) {}
async startCombat(
@@ -147,7 +151,7 @@ export class CombatService {
encounter.status = HuntEncounterStatus.IN_PROGRESS;
await encounters.save(encounter);
return this.toCombatDto(combat, character.name, monster, []);
return this.toCombatDto(combat, character.name, monster, [], null);
});
}
@@ -160,13 +164,14 @@ export class CombatService {
throw combatNotFound();
}
const [character, monster, events] = await Promise.all([
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);
return this.toCombatDto(combat, character.name, monster, events, rewards);
}
async getActiveCombat(characterId: string): Promise<CombatDto | null> {
@@ -184,7 +189,7 @@ export class CombatService {
this.loadEvents(combat.id),
]);
return this.toCombatDto(combat, character.name, monster, events);
return this.toCombatDto(combat, character.name, monster, events, null);
}
async performAction(
@@ -193,9 +198,18 @@ export class CombatService {
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' },
@@ -242,6 +256,14 @@ export class CombatService {
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,
@@ -254,7 +276,7 @@ export class CombatService {
this.loadEvents(combat.id, combatEvents),
]);
return this.toCombatDto(combat, character.name, monster, events);
return this.toCombatDto(combat, character.name, monster, events, rewards);
});
}
@@ -354,6 +376,7 @@ export class CombatService {
playerName: string,
monster: MonsterDefinition,
events: CombatEvent[],
rewards: CombatRewardDto | null,
): CombatDto {
return {
id: combat.id,
@@ -380,6 +403,7 @@ export class CombatService {
target: event.target,
amount: event.amount ?? undefined,
})),
rewards,
};
}
}