1
This commit is contained in:
84
server-node/src/auth/accessSessionCookie.ts
Normal file
84
server-node/src/auth/accessSessionCookie.ts
Normal 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 '';
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '准备发布';
|
||||
|
||||
Reference in New Issue
Block a user