feat(quests): accept, advance and complete a quest chain
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
81
apps/api/src/quests/quest.errors.ts
Normal file
81
apps/api/src/quests/quest.errors.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type QuestErrorCode =
|
||||||
|
| 'QUEST_NOT_FOUND'
|
||||||
|
| 'QUEST_NOT_OFFERED_HERE'
|
||||||
|
| 'QUEST_ALREADY_ACCEPTED'
|
||||||
|
| 'QUEST_NOT_ACTIVE'
|
||||||
|
| 'QUEST_STEP_NOT_HERE'
|
||||||
|
| 'QUEST_OBJECTIVE_INCOMPLETE';
|
||||||
|
|
||||||
|
export class QuestDomainError extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly code: QuestErrorCode,
|
||||||
|
status: HttpStatus,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super({ statusCode: status, code, message }, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questNotFound(): QuestDomainError {
|
||||||
|
return new QuestDomainError(
|
||||||
|
'QUEST_NOT_FOUND',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
'This quest could not be found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questNotOfferedHere(): QuestDomainError {
|
||||||
|
return new QuestDomainError(
|
||||||
|
'QUEST_NOT_OFFERED_HERE',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'This person has nothing to ask of you.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when the quest is already on the character's list.
|
||||||
|
*
|
||||||
|
* Also what a lost race resolves to: the unique index on
|
||||||
|
* (character_id, quest_id) is the real guarantee, and this turns the
|
||||||
|
* constraint violation into something the client can explain (AGENTS.md §30).
|
||||||
|
*/
|
||||||
|
export function questAlreadyAccepted(): QuestDomainError {
|
||||||
|
return new QuestDomainError(
|
||||||
|
'QUEST_ALREADY_ACCEPTED',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'You have already taken this on.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questNotActive(): QuestDomainError {
|
||||||
|
return new QuestDomainError(
|
||||||
|
'QUEST_NOT_ACTIVE',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'You are not on this quest.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when this NPC is not what the quest currently needs.
|
||||||
|
*
|
||||||
|
* Covers both "you are talking to the wrong person" and "you came back too
|
||||||
|
* early": the current step is derived, so a turn-in attempt with four pelts
|
||||||
|
* simply is not the step the character is on.
|
||||||
|
*/
|
||||||
|
export function questStepNotHere(): QuestDomainError {
|
||||||
|
return new QuestDomainError(
|
||||||
|
'QUEST_STEP_NOT_HERE',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'This is not what the quest needs from you right now.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questObjectiveIncomplete(): QuestDomainError {
|
||||||
|
return new QuestDomainError(
|
||||||
|
'QUEST_OBJECTIVE_INCOMPLETE',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
'You do not have what this step needs yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
867
apps/api/src/quests/quest.service.spec.ts
Normal file
867
apps/api/src/quests/quest.service.spec.ts
Normal file
@@ -0,0 +1,867 @@
|
|||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||||
|
import { LootCategory } from '../items/loot-category.enum';
|
||||||
|
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||||
|
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
|
||||||
|
import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
|
||||||
|
import { NpcDefinition } from '../npcs/entities/npc-definition.entity';
|
||||||
|
import { NpcService } from '../npcs/npc.service';
|
||||||
|
import { ReputationService } from '../reputation/reputation.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 {
|
||||||
|
QuestObjectiveState,
|
||||||
|
QuestProgressService,
|
||||||
|
QuestState,
|
||||||
|
} from './quest-progress.service';
|
||||||
|
import { QuestService } from './quest.service';
|
||||||
|
import {
|
||||||
|
CharacterQuestStatus,
|
||||||
|
NpcQuestRole,
|
||||||
|
QuestObjectiveType,
|
||||||
|
QuestStatus,
|
||||||
|
} from './quest.types';
|
||||||
|
|
||||||
|
const CHARACTER_ID = 'character-1';
|
||||||
|
const QUEST_ID = 'quest-1';
|
||||||
|
const QUEST_KEY = 'trouble-beyond-the-gate';
|
||||||
|
const WARDEN_ID = 'npc-warden';
|
||||||
|
const WARDEN_KEY = 'south-gate-warden';
|
||||||
|
const BORIN_ID = 'npc-borin';
|
||||||
|
const BORIN_KEY = 'borin-quartermaster';
|
||||||
|
const REFERRAL_FLAG = 'referred-by-south-gate-warden';
|
||||||
|
const PELT_DEFINITION_ID = 'item-pelt';
|
||||||
|
const HIDE_BAG_DEFINITION_ID = 'bag-hide';
|
||||||
|
|
||||||
|
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 chain (Slice 0.9 §3–§8). */
|
||||||
|
function chain(): QuestObjective[] {
|
||||||
|
return [
|
||||||
|
objective({
|
||||||
|
orderIndex: 0,
|
||||||
|
key: 'collect-pelts-first',
|
||||||
|
advanceWhenBlocked: true,
|
||||||
|
hintText: 'You cannot carry enough pelts. Return to the South Gate Warden.',
|
||||||
|
}),
|
||||||
|
objective({
|
||||||
|
orderIndex: 1,
|
||||||
|
key: 'report-capacity',
|
||||||
|
type: QuestObjectiveType.TALK_TO_NPC,
|
||||||
|
targetKey: WARDEN_KEY,
|
||||||
|
requiredQuantity: 1,
|
||||||
|
description: 'Return to the South Gate Warden',
|
||||||
|
npcLine: 'Go see Borin in Graufurt.',
|
||||||
|
setsFlagKey: REFERRAL_FLAG,
|
||||||
|
setsFlagNpcKey: BORIN_KEY,
|
||||||
|
}),
|
||||||
|
objective({
|
||||||
|
orderIndex: 2,
|
||||||
|
key: 'collect-bag',
|
||||||
|
type: QuestObjectiveType.TALK_TO_NPC,
|
||||||
|
targetKey: BORIN_KEY,
|
||||||
|
requiredQuantity: 1,
|
||||||
|
description: 'Speak with Borin in Graufurt',
|
||||||
|
npcLine: 'Take this.',
|
||||||
|
grantsLootBagKey: 'basic-hide-bag',
|
||||||
|
}),
|
||||||
|
objective({
|
||||||
|
orderIndex: 3,
|
||||||
|
key: 'collect-pelts',
|
||||||
|
consumeOnComplete: true,
|
||||||
|
}),
|
||||||
|
objective({
|
||||||
|
orderIndex: 4,
|
||||||
|
key: 'turn-in',
|
||||||
|
type: QuestObjectiveType.TALK_TO_NPC,
|
||||||
|
targetKey: WARDEN_KEY,
|
||||||
|
requiredQuantity: 1,
|
||||||
|
description: 'Bring the pelts to the South Gate Warden',
|
||||||
|
npcLine: "Good. That's enough for me.",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Fixture {
|
||||||
|
/** Which NPC the character is standing with; null means unreachable. */
|
||||||
|
standingWith?: 'warden' | 'borin' | null;
|
||||||
|
questEnabled?: boolean;
|
||||||
|
hasOfferAssignment?: boolean;
|
||||||
|
/** Undefined means the quest was never accepted. */
|
||||||
|
status?: CharacterQuestStatus;
|
||||||
|
storedIndex?: number;
|
||||||
|
/** The step the derivation says the character is on. */
|
||||||
|
currentIndex?: number | null;
|
||||||
|
pelts?: number;
|
||||||
|
blockedIndexes?: number[];
|
||||||
|
ownsHideBag?: boolean;
|
||||||
|
borinFlags?: Record<string, boolean | string | number>;
|
||||||
|
silver?: number;
|
||||||
|
rewardReputation?: number;
|
||||||
|
rewardSilver?: number;
|
||||||
|
/** Makes the insert fail the way a lost unique-index race would. */
|
||||||
|
insertRace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorld(fixture: Fixture = {}) {
|
||||||
|
const quest = {
|
||||||
|
id: QUEST_ID,
|
||||||
|
key: QUEST_KEY,
|
||||||
|
title: 'Trouble Beyond the Gate',
|
||||||
|
description: 'Five pelts.',
|
||||||
|
rewardFactionKey: 'border-guard',
|
||||||
|
rewardReputation: fixture.rewardReputation ?? 10,
|
||||||
|
rewardSilver: fixture.rewardSilver ?? 0,
|
||||||
|
enabled: fixture.questEnabled ?? true,
|
||||||
|
} as QuestDefinition;
|
||||||
|
|
||||||
|
const objectives = chain();
|
||||||
|
|
||||||
|
const npcs: Record<string, NpcDefinition> = {
|
||||||
|
warden: { id: WARDEN_ID, key: WARDEN_KEY } as NpcDefinition,
|
||||||
|
borin: { id: BORIN_ID, key: BORIN_KEY } as NpcDefinition,
|
||||||
|
};
|
||||||
|
|
||||||
|
const questRow: CharacterQuest | null =
|
||||||
|
fixture.status === undefined
|
||||||
|
? null
|
||||||
|
: ({
|
||||||
|
id: 'character-quest-1',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
questId: QUEST_ID,
|
||||||
|
status: fixture.status,
|
||||||
|
currentObjectiveIndex: fixture.storedIndex ?? 0,
|
||||||
|
acceptedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
completedAt: null,
|
||||||
|
} as CharacterQuest);
|
||||||
|
|
||||||
|
const character = {
|
||||||
|
id: CHARACTER_ID,
|
||||||
|
silver: fixture.silver ?? 0,
|
||||||
|
} as Character;
|
||||||
|
|
||||||
|
const peltStack =
|
||||||
|
(fixture.pelts ?? 0) > 0
|
||||||
|
? ({
|
||||||
|
id: 'character-item-pelt',
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
itemDefinitionId: PELT_DEFINITION_ID,
|
||||||
|
quantity: fixture.pelts as number,
|
||||||
|
} as CharacterItem)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
characterItems: peltStack ? [peltStack] : ([] as CharacterItem[]),
|
||||||
|
lootBags: fixture.ownsHideBag
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
lootBagDefinitionId: HIDE_BAG_DEFINITION_ID,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: ([] as Array<Record<string, unknown>>),
|
||||||
|
npcStates: fixture.borinFlags
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
npcId: BORIN_ID,
|
||||||
|
flags: { ...fixture.borinFlags },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: ([] as Array<Record<string, unknown>>),
|
||||||
|
questRows: questRow ? [questRow] : ([] as CharacterQuest[]),
|
||||||
|
savedQuestRows: [] as CharacterQuest[],
|
||||||
|
savedNpcStates: [] as Array<Record<string, unknown>>,
|
||||||
|
savedLootBags: [] as Array<Record<string, unknown>>,
|
||||||
|
removedItems: [] as CharacterItem[],
|
||||||
|
savedCharacters: [] as Character[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: (entity: unknown) => {
|
||||||
|
if (entity === QuestDefinition) {
|
||||||
|
return {
|
||||||
|
findOneBy: async (criteria: { key: string; enabled: boolean }) =>
|
||||||
|
quest.key === criteria.key && quest.enabled === criteria.enabled
|
||||||
|
? quest
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === NpcQuestAssignment) {
|
||||||
|
return {
|
||||||
|
findOneBy: async (criteria: { npcId: string; role: NpcQuestRole }) =>
|
||||||
|
(fixture.hasOfferAssignment ?? true) &&
|
||||||
|
criteria.npcId === WARDEN_ID &&
|
||||||
|
criteria.role === NpcQuestRole.OFFER
|
||||||
|
? { id: 'assignment-1' }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === CharacterQuest) {
|
||||||
|
return {
|
||||||
|
findOne: async () => state.questRows[0] ?? null,
|
||||||
|
create: (value: Record<string, unknown>) => value,
|
||||||
|
save: async (value: CharacterQuest) => {
|
||||||
|
state.savedQuestRows.push(value);
|
||||||
|
if (fixture.insertRace && state.questRows.length === 0) {
|
||||||
|
// What Postgres raises when the unique index refuses a second
|
||||||
|
// row for the same (character, quest).
|
||||||
|
throw Object.assign(new Error('duplicate key'), {
|
||||||
|
driverError: { code: '23505' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === NpcDefinition) {
|
||||||
|
return {
|
||||||
|
findOneBy: async (criteria: { key: string }) =>
|
||||||
|
Object.values(npcs).find((npc) => npc.key === criteria.key) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === CharacterNpcState) {
|
||||||
|
return {
|
||||||
|
findOneBy: async (criteria: { npcId: string }) =>
|
||||||
|
state.npcStates.find((row) => row.npcId === criteria.npcId) ?? null,
|
||||||
|
create: (value: Record<string, unknown>) => value,
|
||||||
|
save: async (value: Record<string, unknown>) => {
|
||||||
|
state.savedNpcStates.push(value);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === LootBagDefinition) {
|
||||||
|
return {
|
||||||
|
findOneBy: async (criteria: { key: string }) =>
|
||||||
|
criteria.key === 'basic-hide-bag'
|
||||||
|
? {
|
||||||
|
id: HIDE_BAG_DEFINITION_ID,
|
||||||
|
key: 'basic-hide-bag',
|
||||||
|
name: 'Basic Hide Bag',
|
||||||
|
lootCategory: LootCategory.HIDE,
|
||||||
|
capacity: 5,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === CharacterLootBag) {
|
||||||
|
return {
|
||||||
|
findOne: async () => state.lootBags[0] ?? null,
|
||||||
|
create: (value: Record<string, unknown>) => value,
|
||||||
|
save: async (value: Record<string, unknown>) => {
|
||||||
|
state.savedLootBags.push(value);
|
||||||
|
state.lootBags.push(value);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === ItemDefinition) {
|
||||||
|
return {
|
||||||
|
findOneBy: async (criteria: { key: string }) =>
|
||||||
|
criteria.key === 'ash-pelt'
|
||||||
|
? { id: PELT_DEFINITION_ID, key: 'ash-pelt' }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === CharacterItem) {
|
||||||
|
return {
|
||||||
|
findOne: async () => state.characterItems[0] ?? null,
|
||||||
|
save: async (value: CharacterItem) => value,
|
||||||
|
remove: async (value: CharacterItem) => {
|
||||||
|
state.removedItems.push(value);
|
||||||
|
state.characterItems = state.characterItems.filter(
|
||||||
|
(item) => item !== value,
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (entity === Character) {
|
||||||
|
return {
|
||||||
|
findOne: async () => character,
|
||||||
|
save: async (value: Character) => {
|
||||||
|
state.savedCharacters.push(value);
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Unexpected repository');
|
||||||
|
},
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
|
||||||
|
const dataSource = {
|
||||||
|
getRepository: manager.getRepository.bind(manager),
|
||||||
|
transaction: async <T>(run: (m: EntityManager) => Promise<T>) => run(manager),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
|
||||||
|
const objectiveStates: QuestObjectiveState[] = objectives.map(
|
||||||
|
(candidate, index) => ({
|
||||||
|
objective: candidate,
|
||||||
|
current:
|
||||||
|
candidate.type === QuestObjectiveType.COLLECT_ITEM
|
||||||
|
? (fixture.pelts ?? 0)
|
||||||
|
: 0,
|
||||||
|
required: candidate.requiredQuantity,
|
||||||
|
blocked: (fixture.blockedIndexes ?? []).includes(index),
|
||||||
|
satisfied:
|
||||||
|
candidate.type === QuestObjectiveType.COLLECT_ITEM &&
|
||||||
|
(fixture.pelts ?? 0) >= candidate.requiredQuantity,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const status: QuestStatus =
|
||||||
|
fixture.status === undefined
|
||||||
|
? 'AVAILABLE'
|
||||||
|
: fixture.status === CharacterQuestStatus.COMPLETED
|
||||||
|
? 'COMPLETED'
|
||||||
|
: 'ACTIVE';
|
||||||
|
|
||||||
|
const questState: QuestState = {
|
||||||
|
quest,
|
||||||
|
objectives: objectiveStates,
|
||||||
|
row: questRow,
|
||||||
|
status,
|
||||||
|
currentIndex:
|
||||||
|
fixture.currentIndex === undefined
|
||||||
|
? status === 'ACTIVE'
|
||||||
|
? (fixture.storedIndex ?? 0)
|
||||||
|
: null
|
||||||
|
: fixture.currentIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = {
|
||||||
|
getQuestStates: jest.fn().mockResolvedValue([questState]),
|
||||||
|
getQuestState: jest.fn().mockResolvedValue(questState),
|
||||||
|
getNpcQuestStates: jest.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as QuestProgressService;
|
||||||
|
|
||||||
|
const npcService = {
|
||||||
|
requireReachableNpc: jest.fn(async (_characterId: string, key: string) => {
|
||||||
|
// Not `??`: the fixture uses an explicit null to mean "nowhere near
|
||||||
|
// anyone", which `??` would quietly turn back into the warden.
|
||||||
|
const standing =
|
||||||
|
fixture.standingWith === undefined ? 'warden' : fixture.standingWith;
|
||||||
|
if (standing === null) {
|
||||||
|
throw new Error('NPC_UNAVAILABLE');
|
||||||
|
}
|
||||||
|
if (npcs[standing].key !== key) {
|
||||||
|
throw new Error('NPC_UNAVAILABLE');
|
||||||
|
}
|
||||||
|
return npcs[standing];
|
||||||
|
}),
|
||||||
|
} as unknown as NpcService;
|
||||||
|
|
||||||
|
const reputation = {
|
||||||
|
grantReputation: jest.fn().mockResolvedValue({
|
||||||
|
factionKey: 'border-guard',
|
||||||
|
previousReputation: 0,
|
||||||
|
newReputation: 10,
|
||||||
|
previousRank: 'NEUTRAL',
|
||||||
|
newRank: 'NEUTRAL',
|
||||||
|
rankChanged: false,
|
||||||
|
}),
|
||||||
|
} as unknown as ReputationService;
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new QuestService(dataSource, progress, npcService, reputation),
|
||||||
|
state,
|
||||||
|
character,
|
||||||
|
reputation,
|
||||||
|
questState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QuestService.acceptQuest', () => {
|
||||||
|
it('records the quest as active at the first step', async () => {
|
||||||
|
const { service, state } = createWorld({ status: undefined });
|
||||||
|
|
||||||
|
const result = await service.acceptQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
WARDEN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.savedQuestRows[0]).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
questId: QUEST_ID,
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
currentObjectiveIndex: 0,
|
||||||
|
});
|
||||||
|
// The offer line is dialogue content; the accept itself says nothing.
|
||||||
|
expect(result.npcLine).toBeNull();
|
||||||
|
expect(result.grantedBag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an NPC that does not offer the quest', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: undefined,
|
||||||
|
hasOfferAssignment: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_OFFERED_HERE' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unknown or disabled quest', async () => {
|
||||||
|
const { service } = createWorld({ status: undefined, questEnabled: false });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_FOUND' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the quest only once', async () => {
|
||||||
|
const { service } = createWorld({ status: CharacterQuestStatus.ACTIVE });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_ALREADY_ACCEPTED' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a lost unique-index race into the same refusal', async () => {
|
||||||
|
// Two clicks that both pass the existence check: the database refuses the
|
||||||
|
// second, and the player must not see a 500 for it (AGENTS.md §30).
|
||||||
|
const { service } = createWorld({ status: undefined, insertRace: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_ALREADY_ACCEPTED' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never accepts a quest the character cannot reach', async () => {
|
||||||
|
const { service } = createWorld({ status: undefined, standingWith: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toThrow('NPC_UNAVAILABLE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuestService.advanceQuest', () => {
|
||||||
|
it('sets the referral flag on Borin, not on the warden', async () => {
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 1,
|
||||||
|
pelts: 1,
|
||||||
|
blockedIndexes: [0, 3],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.advanceQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
WARDEN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Slice 0.9 §6: the 0.8.5 bypass is evaluated with Borin in context, so
|
||||||
|
// the flag has to live on his row (NPC spec §7).
|
||||||
|
expect(state.savedNpcStates[0]).toMatchObject({
|
||||||
|
npcId: BORIN_ID,
|
||||||
|
flags: { [REFERRAL_FLAG]: true },
|
||||||
|
});
|
||||||
|
expect(result.npcLine).toBe('Go see Borin in Graufurt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the flags an NPC row already carried', async () => {
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 1,
|
||||||
|
pelts: 1,
|
||||||
|
borinFlags: { met: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
|
||||||
|
|
||||||
|
// `met` drives Borin's first-meeting line. Replacing the map instead of
|
||||||
|
// merging into it would make him greet the player as a stranger again.
|
||||||
|
expect(state.savedNpcStates[0].flags).toEqual({
|
||||||
|
met: true,
|
||||||
|
[REFERRAL_FLAG]: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances the stored floor past the step it performed', async () => {
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 1,
|
||||||
|
pelts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
|
||||||
|
|
||||||
|
expect(state.savedQuestRows[0].currentObjectiveIndex).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands the Basic Hide Bag over on the step at Borin', async () => {
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
standingWith: 'borin',
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 2,
|
||||||
|
currentIndex: 2,
|
||||||
|
pelts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.advanceQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
BORIN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.savedLootBags[0]).toMatchObject({
|
||||||
|
lootBagDefinitionId: HIDE_BAG_DEFINITION_ID,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
expect(result.grantedBag).toEqual({
|
||||||
|
key: 'basic-hide-bag',
|
||||||
|
name: 'Basic Hide Bag',
|
||||||
|
lootCategory: LootCategory.HIDE,
|
||||||
|
capacity: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not hand over a bag the character already carries', async () => {
|
||||||
|
// Slice 0.9 §11: the grant is idempotent, so development data or a repeat
|
||||||
|
// click cannot produce a second bag or an error.
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
standingWith: 'borin',
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 2,
|
||||||
|
currentIndex: 2,
|
||||||
|
ownsHideBag: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.advanceQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
BORIN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.savedLootBags).toHaveLength(0);
|
||||||
|
expect(result.grantedBag).toBeNull();
|
||||||
|
// The step still counts: the chain must not stall on an already-owned bag.
|
||||||
|
expect(state.savedQuestRows[0].currentObjectiveIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a step at the wrong NPC', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
standingWith: 'borin',
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.advanceQuest(CHARACTER_ID, BORIN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_STEP_NOT_HERE' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the turn-in while the pelts are still short', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 3,
|
||||||
|
pelts: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_STEP_NOT_HERE' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to advance a quest that was never accepted', async () => {
|
||||||
|
const { service } = createWorld({ status: undefined });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_ACTIVE' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to advance a completed quest', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.COMPLETED,
|
||||||
|
storedIndex: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({ response: { code: 'QUEST_NOT_ACTIVE' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes exactly the pelts the quest asked for', async () => {
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 4,
|
||||||
|
pelts: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.advanceQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
WARDEN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two collect steps ask for five each; only the second consumes, so the
|
||||||
|
// turn-in takes five and leaves the surplus (§8).
|
||||||
|
expect(result.consumedItems).toEqual([{ itemKey: 'ash-pelt', quantity: 5 }]);
|
||||||
|
expect(state.characterItems[0].quantity).toBe(2);
|
||||||
|
expect(state.removedItems).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the item row when the last pelt is consumed', async () => {
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 4,
|
||||||
|
pelts: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
|
||||||
|
|
||||||
|
expect(state.removedItems).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the turn-in when the pelts vanished between read and write', async () => {
|
||||||
|
// The derived step said "turn-in", but the transaction is where ownership
|
||||||
|
// is actually decided.
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 4,
|
||||||
|
pelts: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
response: { code: 'QUEST_OBJECTIVE_INCOMPLETE' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes the quest and stamps the time', async () => {
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 4,
|
||||||
|
pelts: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
|
||||||
|
|
||||||
|
const saved = state.savedQuestRows[0];
|
||||||
|
expect(saved.status).toBe(CharacterQuestStatus.COMPLETED);
|
||||||
|
expect(saved.completedAt).toBeInstanceOf(Date);
|
||||||
|
expect(saved.currentObjectiveIndex).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays the regional reputation reward on completion', async () => {
|
||||||
|
const { service, reputation } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 4,
|
||||||
|
pelts: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.advanceQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
WARDEN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reputation.grantReputation).toHaveBeenCalledWith(
|
||||||
|
CHARACTER_ID,
|
||||||
|
'border-guard',
|
||||||
|
10,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(result.rewards).toEqual({
|
||||||
|
factionKey: 'border-guard',
|
||||||
|
reputation: 10,
|
||||||
|
silver: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays no silver, because the content asks for none', async () => {
|
||||||
|
// Slice 0.9 decision D3. The branch exists so retuning is a seed change,
|
||||||
|
// but the seeded quest deliberately pays nothing.
|
||||||
|
const { service, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 4,
|
||||||
|
pelts: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.advanceQuest(CHARACTER_ID, WARDEN_KEY, QUEST_KEY);
|
||||||
|
|
||||||
|
expect(state.savedCharacters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays silver when content asks for it', async () => {
|
||||||
|
const { service, state, character } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 4,
|
||||||
|
pelts: 5,
|
||||||
|
rewardSilver: 25,
|
||||||
|
silver: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.advanceQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
WARDEN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(character.silver).toBe(29);
|
||||||
|
expect(state.savedCharacters).toHaveLength(1);
|
||||||
|
expect(result.rewards?.silver).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays nothing on a step that is not the last one', async () => {
|
||||||
|
const { service, reputation, state } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 1,
|
||||||
|
pelts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.advanceQuest(
|
||||||
|
CHARACTER_ID,
|
||||||
|
WARDEN_KEY,
|
||||||
|
QUEST_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reputation.grantReputation).not.toHaveBeenCalled();
|
||||||
|
expect(result.rewards).toBeNull();
|
||||||
|
expect(result.consumedItems).toEqual([]);
|
||||||
|
expect(state.savedQuestRows[0].status).toBe(CharacterQuestStatus.ACTIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuestService.getQuestLog', () => {
|
||||||
|
it('renders collect progress against what the step needs', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 0,
|
||||||
|
pelts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [quest] = await service.getQuestLog(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(quest).toMatchObject({
|
||||||
|
key: QUEST_KEY,
|
||||||
|
title: 'Trouble Beyond the Gate',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentObjectiveKey: 'collect-pelts-first',
|
||||||
|
});
|
||||||
|
expect(quest.objectives[0]).toMatchObject({
|
||||||
|
description: 'Collect Ashen Pelts',
|
||||||
|
current: 1,
|
||||||
|
required: 5,
|
||||||
|
completed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the hint while the active step is blocked', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 0,
|
||||||
|
pelts: 1,
|
||||||
|
blockedIndexes: [0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [quest] = await service.getQuestLog(CHARACTER_ID);
|
||||||
|
|
||||||
|
// Slice 0.9 §4/§12: the line that keeps "1 / 5" from reading as a wall.
|
||||||
|
expect(quest.hint).toBe(
|
||||||
|
'You cannot carry enough pelts. Return to the South Gate Warden.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no hint for a step that is merely unfinished', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 0,
|
||||||
|
currentIndex: 0,
|
||||||
|
pelts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [quest] = await service.getQuestLog(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(quest.hint).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks every step behind the current one as done', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
storedIndex: 3,
|
||||||
|
currentIndex: 3,
|
||||||
|
pelts: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [quest] = await service.getQuestLog(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(quest.objectives.map((entry) => entry.completed)).toEqual([
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a completed quest as done throughout and points at no step', async () => {
|
||||||
|
const { service } = createWorld({
|
||||||
|
status: CharacterQuestStatus.COMPLETED,
|
||||||
|
storedIndex: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [quest] = await service.getQuestLog(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(quest.status).toBe('COMPLETED');
|
||||||
|
expect(quest.currentObjectiveKey).toBeNull();
|
||||||
|
expect(quest.hint).toBeNull();
|
||||||
|
expect(quest.objectives.every((entry) => entry.completed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unstarted quest as available with nothing done', async () => {
|
||||||
|
const { service } = createWorld({ status: undefined });
|
||||||
|
|
||||||
|
const [quest] = await service.getQuestLog(CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(quest.status).toBe('AVAILABLE');
|
||||||
|
expect(quest.currentObjectiveKey).toBeNull();
|
||||||
|
expect(quest.objectives.every((entry) => !entry.completed)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
489
apps/api/src/quests/quest.service.ts
Normal file
489
apps/api/src/quests/quest.service.ts
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||||
|
import { ItemDefinition } from '../items/entities/item-definition.entity';
|
||||||
|
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
||||||
|
import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity';
|
||||||
|
import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
|
||||||
|
import { NpcDefinition } from '../npcs/entities/npc-definition.entity';
|
||||||
|
import { NpcService } from '../npcs/npc.service';
|
||||||
|
import { ReputationService } from '../reputation/reputation.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, QuestState } from './quest-progress.service';
|
||||||
|
import {
|
||||||
|
questAlreadyAccepted,
|
||||||
|
questNotActive,
|
||||||
|
questNotFound,
|
||||||
|
questNotOfferedHere,
|
||||||
|
questObjectiveIncomplete,
|
||||||
|
questStepNotHere,
|
||||||
|
} from './quest.errors';
|
||||||
|
import {
|
||||||
|
CharacterQuestStatus,
|
||||||
|
GrantedLootBagDto,
|
||||||
|
NpcQuestRole,
|
||||||
|
QuestConsumedItemDto,
|
||||||
|
QuestDto,
|
||||||
|
QuestInteractionResultDto,
|
||||||
|
QuestObjectiveType,
|
||||||
|
QuestRewardDto,
|
||||||
|
} from './quest.types';
|
||||||
|
|
||||||
|
/** Postgres' unique-violation SQLSTATE. */
|
||||||
|
const UNIQUE_VIOLATION = '23505';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a quest chain (Playable Slice 0.9 §10).
|
||||||
|
*
|
||||||
|
* A step-based state machine and nothing more: §10 asks for exactly that and
|
||||||
|
* warns off a branching narrative engine. What a step *does* is content on the
|
||||||
|
* objective -- set a flag, hand a bag over, consume what it asked for -- so
|
||||||
|
* this service applies effects it finds rather than switching on quest keys
|
||||||
|
* (AGENTS.md §9).
|
||||||
|
*
|
||||||
|
* Steps are triggered by their own endpoints rather than by dialogue actions
|
||||||
|
* (decision D4). NPC spec §40 sketched `START_QUEST` / `COMPLETE_QUEST` as
|
||||||
|
* dialogue actions, but there is no dialogue-response endpoint to carry them,
|
||||||
|
* and building one would be the narrative engine §10 rules out. The beat's line
|
||||||
|
* travels back on the step result instead.
|
||||||
|
*
|
||||||
|
* Every softlock case in §11 is handled by *not* storing progress: the active
|
||||||
|
* step is derived from what the character owns right now
|
||||||
|
* (`QuestProgressService`), so selling the pelts reopens the objective, owning
|
||||||
|
* some before accepting already counts, and a bag obtained some other way just
|
||||||
|
* lets the first hunt finish.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class QuestService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly progress: QuestProgressService,
|
||||||
|
private readonly npcs: NpcService,
|
||||||
|
private readonly reputation: ReputationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Every enabled quest and where this character stands with it (spec §12). */
|
||||||
|
async getQuestLog(characterId: string): Promise<QuestDto[]> {
|
||||||
|
const states = await this.progress.getQuestStates(characterId);
|
||||||
|
return states.map((state) => toQuestDto(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a quest on, once (spec §13).
|
||||||
|
*
|
||||||
|
* The NPC has to actually offer it: an `OFFER` assignment is what makes a
|
||||||
|
* person a quest giver, not a flag on the NPC row (NPC spec §14).
|
||||||
|
*/
|
||||||
|
async acceptQuest(
|
||||||
|
characterId: string,
|
||||||
|
npcKey: string,
|
||||||
|
questKey: string,
|
||||||
|
): Promise<QuestInteractionResultDto> {
|
||||||
|
// The character's own location decides reachability, never the request.
|
||||||
|
const npc = await this.npcs.requireReachableNpc(characterId, npcKey);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const quest = await this.requireQuest(manager, questKey);
|
||||||
|
|
||||||
|
const assignment = await manager
|
||||||
|
.getRepository(NpcQuestAssignment)
|
||||||
|
.findOneBy({
|
||||||
|
npcId: npc.id,
|
||||||
|
questId: quest.id,
|
||||||
|
role: NpcQuestRole.OFFER,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
if (!assignment) {
|
||||||
|
throw questNotOfferedHere();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = manager.getRepository(CharacterQuest);
|
||||||
|
const existing = await rows.findOne({
|
||||||
|
where: { characterId, questId: quest.id },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw questAlreadyAccepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rows.save(
|
||||||
|
rows.create({
|
||||||
|
characterId,
|
||||||
|
questId: quest.id,
|
||||||
|
status: CharacterQuestStatus.ACTIVE,
|
||||||
|
currentObjectiveIndex: 0,
|
||||||
|
acceptedAt: new Date(),
|
||||||
|
completedAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Two clicks can both clear the check above. The unique index is the
|
||||||
|
// real guarantee; this is what stops the loser seeing a 500.
|
||||||
|
if (!isUniqueViolation(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw questAlreadyAccepted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await this.progress.getQuestState(characterId, questKey);
|
||||||
|
if (!state) {
|
||||||
|
throw questNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No line of its own: the warden's offer is a dialogue node, and the NPC
|
||||||
|
// screen re-reads dialogue after the quest state changes.
|
||||||
|
return {
|
||||||
|
quest: toQuestDto(state),
|
||||||
|
npcLine: null,
|
||||||
|
grantedBag: null,
|
||||||
|
consumedItems: [],
|
||||||
|
rewards: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the talk step the character is currently on (spec §5, §6, §8).
|
||||||
|
*
|
||||||
|
* One endpoint for all three beats, because the difference between them is
|
||||||
|
* content: the referral sets a flag, Borin's step grants a bag, and the last
|
||||||
|
* step consumes and completes. Everything runs in one transaction under a
|
||||||
|
* write lock on the quest row, so two clicks cannot both consume the pelts
|
||||||
|
* (AGENTS.md §29, §30).
|
||||||
|
*/
|
||||||
|
async advanceQuest(
|
||||||
|
characterId: string,
|
||||||
|
npcKey: string,
|
||||||
|
questKey: string,
|
||||||
|
): Promise<QuestInteractionResultDto> {
|
||||||
|
const npc = await this.npcs.requireReachableNpc(characterId, npcKey);
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const quest = await this.requireQuest(manager, questKey);
|
||||||
|
|
||||||
|
const rows = manager.getRepository(CharacterQuest);
|
||||||
|
const row = await rows.findOne({
|
||||||
|
where: { characterId, questId: quest.id },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!row || row.status !== CharacterQuestStatus.ACTIVE) {
|
||||||
|
throw questNotActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await this.progress.getQuestState(
|
||||||
|
characterId,
|
||||||
|
questKey,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
if (!state || state.currentIndex === null) {
|
||||||
|
throw questNotActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = state.currentIndex;
|
||||||
|
const step = state.objectives[currentIndex]?.objective;
|
||||||
|
// Also what refuses an early turn-in: with the pelts still short, the
|
||||||
|
// derived step is the hunt, not the hand-over.
|
||||||
|
if (
|
||||||
|
!step ||
|
||||||
|
step.type !== QuestObjectiveType.TALK_TO_NPC ||
|
||||||
|
step.targetKey !== npc.key
|
||||||
|
) {
|
||||||
|
throw questStepNotHere();
|
||||||
|
}
|
||||||
|
|
||||||
|
row.currentObjectiveIndex = currentIndex + 1;
|
||||||
|
|
||||||
|
const grantedBag = await this.grantLootBag(manager, characterId, step);
|
||||||
|
await this.setStepFlag(manager, characterId, step);
|
||||||
|
|
||||||
|
let consumedItems: QuestConsumedItemDto[] = [];
|
||||||
|
let rewards: QuestRewardDto | null = null;
|
||||||
|
|
||||||
|
if (row.currentObjectiveIndex >= state.objectives.length) {
|
||||||
|
consumedItems = await this.consumeQuestItems(
|
||||||
|
manager,
|
||||||
|
characterId,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
rewards = await this.grantRewards(manager, characterId, quest);
|
||||||
|
row.status = CharacterQuestStatus.COMPLETED;
|
||||||
|
row.completedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
await rows.save(row);
|
||||||
|
|
||||||
|
const refreshed = await this.progress.getQuestState(
|
||||||
|
characterId,
|
||||||
|
questKey,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quest: toQuestDto(refreshed ?? state),
|
||||||
|
npcLine: step.npcLine,
|
||||||
|
grantedBag,
|
||||||
|
consumedItems,
|
||||||
|
rewards,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireQuest(
|
||||||
|
manager: EntityManager,
|
||||||
|
questKey: string,
|
||||||
|
): Promise<QuestDefinition> {
|
||||||
|
const quest = await manager
|
||||||
|
.getRepository(QuestDefinition)
|
||||||
|
.findOneBy({ key: questKey, enabled: true });
|
||||||
|
if (!quest) {
|
||||||
|
throw questNotFound();
|
||||||
|
}
|
||||||
|
return quest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the step's bag over, at most once (spec §11, decision D1).
|
||||||
|
*
|
||||||
|
* A bag the character already holds is not an error: development data, a
|
||||||
|
* repeated click and a re-run of the same step must all leave the chain
|
||||||
|
* moving. Only the roomiest active bag per category counts anyway
|
||||||
|
* (Slice 0.7.5 §6), so a second copy would grant nothing.
|
||||||
|
*/
|
||||||
|
private async grantLootBag(
|
||||||
|
manager: EntityManager,
|
||||||
|
characterId: string,
|
||||||
|
step: QuestObjective,
|
||||||
|
): Promise<GrantedLootBagDto | null> {
|
||||||
|
if (!step.grantsLootBagKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = await manager
|
||||||
|
.getRepository(LootBagDefinition)
|
||||||
|
.findOneBy({ key: step.grantsLootBagKey });
|
||||||
|
if (!definition) {
|
||||||
|
// Content names a bag that does not exist. Refusing the whole step would
|
||||||
|
// strand the player on a content bug they cannot fix; the step still
|
||||||
|
// counts and the missing bag is visible in its absence.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bags = manager.getRepository(CharacterLootBag);
|
||||||
|
const existing = await bags.findOne({
|
||||||
|
where: { characterId, lootBagDefinitionId: definition.id },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bags.save(
|
||||||
|
bags.create({
|
||||||
|
characterId,
|
||||||
|
lootBagDefinitionId: definition.id,
|
||||||
|
active: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
name: definition.name,
|
||||||
|
lootCategory: definition.lootCategory,
|
||||||
|
capacity: definition.capacity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the step's dialogue flag onto whichever NPC content names (spec §5).
|
||||||
|
*
|
||||||
|
* The warden's referral belongs on Borin's row, because a flag is per-NPC
|
||||||
|
* player state (NPC spec §7) and Borin is the NPC in context when the Hide
|
||||||
|
* Bag offer's 0.8.5 bypass is evaluated (spec §6). Merged into the existing
|
||||||
|
* flags rather than replacing them -- `met` drives Borin's first-meeting
|
||||||
|
* line, and dropping it would make him greet the player as a stranger again.
|
||||||
|
*/
|
||||||
|
private async setStepFlag(
|
||||||
|
manager: EntityManager,
|
||||||
|
characterId: string,
|
||||||
|
step: QuestObjective,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!step.setsFlagKey || !step.setsFlagNpcKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await manager
|
||||||
|
.getRepository(NpcDefinition)
|
||||||
|
.findOneBy({ key: step.setsFlagNpcKey });
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const states = manager.getRepository(CharacterNpcState);
|
||||||
|
const existing = await states.findOneBy({ characterId, npcId: target.id });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.flags = { ...existing.flags, [step.setsFlagKey]: true };
|
||||||
|
await states.save(existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await states.save(
|
||||||
|
states.create({
|
||||||
|
characterId,
|
||||||
|
npcId: target.id,
|
||||||
|
flags: { [step.setsFlagKey]: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the goods the quest asked for (spec §8).
|
||||||
|
*
|
||||||
|
* Only steps marked `consumeOnComplete` are taken: this chain asks for five
|
||||||
|
* pelts twice, and consuming both would quietly demand ten. Quantities are
|
||||||
|
* re-checked here rather than trusted from the derivation, because the
|
||||||
|
* derivation ran before the lock and the player may have traded in between.
|
||||||
|
*/
|
||||||
|
private async consumeQuestItems(
|
||||||
|
manager: EntityManager,
|
||||||
|
characterId: string,
|
||||||
|
state: QuestState,
|
||||||
|
): Promise<QuestConsumedItemDto[]> {
|
||||||
|
const consumed: QuestConsumedItemDto[] = [];
|
||||||
|
|
||||||
|
for (const entry of state.objectives) {
|
||||||
|
const step = entry.objective;
|
||||||
|
if (
|
||||||
|
step.type !== QuestObjectiveType.COLLECT_ITEM ||
|
||||||
|
!step.consumeOnComplete
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = await manager
|
||||||
|
.getRepository(ItemDefinition)
|
||||||
|
.findOneBy({ key: step.targetKey });
|
||||||
|
if (!definition) {
|
||||||
|
throw questObjectiveIncomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterItems = manager.getRepository(CharacterItem);
|
||||||
|
const owned = await characterItems.findOne({
|
||||||
|
where: { characterId, itemDefinitionId: definition.id },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!owned || owned.quantity < step.requiredQuantity) {
|
||||||
|
throw questObjectiveIncomplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owned.quantity === step.requiredQuantity) {
|
||||||
|
await characterItems.remove(owned);
|
||||||
|
} else {
|
||||||
|
owned.quantity -= step.requiredQuantity;
|
||||||
|
await characterItems.save(owned);
|
||||||
|
}
|
||||||
|
|
||||||
|
consumed.push({
|
||||||
|
itemKey: step.targetKey,
|
||||||
|
quantity: step.requiredQuantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return consumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays the quest out, once (spec §9).
|
||||||
|
*
|
||||||
|
* Reputation and Silver only. There is no XP anywhere in the project any
|
||||||
|
* more (Slice 0.7 V2 §7), and no renown milestone on this quest by decision
|
||||||
|
* D2 -- one would reach World Renown 3 and open a Slice 0.8.5 offer that is
|
||||||
|
* meant to stay out of reach until 0.11. The seeded values pay 10 reputation
|
||||||
|
* and no Silver (D3); both branches exist so retuning stays a content change.
|
||||||
|
*/
|
||||||
|
private async grantRewards(
|
||||||
|
manager: EntityManager,
|
||||||
|
characterId: string,
|
||||||
|
quest: QuestDefinition,
|
||||||
|
): Promise<QuestRewardDto> {
|
||||||
|
if (quest.rewardFactionKey && quest.rewardReputation > 0) {
|
||||||
|
await this.reputation.grantReputation(
|
||||||
|
characterId,
|
||||||
|
quest.rewardFactionKey,
|
||||||
|
quest.rewardReputation,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quest.rewardSilver > 0) {
|
||||||
|
const characters = manager.getRepository(Character);
|
||||||
|
const character = await characters.findOne({
|
||||||
|
where: { id: characterId },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (character) {
|
||||||
|
character.silver += quest.rewardSilver;
|
||||||
|
await characters.save(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
factionKey: quest.rewardFactionKey,
|
||||||
|
reputation: quest.rewardReputation,
|
||||||
|
silver: quest.rewardSilver,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The quest as the player reads it (spec §12).
|
||||||
|
*
|
||||||
|
* `completed` is positional rather than stored: everything behind the derived
|
||||||
|
* step is done, and everything from it onward is not. A finished quest reads as
|
||||||
|
* done throughout, which is what keeps the journal honest after the goods were
|
||||||
|
* consumed.
|
||||||
|
*/
|
||||||
|
export function toQuestDto(state: QuestState): QuestDto {
|
||||||
|
const currentIndex = state.currentIndex;
|
||||||
|
const currentEntry =
|
||||||
|
currentIndex === null ? undefined : state.objectives[currentIndex];
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: state.quest.key,
|
||||||
|
title: state.quest.title,
|
||||||
|
description: state.quest.description,
|
||||||
|
status: state.status,
|
||||||
|
objectives: state.objectives.map((entry, index) => ({
|
||||||
|
key: entry.objective.key,
|
||||||
|
description: entry.objective.description,
|
||||||
|
type: entry.objective.type,
|
||||||
|
targetKey: entry.objective.targetKey,
|
||||||
|
required: entry.required,
|
||||||
|
current: entry.current,
|
||||||
|
completed:
|
||||||
|
state.status === 'COMPLETED' ||
|
||||||
|
(currentIndex !== null && index < currentIndex),
|
||||||
|
})),
|
||||||
|
currentObjectiveKey: currentEntry?.objective.key ?? null,
|
||||||
|
// Only while the step genuinely cannot progress. A step that is merely
|
||||||
|
// unfinished needs no explanation, and saying one anyway would train the
|
||||||
|
// player to ignore the line that matters.
|
||||||
|
hint:
|
||||||
|
currentEntry && currentEntry.blocked && !currentEntry.satisfied
|
||||||
|
? currentEntry.objective.hintText
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
const code = (
|
||||||
|
error as { driverError?: { code?: string }; code?: string } | null
|
||||||
|
)?.driverError?.code;
|
||||||
|
return (
|
||||||
|
code === UNIQUE_VIOLATION ||
|
||||||
|
(error as { code?: string } | null)?.code === UNIQUE_VIOLATION
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user