feat(loot): add LootService with deterministic independent rolls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
13
apps/api/src/loot/loot.module.ts
Normal file
13
apps/api/src/loot/loot.module.ts
Normal file
@@ -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 {}
|
||||||
128
apps/api/src/loot/loot.service.spec.ts
Normal file
128
apps/api/src/loot/loot.service.spec.ts
Normal file
@@ -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>): 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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/api/src/loot/loot.service.ts
Normal file
66
apps/api/src/loot/loot.service.ts
Normal file
@@ -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<LootRollResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user