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:
Bastian Wagner
2026-08-22 22:44:53 +02:00
parent 3a67dca86c
commit e13f6533e5
5 changed files with 720 additions and 1 deletions

View File

@@ -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,

View File

@@ -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({

View 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 {}

View 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([]);
});
});

View 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,
},
]),
);
}
}