Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -0,0 +1,365 @@
import { describe, expect, it } from 'vitest';
import type {
CampEvent,
ChapterState,
GameState,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
} from '../../types';
import { AnimationState } from '../../types';
import {
annotateStoryOptionsWithGoalAffordance,
buildGoalHandoffFromState,
buildGoalStackState,
createGoalPulseSnapshot,
deriveGoalPulseEvent,
describeGoalStackForPrompt,
sortQuestsForGoalPanel,
} from './goalDirector';
function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id' | 'title'>): QuestLogEntry {
return {
id: overrides.id,
issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`,
issuerNpcName: overrides.issuerNpcName ?? '林朔',
sceneId: overrides.sceneId ?? 'scene-ruins',
title: overrides.title,
description: overrides.description ?? `${overrides.title} 的说明`,
summary: overrides.summary ?? `${overrides.title} 的摘要`,
objective: overrides.objective ?? {
kind: 'inspect_treasure',
targetSceneId: 'scene-ruins',
requiredCount: 1,
},
progress: overrides.progress ?? 0,
status: overrides.status ?? 'active',
reward: overrides.reward ?? {
affinityBonus: 10,
currency: 20,
items: [],
},
rewardText: overrides.rewardText ?? '奖励已准备',
narrativeBinding: overrides.narrativeBinding,
steps: overrides.steps,
activeStepId: overrides.activeStepId,
threadId: overrides.threadId ?? null,
completionNotified: overrides.completionNotified ?? false,
};
}
function createSceneDirective() {
return {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
};
}
describe('goalDirector', () => {
it('uses the ready-to-turn-in quest as the current goal and immediate step', () => {
const chapterState: ChapterState = {
id: 'chapter-1',
title: '封桥旧案',
theme: '封桥旧案',
primaryThreadIds: ['thread-bridge'],
stage: 'expansion',
chapterSummary: '桥上的旧案正被重新翻开。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-1',
beatType: 'investigation',
title: '追查旧桥异动',
triggerThreadIds: ['thread-bridge'],
recommendedSceneIds: ['scene-bridge'],
emotionalGoal: '把线索从零散异常收束成可追查的方向。',
};
const setpieceDirective: SetpieceDirective = {
id: 'setpiece-1',
title: '桥门对峙',
setpieceType: 'boss_prelude',
relatedThreadIds: ['thread-bridge'],
sceneFocusId: 'scene-bridge',
dramaticQuestion: '旧桥另一侧到底是谁在阻拦真相?',
};
const currentCampEvent: CampEvent = {
id: 'camp-1',
eventType: 'private_talk',
title: '夜谈未尽之事',
participantCharacterIds: ['companion-1'],
triggerReason: '同伴对桥上的旧案起了新的疑心。',
relatedThreadIds: ['thread-bridge'],
};
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
threadId: 'thread-bridge',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
rewardText: '回去找陆清交付调查结果。',
});
const sideQuest = createQuest({
id: 'quest-side',
title: '整理营地补给',
status: 'active',
narrativeBinding: {
origin: 'fallback_builder',
narrativeType: 'relationship',
dramaticNeed: '营地气氛有些不稳。',
issuerGoal: '先把补给和情绪都稳住。',
playerHook: '这能让后续推进更从容。',
worldReason: '大家都还没完全从上一段冲突里缓过来。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [sideQuest, readyQuest],
worldType: null,
chapterState,
journeyBeat,
setpieceDirective,
currentCampEvent,
currentSceneName: '断桥旧哨',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('setpiece');
expect(goalStack.activeGoal?.sourceKind).toBe('quest');
expect(goalStack.activeGoal?.sourceId).toBe('quest-ready');
expect(goalStack.immediateStepGoal?.title).toContain('陆清');
expect(goalStack.supportGoals.some((goal) => goal.sourceId === 'quest-side')).toBe(true);
expect(goalStack.supportGoals.some((goal) => goal.sourceKind === 'relationship')).toBe(true);
const sortedQuestIds = sortQuestsForGoalPanel([sideQuest, readyQuest], goalStack).map((quest) => quest.id);
expect(sortedQuestIds[0]).toBe('quest-ready');
});
it('falls back to the current journey beat when no quest is active', () => {
const chapterState: ChapterState = {
id: 'chapter-2',
title: '山门前夜',
theme: '山门风声',
primaryThreadIds: ['thread-gate'],
stage: 'opening',
chapterSummary: '风声刚起,矛盾还在缓慢聚拢。',
};
const journeyBeat: JourneyBeat = {
id: 'beat-2',
beatType: 'approach',
title: '接近山门真相',
triggerThreadIds: ['thread-gate'],
recommendedSceneIds: ['scene-gate'],
emotionalGoal: '先把前情、威胁和方向重新拢到一起。',
};
const goalStack = buildGoalStackState({
quests: [],
worldType: null,
chapterState,
journeyBeat,
currentSceneName: '山门外缘',
});
expect(goalStack.northStarGoal?.sourceKind).toBe('chapter');
expect(goalStack.activeGoal?.sourceKind).toBe('journey_beat');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('前往');
expect(goalStack.immediateStepGoal?.nextStepText).toContain('scene-gate');
expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进');
});
it('annotates options with advance/support affordances and builds quest reward handoff', () => {
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
narrativeBinding: {
origin: 'ai_compiled',
narrativeType: 'investigation',
dramaticNeed: '必须确认遗迹的异动来源。',
issuerGoal: '拿到调查结果后继续推进旧桥线索。',
playerHook: '你已经掌握了最关键的现场信息。',
worldReason: '如果再拖下去,线索会继续散掉。',
followupHooks: [],
},
});
const goalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const options: StoryOption[] = [
{
functionId: 'npc.quest_turn_in',
actionText: '把调查结果告诉陆清',
visuals: createSceneDirective(),
interaction: {
kind: 'npc',
npcId: 'quest-ready-issuer',
action: 'quest_turn_in',
questId: 'quest-ready',
},
},
{
functionId: 'idle_explore_forward',
actionText: '继续向前探查',
visuals: createSceneDirective(),
},
];
const annotated = annotateStoryOptionsWithGoalAffordance(options, goalStack);
expect(annotated[0]?.goalAffordance?.relation).toBe('advance');
expect(annotated[0]?.goalAffordance?.label).toBe('推进当前任务');
expect(annotated[1]?.goalAffordance).toBeNull();
const state = {
worldType: null,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
},
chapterState: null,
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-ruins',
name: '断桥旧哨',
description: '',
imageSrc: '',
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 0,
playerMaxHp: 0,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [readyQuest],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
const handoff = buildGoalHandoffFromState(state);
expect(handoff?.title).toContain('陆清');
expect(handoff?.detail).toContain('结果');
});
it('derives pulse events for newly accepted and newly ready quests', () => {
const acceptedQuest = createQuest({
id: 'quest-accepted',
title: '追查桥上的雾信',
status: 'active',
issuerNpcName: '陆清',
summary: '先去断桥边确认最新痕迹。',
});
const acceptedGoalStack = buildGoalStackState({
quests: [acceptedQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const acceptPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot([], acceptedGoalStack),
quests: [acceptedQuest],
goalStack: acceptedGoalStack,
});
expect(acceptPulse?.pulseType).toBe('progress');
expect(acceptPulse?.title).toContain('接取');
const readyQuest = createQuest({
id: 'quest-ready',
title: '回报遗迹调查',
status: 'ready_to_turn_in',
issuerNpcName: '陆清',
summary: '带着结果回去向陆清交待。',
});
const readyGoalStack = buildGoalStackState({
quests: [readyQuest],
worldType: null,
currentSceneName: '断桥旧哨',
});
const readyPulse = deriveGoalPulseEvent({
previous: createGoalPulseSnapshot(
[
{
...readyQuest,
status: 'active',
},
],
readyGoalStack,
),
quests: [readyQuest],
goalStack: readyGoalStack,
});
expect(readyPulse?.pulseType).toBe('ready_to_turn_in');
expect(readyPulse?.detail).toContain('陆清');
});
});

View File

@@ -0,0 +1,895 @@
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import type {
CampEvent,
ChapterState,
GameState,
GoalHandoff,
GoalLayer,
GoalPulseEvent,
GoalStackEntry,
GoalStackState,
GoalStatus,
GoalTrack,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
StoryOption,
WorldType,
} from '../../types';
const TERMINAL_QUEST_STATUSES = new Set<QuestLogEntry['status']>([
'turned_in',
'failed',
'expired',
]);
type GoalPulseSnapshot = {
questStatuses: Record<string, QuestLogEntry['status']>;
activeGoalId: string | null;
immediateGoalId: string | null;
immediateGoalText: string | null;
};
function isLiveQuest(quest: QuestLogEntry) {
return !TERMINAL_QUEST_STATUSES.has(quest.status);
}
function getChapterStageLabel(stage: ChapterState['stage']) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType']) {
switch (beatType) {
case 'approach':
return '接近';
case 'investigation':
return '调查';
case 'camp':
return '休整';
case 'conflict':
return '冲突';
case 'boss_prelude':
return '决战前奏';
case 'climax':
return '高潮';
case 'recovery':
return '恢复';
default:
return '旅程';
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType']) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
}
}
function cleanTaskTitle(title: string, fallback = '当前任务') {
const cleaned = title
.replace(/["']/gu, '')
.replace(/[·|:].*$/u, '')
.replace(/[,.!?;].*$/u, '')
.trim();
if (!cleaned) {
return fallback;
}
return cleaned.length > 12 ? cleaned.slice(0, 10) : cleaned;
}
function buildJourneyTaskTitle(beatType: JourneyBeat['beatType']) {
switch (beatType) {
case 'approach':
return '靠近线索';
case 'investigation':
return '调查线索';
case 'camp':
return '回营整备';
case 'conflict':
return '处理冲突';
case 'boss_prelude':
return '备战对峙';
case 'climax':
return '完成对峙';
case 'recovery':
return '收束结果';
default:
return '继续推进';
}
}
function buildJourneyTaskCondition(params: {
beatType: JourneyBeat['beatType'];
sceneHint: string | null;
}) {
const { beatType, sceneHint } = params;
const place = sceneHint ?? '当前区域';
switch (beatType) {
case 'approach':
return `前往 ${place},确认新的线索。`;
case 'investigation':
return `${place} 调查线索或异常。`;
case 'camp':
return '返回营地,整理队伍或与同伴交谈。';
case 'conflict':
return `处理 ${place} 的冲突。`;
case 'boss_prelude':
return `前往 ${place},准备关键战斗。`;
case 'climax':
return `${place} 完成关键对峙。`;
case 'recovery':
return '查看任务结果,决定下一步去向。';
default:
return `继续推进 ${place} 的任务。`;
}
}
function resolveJourneySceneHint(params: {
beat: JourneyBeat;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const rawSceneId = params.beat.recommendedSceneIds[0] ?? null;
if (!rawSceneId) {
return params.currentSceneName ?? null;
}
if (!params.worldType) {
return rawSceneId;
}
return getScenePresetById(params.worldType, rawSceneId)?.name
?? params.currentSceneName
?? rawSceneId;
}
export function getGoalTrackLabel(track: GoalTrack) {
switch (track) {
case 'main':
return '主推进';
case 'side':
return '支线';
case 'relationship':
return '关系';
case 'survival':
return '整备';
case 'exploration':
return '探索';
default:
return '任务';
}
}
function getQuestSceneHint(quest: QuestLogEntry, worldType: WorldType | null) {
if (!quest.sceneId) {
return null;
}
if (!worldType) {
return quest.sceneId;
}
return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId;
}
function getQuestTrack(quest: QuestLogEntry, fallbackTrack: GoalTrack) {
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'relationship';
}
if (narrativeType === 'investigation' || quest.objective.kind === 'inspect_treasure') {
return fallbackTrack === 'main' ? 'main' : 'exploration';
}
return fallbackTrack;
}
function getQuestStatus(quest: QuestLogEntry): GoalStatus {
if (isQuestReadyToClaim(quest)) {
return 'ready_to_resolve';
}
if (quest.status === 'turned_in') {
return 'resolved';
}
if (quest.status === 'failed' || quest.status === 'expired') {
return 'archived';
}
if (quest.status === 'discovered') {
return 'teased';
}
return 'active';
}
function getQuestUrgency(quest: QuestLogEntry): GoalStackEntry['urgency'] {
if (isQuestReadyToClaim(quest)) {
return 'high';
}
const narrativeType = quest.narrativeBinding?.narrativeType ?? null;
if (narrativeType === 'investigation' || narrativeType === 'retrieval') {
return 'medium';
}
if (narrativeType === 'relationship' || narrativeType === 'trial') {
return 'low';
}
return 'medium';
}
function getQuestProgressLabel(quest: QuestLogEntry) {
if (isQuestReadyToClaim(quest)) {
return '待交付';
}
const activeStep = getQuestActiveStep(quest);
if (activeStep) {
return `步骤 ${activeStep.progress}/${activeStep.requiredCount}`;
}
return `进度 ${quest.progress}/${quest.objective.requiredCount}`;
}
function buildQuestGoalEntry(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
layer: GoalLayer;
fallbackTrack: GoalTrack;
}) {
const { quest, worldType, layer, fallbackTrack } = params;
const sceneHint = getQuestSceneHint(quest, worldType);
const relatedThreadIds = quest.threadId ? [quest.threadId] : [];
return {
id: `goal:${layer}:${quest.id}`,
sourceKind: 'quest',
sourceId: quest.id,
layer,
track: getQuestTrack(quest, fallbackTrack),
title: quest.title,
promiseText:
quest.narrativeBinding?.playerHook
|| quest.description
|| `${quest.issuerNpcName} 把这件事托付给了你。`,
whyNow:
quest.narrativeBinding?.worldReason
|| `${quest.issuerNpcName} 认为现在正是处理这件事的时机。`,
nextStepText: isQuestReadyToClaim(quest)
? `回去找 ${quest.issuerNpcName} 交付委托并领取报酬。`
: getQuestActiveStep(quest)?.revealText ?? quest.summary,
sceneHint,
npcHint: quest.issuerNpcName,
progressLabel: getQuestProgressLabel(quest),
status: getQuestStatus(quest),
urgency: getQuestUrgency(quest),
relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildQuestImmediateGoal(params: {
quest: QuestLogEntry;
worldType: WorldType | null;
}) {
const { quest, worldType } = params;
const activeStep = getQuestActiveStep(quest);
const sceneHint = getQuestSceneHint(quest, worldType);
if (isQuestReadyToClaim(quest)) {
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: `${quest.issuerNpcName} 交付结果`,
promiseText: '委托已经完成,只差最后汇报和结算。',
whyNow: `${quest.issuerNpcName} 的报酬已经准备好,这一步能把当前委托正式结清。`,
nextStepText: `去找 ${quest.issuerNpcName} 对话,把结果说清楚。`,
sceneHint,
npcHint: quest.issuerNpcName,
} satisfies GoalStackEntry;
}
if (!activeStep) {
return null;
}
return {
...buildQuestGoalEntry({
quest,
worldType,
layer: 'immediate_step',
fallbackTrack: 'main',
}),
title: activeStep.title,
promiseText: activeStep.revealText,
whyNow:
quest.narrativeBinding?.issuerGoal
|| `${quest.issuerNpcName} 的委托正在推进中。`,
nextStepText: activeStep.revealText,
npcHint: activeStep.targetNpcId ? quest.issuerNpcName : null,
progressLabel: `步骤 ${activeStep.progress}/${activeStep.requiredCount}`,
} satisfies GoalStackEntry;
}
function buildChapterNorthStarGoal(params: {
chapterState: ChapterState;
journeyBeat: JourneyBeat | null;
setpieceDirective: SetpieceDirective | null;
worldType: WorldType | null;
currentSceneName?: string | null;
}) {
const { chapterState, journeyBeat, setpieceDirective, worldType, currentSceneName } = params;
const sceneHint = journeyBeat
? resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
})
: currentSceneName ?? null;
return {
id: `goal:north_star:chapter:${chapterState.id}`,
sourceKind: 'chapter',
sourceId: chapterState.id,
layer: 'north_star',
track: 'main',
title: cleanTaskTitle(chapterState.theme || chapterState.title, '主线任务'),
promiseText: chapterState.chapterSummary,
whyNow: `当前章节已进入${getChapterStageLabel(chapterState.stage)}阶段。`,
nextStepText: setpieceDirective
? `继续收束线索与局势,逼近 ${setpieceDirective.title}`
: journeyBeat
? buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint,
})
: `围绕 ${chapterState.theme} 继续推进当前主线。`,
sceneHint: null,
npcHint: null,
progressLabel: getChapterStageLabel(chapterState.stage),
status: 'active',
urgency: chapterState.stage === 'climax' || chapterState.stage === 'turning_point'
? 'high'
: chapterState.stage === 'expansion'
? 'medium'
: 'low',
relatedThreadIds: chapterState.primaryThreadIds,
} satisfies GoalStackEntry;
}
function buildJourneyGoal(params: {
journeyBeat: JourneyBeat;
layer: GoalLayer;
currentSceneName?: string | null;
worldType?: WorldType | null;
}) {
const { journeyBeat, layer, currentSceneName, worldType } = params;
const recommendedSceneHint = resolveJourneySceneHint({
beat: journeyBeat,
currentSceneName,
worldType,
});
const nextStepText = buildJourneyTaskCondition({
beatType: journeyBeat.beatType,
sceneHint: recommendedSceneHint,
});
return {
id: `goal:${layer}:journey:${journeyBeat.id}`,
sourceKind: 'journey_beat',
sourceId: journeyBeat.id,
layer,
track: journeyBeat.beatType === 'camp' ? 'relationship' : 'main',
title: buildJourneyTaskTitle(journeyBeat.beatType),
promiseText: journeyBeat.emotionalGoal,
whyNow: journeyBeat.emotionalGoal || '当前主线需要继续推进。',
nextStepText,
sceneHint: recommendedSceneHint,
npcHint: null,
progressLabel: getJourneyBeatLabel(journeyBeat.beatType),
status: 'active',
urgency: journeyBeat.beatType === 'boss_prelude' || journeyBeat.beatType === 'climax'
? 'high'
: journeyBeat.beatType === 'investigation' || journeyBeat.beatType === 'conflict'
? 'medium'
: 'low',
relatedThreadIds: journeyBeat.triggerThreadIds,
} satisfies GoalStackEntry;
}
function buildSetpieceNorthStarGoal(setpieceDirective: SetpieceDirective) {
return {
id: `goal:north_star:setpiece:${setpieceDirective.id}`,
sourceKind: 'setpiece',
sourceId: setpieceDirective.id,
layer: 'north_star',
track: 'main',
title: setpieceDirective.title,
promiseText: setpieceDirective.dramaticQuestion,
whyNow: `当前局势已经逼近${getSetpieceLabel(setpieceDirective.setpieceType)}`,
nextStepText: `继续收束线索、关系和状态,为 ${setpieceDirective.title} 做准备。`,
sceneHint: setpieceDirective.sceneFocusId ?? null,
npcHint: null,
progressLabel: getSetpieceLabel(setpieceDirective.setpieceType),
status: 'active',
urgency: setpieceDirective.setpieceType === 'climax' || setpieceDirective.setpieceType === 'showdown'
? 'high'
: 'medium',
relatedThreadIds: setpieceDirective.relatedThreadIds,
} satisfies GoalStackEntry;
}
function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
return {
id: `goal:support:camp:${currentCampEvent.id}`,
sourceKind: 'relationship',
sourceId: currentCampEvent.id,
layer: 'support',
track: 'relationship',
title: currentCampEvent.title,
promiseText: currentCampEvent.triggerReason,
whyNow: '队伍里的情绪和关系已经积累到值得回应的程度。',
nextStepText: '留意营地或旅途中新的交流时机,把这段关系事件接住。',
sceneHint: null,
npcHint: null,
progressLabel: '关系事件',
status: 'teased',
urgency: currentCampEvent.eventType === 'conflict' || currentCampEvent.eventType === 'decision'
? 'medium'
: 'low',
relatedThreadIds: currentCampEvent.relatedThreadIds,
} satisfies GoalStackEntry;
}
function resolvePrimaryQuest(quests: QuestLogEntry[]) {
const liveQuests = quests.filter(isLiveQuest);
if (liveQuests.length <= 0) {
return null;
}
return liveQuests.find((quest) => isQuestReadyToClaim(quest))
?? liveQuests.find((quest) => quest.status === 'active')
?? liveQuests.find((quest) => quest.status === 'discovered')
?? liveQuests[0]
?? null;
}
export function buildGoalStackState(params: {
quests: QuestLogEntry[];
worldType: WorldType | null;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
setpieceDirective?: SetpieceDirective | null;
currentCampEvent?: CampEvent | null;
currentSceneName?: string | null;
}) {
const {
quests,
worldType,
chapterState = null,
journeyBeat = null,
setpieceDirective = null,
currentCampEvent = null,
currentSceneName = null,
} = params;
const primaryQuest = resolvePrimaryQuest(quests);
const northStarGoal = setpieceDirective
? buildSetpieceNorthStarGoal(setpieceDirective)
: chapterState
? buildChapterNorthStarGoal({
chapterState,
journeyBeat,
setpieceDirective,
worldType,
currentSceneName,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'north_star',
currentSceneName,
worldType,
})
: null;
const activeGoal = primaryQuest
? buildQuestGoalEntry({
quest: primaryQuest,
worldType,
layer: 'active_contract',
fallbackTrack: 'main',
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'active_contract',
currentSceneName,
worldType,
})
: currentCampEvent
? buildCampEventSupportGoal(currentCampEvent)
: northStarGoal;
const immediateStepGoal = primaryQuest
? buildQuestImmediateGoal({
quest: primaryQuest,
worldType,
})
: journeyBeat
? buildJourneyGoal({
journeyBeat,
layer: 'immediate_step',
currentSceneName,
worldType,
})
: null;
const supportGoals: GoalStackEntry[] = quests
.filter((quest) => isLiveQuest(quest) && quest.id !== primaryQuest?.id)
.map((quest) =>
buildQuestGoalEntry({
quest,
worldType,
layer: 'support',
fallbackTrack: 'side',
}),
);
if (
currentCampEvent
&& !supportGoals.some((goal) => goal.sourceKind === 'relationship')
) {
supportGoals.push(buildCampEventSupportGoal(currentCampEvent));
}
return {
northStarGoal,
activeGoal,
immediateStepGoal,
supportGoals: supportGoals.slice(0, 2),
} satisfies GoalStackState;
}
function getQuestPanelPriority(params: {
quest: QuestLogEntry;
goalStack: GoalStackState | null | undefined;
}) {
const { quest, goalStack } = params;
if (goalStack?.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id) {
return 0;
}
if (goalStack?.immediateStepGoal?.sourceKind === 'quest' && goalStack.immediateStepGoal.sourceId === quest.id) {
return 1;
}
if (isQuestReadyToClaim(quest)) {
return 2;
}
if (isLiveQuest(quest)) {
return 3;
}
return 4;
}
export function sortQuestsForGoalPanel(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return [...quests].sort((left, right) => {
const priorityDiff = getQuestPanelPriority({
quest: left,
goalStack,
}) - getQuestPanelPriority({
quest: right,
goalStack,
});
if (priorityDiff !== 0) {
return priorityDiff;
}
if (left.status !== right.status) {
return left.status.localeCompare(right.status);
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
export function describeGoalStackForPrompt(goalStack: GoalStackState | null | undefined) {
if (!goalStack) {
return null;
}
const lines = [
goalStack.northStarGoal
? `- 长期方向:${goalStack.northStarGoal.title};承诺:${goalStack.northStarGoal.promiseText}`
: null,
goalStack.activeGoal
? `- 当前主任务:${goalStack.activeGoal.title};为什么现在做:${goalStack.activeGoal.whyNow}`
: null,
goalStack.immediateStepGoal
? `- 下一步:${goalStack.immediateStepGoal.nextStepText}`
: null,
goalStack.supportGoals.length > 0
? `- 支持任务:${goalStack.supportGoals.map((goal) => goal.title).join(' / ')}`
: null,
].filter(Boolean);
if (lines.length <= 0) {
return null;
}
return ['当前玩家任务推进:', ...lines].join('\n');
}
function buildQuestOptionGoalAffordance(
option: StoryOption,
goalStack: GoalStackState,
) {
if (option.interaction?.kind !== 'npc') {
return null;
}
if (
option.interaction.action === 'quest_turn_in'
&& goalStack.immediateStepGoal?.sourceKind === 'quest'
) {
return {
goalId: goalStack.immediateStepGoal.id,
relation: 'advance',
label: '推进当前任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (option.interaction.action === 'quest_accept') {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: goalStack.activeGoal?.sourceKind === 'quest' ? 'detour' : 'support',
label: goalStack.activeGoal?.sourceKind === 'quest' ? '暂接支线' : '接入委托',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (
goalStack.activeGoal?.track === 'relationship'
&& ['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)
) {
return {
goalId: goalStack.activeGoal.id,
relation: 'advance',
label: '推进关系任务',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
if (['chat', 'gift', 'help', 'recruit'].includes(option.interaction.action)) {
const targetGoal = goalStack.activeGoal ?? goalStack.northStarGoal;
if (!targetGoal) {
return null;
}
return {
goalId: targetGoal.id,
relation: 'support',
label: '经营关系',
} satisfies NonNullable<StoryOption['goalAffordance']>;
}
return null;
}
export function annotateStoryOptionsWithGoalAffordance(
options: StoryOption[],
goalStack: GoalStackState | null | undefined,
) {
if (!goalStack) {
return options.map((option) => ({
...option,
goalAffordance: null,
}));
}
return options.map((option) => {
const questAffordance = buildQuestOptionGoalAffordance(option, goalStack);
if (questAffordance) {
return {
...option,
goalAffordance: questAffordance,
} satisfies StoryOption;
}
if (
isContinueAdventureOption(option)
&& (
goalStack.immediateStepGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'journey_beat'
|| goalStack.activeGoal?.sourceKind === 'chapter'
|| goalStack.northStarGoal?.sourceKind === 'setpiece'
)
) {
const targetGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!targetGoal) {
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
}
return {
...option,
goalAffordance: {
goalId: targetGoal.id,
relation: 'advance',
label: '继续推进',
},
} satisfies StoryOption;
}
return {
...option,
goalAffordance: null,
} satisfies StoryOption;
});
}
export function buildGoalHandoffFromState(state: GameState): GoalHandoff | null {
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
chapterState: state.chapterState ?? state.storyEngineMemory?.currentChapter ?? null,
journeyBeat: state.storyEngineMemory?.currentJourneyBeat ?? null,
setpieceDirective: state.storyEngineMemory?.currentSetpieceDirective ?? null,
currentCampEvent: state.storyEngineMemory?.currentCampEvent ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
});
const nextGoal =
goalStack.immediateStepGoal
?? goalStack.activeGoal
?? goalStack.northStarGoal;
if (!nextGoal) {
return null;
}
if (nextGoal.sourceKind !== 'quest') {
return null;
}
return {
goalId: nextGoal.id,
title: nextGoal.title,
detail: nextGoal.nextStepText,
track: nextGoal.track,
} satisfies GoalHandoff;
}
function isRewardReadyStatus(status: QuestLogEntry['status']) {
return status === 'ready_to_turn_in' || status === 'completed';
}
export function createGoalPulseSnapshot(
quests: QuestLogEntry[],
goalStack: GoalStackState | null | undefined,
) {
return {
questStatuses: Object.fromEntries(
quests.map((quest) => [quest.id, quest.status]),
),
activeGoalId: goalStack?.activeGoal?.id ?? null,
immediateGoalId: goalStack?.immediateStepGoal?.id ?? null,
immediateGoalText: goalStack?.immediateStepGoal?.nextStepText ?? null,
} satisfies GoalPulseSnapshot;
}
function buildGoalPulse(params: {
goal: GoalStackEntry;
pulseType: GoalPulseEvent['pulseType'];
title: string;
detail: string;
}) {
const { goal, pulseType, title, detail } = params;
return {
id: `${pulseType}:${goal.id}:${Date.now()}`,
goalId: goal.id,
pulseType,
title,
detail,
track: goal.track,
} satisfies GoalPulseEvent;
}
export function deriveGoalPulseEvent(params: {
previous: GoalPulseSnapshot;
quests: QuestLogEntry[];
goalStack: GoalStackState | null | undefined;
}) {
const { previous, quests, goalStack } = params;
const immediateGoal = goalStack?.immediateStepGoal ?? null;
const activeGoal = goalStack?.activeGoal ?? null;
const fallbackGoal = immediateGoal ?? activeGoal ?? goalStack?.northStarGoal ?? null;
const questGoal =
fallbackGoal && fallbackGoal.sourceKind === 'quest'
? fallbackGoal
: null;
const newQuest = quests.find(
(quest) =>
previous.questStatuses[quest.id] == null
&& !TERMINAL_QUEST_STATUSES.has(quest.status),
);
if (newQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'progress',
title: '已接取新任务',
detail: immediateGoal?.nextStepText ?? newQuest.summary,
});
}
const newlyReadyQuest = quests.find((quest) => {
const previousStatus = previous.questStatuses[quest.id];
return !isRewardReadyStatus(previousStatus ?? 'active')
&& isRewardReadyStatus(quest.status);
});
if (newlyReadyQuest && questGoal) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'ready_to_turn_in',
title: '当前任务可交付',
detail: `回去找 ${newlyReadyQuest.issuerNpcName} 对话,把结果说清楚。`,
});
}
if (
questGoal
&& (
previous.immediateGoalId !== (immediateGoal?.id ?? null)
|| previous.immediateGoalText !== (immediateGoal?.nextStepText ?? null)
|| previous.activeGoalId !== (activeGoal?.id ?? null)
)
) {
return buildGoalPulse({
goal: questGoal,
pulseType: 'handoff',
title:
previous.activeGoalId !== (activeGoal?.id ?? null)
? '当前任务已更新'
: '下一步已更新',
detail: questGoal.nextStepText,
});
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,18 @@ type ThemePackPreset = Omit<ThemePack, 'id' | 'displayName'> & {
};
const THEME_PACK_PRESETS: Record<string, ThemePackPreset> = {
mythic: {
displayName: '自定义回响',
toneRange: ['未知', '克制', '余波未定', '局势待开'],
institutionLexicon: ['据点', '同盟', '旅团', '档案室', '哨站', '归舍'],
tabooLexicon: ['失约', '旧痕', '越界', '封存', '误触', '回响'],
artifactClasses: ['信物', '残页', '封匣', '样本', '旧钥', '印记'],
actorArchetypes: ['见证者', '守望人', '异乡来客', '带路人', '失序幸存者'],
conflictForms: ['追查', '护送', '回收', '分歧对峙', '失踪追索'],
clueForms: ['痕迹', '记录', '口供', '残片', '旧图'],
namingPatterns: ['地点+余痕+器类', '势力+旧称+用途', '事件+残响+物件'],
revealStyles: ['循序松口', '线索回指', '保留一层', '让事实自己浮出'],
},
martial: {
displayName: '江湖旧事',
toneRange: ['冷峻', '克制', '刀锋般紧绷', '旧案余震'],
@@ -113,7 +125,7 @@ function resolveThemeModeFromWorldType(
if (worldType === 'XIANXIA') {
return 'arcane';
}
return 'martial';
return 'mythic';
}
export function resolveFallbackThemePack(