From af665dc6778145385afcdfecacaf14441c57c7df Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 00:13:37 +0200 Subject: [PATCH] feat(rewards): add CombatRewardService with idempotent victory rewards Co-Authored-By: Claude Opus 5 --- .../src/rewards/combat-reward.service.spec.ts | 355 ++++++++++++++++++ apps/api/src/rewards/combat-reward.service.ts | 216 +++++++++++ apps/api/src/rewards/rewards.errors.ts | 29 ++ apps/api/src/rewards/rewards.module.ts | 31 ++ 4 files changed, 631 insertions(+) create mode 100644 apps/api/src/rewards/combat-reward.service.spec.ts create mode 100644 apps/api/src/rewards/combat-reward.service.ts create mode 100644 apps/api/src/rewards/rewards.errors.ts create mode 100644 apps/api/src/rewards/rewards.module.ts diff --git a/apps/api/src/rewards/combat-reward.service.spec.ts b/apps/api/src/rewards/combat-reward.service.spec.ts new file mode 100644 index 0000000..850f02c --- /dev/null +++ b/apps/api/src/rewards/combat-reward.service.spec.ts @@ -0,0 +1,355 @@ +import { EntityManager, EntityTarget } 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 { ItemType } from '../items/item-type.enum'; +import { LootService } from '../loot/loot.service'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import type { RandomSource } from '../shared/random-source'; +import { CombatRewardService } from './combat-reward.service'; +import { CombatReward } from './entities/combat-reward.entity'; +import { CombatRewardItem } from './entities/combat-reward-item.entity'; +import { RewardDomainError } from './rewards.errors'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const COMBAT_ID = '20000000-0000-4000-8000-000000000001'; +const ASH_RAT_ID = '30000000-0000-4000-8000-000000000001'; +const ROAD_BANDIT_ID = '30000000-0000-4000-8000-000000000002'; +const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001'; +const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002'; +const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002'; + +interface State { + characters: Character[]; + monsters: MonsterDefinition[]; + itemDefinitions: ItemDefinition[]; + characterItems: CharacterItem[]; + combatRewards: CombatReward[]; + combatRewardItems: CombatRewardItem[]; +} + +class FakeRepository { + constructor( + private readonly rows: T[], + private readonly prefix: string, + ) {} + + findOne(options: { where: Partial }): Promise { + return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); + } + + findOneBy(where: Partial): Promise { + return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); + } + + find(options: { where: Partial }): Promise { + return Promise.resolve(this.rows.filter((row) => this.matches(row, options.where))); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + if (!entity.id) { + entity.id = `${this.prefix}-${this.rows.length + 1}`; + } + const index = this.rows.findIndex((row) => row.id === entity.id); + if (index === -1) { + this.rows.push(entity); + } else { + this.rows[index] = entity; + } + return Promise.resolve(entity); + } + + private matches(row: T, where: Partial): boolean { + return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + } +} + +function fakeManager(state: State): EntityManager { + return { + getRepository: (target: EntityTarget) => { + if (target === Character) return new FakeRepository(state.characters, 'character') as never; + if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never; + if (target === ItemDefinition) + return new FakeRepository(state.itemDefinitions, 'definition') as never; + if (target === CharacterItem) + return new FakeRepository(state.characterItems, 'character-item') as never; + if (target === CombatReward) + return new FakeRepository(state.combatRewards, 'reward') as never; + if (target === CombatRewardItem) + return new FakeRepository(state.combatRewardItems, 'reward-item') as never; + throw new Error('Unsupported repository'); + }, + } as unknown as EntityManager; +} + +function combat(overrides: Partial = {}): Combat { + return { + id: COMBAT_ID, + characterId: CHARACTER_ID, + monsterDefinitionId: ASH_RAT_ID, + status: CombatStatus.WON, + round: 4, + ...overrides, + } as Combat; +} + +function createState(overrides: Partial = {}): State { + return { + characters: [{ id: CHARACTER_ID, experience: 12, silver: 3 } as Character], + monsters: [ + { + id: ASH_RAT_ID, + key: 'ash-rat', + experienceReward: 8, + silverMin: 4, + silverMax: 7, + lootTableId: ASH_RAT_TABLE, + } as MonsterDefinition, + { + id: ROAD_BANDIT_ID, + key: 'road-bandit', + experienceReward: 16, + silverMin: 9, + silverMax: 15, + lootTableId: ROAD_BANDIT_TABLE, + } as MonsterDefinition, + ], + itemDefinitions: [ + { + id: BANDIT_BLADE, + key: 'bandit-blade', + name: 'Räuberklinge', + type: ItemType.WEAPON, + rarity: ItemRarity.COMMON, + iconPath: '/images/items/bandit-blade.png', + } as ItemDefinition, + ], + characterItems: [], + combatRewards: [], + combatRewardItems: [], + ...overrides, + }; +} + +function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService { + return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService; +} + +function fixedRandom(value: number): RandomSource { + return { next: () => value }; +} + +function service( + state: State, + loot: LootService = fakeLoot(), + random: RandomSource = fixedRandom(0.5), +): CombatRewardService { + return new CombatRewardService({} as never, loot, random); +} + +describe('CombatRewardService', () => { + describe('eligibility', () => { + it('rejects an ACTIVE combat', async () => { + const state = createState(); + + await expect( + service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.ACTIVE })), + ).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' }); + expect(state.combatRewards).toHaveLength(0); + expect(state.characters[0].experience).toBe(12); + expect(state.characters[0].silver).toBe(3); + }); + + it('rejects a LOST combat', async () => { + const state = createState(); + + await expect( + service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })), + ).rejects.toBeInstanceOf(RewardDomainError); + expect(state.combatRewards).toHaveLength(0); + }); + + it('grants rewards for a WON combat', async () => { + const state = createState(); + + const reward = await service(state).grantVictoryRewards(fakeManager(state), combat()); + + expect(reward).toEqual({ experience: 8, silver: 6, items: [] }); + expect(state.combatRewards).toHaveLength(1); + }); + }); + + describe('Aschenratte', () => { + it('grants 8 XP and a silver roll inside 4-7, persisted on the character', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( + fakeManager(state), + combat(), + ); + + expect(reward.experience).toBe(8); + expect(reward.silver).toBe(4); + expect(state.characters[0].experience).toBe(20); + expect(state.characters[0].silver).toBe(7); + }); + + it('rolls the top of the silver range from the top of the random range', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards( + fakeManager(state), + combat(), + ); + + expect(reward.silver).toBe(7); + }); + }); + + describe('Straßenräuber', () => { + const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID }); + + it('grants 16 XP and a silver roll inside 9-15', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( + fakeManager(state), + banditCombat, + ); + + expect(reward.experience).toBe(16); + expect(reward.silver).toBe(9); + }); + + it('persists a dropped Räuberklinge as a CharacterItem and references it in the reward', async () => { + const state = createState(); + + const reward = await service( + state, + fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }), + ).grantVictoryRewards(fakeManager(state), banditCombat); + + expect(state.characterItems).toEqual([ + expect.objectContaining({ + characterId: CHARACTER_ID, + itemDefinitionId: BANDIT_BLADE, + quantity: 1, + }), + ]); + expect(reward.items).toEqual([ + { + characterItemId: state.characterItems[0].id, + item: { + key: 'bandit-blade', + name: 'Räuberklinge', + rarity: ItemRarity.COMMON, + iconPath: '/images/items/bandit-blade.png', + }, + quantity: 1, + }, + ]); + expect(state.combatRewardItems).toHaveLength(1); + }); + + it('reports no items when the Räuberklinge does not drop', async () => { + const state = createState(); + + const reward = await service(state, fakeLoot()).grantVictoryRewards( + fakeManager(state), + banditCombat, + ); + + expect(reward.items).toEqual([]); + expect(state.characterItems).toHaveLength(0); + expect(state.combatRewardItems).toHaveLength(0); + }); + + it('stacks a duplicate drop onto the existing CharacterItem without duplicate protection', async () => { + const state = createState({ + characterItems: [ + { + id: 'character-item-existing', + characterId: CHARACTER_ID, + itemDefinitionId: BANDIT_BLADE, + quantity: 1, + } as CharacterItem, + ], + }); + + const reward = await service( + state, + fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }), + ).grantVictoryRewards(fakeManager(state), banditCombat); + + expect(state.characterItems).toHaveLength(1); + expect(state.characterItems[0].quantity).toBe(2); + // The reward reports what THIS combat granted, not the stack total. + expect(reward.items[0].quantity).toBe(1); + }); + }); + + describe('idempotency', () => { + it('grants once and returns the same persisted reward on a repeat call', async () => { + const state = createState(); + const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); + const subject = service(state, loot, fixedRandom(0)); + const manager = fakeManager(state); + + const first = await subject.grantVictoryRewards(manager, combat()); + const second = await subject.grantVictoryRewards(manager, combat()); + + expect(second).toEqual(first); + expect(state.combatRewards).toHaveLength(1); + expect(state.combatRewardItems).toHaveLength(1); + expect(state.characterItems).toHaveLength(1); + expect(state.characterItems[0].quantity).toBe(1); + expect(state.characters[0].experience).toBe(20); + expect(state.characters[0].silver).toBe(7); + expect(loot.rollLoot).toHaveBeenCalledTimes(1); + }); + }); + + describe('loadRewards', () => { + it('returns null for a combat that was never rewarded', async () => { + const state = createState(); + + await expect( + service(state).loadRewards(COMBAT_ID, fakeManager(state)), + ).resolves.toBeNull(); + }); + + it('replays the persisted reward without rerolling', async () => { + const state = createState(); + const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); + const subject = service(state, loot, fixedRandom(0)); + const manager = fakeManager(state); + const granted = await subject.grantVictoryRewards(manager, combat()); + + const replayed = await subject.loadRewards(COMBAT_ID, manager); + + expect(replayed).toEqual(granted); + expect(loot.rollLoot).toHaveBeenCalledTimes(1); + }); + }); + + describe('failure handling', () => { + it('throws instead of half-granting when a rolled item definition is missing', async () => { + const state = createState(); + + await expect( + service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards( + fakeManager(state), + combat(), + ), + ).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' }); + expect(state.combatRewardItems).toHaveLength(0); + }); + }); +}); diff --git a/apps/api/src/rewards/combat-reward.service.ts b/apps/api/src/rewards/combat-reward.service.ts new file mode 100644 index 0000000..cc9ef25 --- /dev/null +++ b/apps/api/src/rewards/combat-reward.service.ts @@ -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; + +@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); + + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/rewards/rewards.errors.ts b/apps/api/src/rewards/rewards.errors.ts new file mode 100644 index 0000000..7594221 --- /dev/null +++ b/apps/api/src/rewards/rewards.errors.ts @@ -0,0 +1,29 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type RewardErrorCode = 'COMBAT_NOT_WON' | 'REWARD_STATE_INVALID'; + +export class RewardDomainError extends HttpException { + constructor( + public readonly code: RewardErrorCode, + status: HttpStatus, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function combatNotWon(): RewardDomainError { + return new RewardDomainError( + 'COMBAT_NOT_WON', + HttpStatus.CONFLICT, + 'Only a won combat can grant victory rewards.', + ); +} + +export function rewardStateInvalid(): RewardDomainError { + return new RewardDomainError( + 'REWARD_STATE_INVALID', + HttpStatus.INTERNAL_SERVER_ERROR, + 'The reward references unavailable data.', + ); +} diff --git a/apps/api/src/rewards/rewards.module.ts b/apps/api/src/rewards/rewards.module.ts new file mode 100644 index 0000000..3b2419e --- /dev/null +++ b/apps/api/src/rewards/rewards.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootModule } from '../loot/loot.module'; +import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; +import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; +import { CombatRewardService } from './combat-reward.service'; +import { CombatReward } from './entities/combat-reward.entity'; +import { CombatRewardItem } from './entities/combat-reward-item.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Character, + CharacterItem, + ItemDefinition, + MonsterDefinition, + CombatReward, + CombatRewardItem, + ]), + LootModule, + ], + providers: [ + CombatRewardService, + { provide: RANDOM_SOURCE, useValue: systemRandomSource }, + ], + exports: [CombatRewardService], +}) +export class RewardsModule {}