feat(rewards): add CombatRewardService with idempotent victory rewards
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
355
apps/api/src/rewards/combat-reward.service.spec.ts
Normal file
355
apps/api/src/rewards/combat-reward.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
apps/api/src/rewards/rewards.errors.ts
Normal file
29
apps/api/src/rewards/rewards.errors.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
31
apps/api/src/rewards/rewards.module.ts
Normal file
31
apps/api/src/rewards/rewards.module.ts
Normal 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 {}
|
||||
Reference in New Issue
Block a user