fix(rewards): resolve item definitions up front and stabilize reward item order

Resolve every rolled ItemDefinition before mutating the character or
creating the CombatReward row, so a missing definition can no longer
half-grant (XP/silver saved, reward row created, then throw). Also
make the immediate grant response and a later loadRewards replay
agree on item order by sorting both on itemDefinitionId instead of
roll/insertion order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 00:36:54 +02:00
parent af665dc677
commit d9585b166a
2 changed files with 130 additions and 12 deletions

View File

@@ -84,6 +84,25 @@ export class CombatRewardService {
);
const roll = await this.lootService.rollLoot(monster.lootTableId, manager);
// Resolve every rolled item definition up front, before any mutation, so
// a missing definition throws `rewardStateInvalid()` before the
// character's XP/silver are touched or a `CombatReward` row is created.
// This keeps a failed grant from leaving partial writes behind.
const definitions = manager.getRepository(ItemDefinition);
const resolvedDefinitions = new Map<string, ItemDefinition>();
for (const rolled of roll.items) {
if (resolvedDefinitions.has(rolled.itemDefinitionId)) {
continue;
}
const definition = await definitions.findOneBy({
id: rolled.itemDefinitionId,
});
if (!definition) {
throw rewardStateInvalid();
}
resolvedDefinitions.set(rolled.itemDefinitionId, definition);
}
const characters = manager.getRepository(Character);
const character = await characters.findOne({
where: { id: combat.characterId },
@@ -105,18 +124,12 @@ export class CombatRewardService {
}),
);
const definitions = manager.getRepository(ItemDefinition);
const characterItems = manager.getRepository(CharacterItem);
const rewardItems = manager.getRepository(CombatRewardItem);
const items: CombatRewardItemDto[] = [];
const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = [];
for (const rolled of roll.items) {
const definition = await definitions.findOneBy({
id: rolled.itemDefinitionId,
});
if (!definition) {
throw rewardStateInvalid();
}
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
const existingStack = await characterItems.findOne({
where: {
@@ -146,9 +159,18 @@ export class CombatRewardService {
}),
);
items.push(this.toItemDto(characterItem.id, definition, rolled.quantity));
granted.push({
itemDefinitionId: rolled.itemDefinitionId,
dto: this.toItemDto(characterItem.id, definition, rolled.quantity),
});
}
// The immediate response and a later `loadRewards` replay must agree on
// item order; both sort on the same stable key (itemDefinitionId, which
// `toDto`'s query also orders by) rather than roll order.
granted.sort((a, b) => a.itemDefinitionId.localeCompare(b.itemDefinitionId));
const items = granted.map((entry) => entry.dto);
return { experience, silver, items };
}
@@ -169,9 +191,15 @@ export class CombatRewardService {
scope: RepositoryScope,
reward: CombatReward,
): Promise<CombatRewardDto> {
// Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards`
// applies to its own response — the immediate grant and a later replay
// must list items identically.
const rewardItems = await scope
.getRepository(CombatRewardItem)
.find({ where: { combatRewardId: reward.id } });
.find({
where: { combatRewardId: reward.id },
order: { itemDefinitionId: 'ASC' },
});
const definitions = scope.getRepository(ItemDefinition);
const items: CombatRewardItemDto[] = [];