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; @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 { 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(); 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 { 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 { // 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, }; } }