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>
245 lines
8.1 KiB
TypeScript
245 lines
8.1 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import { DataSource, EntityManager } from 'typeorm';
|
|
import { Character } from '../characters/entities/character.entity';
|
|
import { CombatStatus } from '../combat/combat-status.enum';
|
|
import { Combat } from '../combat/entities/combat.entity';
|
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
|
import { ItemRarity } from '../items/item-rarity.enum';
|
|
import { LootService } from '../loot/loot.service';
|
|
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
|
|
import { RANDOM_SOURCE } from '../shared/random-source';
|
|
import type { RandomSource } from '../shared/random-source';
|
|
import { rollInclusive } from '../shared/roll-range';
|
|
import { CombatReward } from './entities/combat-reward.entity';
|
|
import { CombatRewardItem } from './entities/combat-reward-item.entity';
|
|
import { combatNotWon, rewardStateInvalid } from './rewards.errors';
|
|
|
|
export interface CombatRewardItemDto {
|
|
characterItemId: string;
|
|
item: {
|
|
key: string;
|
|
name: string;
|
|
rarity: ItemRarity;
|
|
iconPath: string;
|
|
};
|
|
quantity: number;
|
|
}
|
|
|
|
export interface CombatRewardDto {
|
|
experience: number;
|
|
silver: number;
|
|
items: CombatRewardItemDto[];
|
|
}
|
|
|
|
// Both DataSource and EntityManager expose this; naming it keeps the read path
|
|
// usable inside and outside a transaction without a union type.
|
|
type RepositoryScope = Pick<DataSource, 'getRepository'>;
|
|
|
|
@Injectable()
|
|
export class CombatRewardService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly lootService: LootService,
|
|
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
|
|
) {}
|
|
|
|
/**
|
|
* Grants a won combat's rewards exactly once (spec §7, §21).
|
|
*
|
|
* Runs inside the caller's transaction — `CombatService.performAction`
|
|
* already holds a pessimistic write lock on the combat row — so either
|
|
* everything below commits or nothing does.
|
|
*
|
|
* Roll order is fixed: silver first, then the loot table in `position`
|
|
* order. Tests depend on it.
|
|
*/
|
|
async grantVictoryRewards(
|
|
manager: EntityManager,
|
|
combat: Combat,
|
|
): Promise<CombatRewardDto> {
|
|
if (combat.status !== CombatStatus.WON) {
|
|
throw combatNotWon();
|
|
}
|
|
|
|
const rewards = manager.getRepository(CombatReward);
|
|
const existing = await rewards.findOne({ where: { combatId: combat.id } });
|
|
if (existing) {
|
|
// Already rewarded: replay rather than roll again.
|
|
return this.toDto(manager, existing);
|
|
}
|
|
|
|
const monster = await manager
|
|
.getRepository(MonsterDefinition)
|
|
.findOneBy({ id: combat.monsterDefinitionId });
|
|
if (!monster) {
|
|
throw rewardStateInvalid();
|
|
}
|
|
|
|
const experience = monster.experienceReward;
|
|
const silver = rollInclusive(
|
|
this.randomSource,
|
|
monster.silverMin,
|
|
monster.silverMax,
|
|
);
|
|
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 },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!character) {
|
|
throw rewardStateInvalid();
|
|
}
|
|
character.experience += experience;
|
|
character.silver += silver;
|
|
await characters.save(character);
|
|
|
|
const reward = await rewards.save(
|
|
rewards.create({
|
|
combatId: combat.id,
|
|
characterId: combat.characterId,
|
|
experienceGranted: experience,
|
|
silverGranted: silver,
|
|
}),
|
|
);
|
|
|
|
const characterItems = manager.getRepository(CharacterItem);
|
|
const rewardItems = manager.getRepository(CombatRewardItem);
|
|
const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = [];
|
|
|
|
for (const rolled of roll.items) {
|
|
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
|
|
|
|
const existingStack = await characterItems.findOne({
|
|
where: {
|
|
characterId: combat.characterId,
|
|
itemDefinitionId: rolled.itemDefinitionId,
|
|
},
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
// Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28).
|
|
const characterItem = existingStack
|
|
? Object.assign(existingStack, {
|
|
quantity: existingStack.quantity + rolled.quantity,
|
|
})
|
|
: characterItems.create({
|
|
characterId: combat.characterId,
|
|
itemDefinitionId: rolled.itemDefinitionId,
|
|
quantity: rolled.quantity,
|
|
});
|
|
await characterItems.save(characterItem);
|
|
|
|
await rewardItems.save(
|
|
rewardItems.create({
|
|
combatRewardId: reward.id,
|
|
characterItemId: characterItem.id,
|
|
itemDefinitionId: definition.id,
|
|
quantity: 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 };
|
|
}
|
|
|
|
/** Reads a persisted reward so a refresh replays it (spec §25, §48). */
|
|
async loadRewards(
|
|
combatId: string,
|
|
manager?: EntityManager,
|
|
): Promise<CombatRewardDto | null> {
|
|
const scope: RepositoryScope = manager ?? this.dataSource;
|
|
const reward = await scope
|
|
.getRepository(CombatReward)
|
|
.findOne({ where: { combatId } });
|
|
|
|
return reward ? this.toDto(scope, reward) : null;
|
|
}
|
|
|
|
private async toDto(
|
|
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 },
|
|
order: { itemDefinitionId: 'ASC' },
|
|
});
|
|
const definitions = scope.getRepository(ItemDefinition);
|
|
|
|
const items: CombatRewardItemDto[] = [];
|
|
for (const rewardItem of rewardItems) {
|
|
const definition = await definitions.findOneBy({
|
|
id: rewardItem.itemDefinitionId,
|
|
});
|
|
if (!definition) {
|
|
// combat_reward_items.item_definition_id is a RESTRICT FK.
|
|
throw rewardStateInvalid();
|
|
}
|
|
items.push(
|
|
this.toItemDto(rewardItem.characterItemId, definition, rewardItem.quantity),
|
|
);
|
|
}
|
|
|
|
return {
|
|
experience: reward.experienceGranted,
|
|
silver: reward.silverGranted,
|
|
items,
|
|
};
|
|
}
|
|
|
|
private toItemDto(
|
|
characterItemId: string,
|
|
definition: ItemDefinition,
|
|
quantity: number,
|
|
): CombatRewardItemDto {
|
|
// Drop chance, roll results, and loot-table ids never leave the server
|
|
// (spec §26).
|
|
return {
|
|
characterItemId,
|
|
item: {
|
|
key: definition.key,
|
|
name: definition.name,
|
|
rarity: definition.rarity,
|
|
iconPath: definition.iconPath,
|
|
},
|
|
quantity,
|
|
};
|
|
}
|
|
}
|