This commit is contained in:
2026-04-21 10:30:12 +08:00
parent ae28dab032
commit 13bc79306f
49 changed files with 3691 additions and 1357 deletions

View File

@@ -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,
};

View File

@@ -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':

View File

@@ -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' },
};

View File

@@ -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;
}

View File

@@ -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 =