feat(rewards): add CombatRewardService with idempotent victory rewards

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 00:13:37 +02:00
parent 07984110bf
commit af665dc677
4 changed files with 631 additions and 0 deletions

View File

@@ -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<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
) {}
findOne(options: { where: Partial<T> }): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null);
}
find(options: { where: Partial<T> }): Promise<T[]> {
return Promise.resolve(this.rows.filter((row) => this.matches(row, options.where)));
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
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<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
}
function fakeManager(state: State): EntityManager {
return {
getRepository: <T extends { id: string }>(target: EntityTarget<T>) => {
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> = {}): 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> = {}): 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);
});
});
});

View 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,
};
}
}

View File

@@ -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.',
);
}

View File

@@ -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 {}