feat(rewards): add CombatRewardService with idempotent victory rewards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
216
apps/api/src/rewards/combat-reward.service.ts
Normal file
216
apps/api/src/rewards/combat-reward.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
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);
|
||||
|
||||
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 definitions = manager.getRepository(ItemDefinition);
|
||||
const characterItems = manager.getRepository(CharacterItem);
|
||||
const rewardItems = manager.getRepository(CombatRewardItem);
|
||||
const items: CombatRewardItemDto[] = [];
|
||||
|
||||
for (const rolled of roll.items) {
|
||||
const definition = await definitions.findOneBy({
|
||||
id: rolled.itemDefinitionId,
|
||||
});
|
||||
if (!definition) {
|
||||
throw rewardStateInvalid();
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
items.push(this.toItemDto(characterItem.id, definition, rolled.quantity));
|
||||
}
|
||||
|
||||
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> {
|
||||
const rewardItems = await scope
|
||||
.getRepository(CombatRewardItem)
|
||||
.find({ where: { combatRewardId: reward.id } });
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user