Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
365
src/services/storyEngine/goalDirector.test.ts
Normal file
365
src/services/storyEngine/goalDirector.test.ts
Normal 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('陆清');
|
||||
});
|
||||
});
|
||||
895
src/services/storyEngine/goalDirector.ts
Normal file
895
src/services/storyEngine/goalDirector.ts
Normal 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;
|
||||
}
|
||||
1175
src/services/storyEngine/storyAuditReport.ts
Normal file
1175
src/services/storyEngine/storyAuditReport.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user