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