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 <noreply@anthropic.com>
This commit is contained in:
@@ -160,7 +160,13 @@ export const QUEST_OBJECTIVES: SeedQuestObjective[] = [
|
|||||||
requiredQuantity: 5,
|
requiredQuantity: 5,
|
||||||
description: 'Collect Ashen Pelts',
|
description: 'Collect Ashen Pelts',
|
||||||
npcLine: null,
|
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,
|
advanceWhenBlocked: false,
|
||||||
consumeOnComplete: true,
|
consumeOnComplete: true,
|
||||||
grantsLootBagKey: null,
|
grantsLootBagKey: null,
|
||||||
|
|||||||
@@ -1106,6 +1106,26 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
expect(collectSteps.map((row) => row.requiredQuantity)).toEqual([5, 5]);
|
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 () => {
|
it('writes the referral flag onto Borin, not onto the warden', async () => {
|
||||||
const questObjectiveRepository = new InMemoryRepository();
|
const questObjectiveRepository = new InMemoryRepository();
|
||||||
const dataSource = createDataSource({
|
const dataSource = createDataSource({
|
||||||
|
|||||||
40
apps/api/src/quests/quest-progress.module.ts
Normal file
40
apps/api/src/quests/quest-progress.module.ts
Normal file
@@ -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 {}
|
||||||
386
apps/api/src/quests/quest-progress.service.spec.ts
Normal file
386
apps/api/src/quests/quest-progress.service.spec.ts
Normal file
@@ -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<CharacterQuest> | null;
|
||||||
|
objectiveOverrides?: Array<Partial<QuestObjective>>;
|
||||||
|
disabledObjectiveKeys?: string[];
|
||||||
|
assignments?: Array<{ npcId: string; role: NpcQuestRole; enabled?: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objective(over: Partial<QuestObjective>): 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
267
apps/api/src/quests/quest-progress.service.ts
Normal file
267
apps/api/src/quests/quest-progress.service.ts
Normal file
@@ -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<DataSource, 'getRepository'>;
|
||||||
|
|
||||||
|
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<QuestState[]> {
|
||||||
|
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<QuestState | null> {
|
||||||
|
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<NpcQuestState[]> {
|
||||||
|
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<string, NpcQuestRole[]>();
|
||||||
|
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<string, { quantity: number; lootCategory: LootCategory | null }>,
|
||||||
|
capacityFull: Map<LootCategory, boolean>,
|
||||||
|
): 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<string, { quantity: number; lootCategory: LootCategory | null }>,
|
||||||
|
capacityFull: Map<LootCategory, boolean>,
|
||||||
|
): 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<string, { quantity: number; lootCategory: LootCategory | null }>
|
||||||
|
> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user