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:
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user