From e13f6533e55ddefd1cdffaed77224d162f3ecdd2 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 22:44:53 +0200 Subject: [PATCH] feat(quests): read quest progress from owned items and bag capacity Adds a hint to the second pelt hunt: the Hide Bag holds five of any hide, so Tough Hides can crowd out the fifth pelt with no way forward shown. Co-Authored-By: Claude Opus 5 --- apps/api/src/database/seeds/quest-content.ts | 8 +- .../seeds/vertical-slice.seed.spec.ts | 20 + apps/api/src/quests/quest-progress.module.ts | 40 ++ .../src/quests/quest-progress.service.spec.ts | 386 ++++++++++++++++++ apps/api/src/quests/quest-progress.service.ts | 267 ++++++++++++ 5 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/quests/quest-progress.module.ts create mode 100644 apps/api/src/quests/quest-progress.service.spec.ts create mode 100644 apps/api/src/quests/quest-progress.service.ts diff --git a/apps/api/src/database/seeds/quest-content.ts b/apps/api/src/database/seeds/quest-content.ts index 8d55401..4e35fc9 100644 --- a/apps/api/src/database/seeds/quest-content.ts +++ b/apps/api/src/database/seeds/quest-content.ts @@ -160,7 +160,13 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [ requiredQuantity: 5, description: 'Collect Ashen Pelts', npcLine: null, - hintText: null, + // The bag holds five of *any* hide, not five pelts: a player carrying + // Tough Hides alongside them can fill it before the fifth pelt. This step + // must not advance for that -- it is meant to be finished (§7) -- so it + // says what to do instead, which is the same courtesy §4 asks for at the + // bagless limit. + hintText: + 'Your hide bag is full of other goods. Trade some to Borin to make room for pelts.', advanceWhenBlocked: false, consumeOnComplete: true, grantsLootBagKey: null, diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 4582f0b..a373796 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -1106,6 +1106,26 @@ describe('seedVisibleVerticalSlice', () => { expect(collectSteps.map((row) => row.requiredQuantity)).toEqual([5, 5]); }); + it('tells the player what to do when the bag fills with other hides', async () => { + const questObjectiveRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + questObjective: questObjectiveRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + // The Hide Bag holds five of any hide, so Tough Hides can crowd out the + // fifth pelt. That step must not give up -- it is meant to be finished + // (§7) -- so it needs a way forward instead. + const secondCollect = questObjectiveRepository.rows.find( + (row) => row.orderIndex === 3, + ) as Row; + expect(secondCollect.advanceWhenBlocked).toBe(false); + expect(secondCollect.hintText).toBe( + 'Your hide bag is full of other goods. Trade some to Borin to make room for pelts.', + ); + }); + it('writes the referral flag onto Borin, not onto the warden', async () => { const questObjectiveRepository = new InMemoryRepository(); const dataSource = createDataSource({ diff --git a/apps/api/src/quests/quest-progress.module.ts b/apps/api/src/quests/quest-progress.module.ts new file mode 100644 index 0000000..71525a1 --- /dev/null +++ b/apps/api/src/quests/quest-progress.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootBagsModule } from '../loot-bags/loot-bags.module'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { QuestProgressService } from './quest-progress.service'; + +/** + * The read-only half of the quest system, on its own so the graph stays acyclic. + * + * `NpcService` needs quest state to decide which markers an NPC shows, and + * `QuestService` needs `NpcService` to verify the character is standing with + * the NPC they claim to be talking to. If both lived in `QuestsModule`, those + * two would import each other. Splitting the reads out costs one module and + * avoids `forwardRef`, which hides the cycle rather than removing it. + * + * `forFeature` is required even though the service resolves its repositories + * off the DataSource: the runtime config uses `autoLoadEntities`, which only + * registers entities a module declares. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CharacterItem, + CharacterQuest, + ItemDefinition, + NpcQuestAssignment, + QuestDefinition, + QuestObjective, + ]), + LootBagsModule, + ], + providers: [QuestProgressService], + exports: [QuestProgressService], +}) +export class QuestProgressModule {} diff --git a/apps/api/src/quests/quest-progress.service.spec.ts b/apps/api/src/quests/quest-progress.service.spec.ts new file mode 100644 index 0000000..71531e4 --- /dev/null +++ b/apps/api/src/quests/quest-progress.service.spec.ts @@ -0,0 +1,386 @@ +import { DataSource } from 'typeorm'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootCategory } from '../items/loot-category.enum'; +import { + LootCapacityDto, + LootCapacityService, +} from '../loot-bags/loot-capacity.service'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { QuestProgressService } from './quest-progress.service'; +import { + CharacterQuestStatus, + NpcQuestRole, + QuestObjectiveType, +} from './quest.types'; + +const CHARACTER_ID = 'character-1'; +const QUEST_ID = 'quest-1'; +const WARDEN_ID = 'npc-warden'; +const BORIN_ID = 'npc-borin'; + +interface Fixture { + questEnabled?: boolean; + /** Owned quantity of `ash-pelt`. */ + pelts?: number; + /** Owned quantity of `bandit-hood`, an item with no loot category. */ + hoods?: number; + hideCarried?: number; + hideCapacity?: number; + row?: Partial | null; + objectiveOverrides?: Array>; + disabledObjectiveKeys?: string[]; + assignments?: Array<{ npcId: string; role: NpcQuestRole; enabled?: boolean }>; +} + +function objective(over: Partial): QuestObjective { + return { + id: `objective-${over.orderIndex ?? 0}`, + questId: QUEST_ID, + key: `step-${over.orderIndex ?? 0}`, + orderIndex: 0, + type: QuestObjectiveType.COLLECT_ITEM, + targetKey: 'ash-pelt', + requiredQuantity: 5, + description: 'Collect Ashen Pelts', + npcLine: null, + hintText: null, + advanceWhenBlocked: false, + consumeOnComplete: false, + grantsLootBagKey: null, + setsFlagKey: null, + setsFlagNpcKey: null, + enabled: true, + ...over, + } as QuestObjective; +} + +/** The real five-step shape of Trouble Beyond the Gate. */ +function chainObjectives(): QuestObjective[] { + return [ + objective({ orderIndex: 0, key: 'collect-first', advanceWhenBlocked: true }), + objective({ + orderIndex: 1, + key: 'report', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'south-gate-warden', + requiredQuantity: 1, + }), + objective({ + orderIndex: 2, + key: 'bag', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'borin-quartermaster', + requiredQuantity: 1, + }), + objective({ orderIndex: 3, key: 'collect', consumeOnComplete: true }), + objective({ + orderIndex: 4, + key: 'turn-in', + type: QuestObjectiveType.TALK_TO_NPC, + targetKey: 'south-gate-warden', + requiredQuantity: 1, + }), + ]; +} + +function createService(fixture: Fixture = {}) { + const quest = { + id: QUEST_ID, + key: 'trouble-beyond-the-gate', + title: 'Trouble Beyond the Gate', + description: 'Five pelts.', + rewardFactionKey: 'border-guard', + rewardReputation: 10, + rewardSilver: 0, + enabled: fixture.questEnabled ?? true, + } as QuestDefinition; + + const objectives = (fixture.objectiveOverrides + ? fixture.objectiveOverrides.map((over) => objective(over)) + : chainObjectives() + ).filter( + (candidate) => !(fixture.disabledObjectiveKeys ?? []).includes(candidate.key), + ); + + const definitions = [ + { + id: 'item-pelt', + key: 'ash-pelt', + lootCategory: LootCategory.HIDE, + }, + { + id: 'item-hood', + key: 'bandit-hood', + // Equipment: outside every loot category, so a full bag cannot block it. + lootCategory: null, + }, + ] as ItemDefinition[]; + + const characterItems = [ + { itemDefinitionId: 'item-pelt', quantity: fixture.pelts ?? 0 }, + { itemDefinitionId: 'item-hood', quantity: fixture.hoods ?? 0 }, + ].filter((item) => item.quantity > 0) as CharacterItem[]; + + const rows = + fixture.row === null || fixture.row === undefined + ? [] + : ([ + { + id: 'character-quest-1', + characterId: CHARACTER_ID, + questId: QUEST_ID, + status: CharacterQuestStatus.ACTIVE, + currentObjectiveIndex: 0, + acceptedAt: new Date(), + completedAt: null, + ...fixture.row, + }, + ] as CharacterQuest[]); + + const assignments = (fixture.assignments ?? []).map((entry, index) => ({ + id: `assignment-${index}`, + npcId: entry.npcId, + questId: QUEST_ID, + role: entry.role, + enabled: entry.enabled ?? true, + })) as NpcQuestAssignment[]; + + const dataSource = { + getRepository: (entity: unknown) => { + if (entity === QuestDefinition) { + return { + find: async ({ where }: { where: { enabled: boolean } }) => + quest.enabled === where.enabled ? [quest] : [], + }; + } + if (entity === QuestObjective) { + return { find: async () => objectives }; + } + if (entity === CharacterQuest) { + return { find: async () => rows }; + } + if (entity === NpcQuestAssignment) { + return { + find: async ({ + where, + }: { + where: { npcId: string; enabled: boolean }; + }) => + assignments.filter( + (assignment) => + assignment.npcId === where.npcId && + assignment.enabled === where.enabled, + ), + }; + } + if (entity === ItemDefinition) { + return { find: async () => definitions }; + } + if (entity === CharacterItem) { + return { find: async () => characterItems }; + } + throw new Error('Unexpected repository'); + }, + } as unknown as DataSource; + + const capacities: LootCapacityDto[] = [ + { + category: LootCategory.HIDE, + current: fixture.hideCarried ?? 0, + capacity: fixture.hideCapacity ?? 1, + bag: null, + }, + ]; + const lootCapacity = { + getCapacities: jest.fn().mockResolvedValue(capacities), + } as unknown as LootCapacityService; + + return { + service: new QuestProgressService(dataSource, lootCapacity), + lootCapacity, + }; +} + +describe('QuestProgressService', () => { + it('reports an unstarted quest as available with no current step', async () => { + const { service } = createService({ row: null }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.status).toBe('AVAILABLE'); + expect(state.currentIndex).toBeNull(); + expect(state.objectives).toHaveLength(5); + expect(state.objectives[0].current).toBe(0); + }); + + it('counts pelts the character already owned before accepting', async () => { + // Slice 0.9 §11: owning quest goods up front must not have to be undone. + const { service } = createService({ + pelts: 2, + hideCarried: 2, + hideCapacity: 5, + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives[0].current).toBe(2); + expect(state.objectives[0].required).toBe(5); + expect(state.currentIndex).toBe(0); + }); + + it('marks a collect step blocked once the hide bag is full', async () => { + const { service } = createService({ + pelts: 1, + hideCarried: 1, + hideCapacity: 1, + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives[0].blocked).toBe(true); + // advanceWhenBlocked on step 0 hands the player to the warden (§4). + expect(state.currentIndex).toBe(1); + }); + + it('never blocks a step whose item has no loot category', async () => { + const { service } = createService({ + hoods: 1, + hideCarried: 1, + hideCapacity: 1, + objectiveOverrides: [ + { orderIndex: 0, key: 'collect-hoods', targetKey: 'bandit-hood' }, + ], + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + // Equipment is unaffected by bag capacity (Slice 0.7.5 §8). + expect(state.objectives[0].blocked).toBe(false); + }); + + it('reads an item the character has never owned as zero', async () => { + const { service } = createService({ row: { currentObjectiveIndex: 0 } }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives[0].current).toBe(0); + expect(state.objectives[0].satisfied).toBe(false); + }); + + it('derives the active step from the stored floor and current items', async () => { + const { service } = createService({ + pelts: 5, + hideCarried: 5, + hideCapacity: 5, + row: { currentObjectiveIndex: 3 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.currentIndex).toBe(4); + expect(state.objectives[3].satisfied).toBe(true); + }); + + it('reports a completed quest without a current step', async () => { + const { service } = createService({ + row: { + status: CharacterQuestStatus.COMPLETED, + currentObjectiveIndex: 5, + completedAt: new Date(), + }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.status).toBe('COMPLETED'); + expect(state.currentIndex).toBeNull(); + }); + + it('ignores a disabled quest entirely', async () => { + const { service } = createService({ questEnabled: false }); + + expect(await service.getQuestStates(CHARACTER_ID)).toEqual([]); + }); + + it('ignores a disabled objective', async () => { + const { service } = createService({ + disabledObjectiveKeys: ['bag'], + row: { currentObjectiveIndex: 0 }, + }); + + const [state] = await service.getQuestStates(CHARACTER_ID); + + expect(state.objectives).toHaveLength(4); + expect(state.objectives.map((entry) => entry.objective.key)).not.toContain( + 'bag', + ); + }); + + it('measures every collect step against one capacity read', async () => { + const { service, lootCapacity } = createService({ + row: { currentObjectiveIndex: 0 }, + }); + + await service.getQuestStates(CHARACTER_ID); + + // Two collect steps in this chain; both are measured against the same + // carrying state, so re-reading it per step would be pure waste. + expect(lootCapacity.getCapacities).toHaveBeenCalledTimes(1); + }); + + it('finds one quest by key', async () => { + const { service } = createService({ row: null }); + + expect( + await service.getQuestState(CHARACTER_ID, 'trouble-beyond-the-gate'), + ).not.toBeNull(); + expect(await service.getQuestState(CHARACTER_ID, 'no-such-quest')).toBeNull(); + }); + + it('reports every role an NPC holds for a quest', async () => { + const { service } = createService({ + row: null, + assignments: [ + { npcId: WARDEN_ID, role: NpcQuestRole.OFFER }, + { npcId: WARDEN_ID, role: NpcQuestRole.TURN_IN }, + { npcId: BORIN_ID, role: NpcQuestRole.PROGRESS }, + ], + }); + + const warden = await service.getNpcQuestStates(CHARACTER_ID, WARDEN_ID); + + // One person, two roles (NPC spec §14). + expect(warden).toHaveLength(1); + expect(warden[0].roles).toEqual([ + NpcQuestRole.OFFER, + NpcQuestRole.TURN_IN, + ]); + }); + + it('reports nothing for an NPC with no assignment', async () => { + const { service } = createService({ + row: null, + assignments: [{ npcId: WARDEN_ID, role: NpcQuestRole.OFFER }], + }); + + expect(await service.getNpcQuestStates(CHARACTER_ID, BORIN_ID)).toEqual([]); + }); + + it('ignores a disabled assignment', async () => { + const { service } = createService({ + row: null, + assignments: [ + { npcId: WARDEN_ID, role: NpcQuestRole.OFFER, enabled: false }, + ], + }); + + expect(await service.getNpcQuestStates(CHARACTER_ID, WARDEN_ID)).toEqual([]); + }); +}); diff --git a/apps/api/src/quests/quest-progress.service.ts b/apps/api/src/quests/quest-progress.service.ts new file mode 100644 index 0000000..d28faf8 --- /dev/null +++ b/apps/api/src/quests/quest-progress.service.ts @@ -0,0 +1,267 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { CharacterItem } from '../items/entities/character-item.entity'; +import { ItemDefinition } from '../items/entities/item-definition.entity'; +import { LootCategory } from '../items/loot-category.enum'; +import { LootCapacityService } from '../loot-bags/loot-capacity.service'; +import { CharacterQuest } from './entities/character-quest.entity'; +import { NpcQuestAssignment } from './entities/npc-quest-assignment.entity'; +import { QuestDefinition } from './entities/quest-definition.entity'; +import { QuestObjective } from './entities/quest-objective.entity'; +import { + isObjectiveSatisfied, + ObjectiveSnapshot, + resolveCurrentObjectiveIndex, +} from './quest-state'; +import { + CharacterQuestStatus, + NpcQuestRole, + QuestObjectiveType, + QuestStatus, +} from './quest.types'; + +// 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; + +export interface QuestObjectiveState { + objective: QuestObjective; + current: number; + required: number; + /** True when the character cannot carry more of this step's target. */ + blocked: boolean; + satisfied: boolean; +} + +export interface QuestState { + quest: QuestDefinition; + objectives: QuestObjectiveState[]; + row: CharacterQuest | null; + status: QuestStatus; + /** + * Index into `objectives`, or null unless the quest is active. + * `objectives.length` means every step is behind the player. + */ + currentIndex: number | null; +} + +export interface NpcQuestState { + state: QuestState; + roles: NpcQuestRole[]; +} + +/** + * Reads where a character stands with every quest (Slice 0.9 §10, §11). + * + * Read-only on purpose, and deliberately unaware of `NpcService`: `NpcService` + * needs this to work out quest markers, and `QuestService` needs `NpcService` + * to check that the player is standing with the NPC they claim to be talking + * to. Splitting the read half out is what keeps that dependency acyclic -- + * see `QuestProgressModule`. + * + * Nothing here writes. A read that quietly advanced a stored index would make + * "look at your quest log" a state-changing operation, and two clients opening + * the same screen would race. + */ +@Injectable() +export class QuestProgressService { + constructor( + private readonly dataSource: DataSource, + private readonly lootCapacity: LootCapacityService, + ) {} + + /** Every enabled quest, with this character's standing on it. */ + async getQuestStates( + characterId: string, + scope?: RepositoryScope, + ): Promise { + const db = scope ?? this.dataSource; + + const quests = await db + .getRepository(QuestDefinition) + .find({ where: { enabled: true }, order: { key: 'ASC' } }); + if (quests.length === 0) { + return []; + } + + const [objectives, rows, capacities] = await Promise.all([ + db + .getRepository(QuestObjective) + .find({ where: { enabled: true }, order: { orderIndex: 'ASC' } }), + db.getRepository(CharacterQuest).find({ where: { characterId } }), + // Read once for the whole call: every collect step of every quest is + // measured against the same carrying state, and this is the expensive + // read of the three. + this.lootCapacity.getCapacities(characterId, db), + ]); + + const owned = await this.loadOwnedQuantities(characterId, db); + const capacityFull = new Map( + capacities.map((entry) => [ + entry.category, + entry.current >= entry.capacity, + ]), + ); + const rowByQuest = new Map(rows.map((row) => [row.questId, row])); + + return quests.map((quest) => + this.buildState( + quest, + objectives.filter((objective) => objective.questId === quest.id), + rowByQuest.get(quest.id) ?? null, + owned, + capacityFull, + ), + ); + } + + /** One quest by key, or null when no enabled quest carries that key. */ + async getQuestState( + characterId: string, + questKey: string, + scope?: RepositoryScope, + ): Promise { + const states = await this.getQuestStates(characterId, scope); + return states.find((state) => state.quest.key === questKey) ?? null; + } + + /** + * The quests one NPC is involved in, with the roles they hold. + * + * An NPC may hold several roles for the same quest -- the warden both offers + * and receives this one (NPC spec §14) -- so the roles come back as a list + * rather than a single value. + */ + async getNpcQuestStates( + characterId: string, + npcId: string, + scope?: RepositoryScope, + ): Promise { + const db = scope ?? this.dataSource; + + const assignments = await db + .getRepository(NpcQuestAssignment) + .find({ where: { npcId, enabled: true } }); + if (assignments.length === 0) { + return []; + } + + const rolesByQuest = new Map(); + for (const assignment of assignments) { + const roles = rolesByQuest.get(assignment.questId) ?? []; + roles.push(assignment.role); + rolesByQuest.set(assignment.questId, roles); + } + + const states = await this.getQuestStates(characterId, db); + return states + .filter((state) => rolesByQuest.has(state.quest.id)) + .map((state) => ({ + state, + roles: rolesByQuest.get(state.quest.id) as NpcQuestRole[], + })); + } + + private buildState( + quest: QuestDefinition, + objectives: QuestObjective[], + row: CharacterQuest | null, + owned: Map, + capacityFull: Map, + ): QuestState { + const snapshots: ObjectiveSnapshot[] = objectives.map((objective) => + this.toSnapshot(objective, owned, capacityFull), + ); + + const objectiveStates: QuestObjectiveState[] = objectives.map( + (objective, index) => ({ + objective, + current: snapshots[index].current, + required: objective.requiredQuantity, + blocked: snapshots[index].blocked, + satisfied: isObjectiveSatisfied(snapshots[index]), + }), + ); + + const status: QuestStatus = + row === null + ? 'AVAILABLE' + : row.status === CharacterQuestStatus.COMPLETED + ? 'COMPLETED' + : 'ACTIVE'; + + return { + quest, + objectives: objectiveStates, + row, + status, + currentIndex: + status === 'ACTIVE' && row !== null + ? resolveCurrentObjectiveIndex(snapshots, row.currentObjectiveIndex) + : null, + }; + } + + private toSnapshot( + objective: QuestObjective, + owned: Map, + capacityFull: Map, + ): ObjectiveSnapshot { + if (objective.type !== QuestObjectiveType.COLLECT_ITEM) { + return { + type: objective.type, + requiredQuantity: objective.requiredQuantity, + advanceWhenBlocked: objective.advanceWhenBlocked, + current: 0, + blocked: false, + }; + } + + const target = owned.get(objective.targetKey); + const category = target?.lootCategory ?? null; + + return { + type: objective.type, + requiredQuantity: objective.requiredQuantity, + advanceWhenBlocked: objective.advanceWhenBlocked, + current: target?.quantity ?? 0, + // Items outside every loot category are not trade goods and are never + // limited by a bag (Slice 0.7.5 §8), so they can never block a step. + blocked: category === null ? false : (capacityFull.get(category) ?? false), + }; + } + + /** + * Owned quantity and loot category per item key. + * + * Keyed by the item's business key because that is what an objective names; + * an item the character has never owned is simply absent and reads as zero. + */ + private async loadOwnedQuantities( + characterId: string, + db: RepositoryScope, + ): Promise< + Map + > { + const [definitions, items] = await Promise.all([ + db.getRepository(ItemDefinition).find(), + db + .getRepository(CharacterItem) + .find({ where: { characterId }, relations: { itemDefinition: true } }), + ]); + + const quantityByDefinition = new Map( + items.map((item) => [item.itemDefinitionId, item.quantity]), + ); + + return new Map( + definitions.map((definition) => [ + definition.key, + { + quantity: quantityByDefinition.get(definition.id) ?? 0, + lootCategory: definition.lootCategory, + }, + ]), + ); + } +}