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

@@ -0,0 +1,84 @@
import type { Request, Response } from 'express';
import type { AppConfig } from '../config.js';
function buildCookieParts(
config: AppConfig,
value: string,
options: {
maxAgeSeconds: number;
},
) {
const parts = [
`${config.authSession.accessCookieName}=${encodeURIComponent(value)}`,
`Path=${config.authSession.accessCookiePath}`,
'HttpOnly',
`SameSite=${config.authSession.accessCookieSameSite}`,
`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`,
];
if (config.authSession.accessCookieSecure) {
parts.push('Secure');
}
return parts.join('; ');
}
function appendSetCookieHeader(response: Response, cookieValue: string) {
const currentHeader = response.getHeader('Set-Cookie');
if (!currentHeader) {
response.setHeader('Set-Cookie', cookieValue);
return;
}
if (Array.isArray(currentHeader)) {
response.setHeader('Set-Cookie', [...currentHeader, cookieValue]);
return;
}
response.setHeader('Set-Cookie', [String(currentHeader), cookieValue]);
}
export function setAccessSessionCookie(
response: Response,
config: AppConfig,
token: string,
maxAgeSeconds: number,
) {
appendSetCookieHeader(
response,
buildCookieParts(config, token, {
maxAgeSeconds,
}),
);
}
export function clearAccessSessionCookie(response: Response, config: AppConfig) {
appendSetCookieHeader(
response,
buildCookieParts(config, '', {
maxAgeSeconds: 0,
}),
);
}
export function readAccessSessionToken(request: Request, config: AppConfig) {
const cookieHeader = request.header('cookie')?.trim() || '';
if (!cookieHeader) {
return '';
}
const cookieEntries = cookieHeader.split(';');
for (const entry of cookieEntries) {
const [rawName, ...valueParts] = entry.split('=');
const name = rawName?.trim();
if (name !== config.authSession.accessCookieName) {
continue;
}
const rawValue = valueParts.join('=').trim();
return rawValue ? decodeURIComponent(rawValue) : '';
}
return '';
}

View File

