1
This commit is contained in:
@@ -23,6 +23,8 @@ import type {
|
||||
CustomWorldRoleProfile,
|
||||
CustomWorldRoleSkill,
|
||||
RoleAttributeProfile,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
WorldType,
|
||||
@@ -83,6 +85,18 @@ const WORLD_ATTRIBUTE_SLOT_IDS = [
|
||||
'axis_e',
|
||||
'axis_f',
|
||||
] as const;
|
||||
const SCENE_ACT_STAGES = new Set([
|
||||
'opening',
|
||||
'expansion',
|
||||
'turning_point',
|
||||
'climax',
|
||||
'aftermath',
|
||||
]);
|
||||
const SCENE_ACT_ADVANCE_RULES = new Set([
|
||||
'after_primary_contact',
|
||||
'after_active_step_complete',
|
||||
'after_chapter_resolution',
|
||||
]);
|
||||
|
||||
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
|
||||
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
|
||||
@@ -1434,6 +1448,105 @@ function normalizeItemList(value: unknown) {
|
||||
.filter((entry) => entry.name && entry.category);
|
||||
}
|
||||
|
||||
function normalizeSceneActStageCoverage(value: unknown) {
|
||||
const stageCoverage = Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
|
||||
SCENE_ACT_STAGES.has(entry as never),
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...new Set(stageCoverage)];
|
||||
}
|
||||
|
||||
function normalizeSceneActBlueprint(
|
||||
value: unknown,
|
||||
index: number,
|
||||
sceneId: string,
|
||||
): SceneActBlueprint | null {
|
||||
const item =
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encounterNpcIds = toStringArray(item.encounterNpcIds);
|
||||
const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage);
|
||||
const advanceRule = toText(item.advanceRule);
|
||||
const title = toText(item.title);
|
||||
const summary = toText(item.summary);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || `saved-scene-act-${sceneId}-${index + 1}`,
|
||||
sceneId,
|
||||
title: title || `第 ${index + 1} 幕`,
|
||||
summary: summary || title || `围绕${sceneId}继续推进`,
|
||||
stageCoverage:
|
||||
stageCoverage.length > 0
|
||||
? stageCoverage
|
||||
: index === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: toText(item.backgroundImageSrc) || undefined,
|
||||
backgroundAssetId: toText(item.backgroundAssetId) || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '',
|
||||
linkedThreadIds: toStringArray(item.linkedThreadIds),
|
||||
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
|
||||
? (advanceRule as SceneActBlueprint['advanceRule'])
|
||||
: 'after_active_step_complete',
|
||||
actGoal: toText(item.actGoal),
|
||||
transitionHook: toText(item.transitionHook),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.filter(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),
|
||||
)
|
||||
.map((entry, index) => {
|
||||
const sceneId = toText(entry.sceneId);
|
||||
if (!sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const acts = Array.isArray(entry.acts)
|
||||
? entry.acts
|
||||
.map((act, actIndex) =>
|
||||
normalizeSceneActBlueprint(act, actIndex, sceneId),
|
||||
)
|
||||
.filter((act): act is SceneActBlueprint => Boolean(act))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: toText(entry.id) || `saved-scene-chapter-${sceneId}-${index + 1}`,
|
||||
sceneId,
|
||||
title: toText(entry.title) || toText(entry.sceneName) || sceneId,
|
||||
summary: toText(entry.summary),
|
||||
linkedThreadIds: toStringArray(entry.linkedThreadIds),
|
||||
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
|
||||
acts,
|
||||
} satisfies SceneChapterBlueprint;
|
||||
})
|
||||
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeLandmarks(params: {
|
||||
landmarks: Array<Record<string, unknown>>;
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
@@ -1655,6 +1768,9 @@ export function normalizeCustomWorldProfile(
|
||||
Array.isArray(item.threadContracts)
|
||||
? (item.threadContracts as Array<Record<string, unknown>>)
|
||||
: null,
|
||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||
item.sceneChapterBlueprints,
|
||||
),
|
||||
scenarioPackId: toText(item.scenarioPackId) || null,
|
||||
campaignPackId: toText(item.campaignPackId) || null,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
@@ -29,6 +30,9 @@ import {
|
||||
} from '../story/runtimeSession.js';
|
||||
|
||||
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'npc_chat_quest_offer_abandon',
|
||||
'npc_chat_quest_offer_replace',
|
||||
'npc_chat_quest_offer_view',
|
||||
'npc_quest_accept',
|
||||
'npc_quest_turn_in',
|
||||
]);
|
||||
@@ -37,6 +41,9 @@ type QuestStoryResolution = {
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
storyText?: string;
|
||||
presentationOptions?: RuntimeStoryOptionView[];
|
||||
savedCurrentStory?: JsonRecord;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -140,6 +147,144 @@ function readPendingQuestOffer(
|
||||
return quest as RuntimeQuestLogEntry;
|
||||
}
|
||||
|
||||
function readPendingQuestOfferContext(
|
||||
currentStory: unknown,
|
||||
npcKey: string,
|
||||
) {
|
||||
if (!isObject(currentStory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcChatState = isObject(currentStory.npcChatState)
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
|
||||
? npcChatState.pendingQuestOffer
|
||||
: null;
|
||||
const quest = readPendingQuestOffer(currentStory, npcKey);
|
||||
|
||||
if (!quest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dialogue = Array.isArray(currentStory.dialogue)
|
||||
? currentStory.dialogue
|
||||
.filter((entry) => isObject(entry))
|
||||
.map((entry) => ({ ...entry }))
|
||||
: [];
|
||||
const turnCount =
|
||||
typeof npcChatState?.turnCount === 'number' &&
|
||||
Number.isFinite(npcChatState.turnCount)
|
||||
? Math.max(0, Math.round(npcChatState.turnCount))
|
||||
: 0;
|
||||
const customInputPlaceholder =
|
||||
readString(npcChatState?.customInputPlaceholder) || '输入你想对 TA 说的话';
|
||||
|
||||
return {
|
||||
dialogue,
|
||||
turnCount,
|
||||
customInputPlaceholder,
|
||||
quest,
|
||||
introText: readString(pendingQuestOffer?.introText),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNpcChatOption(
|
||||
encounter: RuntimeEncounter,
|
||||
actionText: string,
|
||||
) {
|
||||
return {
|
||||
functionId: 'npc_chat',
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'chat',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} satisfies JsonRecord;
|
||||
}
|
||||
|
||||
function buildPendingQuestOfferOptions(encounter: RuntimeEncounter) {
|
||||
const npcId = encounter.id ?? encounter.npcName;
|
||||
const buildOption = (
|
||||
functionId:
|
||||
| 'npc_chat_quest_offer_view'
|
||||
| 'npc_chat_quest_offer_replace'
|
||||
| 'npc_chat_quest_offer_abandon',
|
||||
actionText: string,
|
||||
action: 'quest_offer_view' | 'quest_offer_replace' | 'quest_offer_abandon',
|
||||
) =>
|
||||
({
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
detailText: '',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId,
|
||||
action,
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
runtimePayload:
|
||||
functionId === 'npc_chat_quest_offer_view'
|
||||
? { npcChatQuestOfferAction: 'view' }
|
||||
: functionId === 'npc_chat_quest_offer_replace'
|
||||
? { npcChatQuestOfferAction: 'replace' }
|
||||
: { npcChatQuestOfferAction: 'abandon' },
|
||||
}) satisfies JsonRecord;
|
||||
|
||||
return [
|
||||
buildOption('npc_chat_quest_offer_view', '查看任务', 'quest_offer_view'),
|
||||
buildOption(
|
||||
'npc_chat_quest_offer_replace',
|
||||
'更换任务',
|
||||
'quest_offer_replace',
|
||||
),
|
||||
buildOption(
|
||||
'npc_chat_quest_offer_abandon',
|
||||
'放弃任务',
|
||||
'quest_offer_abandon',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildPostQuestOfferChatOptions(encounter: RuntimeEncounter) {
|
||||
return [
|
||||
'那先继续聊聊你刚才没说完的部分',
|
||||
'除了委托,你对眼前局势还有什么判断',
|
||||
'先把这附近真正危险的地方说清楚',
|
||||
].map((actionText) => buildNpcChatOption(encounter, actionText));
|
||||
}
|
||||
|
||||
function buildQuestOfferDialogueText(
|
||||
encounter: RuntimeEncounter,
|
||||
quest: RuntimeQuestLogEntry,
|
||||
) {
|
||||
const summaryText = readString(quest.summary) || readString(quest.description);
|
||||
return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
|
||||
summaryText
|
||||
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
|
||||
: '如果你愿意,我想把眼前这件事正式交给你。'
|
||||
}`;
|
||||
}
|
||||
|
||||
function ensureEncounterQuestContext(session: RuntimeSession) {
|
||||
const state = session.rawGameState as unknown as RuntimeGameState;
|
||||
const encounter = getNpcEncounter(session, state);
|
||||
@@ -225,6 +370,171 @@ function resolveQuestAcceptAction(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuestOfferViewAction(
|
||||
session: RuntimeSession,
|
||||
currentStory?: unknown,
|
||||
): QuestStoryResolution {
|
||||
const { encounter, npcKey } = ensureEncounterQuestContext(session);
|
||||
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
|
||||
if (!pendingOffer) {
|
||||
throw conflict('当前没有待处理的委托可查看。');
|
||||
}
|
||||
|
||||
return {
|
||||
actionText: `查看${encounter.npcName}提出的委托`,
|
||||
resultText: readString(pendingOffer.introText) || buildQuestOfferDialogueText(encounter, pendingOffer.quest),
|
||||
patches: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuestOfferReplaceAction(
|
||||
session: RuntimeSession,
|
||||
currentStory?: unknown,
|
||||
): QuestStoryResolution {
|
||||
const { state, encounter, npcKey } = ensureEncounterQuestContext(session);
|
||||
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
|
||||
if (!pendingOffer) {
|
||||
throw conflict('当前没有待处理的委托可更换。');
|
||||
}
|
||||
|
||||
const nextQuest = buildQuestForEncounter({
|
||||
issuerNpcId: npcKey,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
context: {
|
||||
worldType: state.worldType,
|
||||
recentStoryMoments: Array.isArray(state.storyHistory)
|
||||
? state.storyHistory.slice(-6)
|
||||
: [],
|
||||
playerCharacter: state.playerCharacter ?? null,
|
||||
playerProgression: state.playerProgression ?? null,
|
||||
},
|
||||
currentQuests: (Array.isArray(state.quests) ? state.quests : []).map((item) => ({
|
||||
id: item.id,
|
||||
issuerNpcId: item.issuerNpcId,
|
||||
status: item.status,
|
||||
})),
|
||||
});
|
||||
|
||||
if (!nextQuest) {
|
||||
throw conflict('当前没有更合适的委托可供更换。');
|
||||
}
|
||||
|
||||
const dialogue = [
|
||||
...pendingOffer.dialogue,
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '能不能换一份更适合眼下局势的委托?',
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: buildQuestOfferDialogueText(encounter, nextQuest),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
actionText: `请${encounter.npcName}更换委托`,
|
||||
resultText: buildQuestOfferDialogueText(encounter, nextQuest),
|
||||
storyText: buildQuestOfferDialogueText(encounter, nextQuest),
|
||||
savedCurrentStory: {
|
||||
text: dialogue
|
||||
.map((entry) => readString(entry.text))
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
options: buildPendingQuestOfferOptions(encounter),
|
||||
displayMode: 'dialogue',
|
||||
dialogue,
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: npcKey,
|
||||
npcName: encounter.npcName,
|
||||
turnCount: pendingOffer.turnCount,
|
||||
customInputPlaceholder: pendingOffer.customInputPlaceholder,
|
||||
pendingQuestOffer: {
|
||||
quest: nextQuest,
|
||||
},
|
||||
},
|
||||
},
|
||||
presentationOptions: buildPendingQuestOfferOptions(encounter).map((option) => ({
|
||||
functionId: readString(option.functionId),
|
||||
actionText: readString(option.actionText),
|
||||
detailText: '',
|
||||
scope: 'npc',
|
||||
interaction: isObject(option.interaction)
|
||||
? (option.interaction as RuntimeStoryOptionView['interaction'])
|
||||
: undefined,
|
||||
payload: isObject(option.runtimePayload)
|
||||
? (option.runtimePayload as Record<string, unknown>)
|
||||
: undefined,
|
||||
})),
|
||||
patches: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuestOfferAbandonAction(
|
||||
session: RuntimeSession,
|
||||
currentStory?: unknown,
|
||||
): QuestStoryResolution {
|
||||
const { encounter, npcKey } = ensureEncounterQuestContext(session);
|
||||
const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey);
|
||||
if (!pendingOffer) {
|
||||
throw conflict('当前没有待处理的委托可放弃。');
|
||||
}
|
||||
|
||||
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
|
||||
const dialogue = [
|
||||
...pendingOffer.dialogue,
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '这件事我先不接,咱们还是先聊别的。',
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: npcReply,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
actionText: `暂不接受${encounter.npcName}的委托`,
|
||||
resultText: npcReply,
|
||||
storyText: npcReply,
|
||||
savedCurrentStory: {
|
||||
text: dialogue
|
||||
.map((entry) => readString(entry.text))
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
options: buildPostQuestOfferChatOptions(encounter),
|
||||
displayMode: 'dialogue',
|
||||
dialogue,
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: npcKey,
|
||||
npcName: encounter.npcName,
|
||||
turnCount: pendingOffer.turnCount,
|
||||
customInputPlaceholder: pendingOffer.customInputPlaceholder,
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
},
|
||||
presentationOptions: buildPostQuestOfferChatOptions(encounter).map((option) => ({
|
||||
functionId: readString(option.functionId),
|
||||
actionText: readString(option.actionText),
|
||||
detailText: '',
|
||||
scope: 'npc',
|
||||
interaction: isObject(option.interaction)
|
||||
? (option.interaction as RuntimeStoryOptionView['interaction'])
|
||||
: undefined,
|
||||
payload: isObject(option.runtimePayload)
|
||||
? (option.runtimePayload as Record<string, unknown>)
|
||||
: undefined,
|
||||
})),
|
||||
patches: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuestTurnInAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
@@ -311,6 +621,12 @@ export function resolveQuestStoryAction(
|
||||
} = {},
|
||||
): QuestStoryResolution {
|
||||
switch (request.action.functionId) {
|
||||
case 'npc_chat_quest_offer_view':
|
||||
return resolveQuestOfferViewAction(session, options.currentStory);
|
||||
case 'npc_chat_quest_offer_replace':
|
||||
return resolveQuestOfferReplaceAction(session, options.currentStory);
|
||||
case 'npc_chat_quest_offer_abandon':
|
||||
return resolveQuestOfferAbandonAction(session, options.currentStory);
|
||||
case 'npc_quest_accept':
|
||||
return resolveQuestAcceptAction(session, options.currentStory);
|
||||
case 'npc_quest_turn_in':
|
||||
|
||||
@@ -738,6 +738,21 @@ function buildOptionInteraction(
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_chat_quest_offer_view: {
|
||||
kind: 'npc',
|
||||
npcId,
|
||||
action: 'quest_offer_view',
|
||||
},
|
||||
npc_chat_quest_offer_replace: {
|
||||
kind: 'npc',
|
||||
npcId,
|
||||
action: 'quest_offer_replace',
|
||||
},
|
||||
npc_chat_quest_offer_abandon: {
|
||||
kind: 'npc',
|
||||
npcId,
|
||||
action: 'quest_offer_abandon',
|
||||
},
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryStateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { AppContext } from '../../context.js';
|
||||
import { badRequest } from '../../errors.js';
|
||||
import { asyncHandler, sendApiResponse } from '../../http.js';
|
||||
@@ -17,6 +20,7 @@ const actionPayloadSchema = z.record(z.string(), z.unknown());
|
||||
const runtimeStoryActionSchema = z.object({
|
||||
sessionId: z.string().trim().min(1),
|
||||
clientVersion: z.number().int().min(0).optional(),
|
||||
snapshot: z.unknown().optional(),
|
||||
action: z.object({
|
||||
type: z.literal('story_choice'),
|
||||
functionId: z.string().trim().min(1),
|
||||
@@ -25,6 +29,12 @@ const runtimeStoryActionSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const runtimeStoryStateResolveSchema = z.object({
|
||||
sessionId: z.string().trim().min(1),
|
||||
clientVersion: z.number().int().min(0).optional(),
|
||||
snapshot: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export function createStoryActionRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
@@ -70,5 +80,25 @@ export function createStoryActionRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/state/resolve',
|
||||
routeMeta({ operation: 'runtime.story.state.resolve' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = runtimeStoryStateResolveSchema.parse(
|
||||
request.body,
|
||||
) as RuntimeStoryStateRequest;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await getRuntimeStoryState({
|
||||
runtimeRepository: context.runtimeRepository,
|
||||
userId: request.userId!,
|
||||
sessionId: payload.sessionId,
|
||||
clientVersion: payload.clientVersion,
|
||||
snapshot: payload.snapshot,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch,
|
||||
RuntimeStoryStateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
|
||||
@@ -59,6 +60,8 @@ type StoryResolution = {
|
||||
resultText: string;
|
||||
patches: RuntimeStoryPatch[];
|
||||
storyText?: string;
|
||||
presentationOptions?: RuntimeStoryOptionView[];
|
||||
savedCurrentStory?: JsonRecord;
|
||||
battle?: RuntimeBattlePresentation | null;
|
||||
toast?: string | null;
|
||||
};
|
||||
@@ -604,6 +607,48 @@ function readSavedStoryText(currentStory: unknown) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeIncomingSnapshot(snapshot: unknown) {
|
||||
if (!isObject(snapshot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gameState = 'gameState' in snapshot ? snapshot.gameState : null;
|
||||
const bottomTab = readString(snapshot.bottomTab) || 'adventure';
|
||||
const currentStory = 'currentStory' in snapshot ? snapshot.currentStory : null;
|
||||
const savedAt = readString(snapshot.savedAt) || new Date().toISOString();
|
||||
|
||||
if (!gameState || !isObject(gameState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSavedSnapshotPayload({
|
||||
savedAt,
|
||||
bottomTab,
|
||||
gameState,
|
||||
currentStory: currentStory ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSnapshotForRequest(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
userId: string;
|
||||
snapshot?: unknown;
|
||||
}) {
|
||||
const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot);
|
||||
if (incomingSnapshot) {
|
||||
return hydrateSavedSnapshot(
|
||||
await params.runtimeRepository.putSnapshot(params.userId, incomingSnapshot),
|
||||
)!;
|
||||
}
|
||||
|
||||
const persistedSnapshot = await params.runtimeRepository.getSnapshot(params.userId);
|
||||
if (!persistedSnapshot) {
|
||||
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
|
||||
}
|
||||
|
||||
return hydrateSavedSnapshot(persistedSnapshot)!;
|
||||
}
|
||||
|
||||
function buildFallbackStoryText(session: RuntimeSession) {
|
||||
if (session.inBattle && session.sceneHostileNpcs.length > 0) {
|
||||
return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`;
|
||||
@@ -860,11 +905,11 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
userId: string;
|
||||
request: RuntimeStoryActionRequest;
|
||||
}) {
|
||||
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
|
||||
if (!snapshot) {
|
||||
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
|
||||
}
|
||||
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
|
||||
const hydratedSnapshot = await resolveSnapshotForRequest({
|
||||
runtimeRepository: params.runtimeRepository,
|
||||
userId: params.userId,
|
||||
snapshot: params.request.snapshot,
|
||||
});
|
||||
|
||||
const functionId =
|
||||
typeof params.request.action.functionId === 'string'
|
||||
@@ -968,6 +1013,12 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
storyText,
|
||||
options,
|
||||
);
|
||||
if (resolution.presentationOptions?.length) {
|
||||
options = resolution.presentationOptions;
|
||||
}
|
||||
if (resolution.savedCurrentStory) {
|
||||
savedCurrentStory = resolution.savedCurrentStory;
|
||||
}
|
||||
const pendingQuestAcceptedCurrentStory =
|
||||
functionId === 'npc_quest_accept'
|
||||
? buildPendingQuestAcceptedCurrentStory({
|
||||
@@ -1061,14 +1112,25 @@ export async function getRuntimeStoryState(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
clientVersion?: number;
|
||||
snapshot?: RuntimeStoryStateRequest['snapshot'];
|
||||
}) {
|
||||
const snapshot = await params.runtimeRepository.getSnapshot(params.userId);
|
||||
if (!snapshot) {
|
||||
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
|
||||
}
|
||||
const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!;
|
||||
const hydratedSnapshot = await resolveSnapshotForRequest({
|
||||
runtimeRepository: params.runtimeRepository,
|
||||
userId: params.userId,
|
||||
snapshot: params.snapshot,
|
||||
});
|
||||
|
||||
const session = loadRuntimeSession(hydratedSnapshot, params.sessionId);
|
||||
if (
|
||||
typeof params.clientVersion === 'number' &&
|
||||
params.clientVersion !== session.runtimeVersion
|
||||
) {
|
||||
throw conflict('运行时版本已变化,请先同步最新快照后再读取状态', {
|
||||
clientVersion: params.clientVersion,
|
||||
serverVersion: session.runtimeVersion,
|
||||
});
|
||||
}
|
||||
ensureNpcInventorySessionState(session);
|
||||
const options = buildAvailableOptions(session);
|
||||
const storyText =
|
||||
|
||||
Reference in New Issue
Block a user