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:
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