@@ -1802,10 +1802,17 @@ test('runtime persistence is isolated by user', async () => {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
musicVolume: 0.25,
|
||||
platformTheme: 'dark',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(settingsResponse.status, 200);
|
||||
const settingsPayload = (await settingsResponse.json()) as {
|
||||
musicVolume: number;
|
||||
platformTheme: 'light' | 'dark';
|
||||
};
|
||||
assert.equal(settingsPayload.musicVolume, 0.25);
|
||||
assert.equal(settingsPayload.platformTheme, 'dark');
|
||||
|
||||
const libraryResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-a`,
|
||||
@@ -1854,8 +1861,10 @@ test('runtime persistence is isolated by user', async () => {
|
||||
});
|
||||
const userBSettingsPayload = (await userBSettings.json()) as {
|
||||
musicVolume: number;
|
||||
platformTheme: 'light' | 'dark';
|
||||
};
|
||||
assert.equal(userBSettingsPayload.musicVolume, 0.42);
|
||||
assert.equal(userBSettingsPayload.platformTheme, 'light');
|
||||
|
||||
const userBLibrary = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library`,
|
||||
|
||||
@@ -112,6 +112,9 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'20260414_010_custom_world_gallery_metadata',
|
||||
'20260416_011_profile_dashboard_tables',
|
||||
'20260416_012_user_browse_history',
|
||||
'20260417_013_custom_world_profile_soft_delete',
|
||||
'20260419_014_profile_save_archives',
|
||||
'20260419_015_runtime_settings_platform_theme',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -130,6 +133,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'custom_world_sessions',
|
||||
'profile_dashboard_state',
|
||||
'profile_played_worlds',
|
||||
'profile_save_archives',
|
||||
'profile_wallet_ledger',
|
||||
'save_snapshots',
|
||||
'runtime_settings',
|
||||
@@ -149,6 +153,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
|
||||
'custom_world_sessions',
|
||||
'profile_dashboard_state',
|
||||
'profile_played_worlds',
|
||||
'profile_save_archives',
|
||||
'profile_wallet_ledger',
|
||||
'runtime_settings',
|
||||
'save_snapshots',
|
||||
|
||||
@@ -5,28 +5,34 @@ import type {
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
type NpcChatPendingQuestOffer,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import { prepareEventStreamResponse } from '../../http.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questService.js';
|
||||
import {
|
||||
applyQuestSignal,
|
||||
getQuestForIssuer,
|
||||
} from '../quest/questProgressionService.js';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
buildCharacterPanelChatSummaryPrompt,
|
||||
buildNpcChatTurnReplyPrompt,
|
||||
buildNpcChatTurnSuggestionPrompt,
|
||||
buildNpcRecruitDialoguePrompt,
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
buildNpcRecruitDialoguePrompt,
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
buildNpcChatTurnReplyPrompt,
|
||||
buildNpcChatTurnSuggestionPrompt,
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT,
|
||||
NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
} from './chatPromptBuilders.js';
|
||||
import { prepareEventStreamResponse } from '../../http.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
function writeSseEvent(
|
||||
response: Response,
|
||||
@@ -47,6 +53,14 @@ function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function countKeywordMatches(text: string, keywords: string[]) {
|
||||
return keywords.reduce(
|
||||
(count, keyword) => (text.includes(keyword) ? count + 1 : count),
|
||||
@@ -135,6 +149,98 @@ function buildFallbackNpcChatSuggestions(playerMessage: string) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildQuestOfferDialogueText(
|
||||
npcName: string,
|
||||
quest: Record<string, unknown>,
|
||||
) {
|
||||
const summaryText =
|
||||
readString(quest.summary) || readString(quest.description);
|
||||
|
||||
return `${npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
|
||||
summaryText
|
||||
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
|
||||
: '如果你愿意,我想把眼前这件事正式交给你。'
|
||||
}`;
|
||||
}
|
||||
|
||||
async function maybeBuildPendingNpcQuestOffer(
|
||||
llmClient: UpstreamLlmClient,
|
||||
payload: NpcChatTurnRequest,
|
||||
affinityDelta: number,
|
||||
): Promise<NpcChatPendingQuestOffer | null> {
|
||||
const questOfferContext = readRecord(payload.questOfferContext);
|
||||
const state = readRecord(questOfferContext?.state);
|
||||
const encounter = readRecord(questOfferContext?.encounter);
|
||||
if (!state || !encounter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcId = readString(encounter.id) || readString(encounter.npcName);
|
||||
const npcName = readString(encounter.npcName);
|
||||
const characterId = readString(encounter.characterId);
|
||||
if (!npcId || !npcName || !characterId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const turnCount = Math.max(
|
||||
0,
|
||||
Math.round(readNumber(questOfferContext?.turnCount, 0)),
|
||||
);
|
||||
const npcStates = readRecord(state.npcStates) ?? {};
|
||||
const currentNpcState = readRecord(npcStates[npcId]) ?? {};
|
||||
const currentQuests = readArray(state.quests) as Parameters<
|
||||
typeof getQuestForIssuer
|
||||
>[0];
|
||||
const questsAfterTalk = applyQuestSignal(currentQuests, {
|
||||
kind: 'npc_talk_completed',
|
||||
npcId,
|
||||
}).nextQuests;
|
||||
const nextAffinity = readNumber(currentNpcState.affinity, 0) + affinityDelta;
|
||||
const warmupTurns = nextAffinity >= 30 ? 1 : 2;
|
||||
|
||||
if (nextAffinity <= 0 || turnCount < warmupTurns) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (getQuestForIssuer(questsAfterTalk, npcId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
...state,
|
||||
quests: questsAfterTalk,
|
||||
npcStates: {
|
||||
...npcStates,
|
||||
[npcId]: {
|
||||
...currentNpcState,
|
||||
affinity: nextAffinity,
|
||||
chattedCount: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(currentNpcState.chattedCount, 0)),
|
||||
) + 1,
|
||||
firstMeaningfulContactResolved: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const quest = await generateQuestForNpcEncounter(llmClient, {
|
||||
state: nextState as never,
|
||||
encounter: encounter as never,
|
||||
});
|
||||
|
||||
if (!quest || typeof quest !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
quest,
|
||||
introText: buildQuestOfferDialogueText(
|
||||
npcName,
|
||||
quest as Record<string, unknown>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateCharacterChatSuggestionsFromOrchestrator(
|
||||
llmClient: UpstreamLlmClient,
|
||||
payload: CharacterChatSuggestionsRequest,
|
||||
@@ -229,6 +335,11 @@ export async function streamNpcChatTurnFromOrchestrator(
|
||||
npcReply: npcReply || streamedReply,
|
||||
chattedCount,
|
||||
});
|
||||
const pendingQuestOffer = await maybeBuildPendingNpcQuestOffer(
|
||||
llmClient,
|
||||
params.payload,
|
||||
affinityDelta,
|
||||
);
|
||||
|
||||
writeSseEvent(params.response, 'complete', {
|
||||
npcReply: npcReply || streamedReply,
|
||||
@@ -238,6 +349,7 @@ export async function streamNpcChatTurnFromOrchestrator(
|
||||
suggestions.length === 3
|
||||
? suggestions
|
||||
: buildFallbackNpcChatSuggestions(params.payload.playerMessage),
|
||||
pendingQuestOffer,
|
||||
});
|
||||
params.response.write('data: [DONE]\n\n');
|
||||
params.response.end();
|
||||
|
||||
@@ -5,23 +5,23 @@ import type {
|
||||
} from '../../../../packages/shared/src/contracts/runtime.js';
|
||||
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
buildCompiledCustomWorldProfile,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
validateGeneratedCustomWorldProfile,
|
||||
} from '../../../../src/services/customWorld.js';
|
||||
import { buildExpandedCustomWorldProfile } from '../../../../src/services/customWorldBuilder.js';
|
||||
} from '../custom-world/runtimeProfile.js';
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
deriveCustomWorldLockStateFromIntent,
|
||||
hasMeaningfulCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../../../../src/services/customWorldCreatorIntent.js';
|
||||
} from '../custom-world/creatorIntentRuntime.js';
|
||||
import type {
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldProfile,
|
||||
} from '../../../../src/types.js';
|
||||
} from '../custom-world/runtimeTypes.js';
|
||||
import {
|
||||
buildCustomWorldProfilePrompt,
|
||||
buildCustomWorldProfileRepairPrompt,
|
||||
@@ -396,7 +396,7 @@ export async function generateCustomWorldProfileFromOrchestrator(
|
||||
reporter.complete('llm-profile', '模型已返回世界档案,开始系统归一与运行时编译。');
|
||||
|
||||
reporter.begin('normalize', '正在把模型 JSON 归一为可运行的自定义世界结构。');
|
||||
const expandedProfile = buildExpandedCustomWorldProfile(
|
||||
const expandedProfile = buildCompiledCustomWorldProfile(
|
||||
{
|
||||
...(rawProfile as GeneratedProfile),
|
||||
settingText,
|
||||
|
||||
@@ -3,10 +3,12 @@ import test from 'node:test';
|
||||
|
||||
import type {
|
||||
CharacterChatSuggestionsRequest,
|
||||
NpcChatTurnRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
|
||||
import {
|
||||
generateCharacterChatSuggestionsFromOrchestrator,
|
||||
streamNpcChatTurnFromOrchestrator,
|
||||
} from './chatOrchestrator.js';
|
||||
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
|
||||
import {
|
||||
@@ -195,6 +197,179 @@ test('chat orchestrator builds character suggestion prompts on the server side',
|
||||
assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u'));
|
||||
});
|
||||
|
||||
test('chat orchestrator returns pending npc quest offers from the server side', async () => {
|
||||
const encounter = {
|
||||
kind: 'npc',
|
||||
id: 'npc_scout_01',
|
||||
npcName: '巡路人',
|
||||
npcDescription: '熟悉桥口风向的探子',
|
||||
context: '巡路人',
|
||||
characterId: 'scout-quest',
|
||||
} as const;
|
||||
const requestPayload = {
|
||||
worldType: TEST_WORLD,
|
||||
character: createTestCharacter(),
|
||||
player: createTestCharacter(),
|
||||
encounter,
|
||||
monsters: [],
|
||||
history: [],
|
||||
context: createStoryContext(),
|
||||
conversationHistory: [
|
||||
{ speaker: 'player', text: '你像是还有别的话想说。' },
|
||||
{ speaker: 'npc', text: '这地方最近确实不太平。' },
|
||||
],
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你像是还有别的话想说。' },
|
||||
{ speaker: 'npc', text: '这地方最近确实不太平。' },
|
||||
],
|
||||
playerMessage: '如果你愿意,我可以继续追下去。',
|
||||
npcState: {
|
||||
affinity: 28,
|
||||
chattedCount: 1,
|
||||
},
|
||||
questOfferContext: {
|
||||
turnCount: 2,
|
||||
encounter,
|
||||
state: {
|
||||
worldType: TEST_WORLD,
|
||||
currentScenePreset: {
|
||||
id: 'quest-bridge',
|
||||
name: '断桥口',
|
||||
description: '桥口被风和旧账压得很紧。',
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hostile: true,
|
||||
monsterPresetId: 'npc_bandit_01',
|
||||
},
|
||||
],
|
||||
treasureHints: [],
|
||||
},
|
||||
currentEncounter: encounter,
|
||||
storyHistory: [],
|
||||
customWorldProfile: null,
|
||||
storyEngineMemory: null,
|
||||
playerCharacter: createTestCharacter(),
|
||||
playerHp: 32,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 12,
|
||||
playerMaxMana: 16,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
companions: [],
|
||||
roster: [],
|
||||
quests: [],
|
||||
npcStates: {
|
||||
npc_scout_01: {
|
||||
affinity: 28,
|
||||
chattedCount: 1,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies NpcChatTurnRequest;
|
||||
const responseChunks: string[] = [];
|
||||
let requestCount = 0;
|
||||
const llmClient = {
|
||||
streamMessageContent: async ({
|
||||
onUpdate,
|
||||
}: {
|
||||
onUpdate?: (text: string) => void;
|
||||
}) => {
|
||||
const reply = '如果你真愿意追查,我这里确实有件事想托给你。';
|
||||
onUpdate?.(reply);
|
||||
return reply;
|
||||
},
|
||||
requestMessageContent: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return '这件事最早是从什么时候开始的\n桥口最近到底少了什么人\n你想让我先盯哪条线';
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
intent: {
|
||||
title: '断桥巡线',
|
||||
description: '巡路人希望你去断桥口查清最近被人故意压下的风声。',
|
||||
summary: '去断桥口查清最近被人故意压下的风声。',
|
||||
narrativeType: 'investigation',
|
||||
dramaticNeed: '有人在桥口刻意遮掩痕迹。',
|
||||
issuerGoal: '确认是谁在桥口截断消息。',
|
||||
playerHook: '你可以顺着桥口的异常把事情继续追下去。',
|
||||
worldReason: '桥口异动会影响接下来整段路的安全。',
|
||||
recommendedObjectiveKinds: ['defeat_hostile_npc', 'talk_to_npc'],
|
||||
urgency: 'medium',
|
||||
intimacy: 'cooperative',
|
||||
rewardTheme: 'currency',
|
||||
followupHooks: ['巡路人还藏着半句没说完的话。'],
|
||||
},
|
||||
});
|
||||
},
|
||||
} as const;
|
||||
const request = {
|
||||
method: 'POST',
|
||||
originalUrl: '/api/runtime/chat/npc/turn/stream',
|
||||
requestId: 'test-request',
|
||||
requestStartedAt: Date.now(),
|
||||
header: () => '',
|
||||
on: () => request,
|
||||
} as never;
|
||||
const response = {
|
||||
locals: {},
|
||||
statusCode: 200,
|
||||
setHeader: () => undefined,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
write(chunk: string) {
|
||||
responseChunks.push(chunk);
|
||||
return true;
|
||||
},
|
||||
end(chunk?: string) {
|
||||
if (chunk) {
|
||||
responseChunks.push(chunk);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
} as never;
|
||||
|
||||
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
|
||||
request,
|
||||
response,
|
||||
payload: requestPayload,
|
||||
});
|
||||
|
||||
const eventText = responseChunks.join('');
|
||||
const completeBlock = eventText
|
||||
.split('\n\n')
|
||||
.find((block) => block.includes('event: complete'));
|
||||
assert.ok(completeBlock);
|
||||
const completeLine = completeBlock
|
||||
?.split('\n')
|
||||
.find((line) => line.startsWith('data:'));
|
||||
assert.ok(completeLine);
|
||||
const payload = JSON.parse(completeLine!.slice(5).trim()) as {
|
||||
pendingQuestOffer?: {
|
||||
quest?: {
|
||||
issuerNpcId?: string;
|
||||
};
|
||||
introText?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
assert.equal(payload.pendingQuestOffer?.quest?.issuerNpcId, 'npc_scout_01');
|
||||
assert.match(payload.pendingQuestOffer?.introText ?? '', /正式交给你/u);
|
||||
});
|
||||
|
||||
test('custom world orchestrator requests LLM content before compiling the profile', async () => {
|
||||
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
|
||||
const storyNpcNames = Array.from(
|
||||
|
||||
@@ -17,14 +17,22 @@ import {
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js';
|
||||
import {
|
||||
buildMasterPrompt,
|
||||
buildVideoActionPrompt,
|
||||
getActionTemplateById,
|
||||
} from '../../../../packages/shared/src/assets/qwenSprite.js';
|
||||
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
|
||||
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import {
|
||||
buildArkCharacterAnimationPrompt,
|
||||
buildCharacterPromptBundleUserPrompt,
|
||||
buildFallbackCharacterPromptBundle,
|
||||
buildFallbackModerationSafeAnimationPrompt,
|
||||
buildImageSequencePrompt,
|
||||
buildNpcAnimationPrompt,
|
||||
buildNpcVisualNegativePrompt,
|
||||
buildNpcVisualPrompt,
|
||||
CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
|
||||
type CharacterPromptBundle,
|
||||
sanitizeCharacterPromptBundle,
|
||||
} from '../../prompts/characterAssetPrompts.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH =
|
||||
@@ -56,24 +64,6 @@ const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000;
|
||||
const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000;
|
||||
const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000;
|
||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS = 5000;
|
||||
const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
|
||||
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
|
||||
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
|
||||
输出格式必须严格为:
|
||||
{
|
||||
"visualPromptText": "角色主图提示词",
|
||||
"animationPromptText": "角色动作提示词",
|
||||
"scenePromptText": "角色关联场景提示词"
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 所有字段都必须是自然中文。
|
||||
- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
|
||||
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
|
||||
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
|
||||
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
|
||||
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
|
||||
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
|
||||
|
||||
const BUILT_IN_MOTION_TEMPLATES = [
|
||||
{
|
||||
@@ -142,14 +132,6 @@ function applyChromaKeyToMediaPayload(payload: DecodedMediaPayload) {
|
||||
} satisfies DecodedMediaPayload;
|
||||
}
|
||||
|
||||
type CharacterPromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
type CharacterAssetWorkflowCacheRecord = {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
@@ -306,128 +288,6 @@ function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildFallbackCharacterPromptBundle(params: {
|
||||
characterName: string;
|
||||
roleKind: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
const roleAnchor =
|
||||
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
|
||||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
|
||||
const characterAnchor = params.characterName || '该角色';
|
||||
const descriptionAnchor =
|
||||
params.description || params.backstory || params.personality || '气质鲜明';
|
||||
const combatAnchor =
|
||||
params.combatStyle || params.motivation || '动作发力清晰';
|
||||
const tagAnchor =
|
||||
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterAnchor},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||
tagAnchor,
|
||||
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterAnchor}的核心动作试片。`,
|
||||
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
params.backstory ? `背景线索可参考:${params.backstory}。` : '',
|
||||
params.motivation
|
||||
? `环境中可埋入与当前目标相关的暗示:${params.motivation}。`
|
||||
: '',
|
||||
'整体风格克制统一,适合剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
source: 'fallback' as const,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePromptBundleValue(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength: number,
|
||||
) {
|
||||
const normalized = clampPromptSeedText(value, maxLength);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function sanitizeCharacterPromptBundle(
|
||||
value: unknown,
|
||||
fallback: CharacterPromptBundle,
|
||||
model: string,
|
||||
) {
|
||||
const record = isRecordValue(value) ? value : {};
|
||||
|
||||
return {
|
||||
visualPromptText: sanitizePromptBundleValue(
|
||||
record.visualPromptText,
|
||||
fallback.visualPromptText,
|
||||
280,
|
||||
),
|
||||
animationPromptText: sanitizePromptBundleValue(
|
||||
record.animationPromptText,
|
||||
fallback.animationPromptText,
|
||||
280,
|
||||
),
|
||||
scenePromptText: sanitizePromptBundleValue(
|
||||
record.scenePromptText,
|
||||
fallback.scenePromptText,
|
||||
320,
|
||||
),
|
||||
source: 'llm' as const,
|
||||
model: model.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAnimationPromptText(value: string, maxLength: number) {
|
||||
return value
|
||||
.replace(/\s+/gu, ' ')
|
||||
.replace(/血浆|喷血|鲜血|断肢|斩首|裸体|裸露|色情|性交/gu, '')
|
||||
.replace(/死亡|死去|击杀/gu, '倒地结束')
|
||||
.replace(/受击|受伤/gu, '失衡')
|
||||
.replace(/砍杀|斩击/gu, '挥击')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildCompactAnimationCharacterBrief(value: string) {
|
||||
const normalized = sanitizeAnimationPromptText(value, 160);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[/|\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function isInappropriateContentMessage(value: string) {
|
||||
return /finappropriate-content|inappropriate content|不适当内容|违规内容/iu.test(
|
||||
value,
|
||||
@@ -489,42 +349,6 @@ async function proxyJsonRequestWithPromptFallback(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
characterName: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
return [
|
||||
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
|
||||
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
|
||||
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
|
||||
'',
|
||||
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
|
||||
params.characterName ? `角色名称:${params.characterName}` : '',
|
||||
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
|
||||
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
|
||||
params.description ? `角色描述:${params.description}` : '',
|
||||
params.backstory ? `角色背景:${params.backstory}` : '',
|
||||
params.personality ? `角色性格:${params.personality}` : '',
|
||||
params.motivation ? `角色动机:${params.motivation}` : '',
|
||||
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
|
||||
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
|
||||
'',
|
||||
'角色卡全文:',
|
||||
params.characterBriefText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function createTimestampId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
@@ -1211,187 +1035,6 @@ function extractImageUrls(payload: Record<string, unknown>) {
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function buildNpcVisualPrompt(promptText: string, characterBriefText = '') {
|
||||
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return buildMasterPrompt(
|
||||
mergedBrief || '自定义世界角色,服装完整,姿态自然。',
|
||||
);
|
||||
}
|
||||
|
||||
function buildNpcVisualNegativePrompt() {
|
||||
return [
|
||||
'正面视角',
|
||||
'左朝向',
|
||||
'完全 90 度纯右视图',
|
||||
'镜头透视',
|
||||
'半身像',
|
||||
'脚被裁切',
|
||||
'头顶被裁切',
|
||||
'多角色',
|
||||
'复杂背景',
|
||||
'建筑场景',
|
||||
'漂浮物',
|
||||
'烟雾环境',
|
||||
'武器消失',
|
||||
'武器换手',
|
||||
'额外手臂',
|
||||
'额外腿',
|
||||
'服装变化',
|
||||
'脸部变化',
|
||||
'模糊',
|
||||
'运动模糊',
|
||||
'文字',
|
||||
'水印',
|
||||
'UI 元素',
|
||||
'软萌 Q版大头贴',
|
||||
'儿童绘本风',
|
||||
'厚涂插画感',
|
||||
'低对比柔边',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
function buildImageSequencePrompt(
|
||||
animation: string,
|
||||
promptText: string,
|
||||
frameCount: number,
|
||||
useChromaKey: boolean,
|
||||
) {
|
||||
return [
|
||||
`同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`,
|
||||
'固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。',
|
||||
'帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。',
|
||||
useChromaKey
|
||||
? '纯绿色背景,无地面装饰,方便后期抠像。'
|
||||
: '背景尽量纯净,避免复杂场景。',
|
||||
promptText.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildNpcAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
useChromaKey: boolean;
|
||||
loop: boolean;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
}) {
|
||||
const characterBrief = buildCompactAnimationCharacterBrief(
|
||||
options.characterBriefText ?? '',
|
||||
);
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const loopRule = options.loop
|
||||
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
|
||||
: options.animation === 'die'
|
||||
? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。'
|
||||
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return [
|
||||
buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<
|
||||
typeof getActionTemplateById
|
||||
>[0],
|
||||
),
|
||||
actionDetailText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief: characterBrief || `${options.animation} 动作角色`,
|
||||
}),
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
`单人 NPC 全身动作视频,动作主题是 ${options.animation}。`,
|
||||
'角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
|
||||
'动作连贯,避免服装、发型、面部、武器随机漂移。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
characterBrief ? `角色设定:${characterBrief}` : '',
|
||||
actionDetailText,
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildArkCharacterAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
useChromaKey: boolean;
|
||||
loop: boolean;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
}) {
|
||||
const normalizedAnimationName =
|
||||
options.animation.trim().replace(/\s+/gu, '_') || 'idle';
|
||||
const characterBrief = buildCompactAnimationCharacterBrief(
|
||||
options.characterBriefText ?? '',
|
||||
);
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const frameRule = options.loop
|
||||
? '首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。'
|
||||
: '首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return [
|
||||
buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
|
||||
),
|
||||
actionDetailText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief: characterBrief || `${normalizedAnimationName} action role`,
|
||||
}),
|
||||
`动作英文名:${normalizedAnimationName}。`,
|
||||
frameRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
`单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}。`,
|
||||
'角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
|
||||
'动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
characterBrief ? `角色设定:${characterBrief}` : '',
|
||||
actionDetailText ? `动作细节:${actionDetailText}` : '',
|
||||
frameRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildFallbackModerationSafeAnimationPrompt(options: {
|
||||
animation: string;
|
||||
loop: boolean;
|
||||
useChromaKey: boolean;
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.animation}。`,
|
||||
'角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。',
|
||||
options.loop
|
||||
? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。'
|
||||
: '非循环动作首尾回到角色标准站姿,中段完成动作变化。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素。'
|
||||
: '背景简洁纯净。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getLowestSupportedVideoResolution(model: string, fallback: string) {
|
||||
switch (model) {
|
||||
case 'wan2.6-i2v-flash':
|
||||
|
||||
528
server-node/src/modules/custom-world/creatorIntentRuntime.ts
Normal file
528
server-node/src/modules/custom-world/creatorIntentRuntime.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import type {
|
||||
ActorAnchor,
|
||||
CreatorCharacterSeed,
|
||||
CreatorFactionSeed,
|
||||
CreatorLandmarkSeed,
|
||||
CustomWorldAnchorPack,
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldLockState,
|
||||
LandmarkAnchor,
|
||||
} from './runtimeTypes.js';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
|
||||
0,
|
||||
maxCount,
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function createSeedId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function normalizeCreatorFactionSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorFactionSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const publicGoal = toText(item.publicGoal);
|
||||
const tension = toText(item.tension);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (!name && !publicGoal && !tension && !notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-faction', name || publicGoal, index),
|
||||
name,
|
||||
publicGoal,
|
||||
tension,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorCharacterSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorCharacterSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const role = toText(item.role);
|
||||
const publicMask = toText(item.publicMask);
|
||||
const hiddenHook = toText(item.hiddenHook);
|
||||
const relationToPlayer = toText(item.relationToPlayer);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (
|
||||
!name &&
|
||||
!role &&
|
||||
!publicMask &&
|
||||
!hiddenHook &&
|
||||
!relationToPlayer &&
|
||||
!notes
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-character', name || role || publicMask, index),
|
||||
name,
|
||||
role,
|
||||
publicMask,
|
||||
hiddenHook,
|
||||
relationToPlayer,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorLandmarkSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorLandmarkSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const purpose = toText(item.purpose);
|
||||
const mood = toText(item.mood);
|
||||
const secret = toText(item.secret);
|
||||
|
||||
if (!name && !purpose && !mood && !secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-landmark', name || purpose || mood, index),
|
||||
name,
|
||||
purpose,
|
||||
mood,
|
||||
secret,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnchorArray<T>(
|
||||
value: unknown,
|
||||
normalizer: (value: unknown, index: number) => T | null,
|
||||
maxCount: number,
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index) => normalizer(item, index))
|
||||
.filter((item): item is T => Boolean(item))
|
||||
.slice(0, maxCount);
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldCreatorIntent(
|
||||
value: unknown,
|
||||
fallbackMode: CustomWorldCreatorIntent['sourceMode'] = 'freeform',
|
||||
): CustomWorldCreatorIntent | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const sourceMode =
|
||||
item.sourceMode === 'card' || item.sourceMode === 'freeform'
|
||||
? item.sourceMode
|
||||
: fallbackMode;
|
||||
const rawSettingText = toText(item.rawSettingText);
|
||||
const worldHook = toText(item.worldHook);
|
||||
const playerPremise = toText(item.playerPremise);
|
||||
const openingSituation = toText(item.openingSituation);
|
||||
const themeKeywords = toStringArray(item.themeKeywords, 8);
|
||||
const toneDirectives = toStringArray(item.toneDirectives, 8);
|
||||
const coreConflicts = toStringArray(item.coreConflicts, 6);
|
||||
const iconicElements = toStringArray(item.iconicElements, 8);
|
||||
const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8);
|
||||
const keyFactions = normalizeAnchorArray(
|
||||
item.keyFactions,
|
||||
normalizeCreatorFactionSeed,
|
||||
6,
|
||||
);
|
||||
const keyCharacters = normalizeAnchorArray(
|
||||
item.keyCharacters,
|
||||
normalizeCreatorCharacterSeed,
|
||||
8,
|
||||
);
|
||||
const keyLandmarks = normalizeAnchorArray(
|
||||
item.keyLandmarks,
|
||||
normalizeCreatorLandmarkSeed,
|
||||
8,
|
||||
);
|
||||
|
||||
if (
|
||||
!rawSettingText &&
|
||||
!worldHook &&
|
||||
themeKeywords.length === 0 &&
|
||||
toneDirectives.length === 0 &&
|
||||
!playerPremise &&
|
||||
!openingSituation &&
|
||||
coreConflicts.length === 0 &&
|
||||
keyFactions.length === 0 &&
|
||||
keyCharacters.length === 0 &&
|
||||
keyLandmarks.length === 0 &&
|
||||
iconicElements.length === 0 &&
|
||||
forbiddenDirectives.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceMode,
|
||||
rawSettingText,
|
||||
worldHook,
|
||||
themeKeywords,
|
||||
toneDirectives,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
coreConflicts,
|
||||
keyFactions,
|
||||
keyCharacters,
|
||||
keyLandmarks,
|
||||
iconicElements,
|
||||
forbiddenDirectives,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldLockState(
|
||||
value: unknown,
|
||||
): CustomWorldLockState {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds: [],
|
||||
lockedLandmarkIds: [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
return {
|
||||
worldLockedFields: toStringArray(item.worldLockedFields, 12),
|
||||
lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20),
|
||||
lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20),
|
||||
lockedConflictIds: toStringArray(item.lockedConflictIds, 20),
|
||||
lockedFactionIds: toStringArray(item.lockedFactionIds, 20),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveCustomWorldLockStateFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldLockState {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds:
|
||||
intent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedLandmarkIds:
|
||||
intent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds:
|
||||
intent?.keyFactions
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMeaningfulCustomWorldCreatorIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
intent &&
|
||||
(intent.rawSettingText ||
|
||||
intent.worldHook ||
|
||||
intent.themeKeywords.length > 0 ||
|
||||
intent.toneDirectives.length > 0 ||
|
||||
intent.playerPremise ||
|
||||
intent.openingSituation ||
|
||||
intent.coreConflicts.length > 0 ||
|
||||
intent.keyFactions.length > 0 ||
|
||||
intent.keyCharacters.length > 0 ||
|
||||
intent.keyLandmarks.length > 0 ||
|
||||
intent.iconicElements.length > 0 ||
|
||||
intent.forbiddenDirectives.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnchorLine(label: string, content: string) {
|
||||
return content ? `${label}:${content}` : '';
|
||||
}
|
||||
|
||||
function buildCustomWorldCreatorIntentDisplayText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = [
|
||||
intent?.worldHook ? `世界一句话:${intent.worldHook}` : '',
|
||||
intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '',
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家是谁', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[entry.name, entry.publicGoal, entry.tension]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentGenerationText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections = [
|
||||
buildAnchorLine('世界核心命题', intent?.worldHook || ''),
|
||||
buildAnchorLine('补充设定原文', intent?.rawSettingText || ''),
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家身份', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力锚点',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.publicGoal ? `目标 ${entry.publicGoal}` : '',
|
||||
entry.tension ? `张力 ${entry.tension}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色锚点',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role ? `身份 ${entry.role}` : '',
|
||||
entry.publicMask ? `表面 ${entry.publicMask}` : '',
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点锚点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.purpose ? `作用 ${entry.purpose}` : '',
|
||||
entry.mood ? `氛围 ${entry.mood}` : '',
|
||||
entry.secret ? `秘密 ${entry.secret}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
|
||||
const summary = clampText(
|
||||
[
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键角色',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLandmarkAnchorSummary(
|
||||
entry: CreatorLandmarkSeed,
|
||||
): LandmarkAnchor {
|
||||
const summary = clampText(
|
||||
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键地点',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldAnchorPackFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldAnchorPack | null {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lockedAnchorIds = [
|
||||
...(intent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyFactions
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
worldSummary: clampText(
|
||||
intent?.worldHook || intent?.rawSettingText || '',
|
||||
96,
|
||||
),
|
||||
creatorIntentSummary: clampText(
|
||||
buildCustomWorldCreatorIntentDisplayText(intent),
|
||||
240,
|
||||
),
|
||||
lockedAnchorIds,
|
||||
keyConflictSummaries:
|
||||
intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
|
||||
keyFactionSummaries:
|
||||
intent?.keyFactions.map((entry) =>
|
||||
clampText(
|
||||
[entry.name, entry.publicGoal, entry.tension]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
),
|
||||
) ?? [],
|
||||
keyCharacterAnchors:
|
||||
intent?.keyCharacters.map((entry) =>
|
||||
buildCharacterAnchorSummary(entry),
|
||||
) ?? [],
|
||||
keyLandmarkAnchors:
|
||||
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ??
|
||||
[],
|
||||
motifDirectives: [
|
||||
...(intent?.themeKeywords ?? []),
|
||||
...(intent?.toneDirectives ?? []),
|
||||
...(intent?.iconicElements ?? []),
|
||||
].slice(0, 12),
|
||||
};
|
||||
}
|
||||
133
server-node/src/modules/custom-world/runtimeProfile.test.ts
Normal file
133
server-node/src/modules/custom-world/runtimeProfile.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildCompiledCustomWorldProfile,
|
||||
validateGeneratedCustomWorldProfile,
|
||||
} from './runtimeProfile.js';
|
||||
|
||||
function createPlayableNpc(index: number) {
|
||||
return {
|
||||
name: `角色${index + 1}`,
|
||||
title: `称号${index + 1}`,
|
||||
role: `身份${index + 1}`,
|
||||
description: `角色描述${index + 1}`,
|
||||
backstory: `角色背景${index + 1}`,
|
||||
personality: `角色性格${index + 1}`,
|
||||
motivation: `角色动机${index + 1}`,
|
||||
combatStyle: `战斗风格${index + 1}`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`接触点${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryNpc(index: number) {
|
||||
return {
|
||||
name: `场景角色${index + 1}`,
|
||||
title: `头衔${index + 1}`,
|
||||
role: `职责${index + 1}`,
|
||||
description: `场景角色描述${index + 1}`,
|
||||
backstory: `场景角色背景${index + 1}`,
|
||||
personality: `场景角色性格${index + 1}`,
|
||||
motivation: `场景角色动机${index + 1}`,
|
||||
combatStyle: `场景角色战斗风格${index + 1}`,
|
||||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
tags: [`线索${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
function createLandmark(index: number, storyNpcNames: string[]) {
|
||||
return {
|
||||
name: `场景${index + 1}`,
|
||||
description: `场景描述${index + 1}`,
|
||||
dangerLevel: 'medium',
|
||||
sceneNpcNames: storyNpcNames,
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: `场景${((index + 1) % 10) + 1}`,
|
||||
relativePosition: 'forward',
|
||||
summary: '沿主路前行',
|
||||
},
|
||||
{
|
||||
targetLandmarkName: `场景${((index + 9) % 10) + 1}`,
|
||||
relativePosition: 'back',
|
||||
summary: '回身可返',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('buildCompiledCustomWorldProfile preserves runtime-critical generated fields on the server', () => {
|
||||
const storyNpcs = Array.from({ length: 25 }, (_, index) =>
|
||||
createStoryNpc(index),
|
||||
);
|
||||
const profile = buildCompiledCustomWorldProfile(
|
||||
{
|
||||
id: 'generated-world',
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '紧张、潮湿',
|
||||
playerGoal: '先站稳,再查明真相',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '沉船商盟'],
|
||||
coreConflicts: ['航道解释权正在争夺'],
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
rawSettingText: '',
|
||||
worldHook: '一个被潮雾反复切开的边境世界。',
|
||||
themeKeywords: ['潮雾', '边境'],
|
||||
toneDirectives: ['紧张', '潮湿'],
|
||||
playerPremise: '玩家是前巡夜人。',
|
||||
openingSituation: '刚进城就卷入旧案。',
|
||||
coreConflicts: ['旧案名单再次出现'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'creator-character-1',
|
||||
name: '沈砺',
|
||||
role: '灰炬向导',
|
||||
publicMask: '看起来只是个带路人',
|
||||
hiddenHook: '一直在查旧撤离线',
|
||||
relationToPlayer: '会先怀疑玩家身份',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['裂潮灯塔'],
|
||||
forbiddenDirectives: ['不要出现现代枪械'],
|
||||
},
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs,
|
||||
landmarks: Array.from({ length: 10 }, (_, index) =>
|
||||
createLandmark(index, [
|
||||
storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`,
|
||||
storyNpcs[(index + 1) % storyNpcs.length]?.name ??
|
||||
`场景角色${index + 2}`,
|
||||
storyNpcs[(index + 2) % storyNpcs.length]?.name ??
|
||||
`场景角色${index + 3}`,
|
||||
]),
|
||||
),
|
||||
},
|
||||
'一个被潮雾反复切开的边境世界。',
|
||||
);
|
||||
|
||||
assert.equal(profile.playableNpcs.length, 5);
|
||||
assert.equal(profile.storyNpcs.length, 25);
|
||||
assert.equal(profile.landmarks.length, 10);
|
||||
assert.equal(profile.playableNpcs[0]?.templateCharacterId, 'sword-princess');
|
||||
assert.ok(profile.playableNpcs[0]?.attributeProfile);
|
||||
assert.ok(profile.storyNpcs[0]?.attributeProfile);
|
||||
assert.equal(profile.scenarioPackId, 'scenario-pack:测试世界');
|
||||
assert.equal(profile.campaignPackId, 'campaign-pack:测试世界');
|
||||
assert.equal(profile.creatorIntent?.keyCharacters[0]?.name, '沈砺');
|
||||
assert.ok(profile.anchorPack?.lockedAnchorIds.includes('creator-character-1'));
|
||||
assert.ok(profile.landmarks.every((landmark) => landmark.sceneNpcIds.length >= 3));
|
||||
|
||||
validateGeneratedCustomWorldProfile(profile);
|
||||
});
|
||||
1734
server-node/src/modules/custom-world/runtimeProfile.ts
Normal file
1734
server-node/src/modules/custom-world/runtimeProfile.ts
Normal file
File diff suppressed because it is too large
Load Diff
389
server-node/src/modules/custom-world/runtimeTypes.ts
Normal file
389
server-node/src/modules/custom-world/runtimeTypes.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
export type WorldType = 'WUXIA' | 'XIANXIA' | 'CUSTOM';
|
||||
|
||||
export type CustomWorldGenerationMode = 'fast' | 'full';
|
||||
export type CustomWorldGenerationStatus = 'key_only' | 'complete';
|
||||
export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated';
|
||||
|
||||
export interface CustomWorldCoverProfile {
|
||||
sourceType: CustomWorldCoverSourceType;
|
||||
imageSrc?: string | null;
|
||||
characterRoleIds?: string[];
|
||||
}
|
||||
|
||||
export interface CreatorFactionSeed {
|
||||
id: string;
|
||||
name: string;
|
||||
publicGoal: string;
|
||||
tension: string;
|
||||
notes: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatorCharacterSeed {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
publicMask: string;
|
||||
hiddenHook: string;
|
||||
relationToPlayer: string;
|
||||
notes: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatorLandmarkSeed {
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
mood: string;
|
||||
secret: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface ActorAnchor {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface LandmarkAnchor {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldCreatorIntent {
|
||||
sourceMode: 'freeform' | 'card';
|
||||
rawSettingText: string;
|
||||
worldHook: string;
|
||||
themeKeywords: string[];
|
||||
toneDirectives: string[];
|
||||
playerPremise: string;
|
||||
openingSituation: string;
|
||||
coreConflicts: string[];
|
||||
keyFactions: CreatorFactionSeed[];
|
||||
keyCharacters: CreatorCharacterSeed[];
|
||||
keyLandmarks: CreatorLandmarkSeed[];
|
||||
iconicElements: string[];
|
||||
forbiddenDirectives: string[];
|
||||
}
|
||||
|
||||
export interface CustomWorldAnchorPack {
|
||||
worldSummary: string;
|
||||
creatorIntentSummary: string;
|
||||
lockedAnchorIds: string[];
|
||||
keyConflictSummaries: string[];
|
||||
keyFactionSummaries: string[];
|
||||
keyCharacterAnchors: ActorAnchor[];
|
||||
keyLandmarkAnchors: LandmarkAnchor[];
|
||||
motifDirectives: string[];
|
||||
}
|
||||
|
||||
export interface CustomWorldLockState {
|
||||
worldLockedFields: string[];
|
||||
lockedCharacterIds: string[];
|
||||
lockedLandmarkIds: string[];
|
||||
lockedConflictIds: string[];
|
||||
lockedFactionIds: string[];
|
||||
}
|
||||
|
||||
export interface WorldAttributeSlot {
|
||||
slotId: string;
|
||||
name: string;
|
||||
definition: string;
|
||||
positiveSignals: string[];
|
||||
negativeSignals: string[];
|
||||
combatUseText: string;
|
||||
socialUseText: string;
|
||||
explorationUseText: string;
|
||||
}
|
||||
|
||||
export interface WorldAttributeSchema {
|
||||
id: string;
|
||||
worldId: string;
|
||||
schemaVersion: number;
|
||||
schemaName: string;
|
||||
generatedFrom: {
|
||||
worldType: WorldType;
|
||||
worldName: string;
|
||||
settingSummary: string;
|
||||
tone: string;
|
||||
conflictCore: string;
|
||||
};
|
||||
slots: WorldAttributeSlot[];
|
||||
}
|
||||
|
||||
export type AttributeVector = Record<string, number>;
|
||||
|
||||
export interface RoleAttributeEvidence {
|
||||
slotId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RoleAttributeProfile {
|
||||
schemaId: string;
|
||||
values: AttributeVector;
|
||||
topTraits: string[];
|
||||
hiddenTraits?: string[];
|
||||
evidence: RoleAttributeEvidence[];
|
||||
}
|
||||
|
||||
export interface CharacterBackstoryChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
affinityRequired: number;
|
||||
teaser: string;
|
||||
content: string;
|
||||
contextSnippet: string;
|
||||
}
|
||||
|
||||
export interface CharacterBackstoryRevealConfig {
|
||||
publicSummary: string;
|
||||
privateChatUnlockAffinity: number;
|
||||
chapters: CharacterBackstoryChapter[];
|
||||
}
|
||||
|
||||
export interface ActorNarrativeProfile {
|
||||
publicMask: string;
|
||||
firstContactMask: string;
|
||||
visibleLine: string;
|
||||
hiddenLine: string;
|
||||
contradiction: string;
|
||||
debtOrBurden: string;
|
||||
taboo: string;
|
||||
immediatePressure: string;
|
||||
relatedThreadIds: string[];
|
||||
relatedScarIds: string[];
|
||||
reactionHooks: string[];
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
style: string;
|
||||
actionPromptText?: string;
|
||||
actionPreviewConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleInitialItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
rarity: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CustomWorldRoleProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
backstoryReveal: CharacterBackstoryRevealConfig;
|
||||
skills: CustomWorldRoleSkill[];
|
||||
initialItems: CustomWorldRoleInitialItem[];
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown>;
|
||||
attributeProfile?: RoleAttributeProfile;
|
||||
narrativeProfile?: ActorNarrativeProfile | null;
|
||||
}
|
||||
|
||||
export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {
|
||||
templateCharacterId?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldNpc extends CustomWorldRoleProfile {
|
||||
visual?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CustomWorldItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
rarity: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneConnection {
|
||||
targetLandmarkId: string;
|
||||
relativePosition: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldCampScene {
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldLandmark {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: CustomWorldSceneConnection[];
|
||||
narrativeResidues?:
|
||||
| Array<{
|
||||
summary?: string;
|
||||
changeHint?: string;
|
||||
hiddenTruth?: string;
|
||||
}>
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface ThemePack {
|
||||
id: string;
|
||||
displayName: string;
|
||||
toneRange: string[];
|
||||
institutionLexicon: string[];
|
||||
tabooLexicon: string[];
|
||||
artifactClasses: string[];
|
||||
actorArchetypes: string[];
|
||||
conflictForms: string[];
|
||||
clueForms: string[];
|
||||
namingPatterns: string[];
|
||||
revealStyles: string[];
|
||||
}
|
||||
|
||||
export interface StoryThread {
|
||||
id: string;
|
||||
title: string;
|
||||
visibility: 'visible' | 'hidden';
|
||||
summary: string;
|
||||
conflictType: string;
|
||||
stakes: string;
|
||||
involvedFactionIds: string[];
|
||||
involvedActorIds: string[];
|
||||
relatedLocationIds: string[];
|
||||
}
|
||||
|
||||
export interface StoryScar {
|
||||
id: string;
|
||||
title: string;
|
||||
pastEvent: string;
|
||||
publicResidue: string;
|
||||
hiddenTruth: string;
|
||||
relatedActorIds: string[];
|
||||
relatedLocationIds: string[];
|
||||
}
|
||||
|
||||
export interface StoryMotif {
|
||||
id: string;
|
||||
label: string;
|
||||
semanticRole: string;
|
||||
lexicalHints: string[];
|
||||
}
|
||||
|
||||
export interface WorldStoryGraph {
|
||||
visibleThreads: StoryThread[];
|
||||
hiddenThreads: StoryThread[];
|
||||
scars: StoryScar[];
|
||||
motifs: StoryMotif[];
|
||||
}
|
||||
|
||||
export interface CustomWorldProfile {
|
||||
id: string;
|
||||
settingText: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
cover?: CustomWorldCoverProfile | null;
|
||||
templateWorldType: WorldType;
|
||||
compatibilityTemplateWorldType?: WorldType | null;
|
||||
majorFactions: string[];
|
||||
coreConflicts: string[];
|
||||
attributeSchema: WorldAttributeSchema;
|
||||
playableNpcs: CustomWorldPlayableNpc[];
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
items: CustomWorldItem[];
|
||||
camp?: CustomWorldCampScene | null;
|
||||
landmarks: CustomWorldLandmark[];
|
||||
themePack?: ThemePack | null;
|
||||
storyGraph?: WorldStoryGraph | null;
|
||||
knowledgeFacts?: Array<Record<string, unknown>> | null;
|
||||
threadContracts?: Array<Record<string, unknown>> | null;
|
||||
anchorContent?: Record<string, unknown> | null;
|
||||
creatorIntent?: CustomWorldCreatorIntent | null;
|
||||
anchorPack?: CustomWorldAnchorPack | null;
|
||||
lockState?: CustomWorldLockState | null;
|
||||
ownedSettingLayers?: Record<string, unknown> | null;
|
||||
generationMode?: CustomWorldGenerationMode | null;
|
||||
generationStatus?: CustomWorldGenerationStatus | null;
|
||||
scenarioPackId?: string | null;
|
||||
campaignPackId?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationRoleOutline {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationLandmarkConnectionOutline {
|
||||
targetLandmarkName: string;
|
||||
relativePosition: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationLandmarkOutline {
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcNames: string[];
|
||||
connections: CustomWorldGenerationLandmarkConnectionOutline[];
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationCampOutline {
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationFramework {
|
||||
settingText: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
templateWorldType: WorldType;
|
||||
compatibilityTemplateWorldType: WorldType;
|
||||
majorFactions: string[];
|
||||
coreConflicts: string[];
|
||||
camp: CustomWorldGenerationCampOutline;
|
||||
playableNpcs: CustomWorldGenerationRoleOutline[];
|
||||
storyNpcs: CustomWorldGenerationRoleOutline[];
|
||||
landmarks: CustomWorldGenerationLandmarkOutline[];
|
||||
}
|
||||
|
||||
export type CustomWorldGenerationRoleBatchType = 'playable' | 'story';
|
||||
export type CustomWorldGenerationRoleBatchStage = 'narrative' | 'dossier';
|
||||
@@ -37,6 +37,7 @@ type QuestStoryResolution = {
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0];
|
||||
type RuntimeQuestLogEntry = NonNullable<ReturnType<typeof buildQuestForEncounter>>;
|
||||
type RuntimeNpcState = Parameters<
|
||||
typeof markNpcFirstMeaningfulContactResolved
|
||||
>[0];
|
||||
@@ -83,11 +84,56 @@ function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readQuestId(request: RuntimeStoryActionRequest) {
|
||||
const payload = readPayload(request);
|
||||
return readString(payload.questId) || readString(request.action.targetId);
|
||||
}
|
||||
|
||||
function readPendingQuestOffer(
|
||||
currentStory: unknown,
|
||||
npcKey: string,
|
||||
): RuntimeQuestLogEntry | null {
|
||||
if (!isObject(currentStory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcChatState = isObject(currentStory.npcChatState)
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
|
||||
? npcChatState.pendingQuestOffer
|
||||
: null;
|
||||
const quest = isObject(pendingQuestOffer?.quest)
|
||||
? pendingQuestOffer.quest
|
||||
: null;
|
||||
|
||||
if (!quest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingNpcId = readString(npcChatState?.npcId);
|
||||
const questId = readString(quest.id);
|
||||
const issuerNpcId = readString(quest.issuerNpcId);
|
||||
|
||||
if (!questId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pendingNpcId && pendingNpcId !== npcKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (issuerNpcId && issuerNpcId !== npcKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return quest as RuntimeQuestLogEntry;
|
||||
}
|
||||
|
||||
function ensureEncounterQuestContext(session: RuntimeSession) {
|
||||
const state = session.rawGameState as unknown as RuntimeGameState;
|
||||
const encounter = getNpcEncounter(session, state);
|
||||
@@ -111,6 +157,7 @@ function ensureEncounterQuestContext(session: RuntimeSession) {
|
||||
|
||||
function resolveQuestAcceptAction(
|
||||
session: RuntimeSession,
|
||||
currentStory?: unknown,
|
||||
): QuestStoryResolution {
|
||||
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
|
||||
const quests = Array.isArray(state.quests) ? state.quests : [];
|
||||
@@ -119,18 +166,20 @@ function resolveQuestAcceptAction(
|
||||
throw conflict('当前角色已经有未结清的委托。');
|
||||
}
|
||||
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: npcKey,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: quests.map((item) => ({
|
||||
id: item.id,
|
||||
issuerNpcId: item.issuerNpcId,
|
||||
status: item.status,
|
||||
})),
|
||||
});
|
||||
const quest =
|
||||
readPendingQuestOffer(currentStory, npcKey) ??
|
||||
buildQuestForEncounter({
|
||||
issuerNpcId: npcKey,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: quests.map((item) => ({
|
||||
id: item.id,
|
||||
issuerNpcId: item.issuerNpcId,
|
||||
status: item.status,
|
||||
})),
|
||||
});
|
||||
if (!quest) {
|
||||
throw conflict('当前场景缺少可落地的委托抓手,暂时无法接取任务。');
|
||||
}
|
||||
@@ -228,10 +277,13 @@ export function isSupportedQuestStoryFunctionId(functionId: string) {
|
||||
export function resolveQuestStoryAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
options: {
|
||||
currentStory?: unknown;
|
||||
} = {},
|
||||
): QuestStoryResolution {
|
||||
switch (request.action.functionId) {
|
||||
case 'npc_quest_accept':
|
||||
return resolveQuestAcceptAction(session);
|
||||
return resolveQuestAcceptAction(session, options.currentStory);
|
||||
case 'npc_quest_turn_in':
|
||||
return resolveQuestTurnInAction(session, request);
|
||||
default:
|
||||
|
||||
96
server-node/src/modules/story/runtimeSession.test.ts
Normal file
96
server-node/src/modules/story/runtimeSession.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildAvailableOptions,
|
||||
buildLegacyCurrentStory,
|
||||
loadRuntimeSession,
|
||||
} from './runtimeSession.ts';
|
||||
|
||||
function createNpcSnapshot() {
|
||||
return {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
storyHistory: [],
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_merchant_01',
|
||||
npcName: '沈七',
|
||||
npcDescription: '腰间挂着药囊的行商',
|
||||
context: '受伤行商',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
playerHp: 31,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 9,
|
||||
playerMaxMana: 16,
|
||||
npcStates: {
|
||||
npc_merchant_01: {
|
||||
affinity: 46,
|
||||
chattedCount: 1,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
companions: [],
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
quests: [],
|
||||
playerInventory: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildAvailableOptions attaches npc interaction metadata from the server runtime session', () => {
|
||||
const session = loadRuntimeSession(
|
||||
createNpcSnapshot() as Parameters<typeof loadRuntimeSession>[0],
|
||||
'runtime-main',
|
||||
);
|
||||
|
||||
const options = buildAvailableOptions(session);
|
||||
|
||||
assert.deepEqual(
|
||||
options.find((option) => option.functionId === 'npc_chat')?.interaction,
|
||||
{
|
||||
kind: 'npc',
|
||||
npcId: 'npc_merchant_01',
|
||||
action: 'chat',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
options.find((option) => option.functionId === 'npc_help')?.interaction,
|
||||
{
|
||||
kind: 'npc',
|
||||
npcId: 'npc_merchant_01',
|
||||
action: 'help',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLegacyCurrentStory preserves runtime interaction metadata on projected options', () => {
|
||||
const session = loadRuntimeSession(
|
||||
createNpcSnapshot() as Parameters<typeof loadRuntimeSession>[0],
|
||||
'runtime-main',
|
||||
);
|
||||
const options = buildAvailableOptions(session);
|
||||
|
||||
const currentStory = buildLegacyCurrentStory('服务端已经生成了当前故事。', options);
|
||||
|
||||
assert.deepEqual(
|
||||
currentStory.options.find((option) => option.functionId === 'npc_leave')
|
||||
?.interaction,
|
||||
{
|
||||
kind: 'npc',
|
||||
npcId: 'npc_merchant_01',
|
||||
action: 'leave',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
RuntimeStoryEncounterViewModel,
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryEncounterViewModel,
|
||||
RuntimeStoryOptionInteraction,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryViewModel,
|
||||
Task5RuntimeOptionScope,
|
||||
@@ -180,8 +181,7 @@ const TASK6_RUNTIME_FUNCTION_ID_SET = new Set<string>(
|
||||
TASK6_RUNTIME_FUNCTION_IDS,
|
||||
);
|
||||
|
||||
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([
|
||||
]);
|
||||
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([]);
|
||||
|
||||
const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
|
||||
story_continue_adventure: {
|
||||
@@ -406,13 +406,16 @@ function normalizeNpcState(value: unknown): RuntimeNpcState {
|
||||
? cloneJson(rawState.stanceProfile)
|
||||
: null,
|
||||
revealedFacts: readArray(rawState.revealedFacts).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -456,7 +459,10 @@ function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null {
|
||||
}
|
||||
|
||||
const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1)));
|
||||
const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))));
|
||||
const hp = Math.max(
|
||||
0,
|
||||
Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))),
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -489,7 +495,10 @@ function normalizeNpcStates(value: unknown) {
|
||||
const rawStates = isObject(value) ? value : {};
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]),
|
||||
Object.entries(rawStates).map(([key, state]) => [
|
||||
key,
|
||||
normalizeNpcState(state),
|
||||
]),
|
||||
) as Record<string, RuntimeNpcState>;
|
||||
}
|
||||
|
||||
@@ -672,13 +681,14 @@ function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) {
|
||||
}
|
||||
|
||||
function buildBattleDisabledOption(params: {
|
||||
session: RuntimeSession;
|
||||
functionId: string;
|
||||
actionText?: string;
|
||||
detailText?: string;
|
||||
reason: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
return buildOptionView(params.functionId, {
|
||||
return buildOptionView(params.session, params.functionId, {
|
||||
actionText: params.actionText,
|
||||
detailText: params.detailText,
|
||||
payload: params.payload,
|
||||
@@ -687,6 +697,44 @@ function buildBattleDisabledOption(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function buildOptionInteraction(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
): RuntimeStoryOptionInteraction | undefined {
|
||||
const encounter = session.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = getEncounterKey(encounter);
|
||||
const npcActionMap: Record<string, RuntimeStoryOptionInteraction> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_preview_talk: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, RuntimeStoryOptionInteraction> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildBattleItemSummary(
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
|
||||
) {
|
||||
@@ -711,44 +759,47 @@ function pickPreferredBattleItem(session: RuntimeSession) {
|
||||
const cooldowns = getPlayerSkillCooldowns(session);
|
||||
const hasCoolingSkill = Object.values(cooldowns).some((turns) => turns > 0);
|
||||
const playerHpRatio = session.playerHp / Math.max(session.playerMaxHp, 1);
|
||||
const playerManaRatio = session.playerMana / Math.max(session.playerMaxMana, 1);
|
||||
const playerManaRatio =
|
||||
session.playerMana / Math.max(session.playerMaxMana, 1);
|
||||
|
||||
return getBattleInventoryItems(session)
|
||||
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
|
||||
.map((item) => {
|
||||
const effect = resolveInventoryItemUseEffect(item, character);
|
||||
if (!effect) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
getBattleInventoryItems(session)
|
||||
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
|
||||
.map((item) => {
|
||||
const effect = resolveInventoryItemUseEffect(item, character);
|
||||
if (!effect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const score =
|
||||
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
|
||||
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
|
||||
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
|
||||
effect.buildBuffs.length * 8;
|
||||
const score =
|
||||
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
|
||||
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
|
||||
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
|
||||
effect.buildBuffs.length * 8;
|
||||
|
||||
return {
|
||||
item,
|
||||
effect,
|
||||
score,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
candidate,
|
||||
): candidate is {
|
||||
item: RuntimeBattleInventoryItem;
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
|
||||
score: number;
|
||||
} => Boolean(candidate),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
right.effect.hpRestore - left.effect.hpRestore ||
|
||||
right.effect.manaRestore - left.effect.manaRestore ||
|
||||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
|
||||
)[0] ?? null;
|
||||
return {
|
||||
item,
|
||||
effect,
|
||||
score,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
candidate,
|
||||
): candidate is {
|
||||
item: RuntimeBattleInventoryItem;
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
|
||||
score: number;
|
||||
} => Boolean(candidate),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
right.effect.hpRestore - left.effect.hpRestore ||
|
||||
right.effect.manaRestore - left.effect.manaRestore ||
|
||||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildBattleSkillOptions(session: RuntimeSession) {
|
||||
@@ -762,7 +813,9 @@ function buildBattleSkillOptions(session: RuntimeSession) {
|
||||
return character.skills.map((skill) => {
|
||||
const remainingCooldown = cooldowns[skill.id] ?? 0;
|
||||
const damage = resolvePlayerOutgoingDamageResult(
|
||||
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
|
||||
session.rawGameState as Parameters<
|
||||
typeof resolvePlayerOutgoingDamageResult
|
||||
>[0],
|
||||
character,
|
||||
skill.damage,
|
||||
1,
|
||||
@@ -776,6 +829,7 @@ function buildBattleSkillOptions(session: RuntimeSession) {
|
||||
|
||||
if (remainingCooldown > 0) {
|
||||
return buildBattleDisabledOption({
|
||||
session,
|
||||
functionId: 'battle_use_skill',
|
||||
actionText: skill.name,
|
||||
detailText,
|
||||
@@ -786,6 +840,7 @@ function buildBattleSkillOptions(session: RuntimeSession) {
|
||||
|
||||
if (skill.manaCost > session.playerMana) {
|
||||
return buildBattleDisabledOption({
|
||||
session,
|
||||
functionId: 'battle_use_skill',
|
||||
actionText: skill.name,
|
||||
detailText,
|
||||
@@ -794,7 +849,7 @@ function buildBattleSkillOptions(session: RuntimeSession) {
|
||||
});
|
||||
}
|
||||
|
||||
return buildOptionView('battle_use_skill', {
|
||||
return buildOptionView(session, 'battle_use_skill', {
|
||||
actionText: skill.name,
|
||||
detailText,
|
||||
payload: { skillId: skill.id },
|
||||
@@ -807,7 +862,9 @@ function buildBattleActionOptions(session: RuntimeSession) {
|
||||
const itemCandidate = pickPreferredBattleItem(session);
|
||||
const basicAttackDamage = character
|
||||
? resolvePlayerOutgoingDamageResult(
|
||||
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
|
||||
session.rawGameState as Parameters<
|
||||
typeof resolvePlayerOutgoingDamageResult
|
||||
>[0],
|
||||
character,
|
||||
buildBasicAttackBaseDamage(character),
|
||||
1,
|
||||
@@ -816,30 +873,31 @@ function buildBattleActionOptions(session: RuntimeSession) {
|
||||
: 0;
|
||||
|
||||
return [
|
||||
buildOptionView('battle_attack_basic', {
|
||||
buildOptionView(session, 'battle_attack_basic', {
|
||||
detailText:
|
||||
basicAttackDamage > 0
|
||||
? `不耗蓝 / 伤害 ${basicAttackDamage}`
|
||||
: '不耗蓝的基础攻击',
|
||||
}),
|
||||
buildOptionView('battle_recover_breath', {
|
||||
buildOptionView(session, 'battle_recover_breath', {
|
||||
actionText: '恢复',
|
||||
detailText: '回血 12 / 回蓝 9 / 冷却 -1',
|
||||
}),
|
||||
itemCandidate
|
||||
? buildOptionView('inventory_use', {
|
||||
? buildOptionView(session, 'inventory_use', {
|
||||
actionText: `使用物品:${itemCandidate.item.name}`,
|
||||
detailText: buildBattleItemSummary(itemCandidate.effect),
|
||||
payload: { itemId: itemCandidate.item.id },
|
||||
})
|
||||
: buildBattleDisabledOption({
|
||||
session,
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用物品',
|
||||
detailText: '当前没有可直接结算的战斗消耗品',
|
||||
reason: '暂无可用物品',
|
||||
}),
|
||||
...buildBattleSkillOptions(session),
|
||||
buildOptionView('battle_escape_breakout'),
|
||||
buildOptionView(session, 'battle_escape_breakout'),
|
||||
] satisfies RuntimeStoryOptionView[];
|
||||
}
|
||||
|
||||
@@ -875,7 +933,10 @@ export function loadRuntimeSession(
|
||||
sceneHostileNpcs,
|
||||
inBattle,
|
||||
playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))),
|
||||
playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))),
|
||||
playerMaxHp: Math.max(
|
||||
1,
|
||||
Math.round(readNumber(rawGameState.playerMaxHp, 1)),
|
||||
),
|
||||
playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))),
|
||||
playerMaxMana: Math.max(
|
||||
1,
|
||||
@@ -953,6 +1014,7 @@ export function setEncounterNpcState(
|
||||
}
|
||||
|
||||
function buildOptionView(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
overrides: Partial<RuntimeStoryOptionView> = {},
|
||||
): RuntimeStoryOptionView {
|
||||
@@ -963,6 +1025,7 @@ function buildOptionView(
|
||||
actionText: functionId,
|
||||
detailText: '',
|
||||
scope: 'story',
|
||||
interaction: buildOptionInteraction(session, functionId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -972,6 +1035,7 @@ function buildOptionView(
|
||||
actionText: definition.actionText,
|
||||
detailText: definition.detailText,
|
||||
scope: definition.scope,
|
||||
interaction: buildOptionInteraction(session, functionId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1033,38 +1097,42 @@ export function buildAvailableOptions(session: RuntimeSession) {
|
||||
const npcState = getEncounterNpcState(session);
|
||||
if (session.currentEncounter.hostile) {
|
||||
return [
|
||||
buildOptionView('npc_fight'),
|
||||
buildOptionView('npc_leave'),
|
||||
buildOptionView(session, 'npc_fight'),
|
||||
buildOptionView(session, 'npc_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!session.npcInteractionActive) {
|
||||
return [
|
||||
buildOptionView('npc_preview_talk'),
|
||||
buildOptionView('npc_fight'),
|
||||
buildOptionView('npc_leave'),
|
||||
buildOptionView(session, 'npc_preview_talk'),
|
||||
buildOptionView(session, 'npc_fight'),
|
||||
buildOptionView(session, 'npc_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
const activeQuest = getActiveEncounterQuest(session);
|
||||
const options = [
|
||||
buildOptionView('npc_chat'),
|
||||
buildOptionView('npc_help', npcState?.helpUsed
|
||||
? {
|
||||
disabled: true,
|
||||
reason: '当前 NPC 的一次性援手已经用完了。',
|
||||
}
|
||||
: {}),
|
||||
buildOptionView('npc_spar'),
|
||||
buildOptionView('npc_fight'),
|
||||
buildOptionView(session, 'npc_chat'),
|
||||
buildOptionView(
|
||||
session,
|
||||
'npc_help',
|
||||
npcState?.helpUsed
|
||||
? {
|
||||
disabled: true,
|
||||
reason: '当前 NPC 的一次性援手已经用完了。',
|
||||
}
|
||||
: {},
|
||||
),
|
||||
buildOptionView(session, 'npc_spar'),
|
||||
buildOptionView(session, 'npc_fight'),
|
||||
];
|
||||
|
||||
if ((npcState?.inventory?.length ?? 0) > 0) {
|
||||
options.push(buildOptionView('npc_trade'));
|
||||
options.push(buildOptionView(session, 'npc_trade'));
|
||||
}
|
||||
|
||||
if (hasGiftablePlayerInventory(session)) {
|
||||
options.push(buildOptionView('npc_gift'));
|
||||
options.push(buildOptionView(session, 'npc_gift'));
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1072,14 +1140,15 @@ export function buildAvailableOptions(session: RuntimeSession) {
|
||||
(activeQuest.status === 'completed' ||
|
||||
activeQuest.status === 'ready_to_turn_in')
|
||||
) {
|
||||
options.push(buildOptionView('npc_quest_turn_in'));
|
||||
options.push(buildOptionView(session, 'npc_quest_turn_in'));
|
||||
} else if (!activeQuest) {
|
||||
options.push(buildOptionView('npc_quest_accept'));
|
||||
options.push(buildOptionView(session, 'npc_quest_accept'));
|
||||
}
|
||||
|
||||
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
|
||||
options.push(
|
||||
buildOptionView(
|
||||
session,
|
||||
'npc_recruit',
|
||||
session.companions.length >= MAX_TASK5_COMPANIONS
|
||||
? {
|
||||
@@ -1091,15 +1160,15 @@ export function buildAvailableOptions(session: RuntimeSession) {
|
||||
);
|
||||
}
|
||||
|
||||
options.push(buildOptionView('npc_leave'));
|
||||
options.push(buildOptionView(session, 'npc_leave'));
|
||||
return options;
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'treasure') {
|
||||
return [
|
||||
buildOptionView('treasure_secure'),
|
||||
buildOptionView('treasure_inspect'),
|
||||
buildOptionView('treasure_leave'),
|
||||
buildOptionView(session, 'treasure_secure'),
|
||||
buildOptionView(session, 'treasure_inspect'),
|
||||
buildOptionView(session, 'treasure_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1110,7 +1179,7 @@ export function buildAvailableOptions(session: RuntimeSession) {
|
||||
'idle_explore_forward',
|
||||
'idle_travel_next_scene',
|
||||
'story_continue_adventure',
|
||||
].map((functionId) => buildOptionView(functionId));
|
||||
].map((functionId) => buildOptionView(session, functionId));
|
||||
}
|
||||
|
||||
function buildEncounterViewModel(
|
||||
@@ -1189,6 +1258,7 @@ export function buildLegacyCurrentStory(
|
||||
text: option.actionText,
|
||||
detailText: option.detailText,
|
||||
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
|
||||
interaction: option.interaction,
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
@@ -1221,8 +1291,10 @@ export function syncRawGameState(session: RuntimeSession) {
|
||||
session.rawGameState.npcStates = cloneJson(session.npcStates);
|
||||
session.rawGameState.companions = cloneJson(session.companions);
|
||||
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
|
||||
session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome;
|
||||
session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null;
|
||||
session.rawGameState.currentNpcBattleOutcome =
|
||||
session.currentNpcBattleOutcome;
|
||||
session.rawGameState.currentBattleNpcId =
|
||||
session.currentEncounter?.id ?? null;
|
||||
session.rawGameState.activeCombatEffects = [];
|
||||
session.rawGameState.playerActionMode = 'idle';
|
||||
session.rawGameState.scrollWorld = false;
|
||||
|
||||
@@ -160,7 +160,15 @@ function withBearer(token: string, init: TestRequestInit = {}) {
|
||||
} satisfies TestRequestInit;
|
||||
}
|
||||
|
||||
async function putSnapshot(baseUrl: string, token: string, gameState: unknown) {
|
||||
async function putSnapshot(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
gameState: unknown,
|
||||
currentStory: unknown = {
|
||||
text: '初始化剧情',
|
||||
options: [],
|
||||
},
|
||||
) {
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(token, {
|
||||
@@ -168,10 +176,7 @@ async function putSnapshot(baseUrl: string, token: string, gameState: unknown) {
|
||||
body: JSON.stringify({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '初始化剧情',
|
||||
options: [],
|
||||
},
|
||||
currentStory,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -240,6 +245,73 @@ function createTask6GameState(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingQuestOfferCurrentStory(quest: Record<string, unknown>) {
|
||||
return {
|
||||
text: '巡路人终于把真正的委托说了出来。',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat_quest_offer_view',
|
||||
actionText: '查看任务',
|
||||
text: '查看任务',
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
functionId: 'npc_chat_quest_offer_replace',
|
||||
actionText: '更换任务',
|
||||
text: '更换任务',
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
functionId: 'npc_chat_quest_offer_abandon',
|
||||
actionText: '放弃任务',
|
||||
text: '放弃任务',
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '巡路人',
|
||||
text: '这件事我只想托给你。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc_scout_01',
|
||||
npcName: '巡路人',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const QUEST_BATTLE_SCENE = {
|
||||
id: 'quest-bridge',
|
||||
name: '断桥口',
|
||||
@@ -325,6 +397,11 @@ test('runtime story actions resolve npc chat on the server and persist updated a
|
||||
} | null;
|
||||
availableOptions: Array<{
|
||||
functionId: string;
|
||||
interaction?: {
|
||||
kind: string;
|
||||
npcId?: string;
|
||||
action?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
presentation: {
|
||||
@@ -344,15 +421,28 @@ test('runtime story actions resolve npc chat on the server and persist updated a
|
||||
(option) => option.functionId === 'npc_help',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
payload.viewModel.availableOptions.find(
|
||||
(option) => option.functionId === 'npc_help',
|
||||
)?.interaction,
|
||||
{
|
||||
kind: 'npc',
|
||||
npcId: 'npc_merchant_01',
|
||||
action: 'help',
|
||||
},
|
||||
);
|
||||
assert.ok(
|
||||
payload.patches.some((patch) => patch.type === 'npc_affinity_changed'),
|
||||
);
|
||||
|
||||
const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
const snapshotResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
const snapshotPayload = (await snapshotResponse.json()) as {
|
||||
gameState: {
|
||||
runtimeActionVersion: number;
|
||||
@@ -369,14 +459,95 @@ test('runtime story actions resolve npc chat on the server and persist updated a
|
||||
|
||||
assert.equal(snapshotResponse.status, 200);
|
||||
assert.equal(snapshotPayload.gameState.runtimeActionVersion, 1);
|
||||
assert.equal(snapshotPayload.gameState.npcStates.npc_merchant_01.affinity, 52);
|
||||
assert.equal(
|
||||
snapshotPayload.gameState.npcStates.npc_merchant_01.affinity,
|
||||
52,
|
||||
);
|
||||
assert.match(snapshotPayload.currentStory.text, /沈七/u);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story state exposes npc interaction metadata directly from the server option builder', async () => {
|
||||
await withTestServer('npc-state-options', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_npc_state', 'secret123');
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_merchant_01',
|
||||
npcName: '沈七',
|
||||
npcDescription: '腰间挂着药囊的行商',
|
||||
context: '受伤行商',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
npcStates: {
|
||||
npc_merchant_01: {
|
||||
affinity: 46,
|
||||
chattedCount: 1,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/state/runtime-main`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
viewModel: {
|
||||
availableOptions: Array<{
|
||||
functionId: string;
|
||||
interaction?: {
|
||||
kind: string;
|
||||
npcId?: string;
|
||||
action?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(
|
||||
payload.viewModel.availableOptions.find(
|
||||
(option) => option.functionId === 'npc_chat',
|
||||
)?.interaction,
|
||||
{
|
||||
kind: 'npc',
|
||||
npcId: 'npc_merchant_01',
|
||||
action: 'chat',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
payload.viewModel.availableOptions.find(
|
||||
(option) => option.functionId === 'npc_help',
|
||||
)?.interaction,
|
||||
{
|
||||
kind: 'npc',
|
||||
npcId: 'npc_merchant_01',
|
||||
action: 'help',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => {
|
||||
await withTestServer('combat-finisher', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123');
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_combat_finisher',
|
||||
'secret123',
|
||||
);
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
@@ -457,7 +628,10 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.viewModel.encounter, null);
|
||||
assert.equal(payload.viewModel.status.inBattle, false);
|
||||
assert.equal(payload.viewModel.status.currentNpcBattleOutcome, 'fight_victory');
|
||||
assert.equal(
|
||||
payload.viewModel.status.currentNpcBattleOutcome,
|
||||
'fight_victory',
|
||||
);
|
||||
assert.equal(payload.presentation.battle?.outcome, 'victory');
|
||||
assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12);
|
||||
assert.ok(
|
||||
@@ -466,11 +640,14 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
),
|
||||
);
|
||||
|
||||
const snapshotResponse = await httpRequest(`${baseUrl}/api/runtime/save/snapshot`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
const snapshotResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
const snapshotPayload = (await snapshotResponse.json()) as {
|
||||
gameState: {
|
||||
inBattle: boolean;
|
||||
@@ -484,7 +661,10 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
assert.equal(snapshotPayload.gameState.inBattle, false);
|
||||
assert.equal(snapshotPayload.gameState.currentEncounter, null);
|
||||
assert.deepEqual(snapshotPayload.gameState.sceneHostileNpcs, []);
|
||||
assert.equal(snapshotPayload.gameState.currentNpcBattleOutcome, 'fight_victory');
|
||||
assert.equal(
|
||||
snapshotPayload.gameState.currentNpcBattleOutcome,
|
||||
'fight_victory',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -634,7 +814,11 @@ test('runtime story state exposes the single-action combat option pool with runt
|
||||
|
||||
test('runtime story actions resolve battle_use_skill as a single ongoing combat turn', async () => {
|
||||
await withTestServer('combat-use-skill', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_use_skill', 'secret123');
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_combat_use_skill',
|
||||
'secret123',
|
||||
);
|
||||
const playerCharacter = {
|
||||
...requirePlayerCharacter(),
|
||||
skills: [
|
||||
@@ -768,13 +952,19 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat
|
||||
assert.equal(payload.serverVersion, 1);
|
||||
assert.equal(payload.presentation.battle?.outcome, 'ongoing');
|
||||
assert.ok((payload.presentation.battle?.damageDealt ?? 0) > 0);
|
||||
assert.equal(payload.presentation.storyText, payload.presentation.resultText);
|
||||
assert.equal(
|
||||
payload.presentation.storyText,
|
||||
payload.presentation.resultText,
|
||||
);
|
||||
assert.match(payload.presentation.storyText, /试锋斩/u);
|
||||
assert.equal(payload.viewModel.status.inBattle, true);
|
||||
assert.equal(payload.viewModel.player.mana, 5);
|
||||
assert.equal(payload.snapshot.gameState.playerMana, 5);
|
||||
assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 2);
|
||||
assert.equal(payload.snapshot.gameState.activeBuildBuffs[0]?.id, 'slash:buff');
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.activeBuildBuffs[0]?.id,
|
||||
'slash:buff',
|
||||
);
|
||||
assert.ok(
|
||||
payload.patches.some(
|
||||
(patch) =>
|
||||
@@ -797,7 +987,11 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat
|
||||
|
||||
test('runtime story actions resolve inventory_use and persist updated resources', async () => {
|
||||
await withTestServer('task6-inventory-use', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123');
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_task6_inventory',
|
||||
'secret123',
|
||||
);
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
@@ -957,14 +1151,24 @@ test('runtime story actions resolve equipment_equip and persist updated loadout'
|
||||
assert.ok(payload.viewModel.player.maxHp > 40);
|
||||
assert.match(payload.presentation.storyText, /镇岳甲/u);
|
||||
assert.equal(payload.snapshot.gameState.playerInventory.length, 0);
|
||||
assert.equal(payload.snapshot.gameState.playerEquipment.armor?.id, 'ward-mail');
|
||||
assert.equal(payload.snapshot.gameState.playerEquipment.armor?.name, '镇岳甲');
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.playerEquipment.armor?.id,
|
||||
'ward-mail',
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.playerEquipment.armor?.name,
|
||||
'镇岳甲',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve npc_trade buy transactions on the server', async () => {
|
||||
await withTestServer('task6-trade-buy', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_task6_trade_buy', 'secret123');
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_task6_trade_buy',
|
||||
'secret123',
|
||||
);
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
@@ -1044,7 +1248,8 @@ test('runtime story actions resolve npc_trade buy transactions on the server', a
|
||||
assert.equal(payload.snapshot.gameState.playerInventory[0]?.name, '回气散');
|
||||
assert.equal(payload.snapshot.gameState.playerInventory[0]?.quantity, 2);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0]?.quantity,
|
||||
payload.snapshot.gameState.npcStates.npc_merchant_02.inventory[0]
|
||||
?.quantity,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -1124,15 +1329,26 @@ test('runtime story actions resolve npc_gift and persist affinity changes', asyn
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.snapshot.gameState.playerInventory.length, 0);
|
||||
assert.ok(payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22);
|
||||
assert.equal(payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven, 1);
|
||||
assert.ok(payload.patches.some((patch) => patch.type === 'npc_affinity_changed'));
|
||||
assert.ok(
|
||||
payload.snapshot.gameState.npcStates.npc_merchant_03.affinity > 22,
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.npcStates.npc_merchant_03.giftsGiven,
|
||||
1,
|
||||
);
|
||||
assert.ok(
|
||||
payload.patches.some((patch) => patch.type === 'npc_affinity_changed'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve npc_quest_accept and persist accepted quests', async () => {
|
||||
await withTestServer('task6-quest-accept', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_task6_quest_accept', 'secret123');
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_task6_quest_accept',
|
||||
'secret123',
|
||||
);
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
@@ -1191,13 +1407,129 @@ test('runtime story actions resolve npc_quest_accept and persist accepted quests
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.snapshot.gameState.quests.length, 1);
|
||||
assert.equal(payload.snapshot.gameState.quests[0]?.issuerNpcId, 'npc_scout_01');
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.quests[0]?.issuerNpcId,
|
||||
'npc_scout_01',
|
||||
);
|
||||
assert.equal(payload.snapshot.gameState.quests[0]?.status, 'active');
|
||||
assert.equal(payload.snapshot.gameState.runtimeStats.questsAccepted, 1);
|
||||
assert.match(payload.presentation.storyText, /正式把委托交到了你手上/u);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions accept pending npc quest offers from saved chat state', async () => {
|
||||
await withTestServer('task6-quest-accept-pending-offer', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_q_accept_pending',
|
||||
'secret123',
|
||||
);
|
||||
const seededQuest = buildQuestForEncounter({
|
||||
issuerNpcId: 'npc_scout_01',
|
||||
issuerNpcName: '巡路人',
|
||||
roleText: '巡路人',
|
||||
scene: QUEST_BATTLE_SCENE,
|
||||
worldType: 'WUXIA',
|
||||
currentQuests: [],
|
||||
});
|
||||
assert.ok(seededQuest);
|
||||
const pendingQuest = {
|
||||
...seededQuest,
|
||||
id: 'quest-pending-offer',
|
||||
};
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_scout_01',
|
||||
npcName: '巡路人',
|
||||
npcDescription: '熟悉桥口风向的探子',
|
||||
context: '巡路人',
|
||||
characterId: 'scout-quest',
|
||||
},
|
||||
currentScenePreset: QUEST_BATTLE_SCENE,
|
||||
npcInteractionActive: true,
|
||||
npcStates: {
|
||||
npc_scout_01: {
|
||||
affinity: 16,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
createPendingQuestOfferCurrentStory(pendingQuest),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_quest_accept',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
snapshot: {
|
||||
gameState: {
|
||||
quests: Array<{ id: string; issuerNpcId: string; status: string }>;
|
||||
};
|
||||
currentStory: {
|
||||
displayMode?: string;
|
||||
options?: Array<{ actionText?: string }>;
|
||||
dialogue?: Array<{ speaker?: string; text?: string }>;
|
||||
npcChatState?: {
|
||||
pendingQuestOffer?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.snapshot.gameState.quests.length, 1);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.quests[0]?.id,
|
||||
'quest-pending-offer',
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.quests[0]?.issuerNpcId,
|
||||
'npc_scout_01',
|
||||
);
|
||||
assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue');
|
||||
assert.equal(
|
||||
payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null,
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
payload.snapshot.currentStory.options?.map((option) => option.actionText),
|
||||
[
|
||||
'这件事里你最担心哪一步',
|
||||
'我回来时你最想先知道什么',
|
||||
'除了这份委托,你还想提醒我什么',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.currentStory.dialogue?.at(-2)?.text,
|
||||
'这件事我愿意接下,你把关键要点交给我。',
|
||||
);
|
||||
assert.match(
|
||||
payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '',
|
||||
/那就拜托你了。/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions progress quests from combat victories and npc turn-ins', async () => {
|
||||
await withTestServer('task6-quest-progress-turnin', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_qp_turnin', 'secret123');
|
||||
@@ -1357,10 +1689,15 @@ test('runtime story actions progress quests from combat victories and npc turn-i
|
||||
};
|
||||
|
||||
assert.equal(turnInResponse.status, 200);
|
||||
assert.equal(turnInPayload.snapshot.gameState.quests[0]?.status, 'turned_in');
|
||||
assert.equal(
|
||||
turnInPayload.snapshot.gameState.quests[0]?.status,
|
||||
'turned_in',
|
||||
);
|
||||
assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12);
|
||||
assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0);
|
||||
assert.ok(turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6);
|
||||
assert.ok(
|
||||
turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,16 +14,17 @@ import {
|
||||
} from '../ai/chatPromptBuilders.js';
|
||||
import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js';
|
||||
import { resolveCombatAction } from '../combat/combatResolutionService.js';
|
||||
import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventoryStoryActionService.js';
|
||||
import {
|
||||
isSupportedInventoryStoryFunctionId,
|
||||
resolveInventoryStoryAction,
|
||||
} from '../inventory/inventoryStoryActionService.js';
|
||||
import {
|
||||
ensureNpcInventorySessionState,
|
||||
isSupportedNpcInventoryStoryFunctionId,
|
||||
resolveNpcInventoryStoryAction,
|
||||
} from '../inventory/npcInventoryStoryActionService.js';
|
||||
import { resolveNpcInteraction } from '../npc/npcInteractionService.js';
|
||||
import {
|
||||
applyQuestSignalsForResolvedAction,
|
||||
} from '../quest/questRuntimeSignalService.js';
|
||||
import { applyQuestSignalsForResolvedAction } from '../quest/questRuntimeSignalService.js';
|
||||
import {
|
||||
isSupportedQuestStoryFunctionId,
|
||||
resolveQuestStoryAction,
|
||||
@@ -92,7 +93,10 @@ const DEFAULT_STORY_OPTION_VISUALS = {
|
||||
monsterChanges: [],
|
||||
} as const;
|
||||
|
||||
function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) {
|
||||
function resolveActionText(
|
||||
defaultText: string,
|
||||
request: RuntimeStoryActionRequest,
|
||||
) {
|
||||
const payload = request.action.payload;
|
||||
const optionText =
|
||||
payload && typeof payload.optionText === 'string'
|
||||
@@ -114,54 +118,14 @@ function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function buildStoryOptionInteraction(
|
||||
session: RuntimeSession,
|
||||
option: RuntimeStoryOptionView,
|
||||
) {
|
||||
const encounter = session.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = encounter.id || encounter.npcName;
|
||||
const npcActionMap: Record<string, JsonRecord> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[option.functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, JsonRecord> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[option.functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildStoryOptionFromRuntimeOption(
|
||||
session: RuntimeSession,
|
||||
option: RuntimeStoryOptionView,
|
||||
) {
|
||||
function buildStoryOptionFromRuntimeOption(option: RuntimeStoryOptionView) {
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: option.detailText,
|
||||
visuals: DEFAULT_STORY_OPTION_VISUALS,
|
||||
interaction: buildStoryOptionInteraction(session, option),
|
||||
interaction: option.interaction,
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
@@ -169,10 +133,10 @@ function buildStoryOptionFromRuntimeOption(
|
||||
}
|
||||
|
||||
function buildStoryOptionsFromRuntimeOptions(
|
||||
session: RuntimeSession,
|
||||
_session: RuntimeSession,
|
||||
options: RuntimeStoryOptionView[],
|
||||
) {
|
||||
return options.map((option) => buildStoryOptionFromRuntimeOption(session, option));
|
||||
return options.map((option) => buildStoryOptionFromRuntimeOption(option));
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
@@ -284,10 +248,9 @@ function buildDialogueCurrentStory(params: {
|
||||
}) {
|
||||
return {
|
||||
text: params.text,
|
||||
options: buildStoryOptionsFromRuntimeOptions(
|
||||
params.session,
|
||||
[CONTINUE_ADVENTURE_OPTION],
|
||||
),
|
||||
options: buildStoryOptionsFromRuntimeOptions(params.session, [
|
||||
CONTINUE_ADVENTURE_OPTION,
|
||||
]),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: parseDialogueTurns(params.text, params.npcName),
|
||||
streaming: false,
|
||||
@@ -298,7 +261,150 @@ function buildDialogueCurrentStory(params: {
|
||||
} satisfies JsonRecord;
|
||||
}
|
||||
|
||||
function buildStoryPromptContext(session: RuntimeSession, extras: JsonRecord = {}) {
|
||||
function readDialogueTurns(currentStory: unknown) {
|
||||
if (!isObject(currentStory) || !Array.isArray(currentStory.dialogue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return currentStory.dialogue.filter(isObject).map((turn) => ({ ...turn }));
|
||||
}
|
||||
|
||||
function readPendingQuestOfferContext(params: {
|
||||
currentStory: unknown;
|
||||
encounterId: string;
|
||||
}) {
|
||||
if (!isObject(params.currentStory)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcChatState = isObject(params.currentStory.npcChatState)
|
||||
? params.currentStory.npcChatState
|
||||
: null;
|
||||
const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer)
|
||||
? npcChatState.pendingQuestOffer
|
||||
: null;
|
||||
const quest = isObject(pendingQuestOffer?.quest)
|
||||
? pendingQuestOffer.quest
|
||||
: null;
|
||||
|
||||
if (!quest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcId = readString(npcChatState?.npcId);
|
||||
if (npcId && npcId !== params.encounterId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
dialogue: readDialogueTurns(params.currentStory),
|
||||
turnCount:
|
||||
typeof npcChatState?.turnCount === 'number' &&
|
||||
Number.isFinite(npcChatState.turnCount)
|
||||
? Math.max(0, Math.round(npcChatState.turnCount))
|
||||
: 0,
|
||||
customInputPlaceholder:
|
||||
readString(npcChatState?.customInputPlaceholder) ||
|
||||
'输入你想对 TA 说的话',
|
||||
quest,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNpcChatSuggestionOption(
|
||||
encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' },
|
||||
actionText: string,
|
||||
) {
|
||||
return buildStoryOptionFromRuntimeOption({
|
||||
functionId: 'npc_chat',
|
||||
actionText,
|
||||
detailText: '',
|
||||
scope: 'npc',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id,
|
||||
action: 'chat',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildQuestAcceptedNpcReplyText(quest: JsonRecord) {
|
||||
const activeStepId = readString(quest.activeStepId);
|
||||
const activeStep = readArray(quest.steps)
|
||||
.filter(isObject)
|
||||
.find((step) => readString(step.id) === activeStepId);
|
||||
const revealText = readString(activeStep?.revealText);
|
||||
const summary = readString(quest.summary);
|
||||
|
||||
if (revealText) {
|
||||
return `那就拜托你了。${revealText}`;
|
||||
}
|
||||
|
||||
return `那就拜托你了。${summary || '这份委托的关键要点我已经交给你。'}`;
|
||||
}
|
||||
|
||||
function buildQuestAcceptedSuggestionOptions(
|
||||
encounter: RuntimeSession['currentEncounter'] & { kind: 'npc' },
|
||||
) {
|
||||
return [
|
||||
'这件事里你最担心哪一步',
|
||||
'我回来时你最想先知道什么',
|
||||
'除了这份委托,你还想提醒我什么',
|
||||
].map((actionText) => buildNpcChatSuggestionOption(encounter, actionText));
|
||||
}
|
||||
|
||||
function buildPendingQuestAcceptedCurrentStory(params: {
|
||||
session: RuntimeSession;
|
||||
currentStory: unknown;
|
||||
}) {
|
||||
const encounter = params.session.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingOffer = readPendingQuestOfferContext({
|
||||
currentStory: params.currentStory,
|
||||
encounterId: encounter.id,
|
||||
});
|
||||
if (!pendingOffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dialogue = [
|
||||
...pendingOffer.dialogue,
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '这件事我愿意接下,你把关键要点交给我。',
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: buildQuestAcceptedNpcReplyText(pendingOffer.quest),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
text: dialogue
|
||||
.map((turn) => readString(turn.text))
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
options: buildQuestAcceptedSuggestionOptions(encounter),
|
||||
displayMode: 'dialogue',
|
||||
dialogue,
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: encounter.id,
|
||||
npcName: encounter.npcName,
|
||||
turnCount: pendingOffer.turnCount,
|
||||
customInputPlaceholder: pendingOffer.customInputPlaceholder,
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
} satisfies JsonRecord;
|
||||
}
|
||||
|
||||
function buildStoryPromptContext(
|
||||
session: RuntimeSession,
|
||||
extras: JsonRecord = {},
|
||||
) {
|
||||
const scenePreset = isObject(session.rawGameState.currentScenePreset)
|
||||
? session.rawGameState.currentScenePreset
|
||||
: null;
|
||||
@@ -409,11 +515,19 @@ function buildOpeningCampChatContext(session: RuntimeSession) {
|
||||
|
||||
for (let index = 0; index < session.storyHistory.length - 1; index += 1) {
|
||||
const entry = session.storyHistory[index];
|
||||
if (!entry || entry.historyRole !== 'action' || entry.text !== openingActionText) {
|
||||
if (
|
||||
!entry ||
|
||||
entry.historyRole !== 'action' ||
|
||||
entry.text !== openingActionText
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let nextIndex = index + 1; nextIndex < session.storyHistory.length; nextIndex += 1) {
|
||||
for (
|
||||
let nextIndex = index + 1;
|
||||
nextIndex < session.storyHistory.length;
|
||||
nextIndex += 1
|
||||
) {
|
||||
const nextEntry = session.storyHistory[nextIndex];
|
||||
if (!nextEntry) {
|
||||
continue;
|
||||
@@ -517,7 +631,12 @@ async function generateNpcDialoguePayload(params: {
|
||||
const playerCharacter = isObject(params.session.rawGameState.playerCharacter)
|
||||
? params.session.rawGameState.playerCharacter
|
||||
: null;
|
||||
if (!encounter || encounter.kind !== 'npc' || !playerCharacter || !params.session.worldType) {
|
||||
if (
|
||||
!encounter ||
|
||||
encounter.kind !== 'npc' ||
|
||||
!playerCharacter ||
|
||||
!params.session.worldType
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -533,7 +652,9 @@ async function generateNpcDialoguePayload(params: {
|
||||
worldType: params.session.worldType,
|
||||
character: playerCharacter,
|
||||
encounter: params.session.rawGameState.currentEncounter ?? {},
|
||||
monsters: readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
|
||||
monsters: readArray(
|
||||
params.session.rawGameState.sceneHostileNpcs,
|
||||
).filter(isObject),
|
||||
history,
|
||||
context: buildStoryPromptContext(params.session, {
|
||||
...buildOpeningCampChatContext(params.session),
|
||||
@@ -637,7 +758,8 @@ function resolveStoryFlowAction(
|
||||
case 'story_continue_adventure':
|
||||
return {
|
||||
actionText: '继续推进冒险',
|
||||
resultText: '你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。',
|
||||
resultText:
|
||||
'你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'story_opening_camp_dialogue': {
|
||||
@@ -669,7 +791,8 @@ function resolveStoryFlowAction(
|
||||
|
||||
return {
|
||||
actionText: '交换开场判断',
|
||||
resultText: '你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。',
|
||||
resultText:
|
||||
'你先把眼前局势梳理了一遍,为后续真正推进对话和行动留出了空间。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
}
|
||||
@@ -677,7 +800,8 @@ function resolveStoryFlowAction(
|
||||
clearEncounterState(session);
|
||||
return {
|
||||
actionText: '返回营地',
|
||||
resultText: '你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。',
|
||||
resultText:
|
||||
'你主动结束了当前遭遇,把这轮流程带回了更安全的营地节奏里。',
|
||||
patches: [
|
||||
normalizeStatusPatch(session),
|
||||
{
|
||||
@@ -689,13 +813,15 @@ function resolveStoryFlowAction(
|
||||
case 'idle_call_out':
|
||||
return {
|
||||
actionText: '主动出声试探',
|
||||
resultText: '你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。',
|
||||
resultText:
|
||||
'你的喊话打破了当前的静场,周围潜着的动静也因此更难继续藏着。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'idle_explore_forward':
|
||||
return {
|
||||
actionText: '继续向前探索',
|
||||
resultText: '你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。',
|
||||
resultText:
|
||||
'你没有停在原地,而是继续往前压,把下一段遭遇主动推到了自己面前。',
|
||||
patches: [normalizeStatusPatch(session)],
|
||||
};
|
||||
case 'idle_observe_signs':
|
||||
@@ -706,7 +832,10 @@ function resolveStoryFlowAction(
|
||||
};
|
||||
case 'idle_rest_focus':
|
||||
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 8);
|
||||
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 6);
|
||||
session.playerMana = Math.min(
|
||||
session.playerMaxMana,
|
||||
session.playerMana + 6,
|
||||
);
|
||||
return {
|
||||
actionText: '原地调息',
|
||||
resultText: '你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。',
|
||||
@@ -804,7 +933,9 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
|
||||
resolution = resolveNpcInventoryStoryAction(session, params.request);
|
||||
} else if (isSupportedQuestStoryFunctionId(functionId)) {
|
||||
resolution = resolveQuestStoryAction(session, params.request);
|
||||
resolution = resolveQuestStoryAction(session, params.request, {
|
||||
currentStory: hydratedSnapshot.currentStory,
|
||||
});
|
||||
} else if (isSupportedTreasureStoryFunctionId(functionId)) {
|
||||
resolution = resolveTreasureStoryAction(session, params.request);
|
||||
} else if (isStoryFunctionId(functionId)) {
|
||||
@@ -836,9 +967,21 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
syncRawGameState(session);
|
||||
ensureNpcInventorySessionState(session);
|
||||
let options = buildAvailableOptions(session);
|
||||
let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(storyText, options);
|
||||
let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(
|
||||
storyText,
|
||||
options,
|
||||
);
|
||||
const pendingQuestAcceptedCurrentStory =
|
||||
functionId === 'npc_quest_accept'
|
||||
? buildPendingQuestAcceptedCurrentStory({
|
||||
session,
|
||||
currentStory: hydratedSnapshot.currentStory,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (
|
||||
if (pendingQuestAcceptedCurrentStory) {
|
||||
savedCurrentStory = pendingQuestAcceptedCurrentStory;
|
||||
} else if (
|
||||
params.llmClient &&
|
||||
(functionId === 'npc_chat' || functionId === 'story_opening_camp_dialogue')
|
||||
) {
|
||||
|
||||
379
server-node/src/prompts/characterAssetPrompts.ts
Normal file
379
server-node/src/prompts/characterAssetPrompts.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
buildMasterPrompt,
|
||||
buildVideoActionPrompt,
|
||||
getActionTemplateById,
|
||||
} from '../../../packages/shared/src/prompts/qwenSprite.js';
|
||||
|
||||
function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
export const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
|
||||
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
|
||||
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
|
||||
输出格式必须严格为:
|
||||
{
|
||||
"visualPromptText": "角色主图提示词",
|
||||
"animationPromptText": "角色动作提示词",
|
||||
"scenePromptText": "角色关联场景提示词"
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 所有字段都必须是自然中文。
|
||||
- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
|
||||
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
|
||||
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
|
||||
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
|
||||
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
|
||||
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
|
||||
|
||||
export type CharacterPromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
export function buildFallbackCharacterPromptBundle(params: {
|
||||
characterName: string;
|
||||
roleKind: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
const roleAnchor =
|
||||
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
|
||||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
|
||||
const characterAnchor = params.characterName || '该角色';
|
||||
const descriptionAnchor =
|
||||
params.description || params.backstory || params.personality || '气质鲜明';
|
||||
const combatAnchor =
|
||||
params.combatStyle || params.motivation || '动作发力清晰';
|
||||
const tagAnchor =
|
||||
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterAnchor},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||
tagAnchor,
|
||||
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterAnchor}的核心动作试片。`,
|
||||
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
params.backstory ? `背景线索可参考:${params.backstory}。` : '',
|
||||
params.motivation
|
||||
? `环境中可埋入与当前目标相关的暗示:${params.motivation}。`
|
||||
: '',
|
||||
'整体风格克制统一,适合剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
source: 'fallback' as const,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePromptBundleValue(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength: number,
|
||||
) {
|
||||
const normalized = clampPromptSeedText(value, maxLength);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function sanitizeCharacterPromptBundle(
|
||||
value: unknown,
|
||||
fallback: CharacterPromptBundle,
|
||||
model: string,
|
||||
) {
|
||||
const record = isRecordValue(value) ? value : {};
|
||||
|
||||
return {
|
||||
visualPromptText: sanitizePromptBundleValue(
|
||||
record.visualPromptText,
|
||||
fallback.visualPromptText,
|
||||
280,
|
||||
),
|
||||
animationPromptText: sanitizePromptBundleValue(
|
||||
record.animationPromptText,
|
||||
fallback.animationPromptText,
|
||||
280,
|
||||
),
|
||||
scenePromptText: sanitizePromptBundleValue(
|
||||
record.scenePromptText,
|
||||
fallback.scenePromptText,
|
||||
320,
|
||||
),
|
||||
source: 'llm' as const,
|
||||
model: model.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAnimationPromptText(value: string, maxLength: number) {
|
||||
return value
|
||||
.replace(/\s+/gu, ' ')
|
||||
.replace(/血浆|喷血|鲜血|断肢|斩首|裸体|裸露|色情|性交/gu, '')
|
||||
.replace(/死亡|死去|击杀/gu, '倒地结束')
|
||||
.replace(/受击|受伤/gu, '失衡')
|
||||
.replace(/砍杀|斩击/gu, '挥击')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildCompactAnimationCharacterBrief(value: string) {
|
||||
const normalized = sanitizeAnimationPromptText(value, 160);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[/|\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
export function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
characterName: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
return [
|
||||
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
|
||||
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
|
||||
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
|
||||
'',
|
||||
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
|
||||
params.characterName ? `角色名称:${params.characterName}` : '',
|
||||
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
|
||||
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
|
||||
params.description ? `角色描述:${params.description}` : '',
|
||||
params.backstory ? `角色背景:${params.backstory}` : '',
|
||||
params.personality ? `角色性格:${params.personality}` : '',
|
||||
params.motivation ? `角色动机:${params.motivation}` : '',
|
||||
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
|
||||
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
|
||||
'',
|
||||
'角色卡全文:',
|
||||
params.characterBriefText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildNpcVisualPrompt(promptText: string, characterBriefText = '') {
|
||||
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return buildMasterPrompt(
|
||||
mergedBrief || '自定义世界角色,服装完整,姿态自然。',
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNpcVisualNegativePrompt() {
|
||||
return [
|
||||
'正面视角',
|
||||
'左朝向',
|
||||
'完全 90 度纯右视图',
|
||||
'镜头透视',
|
||||
'半身像',
|
||||
'脚被裁切',
|
||||
'头顶被裁切',
|
||||
'多角色',
|
||||
'复杂背景',
|
||||
'建筑场景',
|
||||
'漂浮物',
|
||||
'烟雾环境',
|
||||
'武器消失',
|
||||
'武器换手',
|
||||
'额外手臂',
|
||||
'额外腿',
|
||||
'服装变化',
|
||||
'脸部变化',
|
||||
'模糊',
|
||||
'运动模糊',
|
||||
'文字',
|
||||
'水印',
|
||||
'UI 元素',
|
||||
'软萌 Q版大头贴',
|
||||
'儿童绘本风',
|
||||
'厚涂插画感',
|
||||
'低对比柔边',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
export function buildImageSequencePrompt(
|
||||
animation: string,
|
||||
promptText: string,
|
||||
frameCount: number,
|
||||
useChromaKey: boolean,
|
||||
) {
|
||||
return [
|
||||
`同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`,
|
||||
'固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。',
|
||||
'帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。',
|
||||
useChromaKey
|
||||
? '纯绿色背景,无地面装饰,方便后期抠像。'
|
||||
: '背景尽量纯净,避免复杂场景。',
|
||||
promptText.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildNpcAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
useChromaKey: boolean;
|
||||
loop: boolean;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
}) {
|
||||
const characterBrief = buildCompactAnimationCharacterBrief(
|
||||
options.characterBriefText ?? '',
|
||||
);
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const loopRule = options.loop
|
||||
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
|
||||
: options.animation === 'die'
|
||||
? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。'
|
||||
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return [
|
||||
buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<
|
||||
typeof getActionTemplateById
|
||||
>[0],
|
||||
),
|
||||
actionDetailText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief: characterBrief || `${options.animation} 动作角色`,
|
||||
}),
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
`单人 NPC 全身动作视频,动作主题是 ${options.animation}。`,
|
||||
'角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
|
||||
'动作连贯,避免服装、发型、面部、武器随机漂移。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
characterBrief ? `角色设定:${characterBrief}` : '',
|
||||
actionDetailText,
|
||||
loopRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildArkCharacterAnimationPrompt(options: {
|
||||
animation: string;
|
||||
promptText: string;
|
||||
useChromaKey: boolean;
|
||||
loop: boolean;
|
||||
characterBriefText?: string;
|
||||
actionTemplateId?: string;
|
||||
}) {
|
||||
const normalizedAnimationName =
|
||||
options.animation.trim().replace(/\s+/gu, '_') || 'idle';
|
||||
const characterBrief = buildCompactAnimationCharacterBrief(
|
||||
options.characterBriefText ?? '',
|
||||
);
|
||||
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
|
||||
const frameRule = options.loop
|
||||
? '首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。'
|
||||
: '首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。';
|
||||
|
||||
if (options.actionTemplateId) {
|
||||
return [
|
||||
buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById(
|
||||
options.actionTemplateId as Parameters<typeof getActionTemplateById>[0],
|
||||
),
|
||||
actionDetailText,
|
||||
useChromaKey: options.useChromaKey,
|
||||
characterBrief: characterBrief || `${normalizedAnimationName} action role`,
|
||||
}),
|
||||
`动作英文名:${normalizedAnimationName}。`,
|
||||
frameRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return [
|
||||
`单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}。`,
|
||||
'角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
|
||||
'动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
|
||||
: '背景简洁纯净,无复杂场景。',
|
||||
characterBrief ? `角色设定:${characterBrief}` : '',
|
||||
actionDetailText ? `动作细节:${actionDetailText}` : '',
|
||||
frameRule,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildFallbackModerationSafeAnimationPrompt(options: {
|
||||
animation: string;
|
||||
loop: boolean;
|
||||
useChromaKey: boolean;
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.animation}。`,
|
||||
'角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。',
|
||||
options.loop
|
||||
? '循环动作直接进入稳定循环,不要静止开场,不要定格首帧。'
|
||||
: '非循环动作首尾回到角色标准站姿,中段完成动作变化。',
|
||||
options.useChromaKey
|
||||
? '背景为纯绿色绿幕,无其他人物和场景元素。'
|
||||
: '背景简洁纯净。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
645
server-node/src/prompts/customWorldPrompts.ts
Normal file
645
server-node/src/prompts/customWorldPrompts.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import type {
|
||||
CustomWorldGenerationFramework,
|
||||
CustomWorldGenerationLandmarkOutline,
|
||||
CustomWorldGenerationRoleBatchStage,
|
||||
CustomWorldGenerationRoleBatchType,
|
||||
CustomWorldGenerationRoleOutline,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldProfile,
|
||||
} from '../modules/custom-world/runtimeTypes.js';
|
||||
|
||||
const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES = [15, 30, 60, 90] as const;
|
||||
const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS = [
|
||||
'forward',
|
||||
'back',
|
||||
'left',
|
||||
'right',
|
||||
'north',
|
||||
'south',
|
||||
'east',
|
||||
'west',
|
||||
'up',
|
||||
'down',
|
||||
'inside',
|
||||
'outside',
|
||||
'portal',
|
||||
] as const;
|
||||
|
||||
function buildFrameworkSummaryText(
|
||||
framework: CustomWorldGenerationFramework,
|
||||
options: {
|
||||
maxLandmarks?: number;
|
||||
} = {},
|
||||
) {
|
||||
const maxLandmarks = options.maxLandmarks ?? MIN_CUSTOM_WORLD_LANDMARK_COUNT;
|
||||
const landmarkText = framework.landmarks
|
||||
.slice(0, maxLandmarks)
|
||||
.map(
|
||||
(landmark) =>
|
||||
`${landmark.name}(${landmark.dangerLevel},${landmark.description})`,
|
||||
)
|
||||
.join('、');
|
||||
|
||||
return [
|
||||
`世界:${framework.name}`,
|
||||
`副标题:${framework.subtitle}`,
|
||||
`世界概述:${framework.summary}`,
|
||||
`世界基调:${framework.tone}`,
|
||||
`玩家核心目标:${framework.playerGoal}`,
|
||||
framework.majorFactions.length > 0
|
||||
? `主要势力:${framework.majorFactions.join('、')}`
|
||||
: '',
|
||||
framework.coreConflicts.length > 0
|
||||
? `核心冲突:${framework.coreConflicts.join('、')}`
|
||||
: '',
|
||||
`开局归处:${framework.camp.name}(${framework.camp.description})`,
|
||||
landmarkText ? `关键场景:${landmarkText}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildLandmarkAppearanceLookup(
|
||||
framework: CustomWorldGenerationFramework,
|
||||
) {
|
||||
const lookup = new Map<string, string[]>();
|
||||
|
||||
framework.landmarks.forEach((landmark) => {
|
||||
landmark.sceneNpcNames.forEach((npcName) => {
|
||||
const key = npcName.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const current = lookup.get(key) ?? [];
|
||||
if (!current.includes(landmark.name)) {
|
||||
current.push(landmark.name);
|
||||
}
|
||||
lookup.set(key, current);
|
||||
});
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function buildRoleOutlinePromptLines(
|
||||
roleBatch: CustomWorldGenerationRoleOutline[],
|
||||
options: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
},
|
||||
) {
|
||||
const appearanceLookup =
|
||||
options.roleType === 'story'
|
||||
? buildLandmarkAppearanceLookup(options.framework)
|
||||
: new Map<string, string[]>();
|
||||
|
||||
return roleBatch
|
||||
.map((role) => {
|
||||
const appearanceText =
|
||||
options.roleType === 'story'
|
||||
? (appearanceLookup.get(role.name)?.join('、') ?? '未指定')
|
||||
: '';
|
||||
return [
|
||||
`- ${role.name} / ${role.title}`,
|
||||
`身份:${role.role}`,
|
||||
`框架描述:${role.description}`,
|
||||
`预设好感:${role.initialAffinity}`,
|
||||
role.relationshipHooks.length > 0
|
||||
? `关系切入口:${role.relationshipHooks.join('、')}`
|
||||
: '',
|
||||
role.tags.length > 0 ? `标签:${role.tags.join('、')}` : '',
|
||||
appearanceText ? `出现场景:${appearanceText}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldFrameworkPrompt(settingText: string) {
|
||||
return [
|
||||
'请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。',
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。',
|
||||
'玩家设定:',
|
||||
settingText.trim(),
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
' "name": "世界名称",',
|
||||
' "subtitle": "世界副标题",',
|
||||
' "summary": "世界概述",',
|
||||
' "tone": "世界基调",',
|
||||
' "playerGoal": "玩家核心目标",',
|
||||
' "templateWorldType": "WUXIA|XIANXIA",',
|
||||
' "majorFactions": ["势力甲", "势力乙"],',
|
||||
' "coreConflicts": ["冲突甲", "冲突乙"],',
|
||||
' "camp": {',
|
||||
' "name": "开局归处名称",',
|
||||
' "description": "这是玩家进入世界后的第一处落脚点描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
|
||||
'- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。',
|
||||
'- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。',
|
||||
'- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。',
|
||||
'- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。',
|
||||
'- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。',
|
||||
'- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。',
|
||||
'- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldFrameworkJsonRepairPrompt(
|
||||
responseText: string,
|
||||
) {
|
||||
return [
|
||||
'下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
'顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
|
||||
'不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。',
|
||||
'majorFactions 与 coreConflicts 必须是字符串数组。',
|
||||
'camp 必须是对象,且包含:name、description、dangerLevel。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldRoleOutlineBatchPrompt(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
batchCount: number;
|
||||
forbiddenNames?: string[];
|
||||
}) {
|
||||
const { framework, roleType, batchCount, forbiddenNames = [] } = params;
|
||||
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
|
||||
const label = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
|
||||
return [
|
||||
`请根据下面的世界核心信息,生成一批${label}框架名单。`,
|
||||
'后续我会继续补全人物档案,所以这一步每个角色只保留最少字段。',
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'世界核心信息:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 0 }),
|
||||
forbiddenNames.length > 0
|
||||
? `这些名字已经生成,禁止重复:${forbiddenNames.join('、')}`
|
||||
: '',
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
` "${key}": [`,
|
||||
' {',
|
||||
' "name": "角色名称",',
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "极简定位描述",',
|
||||
' "initialAffinity": 18,',
|
||||
' "relationshipHooks": ["一个关系切入口"],',
|
||||
' "tags": ["标签1", "标签2"]',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
`- 必须生成恰好 ${batchCount} 个${label}。`,
|
||||
'- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。',
|
||||
'- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。',
|
||||
'- 只保留:name、title、role、description、initialAffinity、relationshipHooks、tags。',
|
||||
'- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。',
|
||||
'- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。',
|
||||
'- initialAffinity 必须是 -40 到 90 的整数。',
|
||||
roleType === 'playable'
|
||||
? '- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。'
|
||||
: '- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldRoleOutlineBatchJsonRepairPrompt(params: {
|
||||
responseText: string;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
expectedCount: number;
|
||||
forbiddenNames?: string[];
|
||||
}) {
|
||||
const { responseText, roleType, expectedCount, forbiddenNames = [] } = params;
|
||||
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
|
||||
|
||||
return [
|
||||
`下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`,
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
`顶层必须只包含一个 ${key} 数组。`,
|
||||
`必须保留恰好 ${expectedCount} 个角色对象。`,
|
||||
forbiddenNames.length > 0
|
||||
? `禁止使用这些重复名:${forbiddenNames.join('、')}。`
|
||||
: '',
|
||||
'每个角色只包含:name、title、role、description、initialAffinity、relationshipHooks、tags。',
|
||||
'如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。',
|
||||
'不要输出 backstory、skills、landmarks 或任何其他字段。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldLandmarkSeedBatchPrompt(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
batchCount: number;
|
||||
forbiddenNames?: string[];
|
||||
}) {
|
||||
const { framework, batchCount, forbiddenNames = [] } = params;
|
||||
|
||||
return [
|
||||
'请根据下面的世界核心信息,生成一批场景地标骨架。',
|
||||
'后续我会继续补全场景角色分布和连接关系,所以这一步只保留最少字段。',
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'世界核心信息:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 0 }),
|
||||
forbiddenNames.length > 0
|
||||
? `这些场景名已经生成,禁止重复:${forbiddenNames.join('、')}`
|
||||
: '',
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
' "landmarks": [',
|
||||
' {',
|
||||
' "name": "场景名称",',
|
||||
' "description": "极简场景描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme"',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
`- 必须生成恰好 ${batchCount} 个 landmarks。`,
|
||||
'- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。',
|
||||
'- 这一步只保留:name、description、dangerLevel。',
|
||||
'- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。',
|
||||
'- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。',
|
||||
'- description 控制在 8 到 18 个汉字内。',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldLandmarkSeedBatchJsonRepairPrompt(params: {
|
||||
responseText: string;
|
||||
expectedCount: number;
|
||||
forbiddenNames?: string[];
|
||||
}) {
|
||||
const { responseText, expectedCount, forbiddenNames = [] } = params;
|
||||
|
||||
return [
|
||||
'下面这段文本本应是自定义世界场景地标骨架批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
'顶层必须只包含一个 landmarks 数组。',
|
||||
`必须保留恰好 ${expectedCount} 个地标对象。`,
|
||||
forbiddenNames.length > 0
|
||||
? `禁止使用这些重复场景名:${forbiddenNames.join('、')}。`
|
||||
: '',
|
||||
'每个地标只包含:name、description、dangerLevel。',
|
||||
'不要输出 sceneNpcNames、connections 或其他字段。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
landmarkBatch: CustomWorldGenerationLandmarkOutline[];
|
||||
storyNpcs: CustomWorldGenerationRoleOutline[];
|
||||
}) {
|
||||
const { framework, landmarkBatch, storyNpcs } = params;
|
||||
const relativePositionValues =
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.join('|');
|
||||
const allLandmarkNames = framework.landmarks.map((landmark) => landmark.name);
|
||||
const storyNpcNames = storyNpcs.map((npc) => npc.name);
|
||||
|
||||
return [
|
||||
'请根据下面的世界信息,为这一批场景补全“出现场景角色”和“地图连接关系”。',
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'世界核心信息:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 0 }),
|
||||
`全部场景名:${allLandmarkNames.join('、')}`,
|
||||
`可用场景角色名:${storyNpcNames.join('、')}`,
|
||||
'本批次场景骨架:',
|
||||
landmarkBatch
|
||||
.map(
|
||||
(landmark) =>
|
||||
`- ${landmark.name} / 危险度:${landmark.dangerLevel} / 描述:${landmark.description}`,
|
||||
)
|
||||
.join('\n'),
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
' "landmarks": [',
|
||||
' {',
|
||||
' "name": "场景名称",',
|
||||
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
|
||||
' "connections": [',
|
||||
' {',
|
||||
' "targetLandmarkName": "其他场景名称",',
|
||||
` "relativePosition": "${CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS[0] ?? 'forward'}",`,
|
||||
' "summary": "极简通路说明"',
|
||||
' }',
|
||||
' ]',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
`- 只输出这批 ${landmarkBatch.length} 个场景,不要输出其他场景。`,
|
||||
'- 这是一个完全独立的自定义世界;summary 不要带入“武侠”“仙侠”等现成世界名称。',
|
||||
'- 名称必须与本批次场景骨架完全一致,不得改名。',
|
||||
'- 每个场景必须提供恰好 3 个唯一 sceneNpcNames,且只能从可用场景角色名里选择。',
|
||||
`- 每个场景必须提供恰好 2 条 connections;relativePosition 只能使用:${relativePositionValues}。`,
|
||||
'- targetLandmarkName 必须来自全部场景名,且不能连接自己,两个目标场景不能重复。',
|
||||
'- summary 控制在 4 到 10 个汉字内。',
|
||||
'- 不要输出 description、dangerLevel、backstory 或其他字段。',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt(params: {
|
||||
responseText: string;
|
||||
expectedNames: string[];
|
||||
}) {
|
||||
const { responseText, expectedNames } = params;
|
||||
|
||||
return [
|
||||
'下面这段文本本应是自定义世界场景连接补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
'顶层必须只包含一个 landmarks 数组。',
|
||||
`landmarks 里只能保留这些场景名:${expectedNames.join('、')}。`,
|
||||
'每个场景对象只包含:name、sceneNpcNames、connections。',
|
||||
'connections 里的每个对象必须包含:targetLandmarkName、relativePosition、summary。',
|
||||
'不要输出 description、dangerLevel 或其他字段。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldRoleBatchPrompt(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
roleBatch: CustomWorldGenerationRoleOutline[];
|
||||
stage: CustomWorldGenerationRoleBatchStage;
|
||||
}) {
|
||||
const { framework, roleType, roleBatch, stage } = params;
|
||||
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
|
||||
const label = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
const roleOutlineText = buildRoleOutlinePromptLines(roleBatch, {
|
||||
framework,
|
||||
roleType,
|
||||
});
|
||||
|
||||
if (stage === 'narrative') {
|
||||
return [
|
||||
`请根据下面的世界框架,补全这一批${label}的叙事基础设定。`,
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'玩家原始设定:',
|
||||
framework.settingText,
|
||||
'',
|
||||
'世界框架摘要:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 8 }),
|
||||
'',
|
||||
`本批次需要补全的${label}(名称必须原样保留):`,
|
||||
roleOutlineText,
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
` "${key}": [`,
|
||||
' {',
|
||||
' "name": "角色名称",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
' "combatStyle": "战斗风格"',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
|
||||
'- 这是一个完全独立的自定义世界;不要在角色背景、性格、动机或战斗风格里直接写“武侠世界”“仙侠世界”等现成世界名。',
|
||||
`- ${key} 的数量必须与本批次名单完全一致。`,
|
||||
'- 名称必须与批次名单完全一致,不得增删改名。',
|
||||
'- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。',
|
||||
'- 必须严格沿用框架中的 title、role、description、initialAffinity、relationshipHooks、tags 所表达的角色定位,不要改名,不要改阵营。',
|
||||
'- backstory 必须写出角色和当前世界的具体关系,至少落到一个势力、一个地点、一个正在发生的局势变化,不要只写抽象气质或泛泛成长史。',
|
||||
'- personality 不能只写单个形容词,要体现角色在这个世界里的处事习惯、应对压力的方式和与人相处的锋面。',
|
||||
'- motivation 必须是“此刻正在推动角色行动”的现实目标,而不是空泛理想;它要和玩家目标、核心冲突或开局处境形成直接拉扯。',
|
||||
'- combatStyle 要体现角色为什么会这样战斗,它最好能反映其身份、经历、所属势力或长期栖身的场景环境。',
|
||||
roleType === 'story'
|
||||
? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。'
|
||||
: '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 每个字符串尽量简洁但不能空泛:backstory/personality/motivation/combatStyle 控制在 18 到 56 个汉字内。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
`请根据下面的世界框架,补全这一批${label}的背景章节、技能和初始物品。`,
|
||||
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
|
||||
'玩家原始设定:',
|
||||
framework.settingText,
|
||||
'',
|
||||
'世界框架摘要:',
|
||||
buildFrameworkSummaryText(framework, { maxLandmarks: 8 }),
|
||||
'',
|
||||
`本批次需要补全的${label}(名称必须原样保留):`,
|
||||
roleOutlineText,
|
||||
'',
|
||||
'输出 JSON 模板:',
|
||||
'{',
|
||||
` "${key}": [`,
|
||||
' {',
|
||||
' "name": "角色名称",',
|
||||
' "backstoryReveal": {',
|
||||
' "publicSummary": "公开可见的背景摘要",',
|
||||
' "chapters": [',
|
||||
` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`,
|
||||
` { "id": "scar", "title": "旧事裂痕", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[1]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`,
|
||||
` { "id": "hidden", "title": "隐藏执念", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[2]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`,
|
||||
` { "id": "final", "title": "最终底牌", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[3]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" }`,
|
||||
' ]',
|
||||
' },',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "技能说明", "style": "起手压制" },',
|
||||
' { "name": "技能2", "summary": "技能说明", "style": "机动周旋" },',
|
||||
' { "name": "技能3", "summary": "技能说明", "style": "爆发终结" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "初始物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] },',
|
||||
' { "name": "初始物品2", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "物品说明", "tags": ["标签1", "标签2"] },',
|
||||
' { "name": "初始物品3", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "物品说明", "tags": ["标签1", "标签2"] }',
|
||||
' ]',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
'要求:',
|
||||
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
|
||||
'- 这是一个完全独立的自定义世界;不要在公开背景、技能名、物品名或说明里直接带入“武侠”“仙侠”等现成世界名。',
|
||||
`- ${key} 的数量必须与本批次名单完全一致。`,
|
||||
'- 名称必须与批次名单完全一致,不得增删改名。',
|
||||
'- 这一阶段只补全 backstoryReveal、skills、initialItems,不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
|
||||
'- 背景章节、技能和初始物品必须严格围绕框架中的角色定位来写,不要改变阵营、身份或出现场景。',
|
||||
'- backstoryReveal 的 4 章必须形成明显递进:第 1 章写表层来意与第一印象,第 2 章写旧伤或代价,第 3 章写角色真正隐瞒的线索,第 4 章写最终底牌或不可回避的真相。',
|
||||
'- 每一章都必须紧贴当前世界设定,至少落到具体势力、地点、事件、制度、禁忌或关系链中的一项,不要写成可套用到任何世界的空泛心情。',
|
||||
'- teaser 必须像“继续相处后能戳到的钩子”,content 必须像“真正解锁后得到的新信息”,contextSnippet 必须可直接被后续剧情复用,三者不要只是同一句话改写。',
|
||||
'- skills 不只是职业标签,要体现角色的个人经历、所属阵营、地理环境或禁忌系统影响,尽量写出这个世界独有的招式语感。',
|
||||
'- initialItems 不只是常规装备清单,至少要有一件能反映角色背景、关系或任务压力的私人物件。',
|
||||
`- backstoryReveal.chapters 必须恰好 4 章,affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`,
|
||||
'- 每个角色必须提供恰好 3 个 skills 和恰好 3 个 initialItems。',
|
||||
'- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。',
|
||||
roleType === 'story'
|
||||
? '- 怪物型角色仍然放进 storyNpcs,并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观。'
|
||||
: '- 可扮演角色要保持明确的成长空间、协作价值和入队理由。',
|
||||
'- 所有生成文本都必须使用中文。',
|
||||
'- 每个字符串尽量简洁但要有信息量:backstoryReveal.publicSummary 控制在 14 到 36 个汉字内,backstoryReveal.teaser 控制在 12 到 28 个汉字内,backstoryReveal.content 控制在 20 到 64 个汉字内,contextSnippet 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 12 到 32 个汉字内。',
|
||||
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldRoleBatchJsonRepairPrompt(params: {
|
||||
responseText: string;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
expectedNames: string[];
|
||||
stage: CustomWorldGenerationRoleBatchStage;
|
||||
}) {
|
||||
const { responseText, roleType, expectedNames, stage } = params;
|
||||
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
|
||||
|
||||
if (stage === 'narrative') {
|
||||
return [
|
||||
`下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}叙事设定批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`,
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
`顶层必须只包含一个 ${key} 数组。`,
|
||||
`这个数组里只能保留这些角色名:${expectedNames.join('、')}。`,
|
||||
'名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。',
|
||||
'每个角色都必须包含:name、backstory、personality、motivation、combatStyle。',
|
||||
'如果缺少字段:字符串补空字符串。',
|
||||
'不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
`下面这段文本本应是自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。`,
|
||||
'请只输出修复后的 JSON 对象。',
|
||||
`顶层必须只包含一个 ${key} 数组。`,
|
||||
`这个数组里只能保留这些角色名:${expectedNames.join('、')}。`,
|
||||
'名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。',
|
||||
'每个角色都必须包含:name、backstoryReveal、skills、initialItems。',
|
||||
`backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`,
|
||||
'skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。',
|
||||
'不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。',
|
||||
'原始文本:',
|
||||
responseText.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function clampSceneImageText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function describeDangerLevel(dangerLevel: string) {
|
||||
const normalized = dangerLevel.trim().toLowerCase();
|
||||
if (normalized === 'low' || normalized === '低')
|
||||
return '气氛相对平静,但暗藏细节张力';
|
||||
if (normalized === 'medium' || normalized === '中')
|
||||
return '带有明确的探索压力与潜在威胁';
|
||||
if (normalized === 'high' || normalized === '高')
|
||||
return '危险感强烈,空间中有明显压迫感';
|
||||
if (normalized === 'extreme' || normalized === '极高')
|
||||
return '极端危险,环境本身就像会吞没闯入者';
|
||||
return dangerLevel.trim()
|
||||
? `危险氛围:${dangerLevel.trim()}`
|
||||
: '危险气质保持克制但不可忽视';
|
||||
}
|
||||
|
||||
export const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT = [
|
||||
'文字',
|
||||
'水印',
|
||||
'logo',
|
||||
'UI界面',
|
||||
'对话框',
|
||||
'边框',
|
||||
'人物近景特写',
|
||||
'多人合照',
|
||||
'模糊',
|
||||
'低清晰度',
|
||||
'畸形建筑',
|
||||
'现代车辆',
|
||||
'监控摄像头',
|
||||
].join(',');
|
||||
|
||||
export function buildCustomWorldSceneImagePrompt(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
|
||||
userPrompt = '',
|
||||
options: {
|
||||
hasReferenceImage?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const worldName = clampSceneImageText(profile.name, 18) || '未命名世界';
|
||||
const worldSubtitle = clampSceneImageText(profile.subtitle, 18);
|
||||
const worldTone = clampSceneImageText(profile.tone, 48);
|
||||
const worldGoal = clampSceneImageText(profile.playerGoal, 48);
|
||||
const worldSummary = clampSceneImageText(profile.summary, 72);
|
||||
const worldSetting = clampSceneImageText(profile.settingText, 72);
|
||||
const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景';
|
||||
const landmarkDescription = clampSceneImageText(landmark.description, 96);
|
||||
const requestedVisual = clampSceneImageText(userPrompt, 120);
|
||||
const dangerMood = describeDangerLevel(landmark.dangerLevel);
|
||||
|
||||
return [
|
||||
'为横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。',
|
||||
'画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。',
|
||||
'下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。',
|
||||
'下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。',
|
||||
'下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。',
|
||||
options.hasReferenceImage
|
||||
? '已提供一张自定义参考图,请沿用其构图、镜头或氛围线索,同时继续满足本次场景需求。'
|
||||
: '',
|
||||
`世界:${worldName}${worldSubtitle ? `,${worldSubtitle}` : ''}。`,
|
||||
worldSetting ? `玩家设定:${worldSetting}。` : '',
|
||||
worldSummary ? `世界概述:${worldSummary}。` : '',
|
||||
worldTone ? `整体基调:${worldTone}。` : '',
|
||||
worldGoal ? `玩家目标关联:${worldGoal}。` : '',
|
||||
`场景名称:${landmarkName}。`,
|
||||
landmarkDescription ? `场景描述:${landmarkDescription}。` : '',
|
||||
requestedVisual ? `本次想要生成的画面内容:${requestedVisual}。` : '',
|
||||
`${dangerMood}。`,
|
||||
'不要出现 UI、字幕、文字、水印、logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
784
server-node/src/prompts/eightAnchorPrompts.ts
Normal file
784
server-node/src/prompts/eightAnchorPrompts.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
import type {
|
||||
EightAnchorContent,
|
||||
HiddenLineValue,
|
||||
IconicElementValue,
|
||||
KeyRelationshipValue,
|
||||
ThemeBoundaryValue,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
createEmptyEightAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from '../services/eightAnchorCompatibilityService.js';
|
||||
|
||||
export type PromptUserInputSignal =
|
||||
| 'rich'
|
||||
| 'normal'
|
||||
| 'sparse'
|
||||
| 'correction'
|
||||
| 'delegate';
|
||||
|
||||
export type PromptDriftRisk = 'low' | 'medium' | 'high';
|
||||
|
||||
export type PromptConversationMode =
|
||||
| 'bootstrap'
|
||||
| 'expand'
|
||||
| 'compress'
|
||||
| 'repair_direction'
|
||||
| 'force_complete'
|
||||
| 'closing';
|
||||
|
||||
export type PromptDynamicState = {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
conversationMode: PromptConversationMode;
|
||||
judgementSummary: string;
|
||||
};
|
||||
|
||||
export type PromptDynamicStateInference = {
|
||||
userInputSignal?: unknown;
|
||||
driftRisk?: unknown;
|
||||
conversationMode?: unknown;
|
||||
judgementSummary?: unknown;
|
||||
};
|
||||
|
||||
const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。
|
||||
|
||||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||||
1. 当前完整设定结构
|
||||
2. 用户聊天记录
|
||||
|
||||
然后输出:
|
||||
1. 一版新的完整设定结构
|
||||
2. 当前 progress 百分比
|
||||
3. 一段直接回复用户的话
|
||||
|
||||
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
|
||||
你的输出会直接覆盖上一版设定结构。
|
||||
|
||||
你不是在做局部 patch。
|
||||
你不是在做解释报告。
|
||||
你不是在给开发者写分析。
|
||||
你是在同时完成:
|
||||
1. 世界设定更新
|
||||
2. 当前推进程度判断
|
||||
3. 对用户的共创回复`;
|
||||
|
||||
const GLOBAL_HARD_RULES = `全局硬约束:
|
||||
|
||||
1. 必须输出完整的设定结构,而不是只输出变化部分。
|
||||
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
|
||||
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
|
||||
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
|
||||
5. progressPercent 最低为 0,不允许为负数。
|
||||
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
|
||||
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
|
||||
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
|
||||
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
|
||||
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
|
||||
11. 你输出的 JSON 必须可以被直接解析。
|
||||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。`;
|
||||
|
||||
const MODE_RULES: Record<PromptConversationMode, string> = {
|
||||
bootstrap: `当前模式:bootstrap
|
||||
|
||||
目标:
|
||||
1. 先把世界的基本方向抓住
|
||||
2. 不要一次塞太多新设定
|
||||
3. 回复要降低用户开口压力
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
|
||||
2. 如果用户信息很少,不要强行把整套结构一次补满
|
||||
3. replyText 要像共创搭档,而不是像审问
|
||||
4. 默认只推进一个最关键的问题方向
|
||||
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
|
||||
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
|
||||
7. 不要把问题问得像表单采集,不要一口气追问多个维度
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户觉得“现在很容易继续往下说”
|
||||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||||
3. replyText 最好短、稳、可接话
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械`,
|
||||
expand: `当前模式:expand
|
||||
|
||||
目标:
|
||||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||||
2. 尽量让一轮输入覆盖多个关键维度
|
||||
|
||||
本轮行为要求:
|
||||
1. 继续保留上一版里仍成立的设定
|
||||
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
|
||||
3. replyText 要明确体现“你已经理解了哪些内容”
|
||||
4. 不要突然大幅改写已经成形的世界
|
||||
5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步
|
||||
6. 可以适度替用户整理,但不要把回复写成总结报告
|
||||
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚说的内容都被接住了”
|
||||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||||
3. 不要无视用户刚提供的高价值细节
|
||||
4. 不要让用户觉得系统在自顾自重写世界`,
|
||||
compress: `当前模式:compress
|
||||
|
||||
目标:
|
||||
1. 开始收束当前设定
|
||||
2. 减少无效发散
|
||||
3. 让 progress 更接近可进入下一阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 新的设定结构优先保留稳定内容,不要无端重写
|
||||
2. 对用户本轮输入做高密度吸收
|
||||
3. replyText 要更聚焦,不要绕圈
|
||||
4. 默认只推进当前最影响 completion 的一步
|
||||
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
|
||||
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
|
||||
7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||||
2. 让推进感更明确,但不要显得催促
|
||||
3. 回复语气应更笃定一些,减少反复横跳
|
||||
4. 不要把用户刚补进来的细节又冲淡掉`,
|
||||
repair_direction: `当前模式:repair_direction
|
||||
|
||||
目标:
|
||||
1. 处理用户对既有设定的修正
|
||||
2. 避免世界方向飘散或自相矛盾
|
||||
|
||||
本轮行为要求:
|
||||
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
|
||||
2. 对已经不再成立的旧设定,不要机械保留
|
||||
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
|
||||
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
|
||||
5. 先处理“改掉什么”,再决定“往哪里继续推”
|
||||
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
|
||||
7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||||
2. 不要和用户辩论旧方案为什么也行
|
||||
3. 不要表现出对修正的不情愿
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`,
|
||||
force_complete: `当前模式:force_complete
|
||||
|
||||
目标:
|
||||
1. 基于当前方向直接补齐剩余设定
|
||||
2. 生成一版尽量完整、可进入下一阶段的设定结构
|
||||
3. 结束当前收集阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 尽量保留已经形成的世界方向
|
||||
2. 对明显缺失的关键维度进行合理补全
|
||||
3. 不要继续拉长聊天,不要再追问用户
|
||||
4. progressPercent 直接输出为 100
|
||||
5. replyText 要自然引导用户点击“生成游戏设定草稿”
|
||||
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
|
||||
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
|
||||
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||||
3. 回复要有完成感,但不要太官话
|
||||
4. 清楚告诉用户下一步可以做什么`,
|
||||
closing: `当前模式:closing
|
||||
|
||||
目标:
|
||||
1. 尽量形成一版可用的设定底子
|
||||
2. 不再继续发散新世界观
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先收束,而不是扩写
|
||||
2. 不要大改已经成形的核心设定
|
||||
3. progressPercent 接近完成时,replyText 要更像确认与推进
|
||||
4. 如果用户没有大改方向,尽量让下一版内容更稳定
|
||||
5. 可以轻微补足缺口,但不要再大开新支线
|
||||
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
|
||||
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||||
3. 保持留白感,不要把所有东西都一次说死
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话`,
|
||||
};
|
||||
|
||||
const USER_SIGNAL_RULES: Record<PromptUserInputSignal, string> = {
|
||||
rich: `本轮用户输入信息密度高。
|
||||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`,
|
||||
normal: `本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`,
|
||||
sparse: `本轮用户输入较少或较虚。
|
||||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||||
replyText 要让用户容易继续往下说。`,
|
||||
correction: `本轮用户在修正或推翻旧设定。
|
||||
请优先吸收修正,不要机械复读旧版本。
|
||||
新的完整设定结构必须以修正后的方向为准。`,
|
||||
delegate: `本轮用户把部分决定权交给你。
|
||||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`,
|
||||
};
|
||||
|
||||
const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。
|
||||
|
||||
这表示用户接受你基于当前方向自动补完剩余设定。
|
||||
|
||||
本轮要求:
|
||||
1. 不要再继续提问
|
||||
2. 直接输出一版尽量完整的设定结构
|
||||
3. progressPercent 直接输出为 100
|
||||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`;
|
||||
|
||||
const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。
|
||||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||||
|
||||
你必须综合以下信息判断:
|
||||
1. 当前轮次 currentTurn
|
||||
2. 当前完成度 progressPercent
|
||||
3. 用户是否要求自动补全 quickFillRequested
|
||||
4. 当前完整设定结构
|
||||
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
|
||||
|
||||
你需要输出 4 个字段:
|
||||
1. userInputSignal:只能是 rich / normal / sparse / correction / delegate
|
||||
2. driftRisk:只能是 low / medium / high
|
||||
3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
|
||||
4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
|
||||
|
||||
请按下面的语义判断。
|
||||
|
||||
一、userInputSignal 定义
|
||||
1. rich
|
||||
- 用户这一轮给了多条可直接落地的有效信息
|
||||
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
|
||||
- 正式生成时应优先高密度吸收,不要只更新一个点
|
||||
|
||||
2. normal
|
||||
- 用户在顺着当前方向做正常补充
|
||||
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
|
||||
- 正式生成时应稳定推进并自然接住用户内容
|
||||
|
||||
3. sparse
|
||||
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
|
||||
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
|
||||
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
|
||||
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
|
||||
|
||||
4. correction
|
||||
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
|
||||
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
|
||||
- correction 的优先级高于 rich 和 normal
|
||||
|
||||
5. delegate
|
||||
- 用户把部分决定权交给系统
|
||||
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
|
||||
- delegate 关注的是授权关系,不只是信息多寡
|
||||
|
||||
二、driftRisk 定义
|
||||
1. low
|
||||
- 当前轮输入与已有方向基本一致
|
||||
- 没有明显改口或冲突
|
||||
|
||||
2. medium
|
||||
- 当前轮带来一定方向变化或扩张
|
||||
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
|
||||
|
||||
3. high
|
||||
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
|
||||
- 这时最重要的是防止旧方向重新回流到正式生成结果里
|
||||
|
||||
三、conversationMode 选择原则
|
||||
1. bootstrap
|
||||
- 适用于前期、信息少、核心方向未稳定
|
||||
- replyText 更适合低压力确认和单点启发
|
||||
|
||||
2. expand
|
||||
- 适用于方向已成形,正在顺着现有路线继续补充
|
||||
- replyText 更适合总结已接住的内容并往前推一步
|
||||
|
||||
3. compress
|
||||
- 适用于中后段,已有骨架,需要开始收束
|
||||
- replyText 更适合聚焦最关键缺口,而不是继续开支线
|
||||
|
||||
4. repair_direction
|
||||
- 适用于用户正在纠偏
|
||||
- replyText 更适合先承认修正,再沿修正后的方向继续推进
|
||||
|
||||
5. force_complete
|
||||
- 适用于用户明确要求自动补全
|
||||
- replyText 不再提问,而应给出完成感和下一步引导
|
||||
|
||||
6. closing
|
||||
- 适用于接近完成但并非强制一键补全
|
||||
- replyText 更像确认与收束,而不是前期式探索
|
||||
|
||||
四、优先级规则
|
||||
1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete
|
||||
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction
|
||||
3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate
|
||||
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
|
||||
|
||||
五、关于 replyText 风格的专门判断要求
|
||||
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
|
||||
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
|
||||
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
|
||||
4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进
|
||||
5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
|
||||
|
||||
六、关于 replyText 用语的硬约束
|
||||
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
|
||||
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
|
||||
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
|
||||
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
|
||||
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
|
||||
|
||||
七、关于 judgementSummary 的写法
|
||||
1. 必须简洁,不要写成长篇分析
|
||||
2. 必须直接服务于下一轮正式生成
|
||||
3. 最好同时包含两层信息:
|
||||
- 为什么这么判断
|
||||
- 正式生成时最该优先做什么,或最该避免什么
|
||||
|
||||
八、硬性约束
|
||||
1. 只能输出 JSON,不能输出解释、代码块或额外说明
|
||||
2. 不能发明上下文里不存在的设定事实
|
||||
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
|
||||
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
|
||||
5. judgementSummary 必须是中文
|
||||
6. 输出值必须严格落在给定枚举中`;
|
||||
|
||||
const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"userInputSignal": "normal",
|
||||
"driftRisk": "low",
|
||||
"conversationMode": "expand",
|
||||
"judgementSummary": ""
|
||||
}`;
|
||||
|
||||
const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorContent": {
|
||||
"worldPromise": {
|
||||
"hook": "",
|
||||
"differentiator": "",
|
||||
"desiredExperience": ""
|
||||
},
|
||||
"playerFantasy": {
|
||||
"playerRole": "",
|
||||
"corePursuit": "",
|
||||
"fearOfLoss": ""
|
||||
},
|
||||
"themeBoundary": {
|
||||
"toneKeywords": [],
|
||||
"aestheticDirectives": [],
|
||||
"forbiddenDirectives": []
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": "",
|
||||
"openingProblem": "",
|
||||
"entryMotivation": ""
|
||||
},
|
||||
"coreConflict": {
|
||||
"surfaceConflicts": [],
|
||||
"hiddenCrisis": "",
|
||||
"firstTouchedConflict": ""
|
||||
},
|
||||
"keyRelationships": [
|
||||
{
|
||||
"pairs": "",
|
||||
"relationshipType": "",
|
||||
"secretOrCost": ""
|
||||
}
|
||||
],
|
||||
"hiddenLines": {
|
||||
"hiddenTruths": [],
|
||||
"misdirectionHints": [],
|
||||
"revealPacing": ""
|
||||
},
|
||||
"iconicElements": {
|
||||
"iconicMotifs": [],
|
||||
"institutionsOrArtifacts": [],
|
||||
"hardRules": []
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function toJson(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getLatestUserText(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return (
|
||||
[...chatHistory]
|
||||
.reverse()
|
||||
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function includesAny(text: string, patterns: RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function isPromptUserInputSignal(
|
||||
value: unknown,
|
||||
): value is PromptUserInputSignal {
|
||||
return (
|
||||
value === 'rich' ||
|
||||
value === 'normal' ||
|
||||
value === 'sparse' ||
|
||||
value === 'correction' ||
|
||||
value === 'delegate'
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptDriftRisk(value: unknown): value is PromptDriftRisk {
|
||||
return value === 'low' || value === 'medium' || value === 'high';
|
||||
}
|
||||
|
||||
function isPromptConversationMode(
|
||||
value: unknown,
|
||||
): value is PromptConversationMode {
|
||||
return (
|
||||
value === 'bootstrap' ||
|
||||
value === 'expand' ||
|
||||
value === 'compress' ||
|
||||
value === 'repair_direction' ||
|
||||
value === 'force_complete' ||
|
||||
value === 'closing'
|
||||
);
|
||||
}
|
||||
|
||||
export function detectUserInputSignal(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
): PromptUserInputSignal {
|
||||
const latestUserText = getLatestUserText(chatHistory).trim();
|
||||
|
||||
if (!latestUserText) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(不是|改成|改为|换成|重来|推翻|修正)/u])) {
|
||||
return 'correction';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(你帮我想|你来定|你决定|你补完)/u])) {
|
||||
return 'delegate';
|
||||
}
|
||||
|
||||
const segments = latestUserText
|
||||
.split(/[。!?;\n]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (latestUserText.length <= 10 || segments.length <= 1) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (segments.length >= 3 || latestUserText.length >= 60) {
|
||||
return 'rich';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function summarizeDynamicState(
|
||||
state: Pick<
|
||||
PromptDynamicState,
|
||||
'userInputSignal' | 'driftRisk' | 'conversationMode'
|
||||
>,
|
||||
) {
|
||||
return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`;
|
||||
}
|
||||
|
||||
function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.toneKeywords.length > 0 ||
|
||||
value.aestheticDirectives.length > 0 ||
|
||||
value.forbiddenDirectives.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function isRelationshipsFilled(value: KeyRelationshipValue[]) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
function isHiddenLinesFilled(value: HiddenLineValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.hiddenTruths.length > 0 ||
|
||||
value.misdirectionHints.length > 0 ||
|
||||
value.revealPacing),
|
||||
);
|
||||
}
|
||||
|
||||
function isIconicElementsFilled(value: IconicElementValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.iconicMotifs.length > 0 ||
|
||||
value.institutionsOrArtifacts.length > 0 ||
|
||||
value.hardRules.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function detectDriftRisk(params: {
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
}) {
|
||||
const latestUserText = getLatestUserText(params.chatHistory).trim();
|
||||
const recentUserMessages = params.chatHistory
|
||||
.filter((entry) => entry.role === 'user')
|
||||
.slice(-3)
|
||||
.map((entry) => entry.content.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const correctionCount = recentUserMessages.filter((entry) =>
|
||||
/(不是|改成|改为|换成|推翻|重来|修正)/u.test(entry),
|
||||
).length;
|
||||
|
||||
if (
|
||||
correctionCount >= 2 ||
|
||||
(params.progressPercent >= 65 &&
|
||||
/(不是|改成|改为|换成|重来|推翻)/u.test(latestUserText))
|
||||
) {
|
||||
return 'high' as const;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeEightAnchorContent(params.anchorContent);
|
||||
const filledCount = [
|
||||
Boolean(normalizedContent.worldPromise),
|
||||
Boolean(normalizedContent.playerFantasy),
|
||||
isThemeBoundaryFilled(normalizedContent.themeBoundary),
|
||||
Boolean(normalizedContent.playerEntryPoint),
|
||||
Boolean(normalizedContent.coreConflict),
|
||||
isRelationshipsFilled(normalizedContent.keyRelationships),
|
||||
isHiddenLinesFilled(normalizedContent.hiddenLines),
|
||||
isIconicElementsFilled(normalizedContent.iconicElements),
|
||||
].filter(Boolean).length;
|
||||
|
||||
if (filledCount >= 3 && latestUserText.length >= 40) {
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
return 'low' as const;
|
||||
}
|
||||
|
||||
export function pickConversationMode(params: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
}) {
|
||||
if (params.quickFillRequested) {
|
||||
return 'force_complete' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
params.userInputSignal === 'correction' ||
|
||||
params.driftRisk === 'high'
|
||||
) {
|
||||
return 'repair_direction' as const;
|
||||
}
|
||||
|
||||
if (params.progressPercent >= 85 || params.currentTurn >= 15) {
|
||||
return 'closing' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn > 10 || params.progressPercent >= 65) {
|
||||
return 'compress' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn <= 10 && params.progressPercent < 65) {
|
||||
return 'expand' as const;
|
||||
}
|
||||
|
||||
return 'bootstrap' as const;
|
||||
}
|
||||
|
||||
function buildRuleBasedPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}): PromptDynamicState {
|
||||
const userInputSignal = detectUserInputSignal(input.chatHistory);
|
||||
const driftRisk = detectDriftRisk({
|
||||
chatHistory: input.chatHistory,
|
||||
anchorContent: input.currentAnchorContent,
|
||||
progressPercent: input.progressPercent,
|
||||
});
|
||||
|
||||
const conversationMode = pickConversationMode({
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary: summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}, inference?: PromptDynamicStateInference | null): PromptDynamicState {
|
||||
const fallbackState = buildRuleBasedPromptDynamicState(input);
|
||||
|
||||
if (!inference) {
|
||||
return fallbackState;
|
||||
}
|
||||
|
||||
const userInputSignal = isPromptUserInputSignal(inference.userInputSignal)
|
||||
? inference.userInputSignal
|
||||
: fallbackState.userInputSignal;
|
||||
const driftRisk = isPromptDriftRisk(inference.driftRisk)
|
||||
? inference.driftRisk
|
||||
: fallbackState.driftRisk;
|
||||
const conversationMode = isPromptConversationMode(inference.conversationMode)
|
||||
? inference.conversationMode
|
||||
: fallbackState.conversationMode;
|
||||
const judgementSummary =
|
||||
toText(inference.judgementSummary) ||
|
||||
summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicStateInferencePrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
|
||||
return {
|
||||
systemPrompt: [
|
||||
STATE_INFERENCE_SYSTEM_PROMPT,
|
||||
STATE_INFERENCE_OUTPUT_CONTRACT,
|
||||
].join('\n\n'),
|
||||
userPrompt: [
|
||||
`当前轮次:${input.currentTurn}`,
|
||||
`当前完成度:${input.progressPercent}`,
|
||||
`是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`,
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
].join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDynamicStateContext(dynamicState: PromptDynamicState) {
|
||||
return `上一轮预判得到的创作状态如下。
|
||||
正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。
|
||||
|
||||
创作状态:
|
||||
- userInputSignal: ${dynamicState.userInputSignal}
|
||||
- driftRisk: ${dynamicState.driftRisk}
|
||||
- conversationMode: ${dynamicState.conversationMode}
|
||||
- judgementSummary: ${dynamicState.judgementSummary}`;
|
||||
}
|
||||
|
||||
function renderCurrentAnchorContext(anchorContent: EightAnchorContent) {
|
||||
return `当前完整设定结构如下。
|
||||
你必须把它视为上一版有效世界底子。
|
||||
|
||||
如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。
|
||||
如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。
|
||||
|
||||
当前完整设定结构:
|
||||
${toJson(normalizeEightAnchorContent(anchorContent))}`;
|
||||
}
|
||||
|
||||
function renderChatHistoryContext(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return `以下是用户聊天记录。
|
||||
请重点理解最近几轮里用户新增、修正、强调的设定信息。
|
||||
不要把早期已经被用户否定的内容继续当成最终结论。
|
||||
|
||||
用户聊天记录:
|
||||
${toJson(chatHistory)}`;
|
||||
}
|
||||
|
||||
export function buildEightAnchorSingleTurnPrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
dynamicState?: PromptDynamicStateInference | PromptDynamicState | null;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
const dynamicState = buildPromptDynamicState({
|
||||
...input,
|
||||
currentAnchorContent,
|
||||
}, input.dynamicState);
|
||||
|
||||
return {
|
||||
prompt: [
|
||||
BASE_SYSTEM_PROMPT,
|
||||
GLOBAL_HARD_RULES,
|
||||
MODE_RULES[dynamicState.conversationMode],
|
||||
USER_SIGNAL_RULES[dynamicState.userInputSignal],
|
||||
dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null,
|
||||
renderDynamicStateContext(dynamicState),
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
OUTPUT_CONTRACT_REMINDER,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
dynamicState,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,106 @@ function readImageSrc(value: unknown) {
|
||||
return readString(value) || null;
|
||||
}
|
||||
|
||||
type CustomWorldCoverRenderMode = 'image' | 'scene_with_roles';
|
||||
|
||||
function normalizeCoverCharacterRoleIds(
|
||||
value: unknown,
|
||||
playableRoles: Record<string, unknown>[],
|
||||
) {
|
||||
const availableIds = new Set(
|
||||
playableRoles.map((role) => readString(role.id)).filter(Boolean),
|
||||
);
|
||||
const selectedIds = readArray(value)
|
||||
.map((entry) => readString(entry))
|
||||
.filter((entry) => entry && availableIds.has(entry));
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
return [...new Set(selectedIds)].slice(0, 3);
|
||||
}
|
||||
|
||||
return [...availableIds].slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveOpeningSceneImageSrc(profile: CustomWorldProfileRecord) {
|
||||
const campImage = isRecord(profile.camp)
|
||||
? readImageSrc(profile.camp.imageSrc)
|
||||
: null;
|
||||
if (campImage) {
|
||||
return campImage;
|
||||
}
|
||||
|
||||
return (
|
||||
readArray(profile.landmarks)
|
||||
.map((landmark) =>
|
||||
isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null,
|
||||
)
|
||||
.find(Boolean) || null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLeadPlayableImageSrc(playableRoles: Record<string, unknown>[]) {
|
||||
return (
|
||||
playableRoles
|
||||
.map((role) => readImageSrc(role.imageSrc))
|
||||
.find(Boolean) || null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCustomWorldCoverPresentation(
|
||||
profile: CustomWorldProfileRecord,
|
||||
): {
|
||||
imageSrc: string | null;
|
||||
renderMode: CustomWorldCoverRenderMode;
|
||||
characterImageSrcs: string[];
|
||||
sourceType: 'default' | 'uploaded' | 'generated';
|
||||
} {
|
||||
const playableRoles = readArray(profile.playableNpcs).filter(isRecord);
|
||||
const cover = isRecord(profile.cover) ? profile.cover : null;
|
||||
const requestedSourceType = readString(cover?.sourceType);
|
||||
const sourceType =
|
||||
requestedSourceType === 'uploaded' ||
|
||||
requestedSourceType === 'generated'
|
||||
? requestedSourceType
|
||||
: 'default';
|
||||
|
||||
if (sourceType !== 'default') {
|
||||
const explicitImageSrc = readImageSrc(cover?.imageSrc);
|
||||
if (explicitImageSrc) {
|
||||
return {
|
||||
imageSrc: explicitImageSrc,
|
||||
renderMode: 'image',
|
||||
characterImageSrcs: [],
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const openingSceneImageSrc = resolveOpeningSceneImageSrc(profile);
|
||||
const roleById = new Map(
|
||||
playableRoles.map((role) => [readString(role.id), role] as const),
|
||||
);
|
||||
const characterImageSrcs = normalizeCoverCharacterRoleIds(
|
||||
cover?.characterRoleIds,
|
||||
playableRoles,
|
||||
)
|
||||
.map((roleId) => readImageSrc(roleById.get(roleId)?.imageSrc))
|
||||
.filter((imageSrc): imageSrc is string => Boolean(imageSrc));
|
||||
const leadPlayableImageSrc = resolveLeadPlayableImageSrc(playableRoles);
|
||||
|
||||
return {
|
||||
imageSrc: openingSceneImageSrc || leadPlayableImageSrc,
|
||||
renderMode:
|
||||
openingSceneImageSrc && characterImageSrcs.length > 0
|
||||
? 'scene_with_roles'
|
||||
: 'image',
|
||||
characterImageSrcs:
|
||||
openingSceneImageSrc && characterImageSrcs.length > 0
|
||||
? characterImageSrcs
|
||||
: [],
|
||||
sourceType: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
function detectThemeMode(
|
||||
profile: Pick<
|
||||
CustomWorldProfileRecord,
|
||||
@@ -64,28 +164,7 @@ function detectThemeMode(
|
||||
}
|
||||
|
||||
export function buildCustomWorldCoverImageSrc(profile: CustomWorldProfileRecord) {
|
||||
const explicitCampImage = isRecord(profile.camp)
|
||||
? readImageSrc(profile.camp.imageSrc)
|
||||
: null;
|
||||
if (explicitCampImage) {
|
||||
return explicitCampImage;
|
||||
}
|
||||
|
||||
const landmarkImage = readArray(profile.landmarks)
|
||||
.map((landmark) => (isRecord(landmark) ? readImageSrc(landmark.imageSrc) : null))
|
||||
.find(Boolean);
|
||||
if (landmarkImage) {
|
||||
return landmarkImage;
|
||||
}
|
||||
|
||||
const playableImage = readArray(profile.playableNpcs)
|
||||
.map((role) => (isRecord(role) ? readImageSrc(role.imageSrc) : null))
|
||||
.find(Boolean);
|
||||
if (playableImage) {
|
||||
return playableImage;
|
||||
}
|
||||
|
||||
return null;
|
||||
return resolveCustomWorldCoverPresentation(profile).imageSrc;
|
||||
}
|
||||
|
||||
export function extractCustomWorldLibraryMetadata(profile: CustomWorldProfileRecord) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PLATFORM_THEMES,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
@@ -21,7 +20,10 @@ import type {
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshotInput,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import { CUSTOM_WORLD_GENERATION_MODES } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import {
|
||||
CUSTOM_WORLD_GENERATION_MODES,
|
||||
PLATFORM_THEMES,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type {
|
||||
QuestGenerationRequest,
|
||||
RuntimeItemIntentRequest,
|
||||
@@ -70,6 +72,12 @@ import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGene
|
||||
import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js';
|
||||
import { generateQuestForNpcEncounter } from '../services/questService.js';
|
||||
import { generateRuntimeItemIntents } from '../services/runtimeItemService.js';
|
||||
import {
|
||||
customWorldCoverImageSchema,
|
||||
customWorldCoverUploadSchema,
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from '../services/customWorldCoverAssetService.js';
|
||||
import {
|
||||
generateSceneImage,
|
||||
sceneImageSchema,
|
||||
@@ -420,6 +428,24 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/cover-image',
|
||||
routeMeta({ operation: 'runtime.customWorld.coverImage' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldCoverImageSchema.parse(request.body);
|
||||
sendApiResponse(response, await generateCustomWorldCoverImage(context, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/cover-upload',
|
||||
routeMeta({ operation: 'runtime.customWorld.coverUpload' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = customWorldCoverUploadSchema.parse(request.body);
|
||||
sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/custom-world/scene-image',
|
||||
routeMeta({ operation: 'runtime.customWorld.sceneImage' }),
|
||||
|
||||
@@ -19,11 +19,14 @@ import {
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
} from '../prompts/customWorldPrompts.js';
|
||||
import {
|
||||
buildCompiledCustomWorldProfile,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
@@ -32,9 +35,8 @@ import {
|
||||
normalizeCustomWorldGenerationFramework,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch,
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch,
|
||||
} from '../../../src/services/customWorld.js';
|
||||
import { buildExpandedCustomWorldProfile } from '../../../src/services/customWorldBuilder.js';
|
||||
import type { CustomWorldProfile } from '../../../src/types.js';
|
||||
} from '../modules/custom-world/runtimeProfile.js';
|
||||
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
type CreatorCharacterSeedRecord,
|
||||
@@ -792,7 +794,7 @@ type DraftProgressCallback = (
|
||||
payload: DraftProgressPayload,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type MergeableNamedRecord = Record<string, unknown> & {
|
||||
type MergeableNamedRecord = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -1366,7 +1368,9 @@ function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftThreadsFromRuntimeProfile(profile: CustomWorldProfile) {
|
||||
function buildDraftThreadsFromRuntimeProfile(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldFoundationDraftThread[] {
|
||||
const graphThreads = [
|
||||
...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2),
|
||||
...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2),
|
||||
@@ -1558,8 +1562,12 @@ function convertRuntimeProfileToFoundationDraft(params: {
|
||||
summary: clampText(params.profile.camp.description, 88),
|
||||
} satisfies CustomWorldFoundationDraftCamp)
|
||||
: null,
|
||||
themePack: params.profile.themePack ?? null,
|
||||
storyGraph: params.profile.storyGraph ?? null,
|
||||
themePack:
|
||||
(params.profile.themePack as unknown as Record<string, unknown> | null) ??
|
||||
null,
|
||||
storyGraph:
|
||||
(params.profile.storyGraph as unknown as Record<string, unknown> | null) ??
|
||||
null,
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
@@ -1718,7 +1726,7 @@ async function buildFoundationDraftProfileWithLlm(params: {
|
||||
rawProfile.storyNpcs = storyDetailed;
|
||||
rawProfile.landmarks = framework.landmarks;
|
||||
|
||||
const runtimeProfile = buildExpandedCustomWorldProfile(
|
||||
const runtimeProfile = buildCompiledCustomWorldProfile(
|
||||
rawProfile,
|
||||
settingText,
|
||||
);
|
||||
|
||||
@@ -74,6 +74,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
@@ -66,6 +66,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
@@ -67,6 +67,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
@@ -66,6 +66,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
566
server-node/src/services/customWorldCoverAssetService.ts
Normal file
566
server-node/src/services/customWorldCoverAssetService.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import { extractApiErrorMessage } from '../http.js';
|
||||
|
||||
const TEXT_TO_IMAGE_COVER_MODEL = 'wan2.2-t2i-flash';
|
||||
const REFERENCE_IMAGE_COVER_MODEL = 'qwen-image-2.0';
|
||||
|
||||
const coverRoleSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
title: z.string().trim().optional().default(''),
|
||||
role: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverCampSchema = z.object({
|
||||
name: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverLandmarkSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverProfileSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
subtitle: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
tone: z.string().trim().optional().default(''),
|
||||
playerGoal: z.string().trim().optional().default(''),
|
||||
settingText: z.string().trim().optional().default(''),
|
||||
camp: coverCampSchema.nullable().optional(),
|
||||
landmarks: z.array(coverLandmarkSchema).optional().default([]),
|
||||
playableNpcs: z.array(coverRoleSchema).optional().default([]),
|
||||
});
|
||||
|
||||
export const customWorldCoverImageSchema = z.object({
|
||||
profile: coverProfileSchema,
|
||||
userPrompt: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
characterRoleIds: z.array(z.string().trim()).max(3).optional().default([]),
|
||||
size: z.string().trim().optional().default('1600*900'),
|
||||
});
|
||||
|
||||
export const customWorldCoverUploadSchema = z.object({
|
||||
profileId: z.string().trim().optional().default(''),
|
||||
worldName: z.string().trim().optional().default(''),
|
||||
imageDataUrl: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
type CoverProfile = z.infer<typeof coverProfileSchema>;
|
||||
|
||||
function parseImageDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(matched[2], 'base64'),
|
||||
mimeType: matched[1],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsedDataUrl = parseImageDataUrl(trimmedSource);
|
||||
if (parsedDataUrl) {
|
||||
return trimmedSource;
|
||||
}
|
||||
|
||||
if (!trimmedSource.startsWith('/')) {
|
||||
throw badRequest('参考图必须是 Data URL 或 public 目录下的 URL。');
|
||||
}
|
||||
|
||||
const normalizedSource = path.posix
|
||||
.normalize(trimmedSource)
|
||||
.replace(/^\/+/u, '');
|
||||
const absolutePath = path.resolve(
|
||||
rootDir,
|
||||
'public',
|
||||
...normalizedSource.split('/'),
|
||||
);
|
||||
const publicRoot = path.resolve(rootDir, 'public');
|
||||
if (!absolutePath.startsWith(publicRoot)) {
|
||||
throw badRequest('参考图路径越界。');
|
||||
}
|
||||
|
||||
const buffer = await readFile(absolutePath);
|
||||
const extension = path
|
||||
.extname(absolutePath)
|
||||
.replace(/^\./u, '')
|
||||
.toLowerCase();
|
||||
const mimeType = (() => {
|
||||
switch (extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'image/png';
|
||||
}
|
||||
})();
|
||||
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
function collectStringsByKey(
|
||||
value: unknown,
|
||||
targetKey: string,
|
||||
results: string[],
|
||||
) {
|
||||
if (typeof value === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, results));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([key, nestedValue]) => {
|
||||
if (
|
||||
key === targetKey &&
|
||||
typeof nestedValue === 'string' &&
|
||||
nestedValue.trim()
|
||||
) {
|
||||
results.push(nestedValue.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
collectStringsByKey(nestedValue, targetKey, results);
|
||||
});
|
||||
}
|
||||
|
||||
function findFirstStringByKey(value: unknown, targetKey: string) {
|
||||
const results: string[] = [];
|
||||
collectStringsByKey(value, targetKey, results);
|
||||
return results[0] ?? '';
|
||||
}
|
||||
|
||||
function extractTaskId(payload: Record<string, unknown>) {
|
||||
return findFirstStringByKey(payload, 'task_id');
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: Record<string, unknown>) {
|
||||
const urls: string[] = [];
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string, fallback: string) {
|
||||
const normalized = value
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.replace(/-+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '');
|
||||
|
||||
return (normalized || fallback).slice(0, 48);
|
||||
}
|
||||
|
||||
function resolveSelectedRoles(
|
||||
profile: CoverProfile,
|
||||
requestedRoleIds: string[],
|
||||
) {
|
||||
const roleById = new Map(
|
||||
profile.playableNpcs.map((role) => [role.id.trim(), role] as const),
|
||||
);
|
||||
const selectedRoles = [...new Set(requestedRoleIds.map((roleId) => roleId.trim()))]
|
||||
.map((roleId) => roleById.get(roleId))
|
||||
.filter((role): role is z.infer<typeof coverRoleSchema> => Boolean(role));
|
||||
|
||||
if (selectedRoles.length > 0) {
|
||||
return selectedRoles.slice(0, 3);
|
||||
}
|
||||
|
||||
return profile.playableNpcs.slice(0, 3);
|
||||
}
|
||||
|
||||
function buildCustomWorldCoverImagePrompt(
|
||||
profile: CoverProfile,
|
||||
requestedRoleIds: string[],
|
||||
userPrompt: string,
|
||||
options: {
|
||||
hasReferenceImage?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const openingScene = profile.camp ?? profile.landmarks[0] ?? null;
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const roleSummary = selectedRoles
|
||||
.map((role) =>
|
||||
[role.name, role.title || role.role, role.description]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
|
||||
return [
|
||||
'为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。',
|
||||
'画面重点是“开局场景 + 2 到 3 个主要角色”的主视觉,不是纯背景图,也不是 UI 截图。',
|
||||
'构图需要有明显前中后景层次,前景角色清晰、主体集中、适合移动端缩略显示。',
|
||||
'不要出现任何标题文字、UI、按钮、水印、logo、边框或排版装饰。',
|
||||
options.hasReferenceImage
|
||||
? '已提供一张参考图,请尽量沿用其构图、镜头或色彩气质。'
|
||||
: '',
|
||||
profile.name ? `作品名:${profile.name}。` : '',
|
||||
profile.subtitle ? `副标题:${profile.subtitle}。` : '',
|
||||
profile.settingText ? `玩家设定:${profile.settingText}。` : '',
|
||||
profile.summary ? `世界概述:${profile.summary}。` : '',
|
||||
profile.tone ? `整体基调:${profile.tone}。` : '',
|
||||
profile.playerGoal ? `主线目标:${profile.playerGoal}。` : '',
|
||||
openingScene?.name ? `开局场景:${openingScene.name}。` : '',
|
||||
openingScene?.description ? `场景描述:${openingScene.description}。` : '',
|
||||
roleSummary ? `需要出现的角色主形象:${roleSummary}。` : '',
|
||||
userPrompt ? `额外要求:${userPrompt}。` : '',
|
||||
'整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createCoverImageTask(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${params.baseUrl}/services/aigc/text2image/image-synthesis`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: TEXT_TO_IMAGE_COVER_MODEL,
|
||||
input: {
|
||||
prompt: params.prompt,
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: params.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(responseText, '创建作品封面生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function createCoverImageFromReference(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
referenceImage: string;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${params.baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: REFERENCE_IMAGE_COVER_MODEL,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ image: params.referenceImage },
|
||||
{ text: params.prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: params.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(responseText, '创建参考图封面任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const responsePayload = JSON.parse(responseText) as Record<string, unknown>;
|
||||
const imageUrl = extractImageUrls(responsePayload)[0] ?? '';
|
||||
if (!imageUrl) {
|
||||
throw badRequest('封面生成未返回图片地址');
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(),
|
||||
taskId: `cover-edit-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveGeneratedCoverAsset(params: {
|
||||
context: AppContext;
|
||||
profile: CoverProfile;
|
||||
imageUrl: string;
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
actualPrompt: string;
|
||||
size: string;
|
||||
model: string;
|
||||
}) {
|
||||
const imageResponse = await fetch(params.imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw badRequest('下载作品封面失败');
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const contentType = imageResponse.headers.get('content-type') || '';
|
||||
const extension = contentType.includes('png')
|
||||
? 'png'
|
||||
: contentType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const assetId = `custom-cover-${Date.now()}`;
|
||||
const worldSegment = sanitizeSegment(
|
||||
params.profile.id || params.profile.name,
|
||||
'world',
|
||||
);
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-covers',
|
||||
worldSegment,
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(params.context.config.publicDir, relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `cover.${extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), imageBuffer);
|
||||
|
||||
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
sourceType: 'generated',
|
||||
taskId: params.taskId,
|
||||
model: params.model,
|
||||
size: params.size,
|
||||
prompt: params.prompt,
|
||||
actualPrompt: params.actualPrompt,
|
||||
imageSrc,
|
||||
worldName: params.profile.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
assetId,
|
||||
sourceType: 'generated' as const,
|
||||
model: params.model,
|
||||
size: params.size,
|
||||
taskId: params.taskId,
|
||||
prompt: params.prompt,
|
||||
actualPrompt: params.actualPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadCustomWorldCoverImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof customWorldCoverUploadSchema>,
|
||||
) {
|
||||
const payload = customWorldCoverUploadSchema.parse(input);
|
||||
const parsedDataUrl = parseImageDataUrl(payload.imageDataUrl);
|
||||
if (!parsedDataUrl) {
|
||||
throw badRequest('上传封面必须是有效图片 Data URL。');
|
||||
}
|
||||
|
||||
const extension = parsedDataUrl.mimeType.includes('png')
|
||||
? 'png'
|
||||
: parsedDataUrl.mimeType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const assetId = `custom-cover-upload-${Date.now()}`;
|
||||
const worldSegment = sanitizeSegment(
|
||||
payload.profileId || payload.worldName,
|
||||
'world',
|
||||
);
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-covers',
|
||||
worldSegment,
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(context.config.publicDir, relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `cover.${extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), parsedDataUrl.buffer);
|
||||
|
||||
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
sourceType: 'uploaded',
|
||||
imageSrc,
|
||||
worldName: payload.worldName,
|
||||
profileId: payload.profileId,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
assetId,
|
||||
sourceType: 'uploaded' as const,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateCustomWorldCoverImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof customWorldCoverImageSchema>,
|
||||
) {
|
||||
const payload = customWorldCoverImageSchema.parse(input);
|
||||
const prompt = buildCustomWorldCoverImagePrompt(
|
||||
payload.profile,
|
||||
payload.characterRoleIds,
|
||||
payload.userPrompt,
|
||||
{
|
||||
hasReferenceImage: Boolean(payload.referenceImageSrc.trim()),
|
||||
},
|
||||
);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
|
||||
if (referenceImage) {
|
||||
const referenceResult = await createCoverImageFromReference({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
prompt,
|
||||
size: payload.size,
|
||||
referenceImage,
|
||||
});
|
||||
|
||||
return saveGeneratedCoverAsset({
|
||||
context,
|
||||
profile: payload.profile,
|
||||
imageUrl: referenceResult.imageUrl,
|
||||
taskId: referenceResult.taskId,
|
||||
prompt,
|
||||
actualPrompt: referenceResult.actualPrompt,
|
||||
size: payload.size,
|
||||
model: REFERENCE_IMAGE_COVER_MODEL,
|
||||
});
|
||||
}
|
||||
|
||||
const createPayload = await createCoverImageTask({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
prompt,
|
||||
size: payload.size,
|
||||
});
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('作品封面任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询作品封面任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '作品封面生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw badRequest('作品封面生成超时或未返回图片地址');
|
||||
}
|
||||
|
||||
return saveGeneratedCoverAsset({
|
||||
context,
|
||||
profile: payload.profile,
|
||||
imageUrl,
|
||||
taskId,
|
||||
prompt,
|
||||
actualPrompt,
|
||||
size: payload.size,
|
||||
model: TEXT_TO_IMAGE_COVER_MODEL,
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
@@ -140,12 +141,19 @@ function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePublishedCover(profile: Record<string, unknown>) {
|
||||
const camp = toRecord(profile.camp);
|
||||
const playableNpcs = toRecordArray(profile.playableNpcs);
|
||||
const leadNpc = toRecord(playableNpcs[0]);
|
||||
function resolveDraftCover(session: CustomWorldAgentSessionRecord) {
|
||||
const draftProfile = toRecord(session.draftProfile);
|
||||
if (!draftProfile) {
|
||||
return {
|
||||
imageSrc: null,
|
||||
renderMode: 'image' as const,
|
||||
characterImageSrcs: [],
|
||||
};
|
||||
}
|
||||
|
||||
return toText(camp?.imageSrc) || toText(leadNpc?.imageSrc) || null;
|
||||
return resolveCustomWorldCoverPresentation(
|
||||
draftProfile as CustomWorldProfileRecord,
|
||||
);
|
||||
}
|
||||
|
||||
function isLibraryEntry(
|
||||
@@ -175,6 +183,7 @@ export async function listCustomWorldWorkSummaries(
|
||||
const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => {
|
||||
const counts = resolveDraftCounts(session);
|
||||
const roleAssetProgress = resolveDraftRoleAssetProgress(session);
|
||||
const coverPresentation = resolveDraftCover(session);
|
||||
|
||||
return {
|
||||
workId: `draft:${session.sessionId}`,
|
||||
@@ -185,7 +194,9 @@ export async function listCustomWorldWorkSummaries(
|
||||
normalizeFoundationDraftProfile(session.draftProfile)?.subtitle ||
|
||||
formatDraftStageLabel(session.stage),
|
||||
summary: resolveDraftSummary(session),
|
||||
coverImageSrc: null,
|
||||
coverImageSrc: coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt: session.updatedAt,
|
||||
publishedAt: null,
|
||||
stage: session.stage,
|
||||
@@ -213,6 +224,7 @@ export async function listCustomWorldWorkSummaries(
|
||||
(libraryEntry ? toText(libraryEntry.updatedAt) : '') ||
|
||||
toText(profileRecord.updatedAt) ||
|
||||
new Date().toISOString();
|
||||
const coverPresentation = resolveCustomWorldCoverPresentation(profileRecord);
|
||||
const roleVisualReadyCount = playableNpcs.filter(
|
||||
(entry) =>
|
||||
Boolean(toText(entry.imageSrc)) &&
|
||||
@@ -240,7 +252,9 @@ export async function listCustomWorldWorkSummaries(
|
||||
'这个世界已经可以直接进入体验。',
|
||||
coverImageSrc:
|
||||
(libraryEntry ? libraryEntry.coverImageSrc : null) ||
|
||||
resolvePublishedCover(profileRecord),
|
||||
coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt,
|
||||
publishedAt:
|
||||
(libraryEntry ? toText(libraryEntry.publishedAt) : '') ||
|
||||
|
||||
@@ -1,784 +1 @@
|
||||
import type {
|
||||
EightAnchorContent,
|
||||
HiddenLineValue,
|
||||
IconicElementValue,
|
||||
KeyRelationshipValue,
|
||||
ThemeBoundaryValue,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
createEmptyEightAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
export type PromptUserInputSignal =
|
||||
| 'rich'
|
||||
| 'normal'
|
||||
| 'sparse'
|
||||
| 'correction'
|
||||
| 'delegate';
|
||||
|
||||
export type PromptDriftRisk = 'low' | 'medium' | 'high';
|
||||
|
||||
export type PromptConversationMode =
|
||||
| 'bootstrap'
|
||||
| 'expand'
|
||||
| 'compress'
|
||||
| 'repair_direction'
|
||||
| 'force_complete'
|
||||
| 'closing';
|
||||
|
||||
export type PromptDynamicState = {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
conversationMode: PromptConversationMode;
|
||||
judgementSummary: string;
|
||||
};
|
||||
|
||||
export type PromptDynamicStateInference = {
|
||||
userInputSignal?: unknown;
|
||||
driftRisk?: unknown;
|
||||
conversationMode?: unknown;
|
||||
judgementSummary?: unknown;
|
||||
};
|
||||
|
||||
const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。
|
||||
|
||||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||||
1. 当前完整设定结构
|
||||
2. 用户聊天记录
|
||||
|
||||
然后输出:
|
||||
1. 一版新的完整设定结构
|
||||
2. 当前 progress 百分比
|
||||
3. 一段直接回复用户的话
|
||||
|
||||
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
|
||||
你的输出会直接覆盖上一版设定结构。
|
||||
|
||||
你不是在做局部 patch。
|
||||
你不是在做解释报告。
|
||||
你不是在给开发者写分析。
|
||||
你是在同时完成:
|
||||
1. 世界设定更新
|
||||
2. 当前推进程度判断
|
||||
3. 对用户的共创回复`;
|
||||
|
||||
const GLOBAL_HARD_RULES = `全局硬约束:
|
||||
|
||||
1. 必须输出完整的设定结构,而不是只输出变化部分。
|
||||
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
|
||||
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
|
||||
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
|
||||
5. progressPercent 最低为 0,不允许为负数。
|
||||
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
|
||||
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
|
||||
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
|
||||
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
|
||||
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
|
||||
11. 你输出的 JSON 必须可以被直接解析。
|
||||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。`;
|
||||
|
||||
const MODE_RULES: Record<PromptConversationMode, string> = {
|
||||
bootstrap: `当前模式:bootstrap
|
||||
|
||||
目标:
|
||||
1. 先把世界的基本方向抓住
|
||||
2. 不要一次塞太多新设定
|
||||
3. 回复要降低用户开口压力
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
|
||||
2. 如果用户信息很少,不要强行把整套结构一次补满
|
||||
3. replyText 要像共创搭档,而不是像审问
|
||||
4. 默认只推进一个最关键的问题方向
|
||||
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
|
||||
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
|
||||
7. 不要把问题问得像表单采集,不要一口气追问多个维度
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户觉得“现在很容易继续往下说”
|
||||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||||
3. replyText 最好短、稳、可接话
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械`,
|
||||
expand: `当前模式:expand
|
||||
|
||||
目标:
|
||||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||||
2. 尽量让一轮输入覆盖多个关键维度
|
||||
|
||||
本轮行为要求:
|
||||
1. 继续保留上一版里仍成立的设定
|
||||
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
|
||||
3. replyText 要明确体现“你已经理解了哪些内容”
|
||||
4. 不要突然大幅改写已经成形的世界
|
||||
5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步
|
||||
6. 可以适度替用户整理,但不要把回复写成总结报告
|
||||
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚说的内容都被接住了”
|
||||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||||
3. 不要无视用户刚提供的高价值细节
|
||||
4. 不要让用户觉得系统在自顾自重写世界`,
|
||||
compress: `当前模式:compress
|
||||
|
||||
目标:
|
||||
1. 开始收束当前设定
|
||||
2. 减少无效发散
|
||||
3. 让 progress 更接近可进入下一阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 新的设定结构优先保留稳定内容,不要无端重写
|
||||
2. 对用户本轮输入做高密度吸收
|
||||
3. replyText 要更聚焦,不要绕圈
|
||||
4. 默认只推进当前最影响 completion 的一步
|
||||
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
|
||||
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
|
||||
7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||||
2. 让推进感更明确,但不要显得催促
|
||||
3. 回复语气应更笃定一些,减少反复横跳
|
||||
4. 不要把用户刚补进来的细节又冲淡掉`,
|
||||
repair_direction: `当前模式:repair_direction
|
||||
|
||||
目标:
|
||||
1. 处理用户对既有设定的修正
|
||||
2. 避免世界方向飘散或自相矛盾
|
||||
|
||||
本轮行为要求:
|
||||
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
|
||||
2. 对已经不再成立的旧设定,不要机械保留
|
||||
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
|
||||
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
|
||||
5. 先处理“改掉什么”,再决定“往哪里继续推”
|
||||
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
|
||||
7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||||
2. 不要和用户辩论旧方案为什么也行
|
||||
3. 不要表现出对修正的不情愿
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`,
|
||||
force_complete: `当前模式:force_complete
|
||||
|
||||
目标:
|
||||
1. 基于当前方向直接补齐剩余设定
|
||||
2. 生成一版尽量完整、可进入下一阶段的设定结构
|
||||
3. 结束当前收集阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 尽量保留已经形成的世界方向
|
||||
2. 对明显缺失的关键维度进行合理补全
|
||||
3. 不要继续拉长聊天,不要再追问用户
|
||||
4. progressPercent 直接输出为 100
|
||||
5. replyText 要自然引导用户点击“生成游戏设定草稿”
|
||||
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
|
||||
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
|
||||
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||||
3. 回复要有完成感,但不要太官话
|
||||
4. 清楚告诉用户下一步可以做什么`,
|
||||
closing: `当前模式:closing
|
||||
|
||||
目标:
|
||||
1. 尽量形成一版可用的设定底子
|
||||
2. 不再继续发散新世界观
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先收束,而不是扩写
|
||||
2. 不要大改已经成形的核心设定
|
||||
3. progressPercent 接近完成时,replyText 要更像确认与推进
|
||||
4. 如果用户没有大改方向,尽量让下一版内容更稳定
|
||||
5. 可以轻微补足缺口,但不要再大开新支线
|
||||
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
|
||||
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||||
3. 保持留白感,不要把所有东西都一次说死
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话`,
|
||||
};
|
||||
|
||||
const USER_SIGNAL_RULES: Record<PromptUserInputSignal, string> = {
|
||||
rich: `本轮用户输入信息密度高。
|
||||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`,
|
||||
normal: `本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`,
|
||||
sparse: `本轮用户输入较少或较虚。
|
||||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||||
replyText 要让用户容易继续往下说。`,
|
||||
correction: `本轮用户在修正或推翻旧设定。
|
||||
请优先吸收修正,不要机械复读旧版本。
|
||||
新的完整设定结构必须以修正后的方向为准。`,
|
||||
delegate: `本轮用户把部分决定权交给你。
|
||||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`,
|
||||
};
|
||||
|
||||
const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。
|
||||
|
||||
这表示用户接受你基于当前方向自动补完剩余设定。
|
||||
|
||||
本轮要求:
|
||||
1. 不要再继续提问
|
||||
2. 直接输出一版尽量完整的设定结构
|
||||
3. progressPercent 直接输出为 100
|
||||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`;
|
||||
|
||||
const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。
|
||||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||||
|
||||
你必须综合以下信息判断:
|
||||
1. 当前轮次 currentTurn
|
||||
2. 当前完成度 progressPercent
|
||||
3. 用户是否要求自动补全 quickFillRequested
|
||||
4. 当前完整设定结构
|
||||
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
|
||||
|
||||
你需要输出 4 个字段:
|
||||
1. userInputSignal:只能是 rich / normal / sparse / correction / delegate
|
||||
2. driftRisk:只能是 low / medium / high
|
||||
3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
|
||||
4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
|
||||
|
||||
请按下面的语义判断。
|
||||
|
||||
一、userInputSignal 定义
|
||||
1. rich
|
||||
- 用户这一轮给了多条可直接落地的有效信息
|
||||
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
|
||||
- 正式生成时应优先高密度吸收,不要只更新一个点
|
||||
|
||||
2. normal
|
||||
- 用户在顺着当前方向做正常补充
|
||||
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
|
||||
- 正式生成时应稳定推进并自然接住用户内容
|
||||
|
||||
3. sparse
|
||||
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
|
||||
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
|
||||
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
|
||||
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
|
||||
|
||||
4. correction
|
||||
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
|
||||
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
|
||||
- correction 的优先级高于 rich 和 normal
|
||||
|
||||
5. delegate
|
||||
- 用户把部分决定权交给系统
|
||||
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
|
||||
- delegate 关注的是授权关系,不只是信息多寡
|
||||
|
||||
二、driftRisk 定义
|
||||
1. low
|
||||
- 当前轮输入与已有方向基本一致
|
||||
- 没有明显改口或冲突
|
||||
|
||||
2. medium
|
||||
- 当前轮带来一定方向变化或扩张
|
||||
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
|
||||
|
||||
3. high
|
||||
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
|
||||
- 这时最重要的是防止旧方向重新回流到正式生成结果里
|
||||
|
||||
三、conversationMode 选择原则
|
||||
1. bootstrap
|
||||
- 适用于前期、信息少、核心方向未稳定
|
||||
- replyText 更适合低压力确认和单点启发
|
||||
|
||||
2. expand
|
||||
- 适用于方向已成形,正在顺着现有路线继续补充
|
||||
- replyText 更适合总结已接住的内容并往前推一步
|
||||
|
||||
3. compress
|
||||
- 适用于中后段,已有骨架,需要开始收束
|
||||
- replyText 更适合聚焦最关键缺口,而不是继续开支线
|
||||
|
||||
4. repair_direction
|
||||
- 适用于用户正在纠偏
|
||||
- replyText 更适合先承认修正,再沿修正后的方向继续推进
|
||||
|
||||
5. force_complete
|
||||
- 适用于用户明确要求自动补全
|
||||
- replyText 不再提问,而应给出完成感和下一步引导
|
||||
|
||||
6. closing
|
||||
- 适用于接近完成但并非强制一键补全
|
||||
- replyText 更像确认与收束,而不是前期式探索
|
||||
|
||||
四、优先级规则
|
||||
1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete
|
||||
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction
|
||||
3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate
|
||||
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
|
||||
|
||||
五、关于 replyText 风格的专门判断要求
|
||||
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
|
||||
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
|
||||
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
|
||||
4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进
|
||||
5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
|
||||
|
||||
六、关于 replyText 用语的硬约束
|
||||
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
|
||||
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
|
||||
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
|
||||
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
|
||||
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
|
||||
|
||||
七、关于 judgementSummary 的写法
|
||||
1. 必须简洁,不要写成长篇分析
|
||||
2. 必须直接服务于下一轮正式生成
|
||||
3. 最好同时包含两层信息:
|
||||
- 为什么这么判断
|
||||
- 正式生成时最该优先做什么,或最该避免什么
|
||||
|
||||
八、硬性约束
|
||||
1. 只能输出 JSON,不能输出解释、代码块或额外说明
|
||||
2. 不能发明上下文里不存在的设定事实
|
||||
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
|
||||
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
|
||||
5. judgementSummary 必须是中文
|
||||
6. 输出值必须严格落在给定枚举中`;
|
||||
|
||||
const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"userInputSignal": "normal",
|
||||
"driftRisk": "low",
|
||||
"conversationMode": "expand",
|
||||
"judgementSummary": ""
|
||||
}`;
|
||||
|
||||
const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorContent": {
|
||||
"worldPromise": {
|
||||
"hook": "",
|
||||
"differentiator": "",
|
||||
"desiredExperience": ""
|
||||
},
|
||||
"playerFantasy": {
|
||||
"playerRole": "",
|
||||
"corePursuit": "",
|
||||
"fearOfLoss": ""
|
||||
},
|
||||
"themeBoundary": {
|
||||
"toneKeywords": [],
|
||||
"aestheticDirectives": [],
|
||||
"forbiddenDirectives": []
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": "",
|
||||
"openingProblem": "",
|
||||
"entryMotivation": ""
|
||||
},
|
||||
"coreConflict": {
|
||||
"surfaceConflicts": [],
|
||||
"hiddenCrisis": "",
|
||||
"firstTouchedConflict": ""
|
||||
},
|
||||
"keyRelationships": [
|
||||
{
|
||||
"pairs": "",
|
||||
"relationshipType": "",
|
||||
"secretOrCost": ""
|
||||
}
|
||||
],
|
||||
"hiddenLines": {
|
||||
"hiddenTruths": [],
|
||||
"misdirectionHints": [],
|
||||
"revealPacing": ""
|
||||
},
|
||||
"iconicElements": {
|
||||
"iconicMotifs": [],
|
||||
"institutionsOrArtifacts": [],
|
||||
"hardRules": []
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function toJson(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getLatestUserText(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return (
|
||||
[...chatHistory]
|
||||
.reverse()
|
||||
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function includesAny(text: string, patterns: RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function isPromptUserInputSignal(
|
||||
value: unknown,
|
||||
): value is PromptUserInputSignal {
|
||||
return (
|
||||
value === 'rich' ||
|
||||
value === 'normal' ||
|
||||
value === 'sparse' ||
|
||||
value === 'correction' ||
|
||||
value === 'delegate'
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptDriftRisk(value: unknown): value is PromptDriftRisk {
|
||||
return value === 'low' || value === 'medium' || value === 'high';
|
||||
}
|
||||
|
||||
function isPromptConversationMode(
|
||||
value: unknown,
|
||||
): value is PromptConversationMode {
|
||||
return (
|
||||
value === 'bootstrap' ||
|
||||
value === 'expand' ||
|
||||
value === 'compress' ||
|
||||
value === 'repair_direction' ||
|
||||
value === 'force_complete' ||
|
||||
value === 'closing'
|
||||
);
|
||||
}
|
||||
|
||||
export function detectUserInputSignal(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
): PromptUserInputSignal {
|
||||
const latestUserText = getLatestUserText(chatHistory).trim();
|
||||
|
||||
if (!latestUserText) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(不是|改成|改为|换成|重来|推翻|修正)/u])) {
|
||||
return 'correction';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(你帮我想|你来定|你决定|你补完)/u])) {
|
||||
return 'delegate';
|
||||
}
|
||||
|
||||
const segments = latestUserText
|
||||
.split(/[。!?;\n]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (latestUserText.length <= 10 || segments.length <= 1) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (segments.length >= 3 || latestUserText.length >= 60) {
|
||||
return 'rich';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function summarizeDynamicState(
|
||||
state: Pick<
|
||||
PromptDynamicState,
|
||||
'userInputSignal' | 'driftRisk' | 'conversationMode'
|
||||
>,
|
||||
) {
|
||||
return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`;
|
||||
}
|
||||
|
||||
function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.toneKeywords.length > 0 ||
|
||||
value.aestheticDirectives.length > 0 ||
|
||||
value.forbiddenDirectives.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function isRelationshipsFilled(value: KeyRelationshipValue[]) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
function isHiddenLinesFilled(value: HiddenLineValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.hiddenTruths.length > 0 ||
|
||||
value.misdirectionHints.length > 0 ||
|
||||
value.revealPacing),
|
||||
);
|
||||
}
|
||||
|
||||
function isIconicElementsFilled(value: IconicElementValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.iconicMotifs.length > 0 ||
|
||||
value.institutionsOrArtifacts.length > 0 ||
|
||||
value.hardRules.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function detectDriftRisk(params: {
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
}) {
|
||||
const latestUserText = getLatestUserText(params.chatHistory).trim();
|
||||
const recentUserMessages = params.chatHistory
|
||||
.filter((entry) => entry.role === 'user')
|
||||
.slice(-3)
|
||||
.map((entry) => entry.content.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const correctionCount = recentUserMessages.filter((entry) =>
|
||||
/(不是|改成|改为|换成|推翻|重来|修正)/u.test(entry),
|
||||
).length;
|
||||
|
||||
if (
|
||||
correctionCount >= 2 ||
|
||||
(params.progressPercent >= 65 &&
|
||||
/(不是|改成|改为|换成|重来|推翻)/u.test(latestUserText))
|
||||
) {
|
||||
return 'high' as const;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeEightAnchorContent(params.anchorContent);
|
||||
const filledCount = [
|
||||
Boolean(normalizedContent.worldPromise),
|
||||
Boolean(normalizedContent.playerFantasy),
|
||||
isThemeBoundaryFilled(normalizedContent.themeBoundary),
|
||||
Boolean(normalizedContent.playerEntryPoint),
|
||||
Boolean(normalizedContent.coreConflict),
|
||||
isRelationshipsFilled(normalizedContent.keyRelationships),
|
||||
isHiddenLinesFilled(normalizedContent.hiddenLines),
|
||||
isIconicElementsFilled(normalizedContent.iconicElements),
|
||||
].filter(Boolean).length;
|
||||
|
||||
if (filledCount >= 3 && latestUserText.length >= 40) {
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
return 'low' as const;
|
||||
}
|
||||
|
||||
export function pickConversationMode(params: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
}) {
|
||||
if (params.quickFillRequested) {
|
||||
return 'force_complete' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
params.userInputSignal === 'correction' ||
|
||||
params.driftRisk === 'high'
|
||||
) {
|
||||
return 'repair_direction' as const;
|
||||
}
|
||||
|
||||
if (params.progressPercent >= 85 || params.currentTurn >= 15) {
|
||||
return 'closing' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn > 10 || params.progressPercent >= 65) {
|
||||
return 'compress' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn <= 10 && params.progressPercent < 65) {
|
||||
return 'expand' as const;
|
||||
}
|
||||
|
||||
return 'bootstrap' as const;
|
||||
}
|
||||
|
||||
function buildRuleBasedPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}): PromptDynamicState {
|
||||
const userInputSignal = detectUserInputSignal(input.chatHistory);
|
||||
const driftRisk = detectDriftRisk({
|
||||
chatHistory: input.chatHistory,
|
||||
anchorContent: input.currentAnchorContent,
|
||||
progressPercent: input.progressPercent,
|
||||
});
|
||||
|
||||
const conversationMode = pickConversationMode({
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary: summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}, inference?: PromptDynamicStateInference | null): PromptDynamicState {
|
||||
const fallbackState = buildRuleBasedPromptDynamicState(input);
|
||||
|
||||
if (!inference) {
|
||||
return fallbackState;
|
||||
}
|
||||
|
||||
const userInputSignal = isPromptUserInputSignal(inference.userInputSignal)
|
||||
? inference.userInputSignal
|
||||
: fallbackState.userInputSignal;
|
||||
const driftRisk = isPromptDriftRisk(inference.driftRisk)
|
||||
? inference.driftRisk
|
||||
: fallbackState.driftRisk;
|
||||
const conversationMode = isPromptConversationMode(inference.conversationMode)
|
||||
? inference.conversationMode
|
||||
: fallbackState.conversationMode;
|
||||
const judgementSummary =
|
||||
toText(inference.judgementSummary) ||
|
||||
summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicStateInferencePrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
|
||||
return {
|
||||
systemPrompt: [
|
||||
STATE_INFERENCE_SYSTEM_PROMPT,
|
||||
STATE_INFERENCE_OUTPUT_CONTRACT,
|
||||
].join('\n\n'),
|
||||
userPrompt: [
|
||||
`当前轮次:${input.currentTurn}`,
|
||||
`当前完成度:${input.progressPercent}`,
|
||||
`是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`,
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
].join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDynamicStateContext(dynamicState: PromptDynamicState) {
|
||||
return `上一轮预判得到的创作状态如下。
|
||||
正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。
|
||||
|
||||
创作状态:
|
||||
- userInputSignal: ${dynamicState.userInputSignal}
|
||||
- driftRisk: ${dynamicState.driftRisk}
|
||||
- conversationMode: ${dynamicState.conversationMode}
|
||||
- judgementSummary: ${dynamicState.judgementSummary}`;
|
||||
}
|
||||
|
||||
function renderCurrentAnchorContext(anchorContent: EightAnchorContent) {
|
||||
return `当前完整设定结构如下。
|
||||
你必须把它视为上一版有效世界底子。
|
||||
|
||||
如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。
|
||||
如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。
|
||||
|
||||
当前完整设定结构:
|
||||
${toJson(normalizeEightAnchorContent(anchorContent))}`;
|
||||
}
|
||||
|
||||
function renderChatHistoryContext(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return `以下是用户聊天记录。
|
||||
请重点理解最近几轮里用户新增、修正、强调的设定信息。
|
||||
不要把早期已经被用户否定的内容继续当成最终结论。
|
||||
|
||||
用户聊天记录:
|
||||
${toJson(chatHistory)}`;
|
||||
}
|
||||
|
||||
export function buildEightAnchorSingleTurnPrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
dynamicState?: PromptDynamicStateInference | PromptDynamicState | null;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
const dynamicState = buildPromptDynamicState({
|
||||
...input,
|
||||
currentAnchorContent,
|
||||
}, input.dynamicState);
|
||||
|
||||
return {
|
||||
prompt: [
|
||||
BASE_SYSTEM_PROMPT,
|
||||
GLOBAL_HARD_RULES,
|
||||
MODE_RULES[dynamicState.conversationMode],
|
||||
USER_SIGNAL_RULES[dynamicState.userInputSignal],
|
||||
dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null,
|
||||
renderDynamicStateContext(dynamicState),
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
OUTPUT_CONTRACT_REMINDER,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
dynamicState,
|
||||
};
|
||||
}
|
||||
export * from '../prompts/eightAnchorPrompts.js';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -48,10 +52,9 @@ function sendJson(res: ServerResponse, payload: unknown) {
|
||||
}
|
||||
|
||||
async function withHttpServer<T>(
|
||||
buildHandler: (baseUrl: string) => (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void>,
|
||||
buildHandler: (
|
||||
baseUrl: string,
|
||||
) => (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
@@ -93,7 +96,9 @@ async function withHttpServer<T>(
|
||||
}
|
||||
|
||||
test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-scene-image-'),
|
||||
);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
@@ -104,7 +109,9 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
|
||||
req.method === 'POST'
|
||||
? (await readRequestBody(req)).toString('utf8')
|
||||
: undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
@@ -122,7 +129,10 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/scene-task-1') {
|
||||
if (
|
||||
req.method === 'GET' &&
|
||||
url.pathname === '/api/v1/tasks/scene-task-1'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
@@ -168,7 +178,8 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
assert.equal(result.actualPrompt, '整理后的场景提示词');
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) => entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
@@ -186,17 +197,20 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
assert.equal(createPayload.input.negative_prompt, '模糊');
|
||||
assert.equal(createPayload.parameters.size, '1280*720');
|
||||
|
||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
const savedImagePath = path.join(
|
||||
tempRoot,
|
||||
'public',
|
||||
result.imageSrc.slice(1),
|
||||
);
|
||||
assert.equal(fs.existsSync(savedImagePath), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
|
||||
test('generateSceneImage builds the scene prompt on the server when the client only submits world and landmark context', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-scene-image-'),
|
||||
);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
@@ -207,7 +221,9 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
|
||||
req.method === 'POST'
|
||||
? (await readRequestBody(req)).toString('utf8')
|
||||
: undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
@@ -215,7 +231,134 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
url.pathname === '/api/v1/services/aigc/text2image/image-synthesis'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'scene-task-2',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === 'GET' &&
|
||||
url.pathname === '/api/v1/tasks/scene-task-2'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
results: [
|
||||
{
|
||||
url: `${baseUrl}/downloads/scene.png`,
|
||||
actual_prompt: '服务端整理后的像素风提示词',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/scene.png') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(PNG_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
|
||||
} as AppContext;
|
||||
|
||||
const result = await generateSceneImage(context, {
|
||||
worldName: '',
|
||||
profileId: '',
|
||||
landmarkName: '',
|
||||
landmarkId: '',
|
||||
userPrompt: '想让灯塔更偏暴风夜',
|
||||
profile: {
|
||||
id: 'world-3',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '迷雾海界',
|
||||
summary: '岛链被旧航道和风暴一起缠住。',
|
||||
tone: '潮湿、压迫、带着未知回声',
|
||||
playerGoal: '先找到断线的引路火',
|
||||
settingText: '玩家在海雾和旧航道之间寻找可以靠岸的线索。',
|
||||
},
|
||||
landmark: {
|
||||
id: 'landmark-3',
|
||||
name: '旧港灯塔',
|
||||
description: '灯塔外墙被海盐侵蚀,塔下平台还能勉强落脚。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
input: {
|
||||
prompt: string;
|
||||
negative_prompt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.match(createPayload.input.prompt, /世界:潮雾群岛,迷雾海界。/u);
|
||||
assert.match(createPayload.input.prompt, /场景名称:旧港灯塔。/u);
|
||||
assert.match(
|
||||
createPayload.input.prompt,
|
||||
/本次想要生成的画面内容:想让灯塔更偏暴风夜。/u,
|
||||
);
|
||||
assert.match(createPayload.input.prompt, /危险感强烈/u);
|
||||
assert.equal(
|
||||
createPayload.input.negative_prompt,
|
||||
'文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-scene-image-'),
|
||||
);
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(publicDir, 'scene_bg', 'reference-layout.png'),
|
||||
PNG_BUFFER,
|
||||
);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
bodyText?: string;
|
||||
}> = [];
|
||||
|
||||
await withHttpServer(
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST'
|
||||
? (await readRequestBody(req)).toString('utf8')
|
||||
: undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
});
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname ===
|
||||
'/api/v1/services/aigc/multimodal-generation/generation'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
@@ -235,7 +378,10 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') {
|
||||
if (
|
||||
req.method === 'GET' &&
|
||||
url.pathname === '/downloads/reference-scene.png'
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(PNG_BUFFER);
|
||||
@@ -273,7 +419,8 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation',
|
||||
entry.pathname ===
|
||||
'/api/v1/services/aigc/multimodal-generation/generation',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
|
||||
@@ -4,12 +4,33 @@ import path from 'node:path';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
} from '../prompts/customWorldPrompts.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import { extractApiErrorMessage } from '../http.js';
|
||||
|
||||
const sceneImageProfileSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
subtitle: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
tone: z.string().trim().optional().default(''),
|
||||
playerGoal: z.string().trim().optional().default(''),
|
||||
settingText: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const sceneImageLandmarkSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
dangerLevel: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
export const sceneImageSchema = z.object({
|
||||
prompt: z.string().trim().min(1),
|
||||
prompt: z.string().trim().optional().default(''),
|
||||
negativePrompt: z.string().trim().optional().default(''),
|
||||
size: z.string().trim().optional().default('1280*720'),
|
||||
model: z.string().trim().optional().default(''),
|
||||
@@ -18,6 +39,9 @@ export const sceneImageSchema = z.object({
|
||||
landmarkName: z.string().trim().optional().default(''),
|
||||
landmarkId: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
userPrompt: z.string().trim().optional().default(''),
|
||||
profile: sceneImageProfileSchema.optional(),
|
||||
landmark: sceneImageLandmarkSchema.optional(),
|
||||
});
|
||||
const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash';
|
||||
const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0';
|
||||
@@ -63,7 +87,10 @@ async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) {
|
||||
}
|
||||
|
||||
const buffer = await readFile(absolutePath);
|
||||
const extension = path.extname(absolutePath).replace(/^\./u, '').toLowerCase();
|
||||
const extension = path
|
||||
.extname(absolutePath)
|
||||
.replace(/^\./u, '')
|
||||
.toLowerCase();
|
||||
const mimeType = (() => {
|
||||
switch (extension) {
|
||||
case 'jpg':
|
||||
@@ -98,7 +125,11 @@ function collectStringsByKey(
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([key, nestedValue]) => {
|
||||
if (key === targetKey && typeof nestedValue === 'string' && nestedValue.trim()) {
|
||||
if (
|
||||
key === targetKey &&
|
||||
typeof nestedValue === 'string' &&
|
||||
nestedValue.trim()
|
||||
) {
|
||||
results.push(nestedValue.trim());
|
||||
return;
|
||||
}
|
||||
@@ -244,17 +275,53 @@ function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
_defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
}
|
||||
|
||||
const referenceImageSrc =
|
||||
typeof payload.referenceImageSrc === 'string'
|
||||
? payload.referenceImageSrc.trim()
|
||||
: '';
|
||||
const profile = payload.profile ?? sceneImageProfileSchema.parse({});
|
||||
const landmark = payload.landmark ?? sceneImageLandmarkSchema.parse({});
|
||||
const profileId = payload.profileId.trim() || profile.id;
|
||||
const worldName = payload.worldName.trim() || profile.name;
|
||||
const landmarkId = payload.landmarkId.trim() || landmark.id;
|
||||
const landmarkName = payload.landmarkName.trim() || landmark.name;
|
||||
|
||||
if (!landmarkName && !landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
}
|
||||
const prompt =
|
||||
payload.prompt.trim() ||
|
||||
buildCustomWorldSceneImagePrompt(
|
||||
{
|
||||
...profile,
|
||||
id: profileId,
|
||||
name: worldName,
|
||||
},
|
||||
{
|
||||
...landmark,
|
||||
id: landmarkId,
|
||||
name: landmarkName,
|
||||
},
|
||||
payload.userPrompt,
|
||||
{
|
||||
hasReferenceImage: Boolean(referenceImageSrc),
|
||||
},
|
||||
);
|
||||
if (!prompt) {
|
||||
throw badRequest('prompt 不能为空');
|
||||
}
|
||||
const negativePrompt =
|
||||
payload.negativePrompt.trim() ||
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
|
||||
|
||||
return {
|
||||
...payload,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
worldName,
|
||||
profileId,
|
||||
landmarkName,
|
||||
landmarkId,
|
||||
referenceImageSrc,
|
||||
model: referenceImageSrc
|
||||
? REFERENCE_IMAGE_SCENE_MODEL
|
||||
@@ -286,7 +353,11 @@ async function saveSceneImageAsset(params: {
|
||||
const worldSegment = (payload.profileId || payload.worldName || 'world')
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.slice(0, 48);
|
||||
const landmarkSegment = (payload.landmarkId || payload.landmarkName || 'landmark')
|
||||
const landmarkSegment = (
|
||||
payload.landmarkId ||
|
||||
payload.landmarkName ||
|
||||
'landmark'
|
||||
)
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.slice(0, 48);
|
||||
const relativeDir = path.join(
|
||||
@@ -338,7 +409,10 @@ export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const payload = ensurePayload(
|
||||
sceneImageSchema.parse(input),
|
||||
context.config.dashScope.imageModel,
|
||||
);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
|
||||
Reference in New Issue
Block a user