1856 lines
55 KiB
TypeScript
1856 lines
55 KiB
TypeScript
import type { QuestGenerationContext } from '../services/aiTypes';
|
||
import type {
|
||
QuestCompilationRequest,
|
||
QuestContract,
|
||
QuestIntent,
|
||
QuestOpportunity,
|
||
QuestPreviewRequest,
|
||
QuestProgressSignal,
|
||
QuestSceneSnapshot,
|
||
} from '../services/questTypes';
|
||
import { buildNarrativeDocument } from '../services/storyEngine/documentCarrierCompiler';
|
||
import { buildThreadContractsFromProfile } from '../services/storyEngine/threadContract';
|
||
import {
|
||
type CustomWorldProfile,
|
||
type QuestLogEntry,
|
||
type QuestObjective,
|
||
type QuestObjectiveKind,
|
||
type QuestReward,
|
||
type QuestStatus,
|
||
type QuestStep,
|
||
type WorldType,
|
||
} from '../types';
|
||
import { formatCurrency } from './economy';
|
||
import { getHostileNpcPresetById } from './hostileNpcPresets';
|
||
import { getPlayerXpToNextLevel } from './playerProgression';
|
||
import {
|
||
buildLooseRuntimeItemGenerationContext,
|
||
buildQuestRuntimeItemGenerationContext,
|
||
} from './runtimeItemContext';
|
||
import { buildDirectedRuntimeReward } from './runtimeItemDirector';
|
||
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
|
||
import { getSceneFriendlyNpcs, getSceneHostileNpcs } from './scenePresets';
|
||
|
||
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
|
||
const TERMINAL_QUEST_STATUSES: QuestStatus[] = [
|
||
'turned_in',
|
||
'failed',
|
||
'expired',
|
||
];
|
||
|
||
type SceneQuestThreat =
|
||
| {
|
||
kind: 'defeat_hostile_npc';
|
||
targetHostileNpcId: string;
|
||
targetHostileNpcName: string;
|
||
targetSceneId: string;
|
||
suggestedThreatType: 'hostile_npc';
|
||
}
|
||
| {
|
||
kind: 'inspect_treasure';
|
||
targetSceneId: string;
|
||
targetSceneName: string;
|
||
suggestedThreatType: 'treasure';
|
||
}
|
||
| {
|
||
kind: 'spar_with_npc';
|
||
suggestedThreatType: 'relationship';
|
||
};
|
||
|
||
type SceneChapterOverride = {
|
||
title?: string;
|
||
description?: string;
|
||
summary?: string;
|
||
preferredObjectiveKind?: QuestObjectiveKind;
|
||
openingTalk?: Partial<
|
||
Pick<QuestStep, 'title' | 'revealText' | 'completeText'>
|
||
>;
|
||
pressureStep?: Partial<
|
||
Pick<QuestStep, 'title' | 'revealText' | 'completeText'>
|
||
>;
|
||
turningTalk?: Partial<
|
||
Pick<QuestStep, 'title' | 'revealText' | 'completeText'>
|
||
>;
|
||
};
|
||
|
||
export type SceneChapterQuestContext = {
|
||
sceneTaskDescription?: string | null;
|
||
actEventDescriptions?: string[];
|
||
primaryNpcName?: string | null;
|
||
};
|
||
|
||
const SCENE_CHAPTER_OVERRIDES: Record<string, SceneChapterOverride> = {
|
||
'wuxia-palace-court': {
|
||
title: '查清内庭旧痕',
|
||
description:
|
||
'旧宫侍女显然知道宫苑内庭近来的异动不只是一条禁行回廊那么简单,你需要顺着残痕把这一章真正翻开。',
|
||
summary: '在宫苑内庭查清旧案残痕,并逼出侍女压着没说的那一层旧事',
|
||
preferredObjectiveKind: 'inspect_treasure',
|
||
openingTalk: {
|
||
title: '追问禁行回廊',
|
||
revealText:
|
||
'先问清旧宫侍女为什么总拦着那条回廊,这一章的开口多半就藏在她的口风里。',
|
||
completeText:
|
||
'旧宫侍女已经把最表层的理由说出来了,但真正的旧事还压在更深处。',
|
||
},
|
||
pressureStep: {
|
||
title: '调查回廊暗格',
|
||
revealText:
|
||
'先把回廊暗格里的香囊翻出来,确认内庭异动究竟是在遮人,还是在遮旧案。',
|
||
completeText: '回廊暗格已经给出了回应,内庭这章也开始逼近改判前的节点。',
|
||
},
|
||
turningTalk: {
|
||
title: '拿旧金牌去对问侍女',
|
||
revealText:
|
||
'把你查到的旧金牌和暗格痕迹带回去,和旧宫侍女把这层旧事对清楚。',
|
||
completeText: '旧宫侍女已经接住你的追问,这章也被你真正推到了收束前夜。',
|
||
},
|
||
},
|
||
'wuxia-rain-street': {
|
||
title: '追索雨街账册',
|
||
description:
|
||
'夜灯摊主见过太多不该见的人,雨夜长街的异常更像一条被水汽和灯影压着的旧账,你得先把线索翻出来。',
|
||
summary: '在雨夜长街查出湿布包和账册残页背后到底是谁在追索谁',
|
||
preferredObjectiveKind: 'inspect_treasure',
|
||
openingTalk: {
|
||
title: '向摊主问清夜街异样',
|
||
revealText:
|
||
'先和夜灯摊主把这条街最近不对劲的地方问清,别让这章一开始就被人带偏。',
|
||
},
|
||
pressureStep: {
|
||
title: '翻出灯下残页',
|
||
revealText:
|
||
'顺着浸湿的布包和账册残页查下去,这条长街真正压着的旧账就会冒头。',
|
||
},
|
||
turningTalk: {
|
||
title: '拿账册回去对灯摊主',
|
||
revealText:
|
||
'把你翻到的账册残页拿回去,逼夜灯摊主把没说透的那半句话补完。',
|
||
},
|
||
},
|
||
'wuxia-forge-works': {
|
||
title: '追索失落兵谱',
|
||
description:
|
||
'老铸匠一眼就认出你身上的杀气来源,铸坊工场里压着的旧兵谱和铁匣显然不只是废料,你得先把这章的火候逼出来。',
|
||
summary: '在铸坊工场追出失落兵谱的去向,并问清是谁把旧匣压在风箱后面',
|
||
preferredObjectiveKind: 'inspect_treasure',
|
||
openingTalk: {
|
||
title: '追问兵器缺口来路',
|
||
revealText:
|
||
'先让老铸匠把兵器缺口看清楚,他多半已经从上面认出了这章的来路。',
|
||
},
|
||
pressureStep: {
|
||
title: '翻出风箱后兵谱',
|
||
revealText:
|
||
'先把风箱后压着的旧兵谱和铁匣找出来,铸坊这章的真正火候就在那附近。',
|
||
},
|
||
turningTalk: {
|
||
title: '拿兵谱回去问铸匠',
|
||
revealText: '把你翻到的兵谱拿回去,对着老铸匠把来历和去向一并问透。',
|
||
},
|
||
},
|
||
'xianxia-cloud-gate': {
|
||
title: '查明仙门符匣异动',
|
||
description:
|
||
'守门灵官一直像在等一份迟迟未到的回报,云海仙门的符匣和玉牌显然牵着更深的禁制线,你得先把这章的入口找准。',
|
||
summary: '在云海仙门查清符匣和门阙阴影背后的异常,确认谁在借仙门遮掩旧事',
|
||
preferredObjectiveKind: 'inspect_treasure',
|
||
openingTalk: {
|
||
title: '向灵官问清门阙异象',
|
||
revealText:
|
||
'先让守门灵官把云海仙门最近的异象说清楚,这章的入口多半就藏在他守着不放的话头里。',
|
||
},
|
||
pressureStep: {
|
||
title: '调查云阶符匣',
|
||
revealText: '顺着云阶尽头的灵符匣查下去,把仙门这一章真正的异常先钉住。',
|
||
},
|
||
turningTalk: {
|
||
title: '带着符匣回去问灵官',
|
||
revealText:
|
||
'把你查到的符匣线索带回去,逼守门灵官把没说完的禁制旧事补全。',
|
||
},
|
||
},
|
||
'xianxia-star-vessel': {
|
||
title: '追索星图旧航线',
|
||
description:
|
||
'星舟舵手守着旧航线图不肯松手,甲板上压着的星图匣和灵罗盘像在等人把残缺航线拼起来,这一章更适合从调查切进去。',
|
||
summary: '在星舟甲板拼出失落航线的缺口,并问清是谁把旧坐标压在高空风压里',
|
||
preferredObjectiveKind: 'inspect_treasure',
|
||
openingTalk: {
|
||
title: '追问失落航线',
|
||
revealText:
|
||
'先和星舟舵手把旧航线的缺口问清楚,别让甲板上的风声把真正的方向吹散。',
|
||
},
|
||
pressureStep: {
|
||
title: '调查舵台后星图匣',
|
||
revealText:
|
||
'把舵台后的星图匣和灵罗盘先翻出来,这章的方向才会真正落到你手里。',
|
||
},
|
||
turningTalk: {
|
||
title: '带着航线回去问舵手',
|
||
revealText: '把你拼出来的航线缺口带回去,逼星舟舵手把这段旧路说到底。',
|
||
},
|
||
},
|
||
};
|
||
|
||
function resolveQuestRewardRuntimeConfig(params: {
|
||
roleText: string;
|
||
rewardTheme: QuestIntent['rewardTheme'];
|
||
narrativeType: QuestIntent['narrativeType'];
|
||
}) {
|
||
const { roleText, rewardTheme, narrativeType } = params;
|
||
|
||
if (rewardTheme === 'resource') {
|
||
return {
|
||
itemCount: 2,
|
||
fixedKinds: ['material', 'consumable'] as const,
|
||
fixedPermanence: ['resource', 'timed'] as const,
|
||
};
|
||
}
|
||
|
||
if (rewardTheme === 'intel') {
|
||
return {
|
||
itemCount: 2,
|
||
fixedKinds: ['relic', 'consumable'] as const,
|
||
fixedPermanence: ['permanent', 'timed'] as const,
|
||
};
|
||
}
|
||
|
||
if (rewardTheme === 'rare_item' || narrativeType === 'trial') {
|
||
return {
|
||
itemCount: 2,
|
||
fixedKinds: ['equipment', 'relic'] as const,
|
||
fixedPermanence: ['permanent', 'permanent'] as const,
|
||
};
|
||
}
|
||
|
||
if (/猎|山|追踪/u.test(roleText)) {
|
||
return {
|
||
itemCount: 2,
|
||
fixedKinds: ['consumable', 'equipment'] as const,
|
||
fixedPermanence: ['timed', 'permanent'] as const,
|
||
};
|
||
}
|
||
|
||
if (/商|军需/u.test(roleText)) {
|
||
return {
|
||
itemCount: 2,
|
||
fixedKinds: ['material', 'relic'] as const,
|
||
fixedPermanence: ['resource', 'permanent'] as const,
|
||
};
|
||
}
|
||
|
||
if (rewardTheme === 'relationship' || narrativeType === 'relationship') {
|
||
return {
|
||
itemCount: 2,
|
||
fixedKinds: ['relic', 'equipment'] as const,
|
||
fixedPermanence: ['permanent', 'permanent'] as const,
|
||
};
|
||
}
|
||
|
||
return {
|
||
itemCount: 2,
|
||
fixedKinds: ['equipment', 'consumable'] as const,
|
||
fixedPermanence: ['permanent', 'timed'] as const,
|
||
};
|
||
}
|
||
|
||
function roundQuestExperience(value: number) {
|
||
return Math.max(5, Math.round(value / 5) * 5);
|
||
}
|
||
|
||
function resolveQuestTargetLevel(context?: QuestGenerationContext) {
|
||
const level = context?.playerProgression?.level;
|
||
|
||
if (typeof level !== 'number' || !Number.isFinite(level)) {
|
||
return 1;
|
||
}
|
||
|
||
return Math.max(1, Math.floor(level));
|
||
}
|
||
|
||
function resolveQuestStepCountMultiplier(stepCount: number) {
|
||
if (stepCount <= 1) {
|
||
return 0.85;
|
||
}
|
||
|
||
if (stepCount === 2) {
|
||
return 1;
|
||
}
|
||
|
||
return 1.12;
|
||
}
|
||
|
||
function resolveQuestNarrativeXpMultiplier(
|
||
narrativeType: QuestIntent['narrativeType'],
|
||
) {
|
||
return narrativeType === 'trial' || narrativeType === 'bounty' ? 1.08 : 1;
|
||
}
|
||
|
||
function resolveQuestUrgencyXpMultiplier(urgency: QuestIntent['urgency']) {
|
||
return urgency === 'high' ? 1.05 : 1;
|
||
}
|
||
|
||
function buildQuestExperienceReward(params: {
|
||
context?: QuestGenerationContext;
|
||
narrativeType: QuestIntent['narrativeType'];
|
||
urgency: QuestIntent['urgency'];
|
||
stepCount: number;
|
||
}) {
|
||
const targetLevel = resolveQuestTargetLevel(params.context);
|
||
const baseQuestXp = getPlayerXpToNextLevel(targetLevel) * 0.45;
|
||
|
||
return roundQuestExperience(
|
||
baseQuestXp *
|
||
resolveQuestStepCountMultiplier(params.stepCount) *
|
||
resolveQuestNarrativeXpMultiplier(params.narrativeType) *
|
||
resolveQuestUrgencyXpMultiplier(params.urgency),
|
||
);
|
||
}
|
||
|
||
function buildQuestReward(params: {
|
||
issuerNpcId: string;
|
||
issuerNpcName: string;
|
||
worldType: WorldType | null;
|
||
roleText: string;
|
||
rewardTheme: QuestIntent['rewardTheme'];
|
||
narrativeType: QuestIntent['narrativeType'];
|
||
urgency: QuestIntent['urgency'];
|
||
stepCount: number;
|
||
scene: QuestSceneSnapshot | null;
|
||
context?: QuestGenerationContext;
|
||
}): QuestReward {
|
||
const {
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
worldType,
|
||
roleText,
|
||
rewardTheme,
|
||
narrativeType,
|
||
urgency,
|
||
stepCount,
|
||
scene,
|
||
context,
|
||
} = params;
|
||
const runtimeConfig = resolveQuestRewardRuntimeConfig({
|
||
roleText,
|
||
rewardTheme,
|
||
narrativeType,
|
||
});
|
||
const runtimeScene = scene
|
||
? {
|
||
...scene,
|
||
description: scene.description ?? '',
|
||
}
|
||
: null;
|
||
const runtimeContext = context
|
||
? buildQuestRuntimeItemGenerationContext({
|
||
context,
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
roleText,
|
||
scene: runtimeScene,
|
||
})
|
||
: buildLooseRuntimeItemGenerationContext({
|
||
worldType,
|
||
scene: runtimeScene,
|
||
encounter: {
|
||
id: issuerNpcId,
|
||
kind: 'npc',
|
||
npcName: issuerNpcName,
|
||
npcDescription: roleText,
|
||
npcAvatar: '',
|
||
context: roleText,
|
||
},
|
||
playerCharacterId: 'quest-preview-player',
|
||
generationChannel: 'quest_reward',
|
||
});
|
||
const directedReward = buildDirectedRuntimeReward(runtimeContext, {
|
||
seedKey: `quest:${issuerNpcId}:${scene?.id ?? 'scene'}:${rewardTheme}:${narrativeType}`,
|
||
itemCount: runtimeConfig.itemCount,
|
||
fixedKinds: [...runtimeConfig.fixedKinds],
|
||
fixedPermanence: [...runtimeConfig.fixedPermanence],
|
||
});
|
||
const threadContract =
|
||
context?.customWorldProfile?.threadContracts?.find((contract) =>
|
||
(context.activeThreadIds ?? []).includes(contract.threadId),
|
||
) ?? null;
|
||
const rewardItems = flattenDirectedRuntimeRewardItems(directedReward);
|
||
const documentItem =
|
||
rewardTheme === 'intel' && threadContract
|
||
? buildNarrativeDocument({
|
||
contract: threadContract,
|
||
titleSeed: `${issuerNpcName}留下的调查简札`,
|
||
})
|
||
: null;
|
||
|
||
const reward: QuestReward = {
|
||
affinityBonus:
|
||
narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
|
||
currency:
|
||
rewardTheme === 'intel'
|
||
? worldType === 'XIANXIA'
|
||
? 40
|
||
: 58
|
||
: worldType === 'XIANXIA'
|
||
? 54
|
||
: 72,
|
||
experience: buildQuestExperienceReward({
|
||
context,
|
||
narrativeType,
|
||
urgency,
|
||
stepCount,
|
||
}),
|
||
items: documentItem ? [...rewardItems, documentItem] : rewardItems,
|
||
storyHint: directedReward.storyHint,
|
||
};
|
||
|
||
if (rewardTheme === 'intel') {
|
||
reward.intel = {
|
||
rumorText: scene
|
||
? `${scene.name} 附近还藏着一层没有完全揭开的线索。`
|
||
: '对方愿意把一条尚未外传的消息托付给你。',
|
||
unlockedSceneId: scene?.id,
|
||
};
|
||
}
|
||
|
||
return reward;
|
||
}
|
||
|
||
function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
|
||
const itemText =
|
||
reward.items.map((item) => item.name).join('、') || '当前局势相关的补给';
|
||
const experienceText =
|
||
(reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : '';
|
||
const intelText = reward.intel?.rumorText
|
||
? `,以及情报“${reward.intel.rumorText}”`
|
||
: '';
|
||
return `完成后可获得好感 +${reward.affinityBonus}${experienceText}、${formatCurrency(reward.currency, worldType)}、${itemText}${intelText}。`;
|
||
}
|
||
|
||
function resolveQuestThreadContract(params: {
|
||
context?: QuestGenerationContext;
|
||
issuerNpcId: string;
|
||
scene: QuestSceneSnapshot | null;
|
||
}) {
|
||
const profile = params.context?.customWorldProfile;
|
||
if (!profile?.storyGraph) {
|
||
return null;
|
||
}
|
||
|
||
const contracts =
|
||
profile.threadContracts && profile.threadContracts.length > 0
|
||
? profile.threadContracts
|
||
: buildThreadContractsFromProfile(profile);
|
||
const activeThreadIds = params.context?.activeThreadIds ?? [];
|
||
const contract =
|
||
contracts.find(
|
||
(candidate) =>
|
||
activeThreadIds.includes(candidate.threadId) ||
|
||
candidate.issuerActorId === params.issuerNpcId ||
|
||
candidate.steps.some((step) =>
|
||
step.completionSignalIds.some((signalId) =>
|
||
params.scene?.id ? signalId.includes(params.scene.id) : false,
|
||
),
|
||
),
|
||
) ??
|
||
contracts[0] ??
|
||
null;
|
||
|
||
return contract;
|
||
}
|
||
|
||
function buildQuestId(
|
||
issuerNpcId: string,
|
||
kind: QuestObjectiveKind,
|
||
targetKey: string,
|
||
) {
|
||
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
|
||
}
|
||
|
||
export function buildSceneChapterId(sceneId: string) {
|
||
return `chapter:scene:${sceneId}`;
|
||
}
|
||
|
||
function isRewardReadyStatus(status: QuestStatus) {
|
||
return REWARD_READY_STATUSES.includes(status);
|
||
}
|
||
|
||
function isTerminalStatus(status: QuestStatus) {
|
||
return TERMINAL_QUEST_STATUSES.includes(status);
|
||
}
|
||
|
||
function normalizeCount(rawCount: number | undefined) {
|
||
return Math.max(1, Math.round(rawCount ?? 1));
|
||
}
|
||
|
||
function clampProgress(progress: number | undefined, requiredCount: number) {
|
||
return Math.max(
|
||
0,
|
||
Math.min(normalizeCount(requiredCount), Math.round(progress ?? 0)),
|
||
);
|
||
}
|
||
|
||
function compactQuestLabel(label: string, maxLength = 6) {
|
||
const trimmed = label.trim();
|
||
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
||
}
|
||
|
||
function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) {
|
||
const title = rawTitle
|
||
.replace(/[《》「」“”"']/gu, '')
|
||
.replace(/[,。!?;:,.!?;:].*$/u, '')
|
||
.trim();
|
||
|
||
if (title && title.length <= 12) {
|
||
return title;
|
||
}
|
||
|
||
return fallbackTitle.length <= 12
|
||
? fallbackTitle
|
||
: fallbackTitle.slice(0, 10);
|
||
}
|
||
|
||
function getScenePrimaryThreat(
|
||
scene: QuestSceneSnapshot | null,
|
||
worldType: WorldType | null,
|
||
): SceneQuestThreat | null {
|
||
if (!scene) {
|
||
return null;
|
||
}
|
||
|
||
const hostileNpc = getSceneHostileNpcs(scene)[0] ?? null;
|
||
if (hostileNpc) {
|
||
const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id;
|
||
const targetHostileNpcName = worldType
|
||
? (getHostileNpcPresetById(worldType, targetHostileNpcId)?.name ??
|
||
hostileNpc.name ??
|
||
targetHostileNpcId)
|
||
: (hostileNpc.name ?? targetHostileNpcId);
|
||
|
||
return {
|
||
kind: 'defeat_hostile_npc',
|
||
targetHostileNpcId,
|
||
targetHostileNpcName,
|
||
targetSceneId: scene.id,
|
||
suggestedThreatType: 'hostile_npc',
|
||
};
|
||
}
|
||
|
||
if ((scene.treasureHints?.length ?? 0) > 0) {
|
||
return {
|
||
kind: 'inspect_treasure',
|
||
targetSceneId: scene.id,
|
||
targetSceneName: scene.name,
|
||
suggestedThreatType: 'treasure',
|
||
};
|
||
}
|
||
|
||
return {
|
||
kind: 'spar_with_npc',
|
||
suggestedThreatType: 'relationship',
|
||
};
|
||
}
|
||
|
||
function buildStepRevealText(
|
||
step: QuestStep,
|
||
issuerNpcName: string,
|
||
targetLabel: string,
|
||
) {
|
||
switch (step.kind) {
|
||
case 'defeat_hostile_npc':
|
||
return `${issuerNpcName} 希望你先压制 ${targetLabel},再回来说明局势。`;
|
||
case 'inspect_treasure':
|
||
return `${issuerNpcName} 想确认 ${targetLabel} 背后的异常究竟是真是假。`;
|
||
case 'spar_with_npc':
|
||
return `${issuerNpcName} 想先亲自试试你的实力,再决定后续是否继续合作。`;
|
||
case 'talk_to_npc':
|
||
return `回去和 ${issuerNpcName} 对话,把这次委托的结果说明白。`;
|
||
case 'reach_scene':
|
||
return `先抵达 ${targetLabel},看清楚当前局势。`;
|
||
case 'deliver_item':
|
||
return `把目标物品交到 ${targetLabel} 手上。`;
|
||
default:
|
||
return `${issuerNpcName} 还在等待这份委托的新进展。`;
|
||
}
|
||
}
|
||
|
||
function buildStepCompleteText(
|
||
step: QuestStep,
|
||
issuerNpcName: string,
|
||
targetLabel: string,
|
||
) {
|
||
switch (step.kind) {
|
||
case 'defeat_hostile_npc':
|
||
return `${targetLabel} 已被压制,回去向 ${issuerNpcName} 汇报吧。`;
|
||
case 'inspect_treasure':
|
||
return `${targetLabel} 的情况已经查明,可以回去和 ${issuerNpcName} 对情报了。`;
|
||
case 'spar_with_npc':
|
||
return `这场切磋已经结束,${issuerNpcName} 对你的判断也有了变化。`;
|
||
case 'talk_to_npc':
|
||
return `你已经和 ${issuerNpcName} 交代清楚,现在可以正式领取报酬。`;
|
||
case 'reach_scene':
|
||
return `你已经抵达 ${targetLabel},可以继续推进下一步。`;
|
||
case 'deliver_item':
|
||
return `${targetLabel} 已经收到了你送去的物品。`;
|
||
default:
|
||
return `${issuerNpcName} 已经确认这一步骤完成。`;
|
||
}
|
||
}
|
||
|
||
function buildPrimaryQuestStep(params: {
|
||
issuerNpcId: string;
|
||
issuerNpcName: string;
|
||
scene: QuestSceneSnapshot | null;
|
||
worldType: WorldType | null;
|
||
intent: QuestIntent;
|
||
}): QuestStep | null {
|
||
const { issuerNpcId, issuerNpcName, scene, worldType, intent } = params;
|
||
const threat = getScenePrimaryThreat(scene, worldType);
|
||
if (!threat) {
|
||
return null;
|
||
}
|
||
|
||
const preferredKinds =
|
||
intent.recommendedObjectiveKinds.length > 0
|
||
? intent.recommendedObjectiveKinds
|
||
: [threat.kind];
|
||
const chosenKind = preferredKinds.includes(threat.kind)
|
||
? threat.kind
|
||
: (preferredKinds[0] ?? threat.kind);
|
||
|
||
if (
|
||
chosenKind === 'inspect_treasure' &&
|
||
threat.kind === 'inspect_treasure' &&
|
||
scene
|
||
) {
|
||
const title = `调查 ${scene.name} 的异常`;
|
||
return {
|
||
id: 'step_primary',
|
||
kind: 'inspect_treasure',
|
||
targetSceneId: scene.id,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: buildStepRevealText(
|
||
{
|
||
id: 'step_primary',
|
||
kind: 'inspect_treasure',
|
||
targetSceneId: scene.id,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
scene.name,
|
||
),
|
||
completeText: buildStepCompleteText(
|
||
{
|
||
id: 'step_primary',
|
||
kind: 'inspect_treasure',
|
||
targetSceneId: scene.id,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
scene.name,
|
||
),
|
||
};
|
||
}
|
||
|
||
if (chosenKind === 'spar_with_npc') {
|
||
const title = `与 ${issuerNpcName} 切磋`;
|
||
return {
|
||
id: 'step_primary',
|
||
kind: 'spar_with_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: buildStepRevealText(
|
||
{
|
||
id: 'step_primary',
|
||
kind: 'spar_with_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
issuerNpcName,
|
||
),
|
||
completeText: buildStepCompleteText(
|
||
{
|
||
id: 'step_primary',
|
||
kind: 'spar_with_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
issuerNpcName,
|
||
),
|
||
};
|
||
}
|
||
|
||
if (threat.kind === 'defeat_hostile_npc') {
|
||
const hostileNpcName = threat.targetHostileNpcName;
|
||
const title = `压制 ${hostileNpcName}`;
|
||
return {
|
||
id: 'step_primary',
|
||
kind: 'defeat_hostile_npc',
|
||
targetHostileNpcId: threat.targetHostileNpcId,
|
||
targetSceneId: threat.targetSceneId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: buildStepRevealText(
|
||
{
|
||
id: 'step_primary',
|
||
kind: 'defeat_hostile_npc',
|
||
targetHostileNpcId: threat.targetHostileNpcId,
|
||
targetSceneId: threat.targetSceneId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
hostileNpcName,
|
||
),
|
||
completeText: buildStepCompleteText(
|
||
{
|
||
id: 'step_primary',
|
||
kind: 'defeat_hostile_npc',
|
||
targetHostileNpcId: threat.targetHostileNpcId,
|
||
targetSceneId: threat.targetSceneId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
hostileNpcName,
|
||
),
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function buildTalkBackStep(
|
||
issuerNpcId: string,
|
||
issuerNpcName: string,
|
||
): QuestStep {
|
||
const title = `返回与 ${issuerNpcName} 交谈`;
|
||
return {
|
||
id: 'step_report_back',
|
||
kind: 'talk_to_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: buildStepRevealText(
|
||
{
|
||
id: 'step_report_back',
|
||
kind: 'talk_to_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
issuerNpcName,
|
||
),
|
||
completeText: buildStepCompleteText(
|
||
{
|
||
id: 'step_report_back',
|
||
kind: 'talk_to_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText: '',
|
||
completeText: '',
|
||
},
|
||
issuerNpcName,
|
||
issuerNpcName,
|
||
),
|
||
};
|
||
}
|
||
|
||
function buildSceneOpeningTalkStep(params: {
|
||
issuerNpcId: string;
|
||
issuerNpcName: string;
|
||
sceneName: string;
|
||
override?: SceneChapterOverride | null;
|
||
}) {
|
||
const { issuerNpcId, issuerNpcName, sceneName, override } = params;
|
||
const title = override?.openingTalk?.title ?? `向 ${issuerNpcName} 打听异动`;
|
||
return {
|
||
id: 'step_scene_opening',
|
||
kind: 'talk_to_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText:
|
||
override?.openingTalk?.revealText ??
|
||
`${issuerNpcName} 明显知道 ${sceneName} 最近不对劲,先和她把眼前局势问清楚。`,
|
||
completeText:
|
||
override?.openingTalk?.completeText ??
|
||
`${issuerNpcName} 的回应已经把 ${sceneName} 这一章真正带进了正题。`,
|
||
} satisfies QuestStep;
|
||
}
|
||
|
||
function buildSceneTurningTalkStep(params: {
|
||
issuerNpcId: string;
|
||
issuerNpcName: string;
|
||
sceneName: string;
|
||
override?: SceneChapterOverride | null;
|
||
}) {
|
||
const { issuerNpcId, issuerNpcName, sceneName, override } = params;
|
||
const title = override?.turningTalk?.title ?? `回去与 ${issuerNpcName} 对证`;
|
||
return {
|
||
id: 'step_scene_turning',
|
||
kind: 'talk_to_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title,
|
||
revealText:
|
||
override?.turningTalk?.revealText ??
|
||
`把你在 ${sceneName} 查到的情况带回去,和 ${issuerNpcName} 把这一层旧事对清楚。`,
|
||
completeText:
|
||
override?.turningTalk?.completeText ??
|
||
`${issuerNpcName} 已经接住你的回报,这一章也逼近最后的收束。`,
|
||
} satisfies QuestStep;
|
||
}
|
||
|
||
function resolveSceneChapterIssuer(scene: QuestSceneSnapshot | null) {
|
||
const friendlyNpc = getSceneFriendlyNpcs(scene)[0] ?? null;
|
||
if (!friendlyNpc) {
|
||
return {
|
||
issuerNpcId: scene?.id
|
||
? `scene-chapter:${scene.id}`
|
||
: 'scene-chapter:unknown',
|
||
issuerNpcName: scene?.name ?? '当前区域',
|
||
roleText: scene?.description ?? scene?.name ?? '场景章节',
|
||
hasGuideNpc: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
issuerNpcId: friendlyNpc.id,
|
||
issuerNpcName: friendlyNpc.name,
|
||
roleText:
|
||
friendlyNpc.role ||
|
||
friendlyNpc.description ||
|
||
scene?.description ||
|
||
friendlyNpc.name,
|
||
hasGuideNpc: true,
|
||
};
|
||
}
|
||
|
||
function buildSceneChapterPrimaryStep(params: {
|
||
scene: QuestSceneSnapshot;
|
||
worldType: WorldType | null;
|
||
issuerNpcId: string;
|
||
issuerNpcName: string;
|
||
hasGuideNpc: boolean;
|
||
override?: SceneChapterOverride | null;
|
||
}) {
|
||
const {
|
||
scene,
|
||
worldType,
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
hasGuideNpc,
|
||
override,
|
||
} = params;
|
||
const threat = getScenePrimaryThreat(scene, worldType);
|
||
const preferredObjectiveKind = override?.preferredObjectiveKind ?? null;
|
||
|
||
if (
|
||
preferredObjectiveKind === 'inspect_treasure' &&
|
||
(scene.treasureHints?.length ?? 0) > 0
|
||
) {
|
||
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
|
||
return {
|
||
id: 'step_scene_pressure',
|
||
kind: 'inspect_treasure',
|
||
targetSceneId: scene.id,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
|
||
revealText:
|
||
override?.pressureStep?.revealText ??
|
||
(hasGuideNpc
|
||
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
|
||
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`),
|
||
completeText:
|
||
override?.pressureStep?.completeText ??
|
||
`${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
|
||
} satisfies QuestStep;
|
||
}
|
||
|
||
if (
|
||
(preferredObjectiveKind === 'defeat_hostile_npc' ||
|
||
!preferredObjectiveKind) &&
|
||
threat?.kind === 'defeat_hostile_npc'
|
||
) {
|
||
const hostileNpcName = threat.targetHostileNpcName;
|
||
return {
|
||
id: 'step_scene_pressure',
|
||
kind: 'defeat_hostile_npc',
|
||
targetHostileNpcId: threat.targetHostileNpcId,
|
||
targetSceneId: threat.targetSceneId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title: override?.pressureStep?.title ?? `压制 ${hostileNpcName}`,
|
||
revealText:
|
||
override?.pressureStep?.revealText ??
|
||
(hasGuideNpc
|
||
? `${issuerNpcName} 要你先压制 ${hostileNpcName},再回来确认 ${scene.name} 里的异动究竟是谁在推动。`
|
||
: `先压下 ${hostileNpcName} 带来的压力,才能把 ${scene.name} 这一章继续往下推。`),
|
||
completeText:
|
||
override?.pressureStep?.completeText ??
|
||
`${hostileNpcName} 已被压制,${scene.name} 这一章的核心压力开始松动。`,
|
||
} satisfies QuestStep;
|
||
}
|
||
|
||
if ((scene.treasureHints?.length ?? 0) > 0) {
|
||
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
|
||
return {
|
||
id: 'step_scene_pressure',
|
||
kind: 'inspect_treasure',
|
||
targetSceneId: scene.id,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
|
||
revealText:
|
||
override?.pressureStep?.revealText ??
|
||
(hasGuideNpc
|
||
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
|
||
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`),
|
||
completeText:
|
||
override?.pressureStep?.completeText ??
|
||
`${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
|
||
} satisfies QuestStep;
|
||
}
|
||
|
||
return {
|
||
id: 'step_scene_pressure',
|
||
kind: 'talk_to_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
progress: 0,
|
||
title: override?.pressureStep?.title ?? `继续逼问 ${issuerNpcName}`,
|
||
revealText:
|
||
override?.pressureStep?.revealText ??
|
||
`${issuerNpcName} 还压着一层没说透的话,把这章的中段压力继续顶上去。`,
|
||
completeText:
|
||
override?.pressureStep?.completeText ??
|
||
`${issuerNpcName} 的口风终于松了一层,这章也开始逼近转折。`,
|
||
} satisfies QuestStep;
|
||
}
|
||
|
||
function buildSceneChapterSteps(params: {
|
||
scene: QuestSceneSnapshot;
|
||
worldType: WorldType | null;
|
||
issuerNpcId: string;
|
||
issuerNpcName: string;
|
||
hasGuideNpc: boolean;
|
||
override?: SceneChapterOverride | null;
|
||
}) {
|
||
const {
|
||
scene,
|
||
worldType,
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
hasGuideNpc,
|
||
override,
|
||
} = params;
|
||
const steps: QuestStep[] = [];
|
||
|
||
if (hasGuideNpc) {
|
||
steps.push(
|
||
buildSceneOpeningTalkStep({
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
sceneName: scene.name,
|
||
override,
|
||
}),
|
||
);
|
||
}
|
||
|
||
steps.push(
|
||
buildSceneChapterPrimaryStep({
|
||
scene,
|
||
worldType,
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
hasGuideNpc,
|
||
override,
|
||
}),
|
||
);
|
||
|
||
if (hasGuideNpc) {
|
||
steps.push(
|
||
buildSceneTurningTalkStep({
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
sceneName: scene.name,
|
||
override,
|
||
}),
|
||
);
|
||
}
|
||
|
||
return steps;
|
||
}
|
||
|
||
function resolveSceneChapterNarrativeType(
|
||
scene: QuestSceneSnapshot,
|
||
worldType: WorldType | null,
|
||
) {
|
||
const threat = getScenePrimaryThreat(scene, worldType);
|
||
if (threat?.kind === 'defeat_hostile_npc') {
|
||
return 'bounty' as const;
|
||
}
|
||
if ((scene.treasureHints?.length ?? 0) > 0) {
|
||
return 'investigation' as const;
|
||
}
|
||
return 'relationship' as const;
|
||
}
|
||
|
||
function resolveSceneChapterRewardTheme(
|
||
scene: QuestSceneSnapshot,
|
||
worldType: WorldType | null,
|
||
) {
|
||
const threat = getScenePrimaryThreat(scene, worldType);
|
||
if ((scene.treasureHints?.length ?? 0) > 0) {
|
||
return 'intel' as const;
|
||
}
|
||
if (threat?.kind === 'defeat_hostile_npc') {
|
||
return 'resource' as const;
|
||
}
|
||
return 'relationship' as const;
|
||
}
|
||
|
||
function deriveObjectiveFromStep(
|
||
step: QuestStep | null,
|
||
issuerNpcId: string,
|
||
): QuestObjective {
|
||
if (!step) {
|
||
return {
|
||
kind: 'talk_to_npc',
|
||
targetNpcId: issuerNpcId,
|
||
requiredCount: 1,
|
||
};
|
||
}
|
||
|
||
return {
|
||
kind: step.kind,
|
||
targetHostileNpcId: step.targetHostileNpcId,
|
||
targetNpcId: step.targetNpcId,
|
||
targetSceneId: step.targetSceneId,
|
||
targetItemId: step.targetItemId,
|
||
requiredCount: normalizeCount(step.requiredCount),
|
||
};
|
||
}
|
||
|
||
function createLegacyStepFromQuest(quest: QuestLogEntry): QuestStep {
|
||
const requiredCount = normalizeCount(quest.objective.requiredCount);
|
||
const progress =
|
||
isRewardReadyStatus(quest.status) || quest.status === 'turned_in'
|
||
? requiredCount
|
||
: clampProgress(quest.progress, requiredCount);
|
||
|
||
return {
|
||
id: 'step_legacy_primary',
|
||
kind: quest.objective.kind,
|
||
targetHostileNpcId: quest.objective.targetHostileNpcId,
|
||
targetNpcId: quest.objective.targetNpcId,
|
||
targetSceneId: quest.objective.targetSceneId,
|
||
targetItemId: quest.objective.targetItemId,
|
||
requiredCount,
|
||
progress,
|
||
title: quest.summary || quest.title,
|
||
revealText: quest.description,
|
||
completeText: quest.rewardText,
|
||
};
|
||
}
|
||
|
||
export function getQuestActiveStep(
|
||
quest: Pick<QuestLogEntry, 'steps' | 'activeStepId' | 'status'>,
|
||
) {
|
||
if (
|
||
!quest.steps?.length ||
|
||
isTerminalStatus(quest.status) ||
|
||
isRewardReadyStatus(quest.status)
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const explicitStep = quest.activeStepId
|
||
? (quest.steps.find(
|
||
(step) =>
|
||
step.id === quest.activeStepId && step.progress < step.requiredCount,
|
||
) ?? null)
|
||
: null;
|
||
if (explicitStep) {
|
||
return explicitStep;
|
||
}
|
||
|
||
return quest.steps.find((step) => step.progress < step.requiredCount) ?? null;
|
||
}
|
||
|
||
function buildQuestStageSummary(
|
||
quest: Pick<
|
||
QuestLogEntry,
|
||
'issuerNpcName' | 'steps' | 'activeStepId' | 'status' | 'title'
|
||
>,
|
||
) {
|
||
const activeStep = getQuestActiveStep(quest);
|
||
if (activeStep) {
|
||
return activeStep.title;
|
||
}
|
||
if (isRewardReadyStatus(quest.status)) {
|
||
return `返回向 ${quest.issuerNpcName} 领取报酬`;
|
||
}
|
||
if (quest.status === 'turned_in') {
|
||
return `${quest.title} 已完成交付`;
|
||
}
|
||
return quest.title;
|
||
}
|
||
|
||
export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
|
||
const reward = {
|
||
affinityBonus: Math.round(quest.reward?.affinityBonus ?? 0),
|
||
currency: Math.max(0, Math.round(quest.reward?.currency ?? 0)),
|
||
experience: Math.max(0, Math.round(quest.reward?.experience ?? 0)),
|
||
items: quest.reward?.items ?? [],
|
||
storyHint: quest.reward?.storyHint,
|
||
intel: quest.reward?.intel,
|
||
} satisfies QuestReward;
|
||
const steps = (
|
||
quest.steps?.length ? quest.steps : [createLegacyStepFromQuest(quest)]
|
||
).map((step) => {
|
||
const requiredCount = normalizeCount(step.requiredCount);
|
||
return {
|
||
...step,
|
||
requiredCount,
|
||
progress: clampProgress(step.progress, requiredCount),
|
||
title: step.title?.trim() || quest.summary || quest.title,
|
||
revealText: step.revealText?.trim() || quest.description,
|
||
completeText: step.completeText?.trim() || quest.rewardText,
|
||
} satisfies QuestStep;
|
||
});
|
||
|
||
const incompleteStep =
|
||
steps.find((step) => step.progress < step.requiredCount) ?? null;
|
||
const activeStepId = incompleteStep?.id ?? null;
|
||
let status = quest.status ?? 'active';
|
||
|
||
if (!isTerminalStatus(status)) {
|
||
if (!incompleteStep) {
|
||
status = status === 'completed' ? 'completed' : 'ready_to_turn_in';
|
||
} else if (status !== 'discovered') {
|
||
status = 'active';
|
||
}
|
||
}
|
||
|
||
const objectiveSource = incompleteStep ?? steps[steps.length - 1] ?? null;
|
||
const objective = deriveObjectiveFromStep(objectiveSource, quest.issuerNpcId);
|
||
const progress = objectiveSource
|
||
? clampProgress(objectiveSource.progress, objectiveSource.requiredCount)
|
||
: normalizeCount(objective.requiredCount);
|
||
|
||
const normalizedQuest: QuestLogEntry = {
|
||
...quest,
|
||
chapterId: quest.chapterId ?? null,
|
||
reward,
|
||
objective,
|
||
progress,
|
||
status,
|
||
steps,
|
||
activeStepId,
|
||
actId: quest.actId ?? null,
|
||
threadId: quest.threadId ?? null,
|
||
contractId: quest.contractId ?? null,
|
||
discoveredFactIds: quest.discoveredFactIds ?? [],
|
||
relatedCarrierIds: quest.relatedCarrierIds ?? [],
|
||
consequenceIds: quest.consequenceIds ?? [],
|
||
};
|
||
|
||
return {
|
||
...normalizedQuest,
|
||
summary: buildQuestStageSummary(normalizedQuest),
|
||
};
|
||
}
|
||
|
||
export function normalizeQuestLogEntries(quests: QuestLogEntry[]) {
|
||
return quests.map((quest) => normalizeQuestLogEntry(quest));
|
||
}
|
||
|
||
function withNormalizedQuest(quest: QuestLogEntry) {
|
||
return normalizeQuestLogEntry(quest);
|
||
}
|
||
|
||
function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) {
|
||
switch (signal.kind) {
|
||
case 'hostile_npc_defeated':
|
||
return (
|
||
step.kind === 'defeat_hostile_npc' &&
|
||
(!step.targetSceneId || step.targetSceneId === signal.sceneId) &&
|
||
step.targetHostileNpcId === signal.hostileNpcId
|
||
);
|
||
case 'treasure_inspected':
|
||
return (
|
||
step.kind === 'inspect_treasure' &&
|
||
(!step.targetSceneId || step.targetSceneId === signal.sceneId)
|
||
);
|
||
case 'npc_spar_completed':
|
||
return step.kind === 'spar_with_npc' && step.targetNpcId === signal.npcId;
|
||
case 'npc_talk_completed':
|
||
return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId;
|
||
case 'scene_reached':
|
||
return (
|
||
step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId
|
||
);
|
||
case 'item_delivered':
|
||
return (
|
||
step.kind === 'deliver_item' &&
|
||
step.targetNpcId === signal.npcId &&
|
||
step.targetItemId === signal.itemId
|
||
);
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getSignalProgressIncrement(signal: QuestProgressSignal) {
|
||
return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1;
|
||
}
|
||
|
||
function applyQuestProgressSignalToQuest(
|
||
quest: QuestLogEntry,
|
||
signal: QuestProgressSignal,
|
||
) {
|
||
const normalizedQuest = withNormalizedQuest(quest);
|
||
if (
|
||
isTerminalStatus(normalizedQuest.status) ||
|
||
isRewardReadyStatus(normalizedQuest.status)
|
||
) {
|
||
return normalizedQuest;
|
||
}
|
||
|
||
const activeStep = getQuestActiveStep(normalizedQuest);
|
||
if (!activeStep || !stepMatchesSignal(activeStep, signal)) {
|
||
return normalizedQuest;
|
||
}
|
||
|
||
const increment = getSignalProgressIncrement(signal);
|
||
const nextSteps = normalizedQuest.steps!.map((step) => {
|
||
if (step.id !== activeStep.id) {
|
||
return step;
|
||
}
|
||
return {
|
||
...step,
|
||
progress: Math.min(step.requiredCount, step.progress + increment),
|
||
};
|
||
});
|
||
|
||
return normalizeQuestLogEntry({
|
||
...normalizedQuest,
|
||
steps: nextSteps,
|
||
completionNotified: false,
|
||
});
|
||
}
|
||
|
||
export function applyQuestProgressSignal(
|
||
quests: QuestLogEntry[],
|
||
signal: QuestProgressSignal,
|
||
) {
|
||
return quests.map((quest) => applyQuestProgressSignalToQuest(quest, signal));
|
||
}
|
||
|
||
function resolveQuestIdTargetKey(
|
||
primaryStep: QuestStep,
|
||
scene: QuestSceneSnapshot | null,
|
||
) {
|
||
return (
|
||
primaryStep.targetHostileNpcId ??
|
||
primaryStep.targetNpcId ??
|
||
primaryStep.targetSceneId ??
|
||
scene?.id ??
|
||
primaryStep.id
|
||
);
|
||
}
|
||
|
||
export function findQuestById(quests: QuestLogEntry[], questId: string) {
|
||
return quests.find((quest) => quest.id === questId) ?? null;
|
||
}
|
||
|
||
export function getQuestForIssuer(
|
||
quests: QuestLogEntry[],
|
||
issuerNpcId: string,
|
||
) {
|
||
return (
|
||
quests.find(
|
||
(quest) =>
|
||
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
|
||
) ?? null
|
||
);
|
||
}
|
||
|
||
export function getChapterQuestForScene(
|
||
quests: QuestLogEntry[],
|
||
sceneId: string | null | undefined,
|
||
) {
|
||
if (!sceneId) {
|
||
return null;
|
||
}
|
||
|
||
const chapterId = buildSceneChapterId(sceneId);
|
||
return (
|
||
quests.find(
|
||
(quest) =>
|
||
quest.chapterId === chapterId && !isTerminalStatus(quest.status),
|
||
) ?? null
|
||
);
|
||
}
|
||
|
||
export function evaluateQuestOpportunity(
|
||
params: QuestPreviewRequest,
|
||
): QuestOpportunity {
|
||
const { issuerNpcId, scene, currentQuests = [] } = params;
|
||
if (!scene) {
|
||
return {
|
||
shouldOffer: false,
|
||
reason: '当前缺少可落地的场景信息,暂时不适合生成委托。',
|
||
};
|
||
}
|
||
|
||
if (
|
||
currentQuests.some(
|
||
(quest) =>
|
||
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
|
||
)
|
||
) {
|
||
return {
|
||
shouldOffer: false,
|
||
reason: '这名角色还有尚未结清的委托。',
|
||
suggestedIssuerNpcId: issuerNpcId,
|
||
};
|
||
}
|
||
|
||
const liveQuestCount = currentQuests.filter(
|
||
(quest) => !isTerminalStatus(quest.status),
|
||
).length;
|
||
if (liveQuestCount >= 4) {
|
||
return {
|
||
shouldOffer: false,
|
||
reason: '当前未完成委托已经偏多,不再继续塞入新的任务机会。',
|
||
suggestedIssuerNpcId: issuerNpcId,
|
||
};
|
||
}
|
||
|
||
const threat = getScenePrimaryThreat(scene, params.worldType);
|
||
if (!threat) {
|
||
return {
|
||
shouldOffer: false,
|
||
reason: '当前场景里缺少足够明确的任务抓手。',
|
||
suggestedIssuerNpcId: issuerNpcId,
|
||
};
|
||
}
|
||
|
||
return {
|
||
shouldOffer: true,
|
||
reason:
|
||
threat.kind === 'inspect_treasure'
|
||
? `${scene.name} 附近出现了值得调查的异常。`
|
||
: threat.kind === 'spar_with_npc'
|
||
? `${params.issuerNpcName} 更适合给出一份关系驱动的试炼型委托。`
|
||
: `${scene.name} 附近存在可以被明确指向的敌对角色威胁。`,
|
||
suggestedIssuerNpcId: issuerNpcId,
|
||
suggestedThreatType: threat.suggestedThreatType,
|
||
};
|
||
}
|
||
|
||
export function buildFallbackQuestIntent(
|
||
params: QuestCompilationRequest,
|
||
): QuestIntent {
|
||
const { issuerNpcName, scene } = params;
|
||
const threat = getScenePrimaryThreat(scene, params.worldType);
|
||
|
||
if (threat?.kind === 'defeat_hostile_npc') {
|
||
const hostileNpcName = threat.targetHostileNpcName;
|
||
return {
|
||
title: `压制${compactQuestLabel(hostileNpcName, 8)}`,
|
||
description: `${issuerNpcName} 希望你先处理掉 ${scene?.name ?? '前方区域'} 徘徊的 ${hostileNpcName},再回来交换后续情报。`,
|
||
summary: `击退 ${hostileNpcName},然后回去和 ${issuerNpcName} 交谈`,
|
||
narrativeType: 'bounty',
|
||
dramaticNeed: `${scene?.name ?? '前方区域'} 的危险已经影响到 ${issuerNpcName} 的下一步行动。`,
|
||
issuerGoal: `先压下 ${hostileNpcName} 带来的威胁,再确认局势是否稳定。`,
|
||
playerHook: '你正好位于现场,也最适合先去验证这一层风险。',
|
||
worldReason: `${scene?.name ?? '这一区域'} 的局势还没有真正安定下来。`,
|
||
recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'],
|
||
urgency: 'medium',
|
||
intimacy: 'cooperative',
|
||
rewardTheme: 'resource',
|
||
followupHooks: [
|
||
`${issuerNpcName} 手里还握着没完全说开的后续线索。`,
|
||
'这份委托背后还有更深一层的局势变化。',
|
||
],
|
||
};
|
||
}
|
||
|
||
if (threat?.kind === 'inspect_treasure' && scene) {
|
||
return {
|
||
title: `${compactQuestLabel(scene.name)}异动`,
|
||
description: `${issuerNpcName} 不确定 ${scene.name} 一带出现的异动是真是假,想让你先去看清楚,再回来对一遍情报。`,
|
||
summary: `调查 ${scene.name} 的异常,然后回去向 ${issuerNpcName} 汇报`,
|
||
narrativeType: 'investigation',
|
||
dramaticNeed: `${issuerNpcName} 想知道这条线索值不值得继续深挖。`,
|
||
issuerGoal: `确认 ${scene.name} 一带究竟藏着什么。`,
|
||
playerHook: '你已经身在局中,最适合把这层异常先摸清。',
|
||
worldReason: `${scene.name} 周围留下了还没有被说清的痕迹。`,
|
||
recommendedObjectiveKinds: ['inspect_treasure', 'talk_to_npc'],
|
||
urgency: 'medium',
|
||
intimacy: 'cooperative',
|
||
rewardTheme: 'intel',
|
||
followupHooks: [
|
||
`${scene.name} 的异常可能还连着另一处更深的地点。`,
|
||
`${issuerNpcName} 对这里并不是完全陌生。`,
|
||
],
|
||
};
|
||
}
|
||
|
||
return {
|
||
title: `${compactQuestLabel(issuerNpcName)}试炼`,
|
||
description: `${issuerNpcName} 想先亲自试一试你的成色,再决定要不要把更关键的事继续交给你。`,
|
||
summary: `和 ${issuerNpcName} 切磋一场,然后回来把话说透`,
|
||
narrativeType: 'trial',
|
||
dramaticNeed: `${issuerNpcName} 还没完全确认你值不值得信任。`,
|
||
issuerGoal: '通过切磋判断你的实力和态度。',
|
||
playerHook: '你只需要接住这场试探,就能让关系往前推一步。',
|
||
worldReason: '在这种局势里,口头承诺往往不如当面试一试来得直接。',
|
||
recommendedObjectiveKinds: ['spar_with_npc', 'talk_to_npc'],
|
||
urgency: 'low',
|
||
intimacy: 'trust_based',
|
||
rewardTheme: 'relationship',
|
||
followupHooks: [
|
||
`${issuerNpcName} 会根据这次试探重新判断和你的距离。`,
|
||
'这次切磋很可能会牵出下一轮更正式的合作。',
|
||
],
|
||
};
|
||
}
|
||
|
||
export function compileQuestIntentToQuest(
|
||
params: QuestCompilationRequest,
|
||
intent: QuestIntent,
|
||
): QuestLogEntry | null {
|
||
const fallbackIntent = buildFallbackQuestIntent(params);
|
||
const primaryStep = buildPrimaryQuestStep({
|
||
issuerNpcId: params.issuerNpcId,
|
||
issuerNpcName: params.issuerNpcName,
|
||
scene: params.scene,
|
||
worldType: params.worldType,
|
||
intent,
|
||
});
|
||
if (!primaryStep) {
|
||
return null;
|
||
}
|
||
|
||
const steps = [
|
||
primaryStep,
|
||
buildTalkBackStep(params.issuerNpcId, params.issuerNpcName),
|
||
];
|
||
const reward = buildQuestReward({
|
||
issuerNpcId: params.issuerNpcId,
|
||
issuerNpcName: params.issuerNpcName,
|
||
worldType: params.worldType,
|
||
roleText: params.roleText,
|
||
rewardTheme: intent.rewardTheme,
|
||
narrativeType: intent.narrativeType,
|
||
urgency: intent.urgency,
|
||
stepCount: steps.length,
|
||
scene: params.scene,
|
||
context: params.context,
|
||
});
|
||
const rewardText = buildRewardText(reward, params.worldType);
|
||
const contract: QuestContract = {
|
||
id: buildQuestId(
|
||
params.issuerNpcId,
|
||
primaryStep.kind,
|
||
resolveQuestIdTargetKey(primaryStep, params.scene),
|
||
),
|
||
issuerNpcId: params.issuerNpcId,
|
||
issuerNpcName: params.issuerNpcName,
|
||
sceneId: params.scene?.id ?? null,
|
||
questArchetype: intent.narrativeType,
|
||
title: normalizeQuestTitle(intent.title, fallbackIntent.title),
|
||
description: intent.description.trim() || fallbackIntent.description,
|
||
summary: intent.summary.trim() || fallbackIntent.summary,
|
||
steps,
|
||
reward,
|
||
rewardText,
|
||
narrativeBinding: {
|
||
origin: params.origin ?? 'fallback_builder',
|
||
narrativeType: intent.narrativeType,
|
||
dramaticNeed: intent.dramaticNeed,
|
||
issuerGoal: intent.issuerGoal,
|
||
playerHook: intent.playerHook,
|
||
worldReason: intent.worldReason,
|
||
followupHooks: intent.followupHooks,
|
||
},
|
||
failPolicy: 'never',
|
||
};
|
||
const threadContract = resolveQuestThreadContract({
|
||
context: params.context,
|
||
issuerNpcId: params.issuerNpcId,
|
||
scene: params.scene,
|
||
});
|
||
|
||
return normalizeQuestLogEntry({
|
||
id: contract.id,
|
||
issuerNpcId: contract.issuerNpcId,
|
||
issuerNpcName: contract.issuerNpcName,
|
||
sceneId: contract.sceneId,
|
||
actId: params.context?.actState?.id ?? null,
|
||
threadId: threadContract?.threadId ?? null,
|
||
contractId: threadContract?.id ?? null,
|
||
title: contract.title,
|
||
description: contract.description,
|
||
summary: contract.summary,
|
||
objective: deriveObjectiveFromStep(
|
||
contract.steps[0] ?? null,
|
||
contract.issuerNpcId,
|
||
),
|
||
progress: 0,
|
||
status: 'active',
|
||
completionNotified: false,
|
||
reward: contract.reward,
|
||
rewardText: contract.rewardText,
|
||
narrativeBinding: contract.narrativeBinding,
|
||
steps: contract.steps,
|
||
activeStepId: contract.steps[0]?.id ?? null,
|
||
visibleStage: threadContract?.visibleStage ?? 0,
|
||
hiddenFlags: [],
|
||
discoveredFactIds: [],
|
||
relatedCarrierIds: [],
|
||
consequenceIds: [],
|
||
});
|
||
}
|
||
|
||
export function buildQuestForEncounter(
|
||
params: QuestPreviewRequest,
|
||
): QuestLogEntry | null {
|
||
const opportunity = evaluateQuestOpportunity(params);
|
||
if (!opportunity.shouldOffer) {
|
||
return null;
|
||
}
|
||
|
||
return compileQuestIntentToQuest(
|
||
{
|
||
...params,
|
||
origin: 'fallback_builder',
|
||
},
|
||
buildFallbackQuestIntent(params),
|
||
);
|
||
}
|
||
|
||
export function buildChapterQuestForScene(params: {
|
||
scene: QuestSceneSnapshot | null;
|
||
worldType: WorldType | null;
|
||
context?: QuestGenerationContext;
|
||
sceneChapterContext?: SceneChapterQuestContext | null;
|
||
}) {
|
||
const { scene, worldType, context, sceneChapterContext } = params;
|
||
if (!scene) {
|
||
return null;
|
||
}
|
||
|
||
const { issuerNpcId, issuerNpcName, roleText, hasGuideNpc } =
|
||
resolveSceneChapterIssuer(scene);
|
||
const override = SCENE_CHAPTER_OVERRIDES[scene.id] ?? null;
|
||
const steps = buildSceneChapterSteps({
|
||
scene,
|
||
worldType,
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
hasGuideNpc,
|
||
override,
|
||
});
|
||
if (steps.length <= 0) {
|
||
return null;
|
||
}
|
||
|
||
const narrativeType = resolveSceneChapterNarrativeType(scene, worldType);
|
||
const rewardTheme = resolveSceneChapterRewardTheme(scene, worldType);
|
||
const threat = getScenePrimaryThreat(scene, worldType);
|
||
const reward = buildQuestReward({
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
worldType,
|
||
roleText,
|
||
rewardTheme,
|
||
narrativeType,
|
||
urgency: threat?.kind === 'defeat_hostile_npc' ? 'high' : 'medium',
|
||
stepCount: steps.length,
|
||
scene,
|
||
context,
|
||
});
|
||
const rewardText = buildRewardText(reward, worldType);
|
||
const threadContract = resolveQuestThreadContract({
|
||
context,
|
||
issuerNpcId,
|
||
scene,
|
||
});
|
||
const chapterId = buildSceneChapterId(scene.id);
|
||
const sceneTaskDescription = sceneChapterContext?.sceneTaskDescription?.trim() ?? '';
|
||
const actEventDescriptions = sceneChapterContext?.actEventDescriptions
|
||
?.map((entry) => entry.trim())
|
||
.filter(Boolean) ?? [];
|
||
const primaryNpcName = sceneChapterContext?.primaryNpcName?.trim() ?? '';
|
||
const title = normalizeQuestTitle(
|
||
override?.title ?? `${compactQuestLabel(scene.name, 6)}异动`,
|
||
`查明${compactQuestLabel(scene.name, 6)}`,
|
||
);
|
||
const description = sceneTaskDescription
|
||
|| override?.description
|
||
|| (hasGuideNpc
|
||
? `${issuerNpcName} 认为 ${scene.name} 这一带的异动并不简单,希望你把眼前的线索与压力真正查清。`
|
||
: `${scene.name} 当前的局势还没有收束,你需要把这一章的线索和压力真正接住。`);
|
||
const summary = override?.summary
|
||
?? sceneTaskDescription
|
||
?? `在 ${scene.name} 接住这一章的线索并完成收束`;
|
||
const dramaticNeed = sceneTaskDescription
|
||
|| (hasGuideNpc
|
||
? `${issuerNpcName} 明显知道 ${scene.name} 的局势正在失衡,但还没把真正的问题说透。`
|
||
: `${scene.name} 的异常正在把这段局势往前推,你需要先把现场的主压力接住。`);
|
||
const issuerGoal = sceneTaskDescription
|
||
|| (hasGuideNpc
|
||
? `查清 ${scene.name} 的异动到底是谁、哪件旧事或哪层残痕在推动。`
|
||
: `把 ${scene.name} 当前未收束的压力和线索梳理清楚。`);
|
||
const followupHooks = [
|
||
...actEventDescriptions.slice(0, 3),
|
||
`${scene.name} 的这一章收束后,下一段 lead 会开始变得更明确。`,
|
||
];
|
||
|
||
return normalizeQuestLogEntry({
|
||
id: `quest:chapter:${scene.id}`,
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
sceneId: scene.id,
|
||
chapterId,
|
||
actId: context?.actState?.id ?? null,
|
||
threadId: threadContract?.threadId ?? null,
|
||
contractId: threadContract?.id ?? null,
|
||
title,
|
||
description,
|
||
summary,
|
||
objective: deriveObjectiveFromStep(steps[0] ?? null, issuerNpcId),
|
||
progress: 0,
|
||
status: 'active',
|
||
completionNotified: false,
|
||
reward,
|
||
rewardText,
|
||
narrativeBinding: {
|
||
origin: 'fallback_builder',
|
||
narrativeType,
|
||
dramaticNeed,
|
||
issuerGoal,
|
||
playerHook: primaryNpcName
|
||
? `你已经进入 ${scene.name},${primaryNpcName} 会把这一章的压力推到你面前。`
|
||
: `你已经进入 ${scene.name},这一章现在就落在你面前。`,
|
||
worldReason:
|
||
threat?.kind === 'defeat_hostile_npc'
|
||
? `${scene.name} 的敌对压力已经摆到了台前,不先处理就很难继续推进。`
|
||
: `${scene.name} 的线索和残痕已经堆到足以独立成章的程度。`,
|
||
followupHooks,
|
||
},
|
||
steps,
|
||
activeStepId: steps[0]?.id ?? null,
|
||
visibleStage: 0,
|
||
hiddenFlags: [],
|
||
discoveredFactIds: [],
|
||
relatedCarrierIds: [],
|
||
consequenceIds: [],
|
||
});
|
||
}
|
||
|
||
export function buildQuestAcceptDetail(quest: QuestLogEntry) {
|
||
const normalizedQuest = withNormalizedQuest(quest);
|
||
const activeStep = getQuestActiveStep(normalizedQuest);
|
||
return activeStep
|
||
? `${activeStep.revealText} ${normalizedQuest.rewardText}`
|
||
: `${normalizedQuest.summary} ${normalizedQuest.rewardText}`;
|
||
}
|
||
|
||
export function buildQuestTurnInDetail(quest: QuestLogEntry) {
|
||
return `这份委托已经可以结算,去和 ${quest.issuerNpcName} 把结果说清楚吧。${quest.rewardText}`;
|
||
}
|
||
|
||
export function buildQuestAcceptResultText(quest: QuestLogEntry) {
|
||
const activeStep = getQuestActiveStep(quest);
|
||
return `${quest.issuerNpcName} 正式把委托交到了你手上。${activeStep?.revealText ?? quest.summary}`;
|
||
}
|
||
|
||
export function buildQuestTurnInResultText(quest: QuestLogEntry) {
|
||
const itemText =
|
||
quest.reward.items.map((item) => item.name).join('、') || '补给';
|
||
const experienceText =
|
||
(quest.reward.experience ?? 0) > 0
|
||
? `、${quest.reward.experience} 经验`
|
||
: '';
|
||
const intelText = quest.reward.intel?.rumorText
|
||
? `,并额外告诉了你一条消息:${quest.reward.intel.rumorText}`
|
||
: '';
|
||
const storyHintText = quest.reward.storyHint
|
||
? ` ${quest.reward.storyHint}`
|
||
: '';
|
||
return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金${experienceText}和${itemText}${intelText}。${storyHintText}`;
|
||
}
|
||
|
||
export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {
|
||
if (findQuestById(quests, quest.id)) {
|
||
return quests.map((item) => withNormalizedQuest(item));
|
||
}
|
||
return [
|
||
...quests.map((item) => withNormalizedQuest(item)),
|
||
withNormalizedQuest(quest),
|
||
];
|
||
}
|
||
|
||
export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) {
|
||
return quests.map((quest) =>
|
||
quest.id === questId
|
||
? withNormalizedQuest({
|
||
...quest,
|
||
status: 'turned_in',
|
||
completionNotified: true,
|
||
steps: quest.steps?.map((step) => ({
|
||
...step,
|
||
progress: step.requiredCount,
|
||
})),
|
||
})
|
||
: withNormalizedQuest(quest),
|
||
);
|
||
}
|
||
|
||
export function markQuestCompletionNotified(
|
||
quests: QuestLogEntry[],
|
||
questId: string,
|
||
) {
|
||
return quests.map((quest) =>
|
||
quest.id === questId
|
||
? withNormalizedQuest({
|
||
...quest,
|
||
completionNotified: true,
|
||
})
|
||
: withNormalizedQuest(quest),
|
||
);
|
||
}
|
||
|
||
export function applyQuestProgressFromHostileNpcDefeat(
|
||
quests: QuestLogEntry[],
|
||
sceneId: string | null,
|
||
defeatedHostileNpcIds: string[],
|
||
) {
|
||
return defeatedHostileNpcIds.reduce(
|
||
(currentQuests, hostileNpcId) =>
|
||
applyQuestProgressSignal(currentQuests, {
|
||
kind: 'hostile_npc_defeated',
|
||
sceneId,
|
||
hostileNpcId,
|
||
}),
|
||
quests.map((quest) => withNormalizedQuest(quest)),
|
||
);
|
||
}
|
||
|
||
export function applyQuestProgressFromTreasure(
|
||
quests: QuestLogEntry[],
|
||
sceneId: string | null,
|
||
) {
|
||
if (!sceneId) {
|
||
return quests.map((quest) => withNormalizedQuest(quest));
|
||
}
|
||
|
||
return applyQuestProgressSignal(quests, {
|
||
kind: 'treasure_inspected',
|
||
sceneId,
|
||
});
|
||
}
|
||
|
||
export function applyQuestProgressFromSpar(
|
||
quests: QuestLogEntry[],
|
||
npcId: string | null,
|
||
) {
|
||
if (!npcId) {
|
||
return quests.map((quest) => withNormalizedQuest(quest));
|
||
}
|
||
|
||
return applyQuestProgressSignal(quests, {
|
||
kind: 'npc_spar_completed',
|
||
npcId,
|
||
});
|
||
}
|
||
|
||
export function applyQuestProgressFromNpcTalk(
|
||
quests: QuestLogEntry[],
|
||
npcId: string | null,
|
||
) {
|
||
if (!npcId) {
|
||
return quests.map((quest) => withNormalizedQuest(quest));
|
||
}
|
||
|
||
return applyQuestProgressSignal(quests, {
|
||
kind: 'npc_talk_completed',
|
||
npcId,
|
||
});
|
||
}
|
||
|
||
export function applyQuestProgressFromSceneReached(
|
||
quests: QuestLogEntry[],
|
||
sceneId: string | null,
|
||
) {
|
||
if (!sceneId) {
|
||
return quests.map((quest) => withNormalizedQuest(quest));
|
||
}
|
||
|
||
return applyQuestProgressSignal(quests, {
|
||
kind: 'scene_reached',
|
||
sceneId,
|
||
});
|
||
}
|
||
|
||
export function isQuestReadyToClaim(quest: QuestLogEntry) {
|
||
return isRewardReadyStatus(withNormalizedQuest(quest).status);
|
||
}
|
||
|
||
export function buildQuestGenerationSummary(
|
||
customWorldProfile: CustomWorldProfile | null | undefined,
|
||
) {
|
||
if (!customWorldProfile) {
|
||
return null;
|
||
}
|
||
|
||
return `${customWorldProfile.name}: ${customWorldProfile.summary}`;
|
||
}
|