diff --git a/apps/api/src/loot/loot.module.ts b/apps/api/src/loot/loot.module.ts new file mode 100644 index 0000000..c2973c8 --- /dev/null +++ b/apps/api/src/loot/loot.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; +import { LootTable } from './entities/loot-table.entity'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; +import { LootService } from './loot.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])], + providers: [LootService, { provide: RANDOM_SOURCE, useValue: systemRandomSource }], + exports: [LootService], +}) +export class LootModule {} diff --git a/apps/api/src/loot/loot.service.spec.ts b/apps/api/src/loot/loot.service.spec.ts new file mode 100644 index 0000000..e6b315e --- /dev/null +++ b/apps/api/src/loot/loot.service.spec.ts @@ -0,0 +1,128 @@ +import { DataSource } from 'typeorm'; +import type { RandomSource } from '../shared/random-source'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; +import { LootService } from './loot.service'; + +const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001'; +const ASH_PELT = '50000000-0000-4000-8000-00000000000c'; +const WORN_SHORT_SWORD = '50000000-0000-4000-8000-000000000001'; + +function entry(overrides: Partial): LootTableEntry { + return { + id: 'entry-1', + lootTableId: ASH_RAT_TABLE, + itemDefinitionId: ASH_PELT, + position: 1, + dropChance: '0.6000', + minQuantity: 1, + maxQuantity: 1, + enabled: true, + createdAt: new Date('2026-08-19T09:00:00.000Z'), + updatedAt: new Date('2026-08-19T09:00:00.000Z'), + ...overrides, + } as LootTableEntry; +} + +// Hands out the queued values in order, so a test states exactly which roll +// each value answers. +function queuedRandom(...values: number[]): RandomSource { + let index = 0; + return { + next: () => { + if (index >= values.length) { + throw new Error('LootService consumed more random values than the test queued'); + } + return values[index++]; + }, + }; +} + +function dataSourceWith(entries: LootTableEntry[]): DataSource { + return { + getRepository: jest.fn(() => ({ + find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) => + entries + .filter( + (candidate) => + candidate.lootTableId === options.where.lootTableId && + candidate.enabled === options.where.enabled, + ) + .sort((a, b) => a.position - b.position), + ), + })), + } as unknown as DataSource; +} + +describe('LootService', () => { + const ashRatEntries = [ + entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.6000' }), + entry({ + id: 'entry-sword', + itemDefinitionId: WORN_SHORT_SWORD, + position: 2, + dropChance: '0.0800', + }), + ]; + + it('drops an entry when the roll falls under its chance', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.59, 0.07)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [ + { itemDefinitionId: ASH_PELT, quantity: 1 }, + { itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }, + ], + }); + }); + + it('skips an entry when the roll lands on or above its chance', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.6, 0.08)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); + }); + + it('rolls each entry independently, so one combat can drop only the second item', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.9, 0.01)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], + }); + }); + + it('rolls entries in position order so injected values stay predictable', async () => { + const outOfOrder = [ + entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }), + entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }), + ]; + const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], + }); + }); + + it('ignores disabled entries', async () => { + const service = new LootService( + dataSourceWith([entry({ dropChance: '1.0000', enabled: false })]), + queuedRandom(), + ); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); + }); + + it('consumes no quantity roll when min and max match, and one when they differ', async () => { + const stackable = [entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 })]; + // First value drops the entry, second picks the quantity (0.5 -> 3). + const service = new LootService(dataSourceWith(stackable), queuedRandom(0.1, 0.5)); + + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }], + }); + }); + + it('returns nothing for a monster without a loot table', async () => { + const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom()); + + await expect(service.rollLoot(null)).resolves.toEqual({ items: [] }); + }); +}); diff --git a/apps/api/src/loot/loot.service.ts b/apps/api/src/loot/loot.service.ts new file mode 100644 index 0000000..2fcbf9b --- /dev/null +++ b/apps/api/src/loot/loot.service.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { RANDOM_SOURCE } from '../shared/random-source'; +import type { RandomSource } from '../shared/random-source'; +import { rollInclusive } from '../shared/roll-range'; +import { LootTableEntry } from './entities/loot-table-entry.entity'; + +export interface LootRollItem { + itemDefinitionId: string; + quantity: number; +} + +export interface LootRollResult { + items: LootRollItem[]; +} + +@Injectable() +export class LootService { + constructor( + private readonly dataSource: DataSource, + @Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource, + ) {} + + /** + * Rolls a loot table without persisting anything (spec §20). + * + * Every enabled entry is one independent roll in `position` order, so a + * single combat may yield nothing, one item, or several (spec §17). The + * quantity roll is skipped entirely when `minQuantity === maxQuantity`, + * which keeps the random sequence stable for the seeded content. + */ + async rollLoot( + lootTableId: string | null, + manager?: EntityManager, + ): Promise { + if (!lootTableId) { + return { items: [] }; + } + + const entries = await ( + manager?.getRepository(LootTableEntry) ?? + this.dataSource.getRepository(LootTableEntry) + ).find({ + where: { lootTableId, enabled: true }, + order: { position: 'ASC' }, + }); + + const items: LootRollItem[] = []; + for (const entry of entries) { + if (this.randomSource.next() >= Number(entry.dropChance)) { + continue; + } + + items.push({ + itemDefinitionId: entry.itemDefinitionId, + quantity: rollInclusive( + this.randomSource, + entry.minQuantity, + entry.maxQuantity, + ), + }); + } + + return { items }; + } +}