@@ -74,6 +74,11 @@ export type AppConfig = {
mockAvatarUrl: string;
};
authSession: {
accessCookieName: string;
accessCookieTtlSeconds: number;
accessCookieSecure: boolean;
accessCookieSameSite: 'Lax' | 'Strict' | 'None';
accessCookiePath: string;
refreshCookieName: string;
refreshSessionTtlDays: number;
refreshCookieSecure: boolean;
@@ -274,6 +279,11 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
'AUTH_REFRESH_COOKIE_SAME_SITE',
'Lax',
);
const accessSameSite = readString(
env,
'AUTH_ACCESS_COOKIE_SAME_SITE',
'Lax',
);
return {
nodeEnv,
@@ -484,6 +494,30 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''),
},
authSession: {
accessCookieName: readString(
env,
'AUTH_ACCESS_COOKIE_NAME',
'genarrative_access_session',
),
accessCookieTtlSeconds: readPositiveInt(
env,
'AUTH_ACCESS_COOKIE_TTL_SECONDS',
7200,
),
accessCookieSecure: readBoolean(
env,
'AUTH_ACCESS_COOKIE_SECURE',
readString(env, 'NODE_ENV', 'development') === 'production',
),
accessCookieSameSite:
accessSameSite === 'None' || accessSameSite === 'Strict'
? (accessSameSite as AppConfig['authSession']['accessCookieSameSite'])
: 'Lax',
accessCookiePath: readString(
env,
'AUTH_ACCESS_COOKIE_PATH',
'/',
),
refreshCookieName: readString(
env,
'AUTH_REFRESH_COOKIE_NAME',

View File

@@ -1,5 +1,6 @@
import type { NextFunction, Request, Response } from 'express';
import { readAccessSessionToken } from '../auth/accessSessionCookie.js';
import { verifyAccessToken } from '../auth/token.js';
import type { AppConfig } from '../config.js';
import { unauthorized } from '../errors.js';
@@ -16,9 +17,10 @@ function readBearerToken(request: Request) {
export function requireJwtAuth(config: AppConfig, userRepository: UserRepository) {
return async (request: Request, _response: Response, next: NextFunction) => {
try {
const token = readBearerToken(request);
const token =
readBearerToken(request) || readAccessSessionToken(request, config);
if (!token) {
throw unauthorized('缺少 Authorization Bearer Token');
throw unauthorized('缺少登录凭证');
}
const claims = await verifyAccessToken(token, config);

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 =

View File

@@ -1,4 +1,4 @@
import { type Request, Router } from 'express';
import { type Request, type Response, Router } from 'express';
import { z } from 'zod';
import type {
@@ -30,6 +30,10 @@ import {
sendPhoneLoginCode,
startWechatLogin,
} from '../auth/authService.js';
import {
clearAccessSessionCookie,
setAccessSessionCookie,
} from '../auth/accessSessionCookie.js';
import {
clearRefreshSessionCookie,
readRefreshSessionToken,
@@ -112,6 +116,23 @@ function buildRefreshCookieLifetimeSeconds(
);
}
function buildAccessCookieLifetimeSeconds(context: AppContext) {
return Math.max(0, context.config.authSession.accessCookieTtlSeconds);
}
async function writeAccessSessionCookie(
context: AppContext,
response: Response,
token: string,
) {
setAccessSessionCookie(
response,
context.config,
token,
buildAccessCookieLifetimeSeconds(context),
);
}
export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
@@ -145,6 +166,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -223,6 +245,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -298,6 +321,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -309,7 +333,6 @@ export function createAuthRoutes(context: AppContext) {
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_token: result.token,
auth_binding_status: result.user.bindingStatus,
}),
);
@@ -352,6 +375,7 @@ export function createAuthRoutes(context: AppContext) {
user,
requestContext,
);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -369,6 +393,7 @@ export function createAuthRoutes(context: AppContext) {
const refreshToken = readRefreshSessionToken(request, context.config);
try {
const result = await refreshAuthSession(context, refreshToken);
await writeAccessSessionCookie(context, response, result.token);
setRefreshSessionCookie(
response,
context.config,
@@ -376,9 +401,11 @@ export function createAuthRoutes(context: AppContext) {
buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt),
);
sendApiResponse(response, {
ok: true,
token: result.token,
});
} catch (error) {
clearAccessSessionCookie(response, context.config);
clearRefreshSessionCookie(response, context.config);
throw error;
}
@@ -479,6 +506,7 @@ export function createAuthRoutes(context: AppContext) {
routeMeta({ operation: 'auth.logout_all' }),
requireAuth,
asyncHandler(async (request, response) => {
clearAccessSessionCookie(response, context.config);
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
@@ -498,6 +526,7 @@ export function createAuthRoutes(context: AppContext) {
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
await revokeRefreshSession(context, refreshToken);
clearAccessSessionCookie(response, context.config);
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,

View File

@@ -7,6 +7,7 @@ import type {
CustomWorldGalleryResponse,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
GenerateCustomWorldProfileInput,
PlatformBrowseHistoryBatchSyncRequest,
PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry,
@@ -50,6 +51,7 @@ import {
streamNpcChatTurnFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import { generateCustomWorldProfileFromOrchestrator } from '../modules/ai/customWorldOrchestrator.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
@@ -118,6 +120,12 @@ const customWorldProfileSchema = z.object({
profile: jsonObjectSchema,
});
const customWorldProfileGenerationSchema = z.object({
settingText: z.string().trim().min(1),
creatorIntent: jsonObjectSchema.nullish(),
generationMode: z.enum(['fast', 'full']).optional(),
});
const customWorldSceneNpcSchema = z.object({
profile: jsonObjectSchema,
landmarkId: z.string().trim().min(1),
@@ -600,6 +608,23 @@ export function createRuntimeRoutes(context: AppContext) {
}),
);
router.post(
'/runtime/custom-world/profile',
routeMeta({ operation: 'runtime.customWorld.profile' }),
asyncHandler(async (request, response) => {
const payload = customWorldProfileGenerationSchema.parse(
request.body,
) as GenerateCustomWorldProfileInput;
sendApiResponse(
response,
await generateCustomWorldProfileFromOrchestrator(
context.llmClient,
payload,
),
);
}),
);
router.post(
'/runtime/custom-world-library/:profileId/publish',
routeMeta({ operation: 'runtime.customWorldLibrary.publish' }),

View File

@@ -198,6 +198,116 @@ function buildRoleAssetSyncResultText(params: {
return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}`;
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => isRecord(item))
: [];
}
function cloneJsonRecord<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function syncRoleAssetsFromResultProfile(params: {
currentRoles: unknown;
resultRoles: unknown;
}) {
const resultRoleById = new Map(
toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]),
);
return toRecordArray(params.currentRoles).map((currentRole) => {
const resultRole = resultRoleById.get(toText(currentRole.id));
if (!resultRole) {
return currentRole;
}
return {
...currentRole,
imageSrc: toText(resultRole.imageSrc) || null,
generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null,
generatedAnimationSetId:
toText(resultRole.generatedAnimationSetId) || null,
animationMap: isRecord(resultRole.animationMap)
? cloneJsonRecord(resultRole.animationMap)
: null,
} satisfies Record<string, unknown>;
});
}
function syncLandmarkAssetsFromResultProfile(params: {
currentLandmarks: unknown;
resultLandmarks: unknown;
}) {
const resultLandmarkById = new Map(
toRecordArray(params.resultLandmarks).map((landmark) => [
toText(landmark.id),
landmark,
]),
);
return toRecordArray(params.currentLandmarks).map((currentLandmark) => {
const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id));
if (!resultLandmark) {
return currentLandmark;
}
return {
...currentLandmark,
imageSrc: toText(resultLandmark.imageSrc) || null,
} satisfies Record<string, unknown>;
});
}
function syncSceneChapterAssetsFromResultProfile(params: {
currentSceneChapters: unknown;
resultSceneChapters: unknown;
}) {
const resultSceneChapterBySceneId = new Map(
toRecordArray(params.resultSceneChapters).map((chapter) => [
toText(chapter.sceneId),
chapter,
]),
);
return toRecordArray(params.currentSceneChapters).map((currentChapter) => {
const resultChapter = resultSceneChapterBySceneId.get(
toText(currentChapter.sceneId),
);
if (!resultChapter) {
return currentChapter;
}
const resultActById = new Map(
toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]),
);
return {
...currentChapter,
acts: toRecordArray(currentChapter.acts).map((currentAct) => {
const resultAct = resultActById.get(toText(currentAct.id));
if (!resultAct) {
return currentAct;
}
return {
...currentAct,
backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null,
backgroundAssetId: toText(resultAct.backgroundAssetId) || null,
} satisfies Record<string, unknown>;
}),
} satisfies Record<string, unknown>;
});
}
function syncResultProfileIntoDraftProfile(params: {
currentDraftProfile: Record<string, unknown> | null | undefined;
resultProfile: CustomWorldProfile;
@@ -215,6 +325,22 @@ function syncResultProfileIntoDraftProfile(params: {
playerGoal: resultProfile.playerGoal,
majorFactions: resultProfile.majorFactions,
coreConflicts: resultProfile.coreConflicts,
playableNpcs: syncRoleAssetsFromResultProfile({
currentRoles: currentDraftProfile.playableNpcs,
resultRoles: resultProfile.playableNpcs,
}),
storyNpcs: syncRoleAssetsFromResultProfile({
currentRoles: currentDraftProfile.storyNpcs,
resultRoles: resultProfile.storyNpcs,
}),
landmarks: syncLandmarkAssetsFromResultProfile({
currentLandmarks: currentDraftProfile.landmarks,
resultLandmarks: resultProfile.landmarks,
}),
sceneChapters: syncSceneChapterAssetsFromResultProfile({
currentSceneChapters: currentDraftProfile.sceneChapters,
resultSceneChapters: resultProfile.sceneChapterBlueprints,
}),
legacyResultProfile: resultProfile as unknown as Record<string, unknown>,
} satisfies Record<string, unknown>;
}

View File

@@ -421,6 +421,162 @@ test('phase4 sync_result_profile keeps existing foundation structure while updat
);
});
test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
});
const userId = 'user-phase4-sync-result-profile-assets';
const session = await createObjectRefiningSession(orchestrator, userId);
const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!;
const playableRole = baselineProfile.playableNpcs[0]!;
const storyRole = baselineProfile.storyNpcs[0]!;
const landmark = baselineProfile.landmarks[0]!;
const response = await orchestrator.executeAction(userId, session.sessionId, {
action: 'sync_result_profile',
profile: {
id: `agent-draft-${session.sessionId}`,
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·结果页精修版',
subtitle: '旧灯塔与失控航路',
summary: '结果页已经把最新图与动作一起确认。 ',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与假航灯的真正操盘者。',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '潮雾列岛·结果页精修版',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: playableRole.id,
name: playableRole.name,
title: '结果页角色',
role: '关键同行者',
description: '结果页确认的最新角色资产。',
backstory: '测试',
personality: '冷静',
motivation: '验证资产回写',
combatStyle: '观察',
initialAffinity: 12,
relationshipHooks: [],
tags: [],
imageSrc: '/generated/playable/latest-master.png',
generatedVisualAssetId: 'visual-playable-latest',
generatedAnimationSetId: 'anim-playable-latest',
animationMap: {
idle: {
spriteSheetPath: '/generated/playable/idle.png',
},
},
},
],
storyNpcs: [
{
id: storyRole.id,
name: storyRole.name,
title: '结果页场景角色',
role: '场景关键角色',
description: '结果页确认的最新场景角色资产。',
backstory: '测试',
personality: '克制',
motivation: '验证资产回写',
combatStyle: '观察',
initialAffinity: 6,
relationshipHooks: [],
tags: [],
imageSrc: '/generated/story/latest-master.png',
generatedVisualAssetId: 'visual-story-latest',
},
],
items: [],
landmarks: [
{
id: landmark.id,
name: landmark.name,
description: '结果页确认的最新地点图。',
dangerLevel: '中',
sceneNpcIds: [],
connections: [],
imageSrc: '/generated/landmark/latest-scene.png',
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: landmark.id,
title: '灯塔初章',
summary: '结果页确认最新分幕图。',
linkedThreadIds: [],
linkedLandmarkIds: [landmark.id],
acts: [
{
id: `${landmark.id}-act-1`,
sceneId: landmark.id,
title: '第一幕',
summary: '第一幕',
stageCoverage: ['opening'],
backgroundImageSrc: '/generated/scene/act-1-latest.png',
backgroundAssetId: 'scene-asset-latest',
encounterNpcIds: [],
primaryNpcId: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '验证分幕图回写',
transitionHook: '进入下一幕',
},
],
},
],
generationMode: 'full',
generationStatus: 'complete',
},
});
const operation = await waitForOperation(
orchestrator,
userId,
session.sessionId,
response.operation.operationId,
);
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!;
const syncedPlayable = profile.playableNpcs.find(
(entry) => entry.id === playableRole.id,
);
const syncedStory = profile.storyNpcs.find((entry) => entry.id === storyRole.id);
const syncedLandmark = profile.landmarks.find((entry) => entry.id === landmark.id);
const syncedSceneAct = profile.sceneChapters[0]?.acts[0];
assert.equal(operation?.status, 'completed');
assert.equal(syncedPlayable?.imageSrc, '/generated/playable/latest-master.png');
assert.equal(syncedPlayable?.generatedVisualAssetId, 'visual-playable-latest');
assert.equal(syncedPlayable?.generatedAnimationSetId, 'anim-playable-latest');
assert.deepEqual(syncedPlayable?.animationMap, {
idle: {
spriteSheetPath: '/generated/playable/idle.png',
},
});
assert.equal(syncedStory?.imageSrc, '/generated/story/latest-master.png');
assert.equal(syncedStory?.generatedVisualAssetId, 'visual-story-latest');
assert.equal(syncedLandmark?.imageSrc, '/generated/landmark/latest-scene.png');
assert.equal(syncedSceneAct?.backgroundImageSrc, '/generated/scene/act-1-latest.png');
assert.equal(syncedSceneAct?.backgroundAssetId, 'scene-asset-latest');
});
test('phase4 generate_characters appends story npcs and updates work summary counts', async () => {
const runtimeRepository = createRuntimeRepositoryStub();
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);

View File

@@ -368,10 +368,15 @@ function buildCompatibleSuggestedActions(params: {
record: CustomWorldAgentSessionRecord;
stage: CustomWorldAgentStage;
readiness: CreatorIntentReadiness;
draftProfile: Record<string, unknown>;
}) {
if (params.record.suggestedActions.length > 0) {
return params.record.suggestedActions;
// 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。
const compatibleActions = params.record.suggestedActions.filter(
(action) => action.type !== 'refine_focus_target',
);
if (compatibleActions.length > 0) {
return compatibleActions;
}
}
const actions: CustomWorldSuggestedAction[] = [
@@ -384,16 +389,6 @@ function buildCompatibleSuggestedActions(params: {
: '总结当前设定',
},
];
const playableNpcs = Array.isArray(params.draftProfile.playableNpcs)
? params.draftProfile.playableNpcs
: [];
const storyNpcs = Array.isArray(params.draftProfile.storyNpcs)
? params.draftProfile.storyNpcs
: [];
const landmarks = Array.isArray(params.draftProfile.landmarks)
? params.draftProfile.landmarks
: [];
if (params.stage === 'foundation_review' && params.readiness.isReady) {
actions.push({
id: 'draft_foundation',
@@ -403,36 +398,6 @@ function buildCompatibleSuggestedActions(params: {
return actions;
}
if (params.stage === 'object_refining' || params.stage === 'visual_refining') {
const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]);
const firstLandmark = toRecord(landmarks[0]);
actions.push({
id: 'refine_world',
type: 'refine_focus_target',
label: '先看世界总卡',
targetId: 'world-foundation',
});
if (firstCharacter) {
actions.push({
id: `refine-character-${toText(firstCharacter.id) || 'seed'}`,
type: 'refine_focus_target',
label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`,
targetId: toText(firstCharacter.id) || null,
});
}
if (firstLandmark) {
actions.push({
id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`,
type: 'refine_focus_target',
label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`,
targetId: toText(firstLandmark.id) || null,
});
}
}
return actions;
}
@@ -533,7 +498,6 @@ function applyCompatibility(record: CustomWorldAgentSessionRecord) {
record,
stage,
readiness: creatorIntentReadiness,
draftProfile,
}),
assetCoverage: buildCompatibleAssetCoverage(record, draftProfile),
recommendedReplies: normalizeRecommendedReplies(

View File

@@ -55,7 +55,7 @@ function formatDraftStageLabel(stage: CustomWorldAgentStage) {
if (stage === 'collecting_intent') return '收集世界锚点';
if (stage === 'clarifying') return '补齐关键锚点';
if (stage === 'foundation_review') return '准备整理底稿';
if (stage === 'object_refining') return '精修对象';
if (stage === 'object_refining') return '待完善草稿';
if (stage === 'visual_refining') return '视觉工坊';
if (stage === 'long_tail_review') return '扩展长尾';
if (stage === 'ready_to_publish') return '准备发布';