1
This commit is contained in:
@@ -2030,10 +2030,169 @@ test('profile dashboard aggregates wallet, play time and played works at the acc
|
||||
});
|
||||
});
|
||||
|
||||
test('profile save archives list worlds by last played time and can resume a selected archive', async () => {
|
||||
await withTestServer('profile-save-archives', async ({ baseUrl }) => {
|
||||
const user = await authEntry(baseUrl, 'archive_user', 'secret123');
|
||||
|
||||
const firstSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-19T08:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '潮声还在旧灯塔下回荡。',
|
||||
options: [],
|
||||
},
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
playerCurrency: 120,
|
||||
runtimeStats: {
|
||||
playTimeMs: 5400000,
|
||||
},
|
||||
storyEngineMemory: {
|
||||
continueGameDigest: '回到裂潮边城的旧灯塔继续追查假航灯。',
|
||||
},
|
||||
customWorldProfile: {
|
||||
id: 'world-aurora',
|
||||
name: '裂潮边城',
|
||||
summary: '潮声与城线之间的冷铁边疆。',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(firstSaveResponse.status, 200);
|
||||
|
||||
const secondSaveResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
savedAt: '2026-04-19T10:15:00.000Z',
|
||||
bottomTab: 'inventory',
|
||||
currentStory: {
|
||||
text: '江湖新章的风雨夜刚刚开始。',
|
||||
options: [],
|
||||
},
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
playerCurrency: 86,
|
||||
runtimeStats: {
|
||||
playTimeMs: 900000,
|
||||
},
|
||||
currentScenePreset: {
|
||||
name: '江湖新章',
|
||||
summary: '雨夜客栈里的新委托。',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(secondSaveResponse.status, 200);
|
||||
|
||||
const listResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/save-archives`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const listPayload = (await listResponse.json()) as {
|
||||
entries: Array<{
|
||||
worldKey: string;
|
||||
worldName: string;
|
||||
summaryText: string;
|
||||
lastPlayedAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(listResponse.status, 200);
|
||||
assert.deepEqual(
|
||||
listPayload.entries.map((entry) => entry.worldKey),
|
||||
['builtin:WUXIA', 'custom:world-aurora'],
|
||||
);
|
||||
assert.equal(listPayload.entries[0]?.worldName, '江湖新章');
|
||||
assert.equal(
|
||||
listPayload.entries[1]?.summaryText,
|
||||
'回到裂潮边城的旧灯塔继续追查假航灯。',
|
||||
);
|
||||
assert.equal(
|
||||
listPayload.entries[0]?.lastPlayedAt,
|
||||
'2026-04-19T10:15:00.000Z',
|
||||
);
|
||||
|
||||
const resumeResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent('custom:world-aurora')}`,
|
||||
withBearer(user.token, {
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
const resumePayload = (await resumeResponse.json()) as {
|
||||
entry: {
|
||||
worldKey: string;
|
||||
};
|
||||
snapshot: {
|
||||
bottomTab: string;
|
||||
gameState: {
|
||||
playerCurrency: number;
|
||||
customWorldProfile: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(resumeResponse.status, 200);
|
||||
assert.equal(resumePayload.entry.worldKey, 'custom:world-aurora');
|
||||
assert.equal(resumePayload.snapshot.bottomTab, 'adventure');
|
||||
assert.equal(resumePayload.snapshot.gameState.playerCurrency, 120);
|
||||
assert.equal(
|
||||
resumePayload.snapshot.gameState.customWorldProfile?.id,
|
||||
'world-aurora',
|
||||
);
|
||||
|
||||
const currentSnapshotResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/save/snapshot`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const currentSnapshotPayload = (await currentSnapshotResponse.json()) as {
|
||||
bottomTab: string;
|
||||
gameState: {
|
||||
playerCurrency: number;
|
||||
customWorldProfile: {
|
||||
id: string;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(currentSnapshotResponse.status, 200);
|
||||
assert.equal(currentSnapshotPayload.bottomTab, 'adventure');
|
||||
assert.equal(currentSnapshotPayload.gameState.playerCurrency, 120);
|
||||
assert.equal(
|
||||
currentSnapshotPayload.gameState.customWorldProfile?.id,
|
||||
'world-aurora',
|
||||
);
|
||||
|
||||
const dashboardResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/profile/dashboard`,
|
||||
withBearer(user.token),
|
||||
);
|
||||
const dashboardPayload = (await dashboardResponse.json()) as {
|
||||
walletBalance: number;
|
||||
totalPlayTimeMs: number;
|
||||
playedWorldCount: number;
|
||||
};
|
||||
|
||||
assert.equal(dashboardResponse.status, 200);
|
||||
assert.equal(dashboardPayload.walletBalance, 86);
|
||||
assert.equal(dashboardPayload.totalPlayTimeMs, 6300000);
|
||||
assert.equal(dashboardPayload.playedWorldCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('custom worlds stay private until published and then appear in the public gallery', async () => {
|
||||
await withTestServer('custom-world-gallery', async ({ baseUrl }) => {
|
||||
const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123');
|
||||
const viewer = await authEntry(baseUrl, 'gallery_viewer', 'secret123');
|
||||
|
||||
const upsertResponse = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-library/world-a`,
|
||||
@@ -2084,15 +2243,11 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
|
||||
const galleryBeforePublish = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const galleryBeforePayload = (await galleryBeforePublish.json()) as {
|
||||
entries: unknown[];
|
||||
};
|
||||
assert.equal(galleryBeforePublish.status, 200);
|
||||
assert.deepEqual(galleryBeforePayload.entries, []);
|
||||
|
||||
const publishResponse = await httpRequest(
|
||||
@@ -2114,11 +2269,6 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
|
||||
const galleryAfterPublish = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const galleryAfterPayload = (await galleryAfterPublish.json()) as {
|
||||
entries: Array<{
|
||||
@@ -2139,11 +2289,6 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
|
||||
const galleryDetail = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const galleryDetailPayload = (await galleryDetail.json()) as {
|
||||
entry: {
|
||||
@@ -2175,11 +2320,6 @@ test('custom worlds stay private until published and then appear in the public g
|
||||
|
||||
const galleryAfterUnpublish = await httpRequest(
|
||||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${viewer.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const galleryAfterUnpublishPayload =
|
||||
(await galleryAfterUnpublish.json()) as {
|
||||
|
||||
@@ -30,6 +30,7 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
||||
`CREATE TABLE IF NOT EXISTS runtime_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
music_volume REAL NOT NULL,
|
||||
platform_theme TEXT NOT NULL DEFAULT 'light',
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
@@ -316,4 +317,38 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
||||
)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20260419_014_profile_save_archives',
|
||||
name: 'profile save archives',
|
||||
statements: [
|
||||
`CREATE TABLE IF NOT EXISTS profile_save_archives (
|
||||
user_id TEXT NOT NULL,
|
||||
world_key TEXT NOT NULL,
|
||||
owner_user_id TEXT,
|
||||
profile_id TEXT,
|
||||
world_type TEXT,
|
||||
world_name TEXT NOT NULL DEFAULT '',
|
||||
world_subtitle TEXT NOT NULL DEFAULT '',
|
||||
summary_text TEXT NOT NULL DEFAULT '',
|
||||
cover_image_src TEXT,
|
||||
saved_at TEXT NOT NULL,
|
||||
bottom_tab TEXT NOT NULL,
|
||||
game_state_json JSONB NOT NULL,
|
||||
current_story_json JSONB,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, world_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS profile_save_archives_user_saved_idx
|
||||
ON profile_save_archives (user_id, saved_at DESC)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '20260419_015_runtime_settings_platform_theme',
|
||||
name: 'runtime settings platform theme',
|
||||
statements: [
|
||||
`ALTER TABLE runtime_settings
|
||||
ADD COLUMN IF NOT EXISTS platform_theme TEXT NOT NULL DEFAULT 'light'`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -125,11 +125,13 @@ function describeAffinityShift(affinityDelta: number) {
|
||||
}
|
||||
|
||||
function buildFallbackNpcChatSuggestions(playerMessage: string) {
|
||||
const topic = playerMessage.trim() || '刚才那句话';
|
||||
const topic = Array.from(playerMessage.trim() || '刚才那句')
|
||||
.slice(0, 8)
|
||||
.join('');
|
||||
return [
|
||||
`顺着“${topic}”再追问一句`,
|
||||
'先表明你的判断,再看对方反应',
|
||||
'换个更轻一点的语气继续聊下去',
|
||||
'你刚才那句是什么意思',
|
||||
`这事和${topic}有关吗`,
|
||||
'你愿意再说清楚点吗',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,469 +1 @@
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
|
||||
你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 这段内容只是聊天,不是做决定。
|
||||
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
|
||||
- 禁止把情报直接写成对玩家的指令。
|
||||
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
|
||||
|
||||
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
|
||||
你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 这段对话的目标是把“邀请对方入队”自然谈成。
|
||||
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
|
||||
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
|
||||
- 最后一行必须由对方明确答应加入队伍。`;
|
||||
|
||||
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
|
||||
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
|
||||
|
||||
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
|
||||
只输出纯文本,共 3 行,每行 1 条。
|
||||
不要加编号、项目符号、Markdown、JSON 或额外说明。
|
||||
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.map((item) => readString(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return worldType || '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function describeStats(label: string, record: JsonRecord | null) {
|
||||
const hp = readNumber(record?.hp);
|
||||
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
|
||||
const mana = readNumber(record?.mana);
|
||||
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
|
||||
|
||||
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
|
||||
}
|
||||
|
||||
function describeCharacter(label: string, value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const name = readString(record?.name) ?? '未知角色';
|
||||
const title = readString(record?.title) ?? '未知称号';
|
||||
const description = readString(record?.description) ?? '暂无额外描述';
|
||||
const personality = readString(record?.personality) ?? '性格信息未显式提供';
|
||||
|
||||
return [
|
||||
`${label}姓名:${name}`,
|
||||
`${label}称号:${title}`,
|
||||
`${label}描述:${description}`,
|
||||
`${label}性格:${personality}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: unknown) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-4)
|
||||
.map((item) => readString(asRecord(item)?.text))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
|
||||
: '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
function describeConversationHistory(history: unknown) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-12)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
|
||||
const text = readString(record?.text);
|
||||
|
||||
return text ? `- ${speaker}:${text}` : null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['聊天记录:', ...lines].join('\n')
|
||||
: '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
function describeNpcConversationHistory(history: unknown, npcName: string) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '当前聊天记录:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-10)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const speaker = readString(record?.speaker);
|
||||
const speakerName = readString(record?.speakerName);
|
||||
const text = readString(record?.text);
|
||||
if (!text) return null;
|
||||
|
||||
if (speaker === 'player') {
|
||||
return `- 玩家:${text}`;
|
||||
}
|
||||
|
||||
if (speaker === 'npc') {
|
||||
return `- ${speakerName ?? npcName}:${text}`;
|
||||
}
|
||||
|
||||
if (speaker === 'system') {
|
||||
return `- 系统提示:${text}`;
|
||||
}
|
||||
|
||||
return `- ${speakerName ?? '同伴'}:${text}`;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['当前聊天记录:', ...lines].join('\n')
|
||||
: '当前聊天记录:暂无。';
|
||||
}
|
||||
|
||||
function describeSceneContext(context: unknown) {
|
||||
const record = asRecord(context);
|
||||
const sceneName = readString(record?.sceneName) ?? '当前区域';
|
||||
const sceneDescription =
|
||||
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
|
||||
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
|
||||
const customWorldProfile = asRecord(record?.customWorldProfile);
|
||||
const customWorldName = readString(customWorldProfile?.name);
|
||||
const customWorldSummary = readString(customWorldProfile?.summary);
|
||||
|
||||
return [
|
||||
`世界补充:${customWorldName ?? '无'}`,
|
||||
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
|
||||
`场景:${sceneName}`,
|
||||
`场景描述:${sceneDescription}`,
|
||||
`当前状态:${inBattle}`,
|
||||
describeStats('玩家', record),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: unknown) {
|
||||
const record = asRecord(status);
|
||||
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
|
||||
const affinity = record?.affinity;
|
||||
|
||||
return [
|
||||
`对方身份:${roleLabel}`,
|
||||
describeStats('对方', record),
|
||||
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeEncounter(encounter: unknown) {
|
||||
const record = asRecord(encounter);
|
||||
const npcName = readString(record?.npcName) ?? '眼前角色';
|
||||
const contextText =
|
||||
readString(record?.context) ??
|
||||
readString(record?.npcDescription) ??
|
||||
'你们正在当前遭遇里继续对话。';
|
||||
|
||||
return {
|
||||
npcName,
|
||||
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: unknown) {
|
||||
if (!Array.isArray(monsters) || monsters.length === 0) {
|
||||
return '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
const lines = monsters
|
||||
.slice(0, 4)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const name =
|
||||
readString(record?.name) ??
|
||||
readString(record?.npcName) ??
|
||||
readString(record?.id);
|
||||
const hp = readNumber(record?.hp);
|
||||
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
|
||||
|
||||
return name ? `- ${name}(生命 ${hp}/${maxHp})` : null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['当前敌对目标:', ...lines].join('\n')
|
||||
: '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
function describeTargetCharacterName(payload: {
|
||||
targetCharacter?: unknown;
|
||||
encounter?: unknown;
|
||||
}) {
|
||||
return (
|
||||
readString(asRecord(payload.targetCharacter)?.name) ??
|
||||
readString(asRecord(payload.encounter)?.npcName) ??
|
||||
'对方'
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt(
|
||||
payload: CharacterChatReplyRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.conversationSummary
|
||||
? `之前聊天摘要:${payload.conversationSummary}`
|
||||
: '之前聊天摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
|
||||
`现在请以 ${targetName} 的身份,直接回复玩家。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt(
|
||||
payload: CharacterChatSuggestionsRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
const latestCharacterReply = Array.isArray(payload.conversationHistory)
|
||||
? [...payload.conversationHistory]
|
||||
.reverse()
|
||||
.map((item) => asRecord(item))
|
||||
.find((record) => readString(record?.speaker) === 'character')
|
||||
: null;
|
||||
const latestReplyText = readString(latestCharacterReply?.text);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.conversationSummary
|
||||
? `之前聊天摘要:${payload.conversationSummary}`
|
||||
: '之前聊天摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
latestReplyText
|
||||
? `角色刚刚的回复:${latestReplyText}`
|
||||
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
|
||||
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt(
|
||||
payload: CharacterChatSummaryRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.previousSummary
|
||||
? `旧摘要:${payload.previousSummary}`
|
||||
: '旧摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function buildNpcDialoguePromptBase(
|
||||
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const character =
|
||||
(payload as NpcChatTurnRequest).character ??
|
||||
(payload as NpcChatTurnRequest).player;
|
||||
if (!(payload as NpcChatTurnRequest).character && character) {
|
||||
(payload as NpcChatTurnRequest).character = character;
|
||||
}
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.character),
|
||||
encounter.block,
|
||||
describeMonsters(payload.monsters),
|
||||
describeStoryHistory(payload.history),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildStrictNpcChatDialoguePrompt(
|
||||
payload: NpcChatDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const context = asRecord(payload.context);
|
||||
const openingCampBackground = readString(context?.openingCampBackground);
|
||||
const openingCampDialogue = readString(context?.openingCampDialogue);
|
||||
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
|
||||
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
|
||||
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
|
||||
allowedTopics.length > 0
|
||||
? `当前更适合谈的内容:${allowedTopics.join('、')}`
|
||||
: null,
|
||||
blockedTopics.length > 0
|
||||
? `当前避免直接说破:${blockedTopics.join('、')}`
|
||||
: null,
|
||||
`当前聊天主题:${payload.topic}`,
|
||||
payload.resultSummary
|
||||
? `这段聊天希望带来的变化:${payload.resultSummary}`
|
||||
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
|
||||
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcRecruitDialoguePrompt(
|
||||
payload: NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
`玩家邀请:${payload.invitationText}`,
|
||||
payload.recruitSummary
|
||||
? `招募补充条件:${payload.recruitSummary}`
|
||||
: '这轮对话已经具备自然邀请对方入队的条件。',
|
||||
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
|
||||
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcChatTurnReplyPrompt(
|
||||
payload: NpcChatTurnRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const npcState = asRecord(payload.npcState);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
const affinity = readNumber(npcState?.affinity, 0);
|
||||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`当前关系值:${affinity}`,
|
||||
`已聊天轮次:${chattedCount}`,
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcChatTurnSuggestionPrompt(
|
||||
payload: NpcChatTurnRequest,
|
||||
npcReply: string,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`NPC 刚刚回复:${npcReply}`,
|
||||
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
export * from '../../prompts/chatPromptBuilders.js';
|
||||
|
||||
@@ -22,17 +22,15 @@ import type {
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldProfile,
|
||||
} from '../../../../src/types.js';
|
||||
import {
|
||||
buildCustomWorldProfilePrompt,
|
||||
buildCustomWorldProfileRepairPrompt,
|
||||
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
|
||||
CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
} from '../../prompts/customWorldOrchestratorPrompts.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
type GeneratedProfile = Record<string, unknown>;
|
||||
|
||||
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000;
|
||||
|
||||
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
|
||||
@@ -278,59 +276,6 @@ function createCustomWorldGenerationReporter(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomWorldProfilePrompt(params: {
|
||||
settingText: string;
|
||||
generationSeedText: string;
|
||||
creatorIntent: CustomWorldCreatorIntent | null;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
}) {
|
||||
const targets = getCustomWorldGenerationTargets(params.generationMode);
|
||||
const creatorIntentText =
|
||||
params.creatorIntent && hasMeaningfulCustomWorldCreatorIntent(params.creatorIntent)
|
||||
? buildCustomWorldCreatorIntentGenerationText(params.creatorIntent)
|
||||
: '';
|
||||
|
||||
return [
|
||||
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
|
||||
'必须严格输出单个 JSON 对象,不要 Markdown,不要解释。',
|
||||
'',
|
||||
`生成模式:${params.generationMode}`,
|
||||
`可扮演角色数量:${targets.playableCount}`,
|
||||
`场景角色数量:${targets.storyCount}`,
|
||||
`关键场景数量:${targets.landmarkCount}`,
|
||||
'',
|
||||
'创作者输入:',
|
||||
params.generationSeedText,
|
||||
creatorIntentText ? `\n结构化创作锚点:\n${creatorIntentText}` : '',
|
||||
'',
|
||||
'输出 JSON 字段要求:',
|
||||
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
|
||||
'- majorFactions: string[],coreConflicts: string[]',
|
||||
'- camp: { name, description, dangerLevel }',
|
||||
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
|
||||
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
|
||||
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
|
||||
'- connections 每项包含 targetLandmarkName, relativePosition, summary,targetLandmarkName 必须指向本次输出的其他场景名',
|
||||
'',
|
||||
'约束:',
|
||||
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
|
||||
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
|
||||
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
|
||||
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
|
||||
'- dangerLevel 使用 low、medium、high、extreme 之一。',
|
||||
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
|
||||
'- 不要预生成物品档案;items 如需输出,必须为空数组。',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function buildCustomWorldProfileRepairPrompt(responseText: string) {
|
||||
return [
|
||||
'请修复下面的自定义世界 JSON。',
|
||||
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
|
||||
responseText,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
async function parseCustomWorldJsonStage(params: {
|
||||
llmClient: UpstreamLlmClient;
|
||||
responseText: string;
|
||||
@@ -424,16 +369,21 @@ export async function generateCustomWorldProfileFromOrchestrator(
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
} = resolveCustomWorldGenerationInput(input);
|
||||
const targets = getCustomWorldGenerationTargets(generationMode);
|
||||
const creatorIntentText =
|
||||
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
|
||||
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
|
||||
: '';
|
||||
const reporter = createCustomWorldGenerationReporter(options.onProgress);
|
||||
|
||||
try {
|
||||
throwIfCustomWorldGenerationAborted(options.signal);
|
||||
reporter.begin('prepare', '正在整理创作者输入与结构化锚点。');
|
||||
const userPrompt = buildCustomWorldProfilePrompt({
|
||||
settingText,
|
||||
generationSeedText,
|
||||
creatorIntent,
|
||||
generationMode,
|
||||
creatorIntentText,
|
||||
targets,
|
||||
});
|
||||
reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。');
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js';
|
||||
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
import {
|
||||
buildStoryLanguageRepairPrompt,
|
||||
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
|
||||
} from '../../prompts/storyOrchestratorPrompts.js';
|
||||
import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -64,12 +68,6 @@ type RawOptionItem = {
|
||||
actionText?: string;
|
||||
};
|
||||
|
||||
const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||||
你会收到一个已经解析过的剧情 JSON 对象。
|
||||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
|
||||
const DEFAULT_VISUALS = {
|
||||
playerAnimation: 'idle' as const,
|
||||
playerMoveMeters: 0,
|
||||
@@ -83,6 +81,8 @@ const STATIC_FALLBACK_OPTION_MAP: Record<
|
||||
string,
|
||||
Partial<PromptStoryOption> & { actionText: string }
|
||||
> = {
|
||||
battle_attack_basic: { actionText: '普通攻击' },
|
||||
battle_use_skill: { actionText: '释放技能' },
|
||||
battle_all_in_crush: { actionText: '正面强压敌人' },
|
||||
battle_escape_breakout: { actionText: '先脱离眼前追杀' },
|
||||
battle_feint_step: { actionText: '借假动作切进身位' },
|
||||
@@ -334,11 +334,9 @@ function resolveOptionsFromOptionCatalog(
|
||||
function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) {
|
||||
if (context.inBattle === true) {
|
||||
return [
|
||||
'battle_probe_pressure',
|
||||
'battle_guard_break',
|
||||
'battle_attack_basic',
|
||||
'battle_recover_breath',
|
||||
'battle_feint_step',
|
||||
'battle_finisher_window',
|
||||
'battle_use_skill',
|
||||
'battle_escape_breakout',
|
||||
];
|
||||
}
|
||||
@@ -381,25 +379,6 @@ function getFallbackOptions(
|
||||
);
|
||||
}
|
||||
|
||||
function buildStoryLanguageRepairPrompt(response: AIResponse) {
|
||||
return [
|
||||
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
|
||||
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
|
||||
JSON.stringify(
|
||||
{
|
||||
storyText: response.storyText,
|
||||
encounter: response.encounter ?? null,
|
||||
options: response.options.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
function needsStoryLanguageRepair(response: AIResponse) {
|
||||
return hasMixedNarrativeLanguage(response.storyText);
|
||||
}
|
||||
|
||||
54
server-node/src/modules/ai/storyPromptBuilders.test.ts
Normal file
54
server-node/src/modules/ai/storyPromptBuilders.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildUserPrompt } from './storyPromptBuilders.js';
|
||||
|
||||
test('buildUserPrompt adds post-chat reevaluation guidance for npc option catalogs', () => {
|
||||
const prompt = buildUserPrompt({
|
||||
worldType: 'WUXIA',
|
||||
character: {
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
personality: '谨慎',
|
||||
},
|
||||
monsters: [],
|
||||
history: [
|
||||
{ text: '你:刚才那句话是什么意思?' },
|
||||
{ text: '山道客:你最好别继续深究。' },
|
||||
],
|
||||
context: {
|
||||
sceneName: '山道',
|
||||
sceneDescription: '风声贴着碎石一路往前卷。',
|
||||
encounterName: '山道客',
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
pendingSceneEncounter: false,
|
||||
lastFunctionId: 'npc_chat',
|
||||
},
|
||||
choice: '结束与山道客的这轮交谈,重新观察当前局势',
|
||||
requestOptions: {
|
||||
optionCatalog: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
},
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
},
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '看看能交换什么',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(prompt, /刚结束一轮 NPC 交谈后/u);
|
||||
assert.match(prompt, /不要退回/u);
|
||||
assert.match(prompt, /目录只是合法 function 范围/u);
|
||||
});
|
||||
@@ -1,163 +1 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return worldType || '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function describeCharacter(character: JsonRecord) {
|
||||
return [
|
||||
`主角:${readString(character.name) ?? '未知角色'}`,
|
||||
`称号:${readString(character.title) ?? '未知称号'}`,
|
||||
`描述:${readString(character.description) ?? '暂无'}`,
|
||||
`性格:${readString(character.personality) ?? '未显式提供'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: JsonRecord[]) {
|
||||
if (monsters.length <= 0) {
|
||||
return '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'当前敌对目标:',
|
||||
...monsters.slice(0, 4).map((monster) => {
|
||||
const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标';
|
||||
const hp = readNumber(monster.hp);
|
||||
const maxHp = Math.max(1, readNumber(monster.maxHp, hp));
|
||||
return `- ${name}(生命 ${hp}/${maxHp})`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: JsonRecord[]) {
|
||||
if (history.length <= 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'近期剧情:',
|
||||
...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeRequestOptions(options: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const available = options.availableOptions ?? [];
|
||||
const catalog = options.optionCatalog ?? [];
|
||||
|
||||
if (available.length > 0) {
|
||||
return [
|
||||
'固定可选项列表:',
|
||||
...available.map((option, index) => {
|
||||
const functionId = readString(option.functionId) ?? 'unknown';
|
||||
const actionText =
|
||||
readString(option.actionText) ??
|
||||
readString(option.text) ??
|
||||
'未提供文案';
|
||||
return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`;
|
||||
}),
|
||||
'必须保持数量不变,functionId 不变,可以重写 actionText。'.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (catalog.length > 0) {
|
||||
return [
|
||||
'当前局面可调用的交互选项目录:',
|
||||
...catalog.map((option, index) => {
|
||||
const functionId = readString(option.functionId) ?? 'unknown';
|
||||
const actionText =
|
||||
readString(option.actionText) ??
|
||||
readString(option.text) ??
|
||||
'未提供文案';
|
||||
return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`;
|
||||
}),
|
||||
'functionId 只能从上面目录里选择。'.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return '当前没有固定目录,请根据局势生成合理选项。';
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。
|
||||
输出格式必须严格符合:
|
||||
{
|
||||
"storyText": "剧情文本",
|
||||
"encounter": null,
|
||||
"options": [
|
||||
{
|
||||
"functionId": "预定义功能ID",
|
||||
"actionText": "选项显示文本"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
严格规则:
|
||||
- 所有文本必须是中文。
|
||||
- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。
|
||||
- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。
|
||||
- options 只允许输出 functionId 和 actionText。
|
||||
- 如果当前不是“继续推进后下一刻会遇到什么”的场景,encounter 必须保持为 null。`;
|
||||
|
||||
export function buildUserPrompt(params: {
|
||||
worldType: string;
|
||||
character: JsonRecord;
|
||||
monsters: JsonRecord[];
|
||||
history: JsonRecord[];
|
||||
context: JsonRecord;
|
||||
choice?: string;
|
||||
requestOptions?: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}) {
|
||||
const sceneName = readString(params.context.sceneName) ?? '当前区域';
|
||||
const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。';
|
||||
const encounterName = readString(params.context.encounterName);
|
||||
const playerHp = readNumber(params.context.playerHp);
|
||||
const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp));
|
||||
const playerMana = readNumber(params.context.playerMana);
|
||||
const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana));
|
||||
const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗';
|
||||
const pendingSceneEncounter =
|
||||
params.context.pendingSceneEncounter === true ? '是' : '否';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(params.worldType)}`,
|
||||
`场景:${sceneName}`,
|
||||
`场景描述:${sceneDescription}`,
|
||||
encounterName ? `当前面前对象:${encounterName}` : null,
|
||||
`当前状态:${inBattle}`,
|
||||
`玩家生命:${playerHp}/${playerMaxHp}`,
|
||||
`玩家灵力:${playerMana}/${playerMaxMana}`,
|
||||
`是否需要判断下一刻遭遇:${pendingSceneEncounter}`,
|
||||
describeCharacter(params.character),
|
||||
describeMonsters(params.monsters),
|
||||
describeStoryHistory(params.history),
|
||||
params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。',
|
||||
describeRequestOptions(params.requestOptions ?? {}),
|
||||
params.context.pendingSceneEncounter === true
|
||||
? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter;否则 encounter 必须为 null。'
|
||||
: '当前这一步不是新的遭遇生成流程,encounter 必须为 null。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
export * from '../../prompts/storyPromptBuilders.js';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,17 @@
|
||||
import type {
|
||||
RuntimeBattlePresentation,
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { conflict } from '../../errors.js';
|
||||
import {
|
||||
appendBuildBuffs,
|
||||
resolvePlayerOutgoingDamageResult,
|
||||
} from '../runtime/runtimeBuildModule.js';
|
||||
import {
|
||||
getEncounterNpcState,
|
||||
getPlayerCharacter,
|
||||
getPlayerSkillCooldowns,
|
||||
setEncounterNpcState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
@@ -16,6 +23,15 @@ type CombatActionConfig = {
|
||||
counterMultiplier: number;
|
||||
heal?: number;
|
||||
manaRestore?: number;
|
||||
cooldownBonus?: number;
|
||||
selectedSkillId?: string | null;
|
||||
appliedCooldownTurns?: number;
|
||||
buildBuffs?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
durationTurns: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CombatResolution = {
|
||||
@@ -26,46 +42,21 @@ export type CombatResolution = {
|
||||
storyText?: string;
|
||||
};
|
||||
|
||||
const COMBAT_ACTIONS: Record<string, CombatActionConfig> = {
|
||||
battle_all_in_crush: {
|
||||
actionText: '正面强压',
|
||||
manaCost: 14,
|
||||
baseDamage: 22,
|
||||
counterMultiplier: 1.25,
|
||||
},
|
||||
battle_feint_step: {
|
||||
actionText: '虚晃切步',
|
||||
manaCost: 8,
|
||||
baseDamage: 16,
|
||||
counterMultiplier: 0.7,
|
||||
},
|
||||
battle_finisher_window: {
|
||||
actionText: '抓破绽终结',
|
||||
manaCost: 10,
|
||||
baseDamage: 18,
|
||||
counterMultiplier: 0.9,
|
||||
},
|
||||
battle_guard_break: {
|
||||
actionText: '破架重击',
|
||||
manaCost: 9,
|
||||
baseDamage: 17,
|
||||
counterMultiplier: 0.95,
|
||||
},
|
||||
battle_probe_pressure: {
|
||||
actionText: '稳步试探',
|
||||
manaCost: 5,
|
||||
baseDamage: 12,
|
||||
counterMultiplier: 0.8,
|
||||
},
|
||||
battle_recover_breath: {
|
||||
actionText: '边守边调息',
|
||||
manaCost: 0,
|
||||
baseDamage: 0,
|
||||
counterMultiplier: 0.55,
|
||||
heal: 12,
|
||||
manaRestore: 9,
|
||||
},
|
||||
};
|
||||
const LEGACY_ATTACK_FUNCTION_IDS = new Set<string>([
|
||||
'battle_all_in_crush',
|
||||
'battle_guard_break',
|
||||
'battle_probe_pressure',
|
||||
'battle_feint_step',
|
||||
'battle_finisher_window',
|
||||
]);
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getAliveTarget(session: RuntimeSession) {
|
||||
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
|
||||
@@ -124,19 +115,120 @@ function finishBattle(
|
||||
}
|
||||
}
|
||||
|
||||
function buildBasicAttackBaseDamage(session: RuntimeSession) {
|
||||
const character = getPlayerCharacter(session);
|
||||
if (!character) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
8,
|
||||
Math.round(
|
||||
character.attributes.strength * 0.85 +
|
||||
character.attributes.agility * 0.45,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function tickCooldownMap(
|
||||
cooldowns: Record<string, number>,
|
||||
turns: number,
|
||||
) {
|
||||
let nextCooldowns = cooldowns;
|
||||
|
||||
for (let index = 0; index < Math.max(0, Math.floor(turns)); index += 1) {
|
||||
nextCooldowns = Object.fromEntries(
|
||||
Object.entries(nextCooldowns).map(([skillId, value]) => [
|
||||
skillId,
|
||||
Math.max(0, Math.floor(value) - 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return nextCooldowns;
|
||||
}
|
||||
|
||||
function resolveCombatActionConfig(params: {
|
||||
session: RuntimeSession;
|
||||
functionId: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
const { session, functionId, payload } = params;
|
||||
|
||||
if (functionId === 'battle_recover_breath') {
|
||||
return {
|
||||
actionText: '恢复',
|
||||
manaCost: 0,
|
||||
baseDamage: 0,
|
||||
counterMultiplier: 0.55,
|
||||
heal: 12,
|
||||
manaRestore: 9,
|
||||
cooldownBonus: 1,
|
||||
selectedSkillId: null,
|
||||
} satisfies CombatActionConfig;
|
||||
}
|
||||
|
||||
if (functionId === 'battle_attack_basic' || LEGACY_ATTACK_FUNCTION_IDS.has(functionId)) {
|
||||
return {
|
||||
actionText: '普通攻击',
|
||||
manaCost: 0,
|
||||
baseDamage: buildBasicAttackBaseDamage(session),
|
||||
counterMultiplier: 1,
|
||||
selectedSkillId: null,
|
||||
} satisfies CombatActionConfig;
|
||||
}
|
||||
|
||||
if (functionId === 'battle_use_skill') {
|
||||
const character = getPlayerCharacter(session);
|
||||
if (!character) {
|
||||
throw conflict('缺少玩家角色,无法结算技能动作');
|
||||
}
|
||||
|
||||
const skillId = readString(isObject(payload) ? payload.skillId : '');
|
||||
if (!skillId) {
|
||||
throw conflict('battle_use_skill 缺少 skillId');
|
||||
}
|
||||
|
||||
const skill = character.skills.find((candidate) => candidate.id === skillId);
|
||||
if (!skill) {
|
||||
throw conflict(`未找到技能:${skillId}`);
|
||||
}
|
||||
|
||||
const cooldowns = getPlayerSkillCooldowns(session);
|
||||
if ((cooldowns[skill.id] ?? 0) > 0) {
|
||||
throw conflict(`${skill.name} 仍在冷却中`);
|
||||
}
|
||||
|
||||
return {
|
||||
actionText: skill.name,
|
||||
manaCost: skill.manaCost,
|
||||
baseDamage: skill.damage,
|
||||
counterMultiplier: 0.95,
|
||||
selectedSkillId: skill.id,
|
||||
appliedCooldownTurns: skill.cooldownTurns,
|
||||
buildBuffs: skill.buildBuffs ?? [],
|
||||
} satisfies CombatActionConfig;
|
||||
}
|
||||
|
||||
throw conflict(`暂不支持的战斗动作:${functionId}`);
|
||||
}
|
||||
|
||||
export function resolveCombatAction(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
params: {
|
||||
functionId: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
},
|
||||
): CombatResolution {
|
||||
const target = getAliveTarget(session);
|
||||
if (!session.inBattle || !target) {
|
||||
throw conflict('当前不在可结算战斗态,不能执行该战斗动作');
|
||||
}
|
||||
|
||||
if (functionId === 'battle_escape_breakout') {
|
||||
if (params.functionId === 'battle_escape_breakout') {
|
||||
finishBattle(session, 'escaped');
|
||||
return {
|
||||
actionText: '强行脱离战斗',
|
||||
actionText: '逃跑',
|
||||
resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`,
|
||||
battle: {
|
||||
targetId: target.id,
|
||||
@@ -146,7 +238,7 @@ export function resolveCombatAction(
|
||||
patches: [
|
||||
{
|
||||
type: 'battle_resolved',
|
||||
functionId,
|
||||
functionId: params.functionId,
|
||||
targetId: target.id,
|
||||
outcome: 'escaped',
|
||||
},
|
||||
@@ -165,27 +257,66 @@ export function resolveCombatAction(
|
||||
};
|
||||
}
|
||||
|
||||
const action = COMBAT_ACTIONS[functionId];
|
||||
if (!action) {
|
||||
throw conflict(`暂不支持的战斗动作:${functionId}`);
|
||||
}
|
||||
|
||||
const action = resolveCombatActionConfig({
|
||||
session,
|
||||
functionId: params.functionId,
|
||||
payload: params.payload,
|
||||
});
|
||||
if (action.manaCost > session.playerMana) {
|
||||
throw conflict('当前灵力不足,无法执行这个战斗动作');
|
||||
}
|
||||
|
||||
const character = getPlayerCharacter(session);
|
||||
if (!character) {
|
||||
throw conflict('缺少玩家角色,无法结算战斗动作');
|
||||
}
|
||||
|
||||
const isSpar = session.currentNpcBattleMode === 'spar';
|
||||
const targetHpRatio = target.hp / Math.max(target.maxHp, 1);
|
||||
const damageBonus =
|
||||
functionId === 'battle_finisher_window' && targetHpRatio <= 0.4 ? 8 : 0;
|
||||
const damageDealt = isSpar ? 1 : action.baseDamage + damageBonus;
|
||||
const damageResult =
|
||||
action.baseDamage > 0
|
||||
? resolvePlayerOutgoingDamageResult(
|
||||
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
|
||||
character,
|
||||
action.baseDamage,
|
||||
1,
|
||||
`${params.functionId}:${action.selectedSkillId ?? 'default'}:${target.id}:${session.runtimeVersion}`,
|
||||
)
|
||||
: null;
|
||||
const damageDealt = isSpar
|
||||
? action.baseDamage > 0
|
||||
? 1
|
||||
: 0
|
||||
: damageResult?.damage ?? 0;
|
||||
|
||||
session.playerMana -= action.manaCost;
|
||||
session.playerHp += action.heal ?? 0;
|
||||
session.playerMana += action.manaRestore ?? 0;
|
||||
|
||||
let nextCooldowns = tickCooldownMap(getPlayerSkillCooldowns(session), 1);
|
||||
if ((action.cooldownBonus ?? 0) > 0) {
|
||||
nextCooldowns = tickCooldownMap(nextCooldowns, action.cooldownBonus ?? 0);
|
||||
}
|
||||
if (action.selectedSkillId && (action.appliedCooldownTurns ?? 0) > 0) {
|
||||
nextCooldowns = {
|
||||
...nextCooldowns,
|
||||
[action.selectedSkillId]: action.appliedCooldownTurns,
|
||||
};
|
||||
}
|
||||
session.rawGameState.playerSkillCooldowns = nextCooldowns;
|
||||
|
||||
if (action.buildBuffs?.length) {
|
||||
session.rawGameState.activeBuildBuffs = appendBuildBuffs(
|
||||
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
|
||||
[],
|
||||
action.buildBuffs as Parameters<typeof appendBuildBuffs>[1],
|
||||
);
|
||||
}
|
||||
|
||||
clampPlayerVitals(session);
|
||||
|
||||
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt);
|
||||
if (damageDealt > 0) {
|
||||
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt);
|
||||
}
|
||||
|
||||
const patches: RuntimeStoryPatch[] = [];
|
||||
let resultText = '';
|
||||
@@ -204,12 +335,15 @@ export function resolveCombatAction(
|
||||
} else {
|
||||
finishBattle(session, 'victory');
|
||||
outcome = 'victory';
|
||||
resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口。`;
|
||||
resultText = `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`;
|
||||
}
|
||||
} else {
|
||||
const baseCounter = isSpar
|
||||
? 1
|
||||
: Math.max(4, Math.round(target.maxHp * 0.16 * action.counterMultiplier));
|
||||
: Math.max(
|
||||
4,
|
||||
Math.round(target.maxHp * 0.14 * action.counterMultiplier),
|
||||
);
|
||||
damageTaken = baseCounter;
|
||||
session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken);
|
||||
|
||||
@@ -220,7 +354,7 @@ export function resolveCombatAction(
|
||||
patches.push(affinityPatch);
|
||||
}
|
||||
outcome = 'spar_complete';
|
||||
resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`;
|
||||
resultText = `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`;
|
||||
} else if (!isSpar && session.playerHp <= 0) {
|
||||
session.playerHp = 0;
|
||||
session.inBattle = false;
|
||||
@@ -230,15 +364,19 @@ export function resolveCombatAction(
|
||||
session.currentEncounter = null;
|
||||
outcome = 'escaped';
|
||||
resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
|
||||
} else if (params.functionId === 'battle_recover_breath') {
|
||||
resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`;
|
||||
} else if (params.functionId === 'battle_use_skill') {
|
||||
resultText = `${action.actionText}命中了${target.name},这一轮技能效果已经直接结算。`;
|
||||
} else {
|
||||
resultText = `${action.actionText}命中了${target.name},但对方仍然顶住并回敬了一轮压力。`;
|
||||
resultText = `${action.actionText}命中了${target.name},本次攻击已经完成结算。`;
|
||||
}
|
||||
}
|
||||
|
||||
patches.push(
|
||||
{
|
||||
type: 'battle_resolved',
|
||||
functionId,
|
||||
functionId: params.functionId,
|
||||
targetId: target.id,
|
||||
damageDealt,
|
||||
damageTaken,
|
||||
|
||||
@@ -5,8 +5,14 @@ import {
|
||||
QUEST_REWARD_THEMES,
|
||||
QUEST_URGENCY_LEVELS,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
buildQuestIntentPrompt,
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
} from '../../prompts/questPrompts.js';
|
||||
import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js';
|
||||
|
||||
export { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT };
|
||||
|
||||
export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number];
|
||||
export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number];
|
||||
export type QuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
|
||||
@@ -669,169 +675,6 @@ function getSignalProgressIncrement(signal: QuestProgressSignal) {
|
||||
return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1;
|
||||
}
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map((moment) => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary
|
||||
?.map(
|
||||
(quest) =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active =
|
||||
context.activeCompanions?.map((companion) => companion.characterId).join('、') ||
|
||||
'无';
|
||||
const roster =
|
||||
context.rosterCompanions?.map((companion) => companion.characterId).join('、') ||
|
||||
'无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory =
|
||||
context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(
|
||||
scene: QuestSceneSnapshot | null,
|
||||
context: QuestGenerationContext,
|
||||
) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
return context.activeThreadIds?.length
|
||||
? context.activeThreadIds.join('、')
|
||||
: '暂无明确激活线程';
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask ?? '暂无'}`,
|
||||
`表层线:${profile.visibleLine ?? '暂无'}`,
|
||||
`当前压力:${profile.immediatePressure ?? '暂无'}`,
|
||||
profile.reactionHooks?.length
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const { context, scene, opportunity } = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name ?? '自定义世界'}: ${
|
||||
context.customWorldProfile.summary ?? '暂无摘要'
|
||||
}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName}(${context.issuerNpcId})`,
|
||||
`发布者身份:${context.issuerNpcContext || '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function buildQuestGenerationContextFromState(params: {
|
||||
state: RuntimeStateLike;
|
||||
encounter: RuntimeEncounterLike;
|
||||
|
||||
@@ -2,6 +2,12 @@ import {
|
||||
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
|
||||
RUNTIME_ITEM_TONE_VALUES,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
buildRuntimeItemIntentPromptText,
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
} from '../../prompts/runtimeItemPrompts.js';
|
||||
|
||||
export { RUNTIME_ITEM_INTENT_SYSTEM_PROMPT };
|
||||
|
||||
export type RuntimeItemFunctionalBias =
|
||||
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
|
||||
@@ -573,48 +579,16 @@ function describePlan(
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
return buildRuntimeItemIntentPromptText({
|
||||
generationChannel: params.context.generationChannel,
|
||||
planBlocks: params.plans.map((plan, index) =>
|
||||
describePlan(params.context, plan, index),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function buildBaseRuntimeContext(params: {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type {
|
||||
RuntimeStoryEncounterViewModel,
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryViewModel,
|
||||
Task5RuntimeOptionScope,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
|
||||
import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../runtime/runtimeInventoryEffectsModule.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type StoryHistoryRole = 'action' | 'result';
|
||||
@@ -62,6 +68,58 @@ export type RuntimeCompanion = {
|
||||
joinedAtAffinity: number;
|
||||
};
|
||||
|
||||
type RuntimePlayerAttributes = {
|
||||
strength: number;
|
||||
agility: number;
|
||||
intelligence: number;
|
||||
spirit: number;
|
||||
};
|
||||
|
||||
type RuntimePlayerSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
damage: number;
|
||||
manaCost: number;
|
||||
cooldownTurns: number;
|
||||
buildBuffs?: Array<{
|
||||
id: string;
|
||||
sourceType: 'skill' | 'item' | 'forge';
|
||||
sourceId: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
durationTurns: number;
|
||||
maxStacks?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RuntimePlayerCharacter = {
|
||||
attributes: RuntimePlayerAttributes;
|
||||
skills: RuntimePlayerSkill[];
|
||||
};
|
||||
|
||||
type RuntimeBattleItemUseProfile = {
|
||||
hpRestore?: number;
|
||||
manaRestore?: number;
|
||||
cooldownReduction?: number;
|
||||
buildBuffs?: Array<{
|
||||
id: string;
|
||||
sourceType: 'item';
|
||||
sourceId: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
durationTurns: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type RuntimeBattleInventoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
tags: string[];
|
||||
useProfile?: RuntimeBattleItemUseProfile;
|
||||
};
|
||||
|
||||
export type RuntimeSession = {
|
||||
sessionId: string;
|
||||
runtimeVersion: number;
|
||||
@@ -97,6 +155,8 @@ const STORY_FUNCTION_IDS = new Set<string>([
|
||||
]);
|
||||
|
||||
const COMBAT_FUNCTION_IDS = new Set<string>([
|
||||
'battle_attack_basic',
|
||||
'battle_use_skill',
|
||||
'battle_all_in_crush',
|
||||
'battle_escape_breakout',
|
||||
'battle_feint_step',
|
||||
@@ -164,6 +224,16 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
|
||||
detailText: '收束当前遭遇并切往下一段场景流程。',
|
||||
scope: 'story',
|
||||
},
|
||||
battle_attack_basic: {
|
||||
actionText: '普通攻击',
|
||||
detailText: '本回合执行一次不耗蓝的基础攻击。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_use_skill: {
|
||||
actionText: '释放技能',
|
||||
detailText: '直接执行一个具体技能,不再包装成抽象战术动作。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_all_in_crush: {
|
||||
actionText: '正面强压',
|
||||
detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。',
|
||||
@@ -195,8 +265,13 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_recover_breath: {
|
||||
actionText: '边守边调息',
|
||||
detailText: '优先回稳资源,但仍可能吃到轻量反击。',
|
||||
actionText: '恢复',
|
||||
detailText: '直接恢复资源,并推进本回合冷却。',
|
||||
scope: 'combat',
|
||||
},
|
||||
inventory_use: {
|
||||
actionText: '使用物品',
|
||||
detailText: '战斗中优先执行一个可立即结算的消耗品。',
|
||||
scope: 'combat',
|
||||
},
|
||||
npc_chat: {
|
||||
@@ -430,6 +505,344 @@ function normalizeHostileNpcs(value: unknown) {
|
||||
.filter((entry): entry is RuntimeHostileNpc => Boolean(entry));
|
||||
}
|
||||
|
||||
function normalizePlayerSkill(value: unknown): RuntimePlayerSkill | null {
|
||||
const rawSkill = isObject(value) ? value : null;
|
||||
if (!rawSkill) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = readString(rawSkill.id);
|
||||
const name = readString(rawSkill.name, id);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
damage: Math.max(1, Math.round(readNumber(rawSkill.damage, 1))),
|
||||
manaCost: Math.max(0, Math.round(readNumber(rawSkill.manaCost, 0))),
|
||||
cooldownTurns: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawSkill.cooldownTurns, 0)),
|
||||
),
|
||||
buildBuffs: readArray(rawSkill.buildBuffs)
|
||||
.map((entry) => {
|
||||
const rawBuff = isObject(entry) ? entry : null;
|
||||
if (!rawBuff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffId = readString(rawBuff.id);
|
||||
const sourceId = readString(rawBuff.sourceId);
|
||||
const name = readString(rawBuff.name, buffId);
|
||||
if (!buffId || !sourceId || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceType = readString(rawBuff.sourceType, 'skill');
|
||||
return {
|
||||
id: buffId,
|
||||
sourceType:
|
||||
sourceType === 'item' || sourceType === 'forge'
|
||||
? sourceType
|
||||
: 'skill',
|
||||
sourceId,
|
||||
name,
|
||||
tags: readArray(rawBuff.tags).filter(
|
||||
(tag): tag is string =>
|
||||
typeof tag === 'string' && tag.trim().length > 0,
|
||||
),
|
||||
durationTurns: Math.max(
|
||||
1,
|
||||
Math.round(readNumber(rawBuff.durationTurns, 1)),
|
||||
),
|
||||
maxStacks:
|
||||
typeof rawBuff.maxStacks === 'number' &&
|
||||
Number.isFinite(rawBuff.maxStacks)
|
||||
? Math.max(1, Math.round(rawBuff.maxStacks))
|
||||
: undefined,
|
||||
} satisfies NonNullable<RuntimePlayerSkill['buildBuffs']>[number];
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is NonNullable<RuntimePlayerSkill['buildBuffs']>[number] =>
|
||||
Boolean(entry),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerCharacter(
|
||||
value: unknown,
|
||||
): RuntimePlayerCharacter | null {
|
||||
const rawCharacter = isObject(value) ? value : null;
|
||||
const rawAttributes = isObject(rawCharacter?.attributes)
|
||||
? rawCharacter.attributes
|
||||
: null;
|
||||
if (!rawCharacter || !rawAttributes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
strength: Math.max(0, Math.round(readNumber(rawAttributes.strength, 0))),
|
||||
agility: Math.max(0, Math.round(readNumber(rawAttributes.agility, 0))),
|
||||
intelligence: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawAttributes.intelligence, 0)),
|
||||
),
|
||||
spirit: Math.max(0, Math.round(readNumber(rawAttributes.spirit, 0))),
|
||||
},
|
||||
skills: readArray(rawCharacter.skills)
|
||||
.map((entry) => normalizePlayerSkill(entry))
|
||||
.filter((entry): entry is RuntimePlayerSkill => Boolean(entry)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBattleInventoryItem(
|
||||
value: unknown,
|
||||
): RuntimeBattleInventoryItem | null {
|
||||
const rawItem = isObject(value) ? value : null;
|
||||
if (!rawItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = readString(rawItem.id);
|
||||
const name = readString(rawItem.name, id);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rarity = readString(rawItem.rarity, 'common');
|
||||
const normalizedRarity =
|
||||
rarity === 'legendary' ||
|
||||
rarity === 'epic' ||
|
||||
rarity === 'rare' ||
|
||||
rarity === 'uncommon'
|
||||
? rarity
|
||||
: 'common';
|
||||
const useProfile = isObject(rawItem.useProfile)
|
||||
? (cloneJson(rawItem.useProfile) as RuntimeBattleItemUseProfile)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))),
|
||||
rarity: normalizedRarity,
|
||||
tags: readArray(rawItem.tags).filter(
|
||||
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
|
||||
),
|
||||
useProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlayerCharacter(session: RuntimeSession) {
|
||||
return normalizePlayerCharacter(session.rawGameState.playerCharacter);
|
||||
}
|
||||
|
||||
export function getPlayerSkillCooldowns(session: RuntimeSession) {
|
||||
const rawCooldowns = isObject(session.rawGameState.playerSkillCooldowns)
|
||||
? session.rawGameState.playerSkillCooldowns
|
||||
: {};
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawCooldowns).map(([skillId, turns]) => [
|
||||
skillId,
|
||||
Math.max(0, Math.round(readNumber(turns, 0))),
|
||||
]),
|
||||
) as Record<string, number>;
|
||||
}
|
||||
|
||||
function getBattleInventoryItems(session: RuntimeSession) {
|
||||
return readArray(session.rawGameState.playerInventory)
|
||||
.map((entry) => normalizeBattleInventoryItem(entry))
|
||||
.filter((entry): entry is RuntimeBattleInventoryItem => Boolean(entry));
|
||||
}
|
||||
|
||||
function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) {
|
||||
return Math.max(
|
||||
8,
|
||||
Math.round(
|
||||
character.attributes.strength * 0.85 +
|
||||
character.attributes.agility * 0.45,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildBattleDisabledOption(params: {
|
||||
functionId: string;
|
||||
actionText?: string;
|
||||
detailText?: string;
|
||||
reason: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
return buildOptionView(params.functionId, {
|
||||
actionText: params.actionText,
|
||||
detailText: params.detailText,
|
||||
payload: params.payload,
|
||||
disabled: true,
|
||||
reason: params.reason,
|
||||
});
|
||||
}
|
||||
|
||||
function buildBattleItemSummary(
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
|
||||
) {
|
||||
const parts = [
|
||||
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
|
||||
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
|
||||
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
|
||||
effect.buildBuffs.length > 0
|
||||
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(' / ') || '立即结算一次物品效果';
|
||||
}
|
||||
|
||||
function pickPreferredBattleItem(session: RuntimeSession) {
|
||||
const character = getPlayerCharacter(session);
|
||||
if (!character) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
const character = getPlayerCharacter(session);
|
||||
if (!character) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cooldowns = getPlayerSkillCooldowns(session);
|
||||
|
||||
return character.skills.map((skill) => {
|
||||
const remainingCooldown = cooldowns[skill.id] ?? 0;
|
||||
const damage = resolvePlayerOutgoingDamageResult(
|
||||
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
|
||||
character,
|
||||
skill.damage,
|
||||
1,
|
||||
`runtime-skill-preview:${skill.id}`,
|
||||
).damage;
|
||||
const detailText = [
|
||||
`耗蓝 ${skill.manaCost}`,
|
||||
`伤害 ${damage}`,
|
||||
`冷却 ${skill.cooldownTurns}`,
|
||||
].join(' / ');
|
||||
|
||||
if (remainingCooldown > 0) {
|
||||
return buildBattleDisabledOption({
|
||||
functionId: 'battle_use_skill',
|
||||
actionText: skill.name,
|
||||
detailText,
|
||||
payload: { skillId: skill.id },
|
||||
reason: `冷却中,还需 ${remainingCooldown} 回合`,
|
||||
});
|
||||
}
|
||||
|
||||
if (skill.manaCost > session.playerMana) {
|
||||
return buildBattleDisabledOption({
|
||||
functionId: 'battle_use_skill',
|
||||
actionText: skill.name,
|
||||
detailText,
|
||||
payload: { skillId: skill.id },
|
||||
reason: '灵力不足',
|
||||
});
|
||||
}
|
||||
|
||||
return buildOptionView('battle_use_skill', {
|
||||
actionText: skill.name,
|
||||
detailText,
|
||||
payload: { skillId: skill.id },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildBattleActionOptions(session: RuntimeSession) {
|
||||
const character = getPlayerCharacter(session);
|
||||
const itemCandidate = pickPreferredBattleItem(session);
|
||||
const basicAttackDamage = character
|
||||
? resolvePlayerOutgoingDamageResult(
|
||||
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
|
||||
character,
|
||||
buildBasicAttackBaseDamage(character),
|
||||
1,
|
||||
'runtime-basic-attack-preview',
|
||||
).damage
|
||||
: 0;
|
||||
|
||||
return [
|
||||
buildOptionView('battle_attack_basic', {
|
||||
detailText:
|
||||
basicAttackDamage > 0
|
||||
? `不耗蓝 / 伤害 ${basicAttackDamage}`
|
||||
: '不耗蓝的基础攻击',
|
||||
}),
|
||||
buildOptionView('battle_recover_breath', {
|
||||
actionText: '恢复',
|
||||
detailText: '回血 12 / 回蓝 9 / 冷却 -1',
|
||||
}),
|
||||
itemCandidate
|
||||
? buildOptionView('inventory_use', {
|
||||
actionText: `使用物品:${itemCandidate.item.name}`,
|
||||
detailText: buildBattleItemSummary(itemCandidate.effect),
|
||||
payload: { itemId: itemCandidate.item.id },
|
||||
})
|
||||
: buildBattleDisabledOption({
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用物品',
|
||||
detailText: '当前没有可直接结算的战斗消耗品',
|
||||
reason: '暂无可用物品',
|
||||
}),
|
||||
...buildBattleSkillOptions(session),
|
||||
buildOptionView('battle_escape_breakout'),
|
||||
] satisfies RuntimeStoryOptionView[];
|
||||
}
|
||||
|
||||
export function getEncounterKey(encounter: RuntimeEncounter) {
|
||||
return encounter.id || encounter.npcName;
|
||||
}
|
||||
@@ -613,15 +1026,7 @@ function hasGiftablePlayerInventory(session: RuntimeSession) {
|
||||
|
||||
export function buildAvailableOptions(session: RuntimeSession) {
|
||||
if (session.inBattle) {
|
||||
return [
|
||||
'battle_probe_pressure',
|
||||
'battle_guard_break',
|
||||
'battle_feint_step',
|
||||
'battle_finisher_window',
|
||||
'battle_all_in_crush',
|
||||
'battle_recover_breath',
|
||||
'battle_escape_breakout',
|
||||
].map((functionId) => buildOptionView(functionId));
|
||||
return buildBattleActionOptions(session);
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'npc') {
|
||||
@@ -784,6 +1189,9 @@ export function buildLegacyCurrentStory(
|
||||
text: option.actionText,
|
||||
detailText: option.detailText,
|
||||
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
|
||||
@@ -378,46 +378,48 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
await withTestServer('combat-finisher', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123');
|
||||
|
||||
await putSnapshot(baseUrl, entry.token, {
|
||||
worldType: 'WUXIA',
|
||||
storyHistory: [],
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 12,
|
||||
maxHp: 28,
|
||||
description: '桥口劫匪',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerHp: 42,
|
||||
playerMaxHp: 50,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 12,
|
||||
maxHp: 28,
|
||||
description: '桥口劫匪',
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerHp: 42,
|
||||
playerMaxHp: 50,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
companions: [],
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
});
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
@@ -486,6 +488,313 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story state exposes the single-action combat option pool with runtime payload metadata', async () => {
|
||||
await withTestServer('combat-state-options', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_state', 'secret123');
|
||||
const playerCharacter = {
|
||||
...requirePlayerCharacter(),
|
||||
skills: [
|
||||
{
|
||||
id: 'slash',
|
||||
name: '试锋斩',
|
||||
animation: 'attack',
|
||||
damage: 18,
|
||||
manaCost: 4,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
{
|
||||
id: 'wind-step',
|
||||
name: '断风步',
|
||||
animation: 'attack',
|
||||
damage: 12,
|
||||
manaCost: 2,
|
||||
cooldownTurns: 0,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
playerCharacter,
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 36,
|
||||
maxHp: 36,
|
||||
description: '桥口劫匪',
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerMana: 6,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {
|
||||
slash: 2,
|
||||
'wind-step': 0,
|
||||
},
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'focus-tonic',
|
||||
category: '消耗品',
|
||||
name: '凝神灵液',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['mana'],
|
||||
useProfile: {
|
||||
manaRestore: 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/state/runtime-main`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
viewModel: {
|
||||
status: {
|
||||
inBattle: boolean;
|
||||
};
|
||||
availableOptions: Array<{
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload?: {
|
||||
skillId?: string;
|
||||
itemId?: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.viewModel.status.inBattle, true);
|
||||
assert.deepEqual(
|
||||
payload.viewModel.availableOptions.map((option) => option.functionId),
|
||||
[
|
||||
'battle_attack_basic',
|
||||
'battle_recover_breath',
|
||||
'inventory_use',
|
||||
'battle_use_skill',
|
||||
'battle_use_skill',
|
||||
'battle_escape_breakout',
|
||||
],
|
||||
);
|
||||
|
||||
const itemOption = payload.viewModel.availableOptions[2];
|
||||
assert.equal(itemOption?.functionId, 'inventory_use');
|
||||
assert.equal(itemOption?.payload?.itemId, 'focus-tonic');
|
||||
assert.equal(itemOption?.disabled, undefined);
|
||||
|
||||
const slashOption = payload.viewModel.availableOptions[3];
|
||||
assert.equal(slashOption?.actionText, '试锋斩');
|
||||
assert.equal(slashOption?.payload?.skillId, 'slash');
|
||||
assert.equal(slashOption?.disabled, true);
|
||||
assert.match(slashOption?.reason ?? '', /冷却中/u);
|
||||
|
||||
const windStepOption = payload.viewModel.availableOptions[4];
|
||||
assert.equal(windStepOption?.actionText, '断风步');
|
||||
assert.equal(windStepOption?.payload?.skillId, 'wind-step');
|
||||
assert.equal(windStepOption?.disabled, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
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 playerCharacter = {
|
||||
...requirePlayerCharacter(),
|
||||
skills: [
|
||||
{
|
||||
id: 'slash',
|
||||
name: '试锋斩',
|
||||
animation: 'attack',
|
||||
damage: 18,
|
||||
manaCost: 4,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
buildBuffs: [
|
||||
{
|
||||
id: 'slash:buff',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'slash',
|
||||
name: '试锋余势',
|
||||
tags: ['快剑'],
|
||||
durationTurns: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
playerCharacter,
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 80,
|
||||
maxHp: 80,
|
||||
description: '桥口劫匪',
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerHp: 32,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 9,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
|
||||
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: 'battle_use_skill',
|
||||
payload: {
|
||||
skillId: 'slash',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
serverVersion: number;
|
||||
viewModel: {
|
||||
player: {
|
||||
mana: number;
|
||||
};
|
||||
status: {
|
||||
inBattle: boolean;
|
||||
};
|
||||
availableOptions: Array<{
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload?: {
|
||||
skillId?: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
presentation: {
|
||||
resultText: string;
|
||||
storyText: string;
|
||||
battle: {
|
||||
outcome: string;
|
||||
damageDealt: number;
|
||||
} | null;
|
||||
};
|
||||
snapshot: {
|
||||
gameState: {
|
||||
playerMana: number;
|
||||
playerSkillCooldowns: Record<string, number>;
|
||||
activeBuildBuffs: Array<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
patches: Array<{
|
||||
type: string;
|
||||
functionId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
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.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.ok(
|
||||
payload.patches.some(
|
||||
(patch) =>
|
||||
patch.type === 'battle_resolved' &&
|
||||
patch.functionId === 'battle_use_skill',
|
||||
),
|
||||
);
|
||||
|
||||
const skillOption = payload.viewModel.availableOptions.find(
|
||||
(option) =>
|
||||
option.functionId === 'battle_use_skill' &&
|
||||
option.payload?.skillId === 'slash',
|
||||
);
|
||||
assert.ok(skillOption);
|
||||
assert.equal(skillOption.actionText, '试锋斩');
|
||||
assert.equal(skillOption.disabled, true);
|
||||
assert.match(skillOption.reason ?? '', /冷却中/u);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -155,17 +155,16 @@ function buildStoryOptionFromRuntimeOption(
|
||||
session: RuntimeSession,
|
||||
option: RuntimeStoryOptionView,
|
||||
) {
|
||||
const detailParts = [option.detailText, option.disabled ? option.reason : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: detailParts || undefined,
|
||||
detailText: option.detailText,
|
||||
visuals: DEFAULT_STORY_OPTION_VISUALS,
|
||||
interaction: buildStoryOptionInteraction(session, option),
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
} satisfies JsonRecord;
|
||||
}
|
||||
|
||||
@@ -173,9 +172,7 @@ function buildStoryOptionsFromRuntimeOptions(
|
||||
session: RuntimeSession,
|
||||
options: RuntimeStoryOptionView[],
|
||||
) {
|
||||
return options
|
||||
.filter((option) => !option.disabled)
|
||||
.map((option) => buildStoryOptionFromRuntimeOption(session, option));
|
||||
return options.map((option) => buildStoryOptionFromRuntimeOption(session, option));
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
@@ -460,6 +457,22 @@ function normalizeStatusPatch(session: RuntimeSession) {
|
||||
} satisfies RuntimeStoryPatch;
|
||||
}
|
||||
|
||||
function shouldGenerateReasonedCombatStory(
|
||||
functionId: string,
|
||||
resolution: StoryResolution,
|
||||
) {
|
||||
if (!isCombatFunctionId(functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const outcome = resolution.battle?.outcome;
|
||||
return (
|
||||
outcome === 'victory' ||
|
||||
outcome === 'spar_complete' ||
|
||||
outcome === 'escaped'
|
||||
);
|
||||
}
|
||||
|
||||
function clearEncounterState(session: RuntimeSession) {
|
||||
session.currentEncounter = null;
|
||||
session.npcInteractionActive = false;
|
||||
@@ -778,7 +791,12 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
? { ...session.currentEncounter }
|
||||
: null;
|
||||
if (isCombatFunctionId(functionId)) {
|
||||
resolution = resolveCombatAction(session, functionId);
|
||||
resolution = resolveCombatAction(session, {
|
||||
functionId,
|
||||
payload: isObject(params.request.action.payload)
|
||||
? params.request.action.payload
|
||||
: undefined,
|
||||
});
|
||||
} else if (isNpcFunctionId(functionId)) {
|
||||
resolution = resolveNpcInteraction(session, functionId);
|
||||
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
|
||||
@@ -840,7 +858,10 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
} catch {
|
||||
savedCurrentStory = buildLegacyCurrentStory(storyText, options);
|
||||
}
|
||||
} else if (params.llmClient && isCombatFunctionId(functionId)) {
|
||||
} else if (
|
||||
params.llmClient &&
|
||||
shouldGenerateReasonedCombatStory(functionId, resolution)
|
||||
) {
|
||||
try {
|
||||
const generatedPayload = await generateReasonedStoryPayload({
|
||||
llmClient: params.llmClient,
|
||||
|
||||
471
server-node/src/prompts/chatPromptBuilders.ts
Normal file
471
server-node/src/prompts/chatPromptBuilders.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../packages/shared/src/contracts/story.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
|
||||
你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 这段内容只是聊天,不是做决定。
|
||||
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
|
||||
- 禁止把情报直接写成对玩家的指令。
|
||||
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
|
||||
|
||||
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
|
||||
你只能输出纯中文对话正文,不能输出解释、代码、markdown、JSON 或额外说明。
|
||||
|
||||
硬性规则:
|
||||
- 每一行都必须严格以“你:”或“角色名字:”开头。
|
||||
- 第一行必须是“你:”开头。
|
||||
- 总行数控制在 4 到 6 行。
|
||||
- 玩家和对方至少各说 2 次。
|
||||
- 这段对话的目标是把“邀请对方入队”自然谈成。
|
||||
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
|
||||
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
|
||||
- 最后一行必须由对方明确答应加入队伍。`;
|
||||
|
||||
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
|
||||
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
|
||||
|
||||
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
|
||||
只输出纯文本,共 3 行,每行 1 条。
|
||||
不要加编号、项目符号、Markdown、JSON 或额外说明。
|
||||
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.map((item) => readString(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return worldType || '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function describeStats(label: string, record: JsonRecord | null) {
|
||||
const hp = readNumber(record?.hp);
|
||||
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
|
||||
const mana = readNumber(record?.mana);
|
||||
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
|
||||
|
||||
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
|
||||
}
|
||||
|
||||
function describeCharacter(label: string, value: unknown) {
|
||||
const record = asRecord(value);
|
||||
const name = readString(record?.name) ?? '未知角色';
|
||||
const title = readString(record?.title) ?? '未知称号';
|
||||
const description = readString(record?.description) ?? '暂无额外描述';
|
||||
const personality = readString(record?.personality) ?? '性格信息未显式提供';
|
||||
|
||||
return [
|
||||
`${label}姓名:${name}`,
|
||||
`${label}称号:${title}`,
|
||||
`${label}描述:${description}`,
|
||||
`${label}性格:${personality}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: unknown) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-4)
|
||||
.map((item) => readString(asRecord(item)?.text))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
|
||||
: '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
function describeConversationHistory(history: unknown) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-12)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
|
||||
const text = readString(record?.text);
|
||||
|
||||
return text ? `- ${speaker}:${text}` : null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['聊天记录:', ...lines].join('\n')
|
||||
: '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
function describeNpcConversationHistory(history: unknown, npcName: string) {
|
||||
if (!Array.isArray(history) || history.length === 0) {
|
||||
return '当前聊天记录:暂无。';
|
||||
}
|
||||
|
||||
const lines = history
|
||||
.slice(-10)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const speaker = readString(record?.speaker);
|
||||
const speakerName = readString(record?.speakerName);
|
||||
const text = readString(record?.text);
|
||||
if (!text) return null;
|
||||
|
||||
if (speaker === 'player') {
|
||||
return `- 玩家:${text}`;
|
||||
}
|
||||
|
||||
if (speaker === 'npc') {
|
||||
return `- ${speakerName ?? npcName}:${text}`;
|
||||
}
|
||||
|
||||
if (speaker === 'system') {
|
||||
return `- 系统提示:${text}`;
|
||||
}
|
||||
|
||||
return `- ${speakerName ?? '同伴'}:${text}`;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['当前聊天记录:', ...lines].join('\n')
|
||||
: '当前聊天记录:暂无。';
|
||||
}
|
||||
|
||||
function describeSceneContext(context: unknown) {
|
||||
const record = asRecord(context);
|
||||
const sceneName = readString(record?.sceneName) ?? '当前区域';
|
||||
const sceneDescription =
|
||||
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
|
||||
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
|
||||
const customWorldProfile = asRecord(record?.customWorldProfile);
|
||||
const customWorldName = readString(customWorldProfile?.name);
|
||||
const customWorldSummary = readString(customWorldProfile?.summary);
|
||||
|
||||
return [
|
||||
`世界补充:${customWorldName ?? '无'}`,
|
||||
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
|
||||
`场景:${sceneName}`,
|
||||
`场景描述:${sceneDescription}`,
|
||||
`当前状态:${inBattle}`,
|
||||
describeStats('玩家', record),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: unknown) {
|
||||
const record = asRecord(status);
|
||||
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
|
||||
const affinity = record?.affinity;
|
||||
|
||||
return [
|
||||
`对方身份:${roleLabel}`,
|
||||
describeStats('对方', record),
|
||||
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeEncounter(encounter: unknown) {
|
||||
const record = asRecord(encounter);
|
||||
const npcName = readString(record?.npcName) ?? '眼前角色';
|
||||
const contextText =
|
||||
readString(record?.context) ??
|
||||
readString(record?.npcDescription) ??
|
||||
'你们正在当前遭遇里继续对话。';
|
||||
|
||||
return {
|
||||
npcName,
|
||||
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: unknown) {
|
||||
if (!Array.isArray(monsters) || monsters.length === 0) {
|
||||
return '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
const lines = monsters
|
||||
.slice(0, 4)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const name =
|
||||
readString(record?.name) ??
|
||||
readString(record?.npcName) ??
|
||||
readString(record?.id);
|
||||
const hp = readNumber(record?.hp);
|
||||
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
|
||||
|
||||
return name ? `- ${name}(生命 ${hp}/${maxHp})` : null;
|
||||
})
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return lines.length > 0
|
||||
? ['当前敌对目标:', ...lines].join('\n')
|
||||
: '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
function describeTargetCharacterName(payload: {
|
||||
targetCharacter?: unknown;
|
||||
encounter?: unknown;
|
||||
}) {
|
||||
return (
|
||||
readString(asRecord(payload.targetCharacter)?.name) ??
|
||||
readString(asRecord(payload.encounter)?.npcName) ??
|
||||
'对方'
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt(
|
||||
payload: CharacterChatReplyRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.conversationSummary
|
||||
? `之前聊天摘要:${payload.conversationSummary}`
|
||||
: '之前聊天摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
|
||||
`现在请以 ${targetName} 的身份,直接回复玩家。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt(
|
||||
payload: CharacterChatSuggestionsRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
const latestCharacterReply = Array.isArray(payload.conversationHistory)
|
||||
? [...payload.conversationHistory]
|
||||
.reverse()
|
||||
.map((item) => asRecord(item))
|
||||
.find((record) => readString(record?.speaker) === 'character')
|
||||
: null;
|
||||
const latestReplyText = readString(latestCharacterReply?.text);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.conversationSummary
|
||||
? `之前聊天摘要:${payload.conversationSummary}`
|
||||
: '之前聊天摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
latestReplyText
|
||||
? `角色刚刚的回复:${latestReplyText}`
|
||||
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
|
||||
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt(
|
||||
payload: CharacterChatSummaryRequest,
|
||||
) {
|
||||
const targetName = describeTargetCharacterName(payload);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.playerCharacter),
|
||||
describeCharacter('对方 / ', payload.targetCharacter),
|
||||
describeTargetStatus(payload.targetStatus),
|
||||
describeStoryHistory(payload.storyHistory),
|
||||
payload.previousSummary
|
||||
? `旧摘要:${payload.previousSummary}`
|
||||
: '旧摘要:暂无。',
|
||||
describeConversationHistory(payload.conversationHistory),
|
||||
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function buildNpcDialoguePromptBase(
|
||||
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const character =
|
||||
(payload as NpcChatTurnRequest).character ??
|
||||
(payload as NpcChatTurnRequest).player;
|
||||
if (!(payload as NpcChatTurnRequest).character && character) {
|
||||
(payload as NpcChatTurnRequest).character = character;
|
||||
}
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
describeSceneContext(payload.context),
|
||||
describeCharacter('玩家 / ', payload.character),
|
||||
encounter.block,
|
||||
describeMonsters(payload.monsters),
|
||||
describeStoryHistory(payload.history),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildStrictNpcChatDialoguePrompt(
|
||||
payload: NpcChatDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const context = asRecord(payload.context);
|
||||
const openingCampBackground = readString(context?.openingCampBackground);
|
||||
const openingCampDialogue = readString(context?.openingCampDialogue);
|
||||
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
|
||||
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
|
||||
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
|
||||
allowedTopics.length > 0
|
||||
? `当前更适合谈的内容:${allowedTopics.join('、')}`
|
||||
: null,
|
||||
blockedTopics.length > 0
|
||||
? `当前避免直接说破:${blockedTopics.join('、')}`
|
||||
: null,
|
||||
`当前聊天主题:${payload.topic}`,
|
||||
payload.resultSummary
|
||||
? `这段聊天希望带来的变化:${payload.resultSummary}`
|
||||
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
|
||||
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcRecruitDialoguePrompt(
|
||||
payload: NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
`玩家邀请:${payload.invitationText}`,
|
||||
payload.recruitSummary
|
||||
? `招募补充条件:${payload.recruitSummary}`
|
||||
: '这轮对话已经具备自然邀请对方入队的条件。',
|
||||
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
|
||||
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcChatTurnReplyPrompt(
|
||||
payload: NpcChatTurnRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const npcState = asRecord(payload.npcState);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
const affinity = readNumber(npcState?.affinity, 0);
|
||||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`当前关系值:${affinity}`,
|
||||
`已聊天轮次:${chattedCount}`,
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildNpcChatTurnSuggestionPrompt(
|
||||
payload: NpcChatTurnRequest,
|
||||
npcReply: string,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`NPC 刚刚回复:${npcReply}`,
|
||||
`请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`,
|
||||
'每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。',
|
||||
'每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
57
server-node/src/prompts/customWorldAgentPrompts.ts
Normal file
57
server-node/src/prompts/customWorldAgentPrompts.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
|
||||
export const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
|
||||
export const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT =
|
||||
'你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。';
|
||||
|
||||
export const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT =
|
||||
'你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。';
|
||||
|
||||
export function buildCustomWorldAgentCharacterExpansionPrompt(params: {
|
||||
worldName: string;
|
||||
worldSummary: string;
|
||||
creatorIntentSummary: string;
|
||||
anchorSummary: string;
|
||||
existingNames: string[];
|
||||
count: number;
|
||||
promptSeed: string;
|
||||
}) {
|
||||
return [
|
||||
`当前世界:${params.worldName}`,
|
||||
`世界摘要:${params.worldSummary}`,
|
||||
`创作意图摘要:${params.creatorIntentSummary}`,
|
||||
`参考锚点:${params.anchorSummary}`,
|
||||
`已有角色:${params.existingNames.join('、') || '暂无'}`,
|
||||
`数量:${params.count}`,
|
||||
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
|
||||
'返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。',
|
||||
'threadIds 必须优先引用现有线程 id。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldAgentLandmarkExpansionPrompt(params: {
|
||||
worldName: string;
|
||||
worldSummary: string;
|
||||
creatorIntentSummary: string;
|
||||
anchorSummary: string;
|
||||
existingNames: string[];
|
||||
count: number;
|
||||
promptSeed: string;
|
||||
}) {
|
||||
return [
|
||||
`当前世界:${params.worldName}`,
|
||||
`世界摘要:${params.worldSummary}`,
|
||||
`创作意图摘要:${params.creatorIntentSummary}`,
|
||||
`参考锚点:${params.anchorSummary}`,
|
||||
`已有地点:${params.existingNames.join('、') || '暂无'}`,
|
||||
`数量:${params.count}`,
|
||||
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
|
||||
'返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。',
|
||||
'threadIds / characterIds 必须优先引用现有对象 id。',
|
||||
].join('\n');
|
||||
}
|
||||
249
server-node/src/prompts/customWorldEntityPrompts.ts
Normal file
249
server-node/src/prompts/customWorldEntityPrompts.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
type ParsedRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
visualDescription: string;
|
||||
actionDescription: string;
|
||||
sceneVisualDescription: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type ParsedLandmarkConnection = {
|
||||
targetLandmarkId: string;
|
||||
summary: string;
|
||||
relativePosition: string;
|
||||
};
|
||||
|
||||
type ParsedLandmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: ParsedLandmarkConnection[];
|
||||
};
|
||||
|
||||
type ParsedProfile = {
|
||||
name: string;
|
||||
settingText: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
playableNpcs: ParsedRole[];
|
||||
storyNpcs: ParsedRole[];
|
||||
landmarks: ParsedLandmark[];
|
||||
};
|
||||
|
||||
export const CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT =
|
||||
'你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。';
|
||||
|
||||
function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
|
||||
if (roles.length === 0) {
|
||||
return emptyText;
|
||||
}
|
||||
|
||||
return roles
|
||||
.slice(0, 12)
|
||||
.map(
|
||||
(role, index) =>
|
||||
`${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${
|
||||
role.role || '未写'
|
||||
} / 描述:${role.description || '未写'} / 背景:${
|
||||
role.backstory || '未写'
|
||||
} / 性格:${role.personality || '未写'} / 动机:${
|
||||
role.motivation || '未写'
|
||||
} / 形象:${role.visualDescription || '未写'} / 动作表现:${
|
||||
role.actionDescription || '未写'
|
||||
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
|
||||
role.tags.join('、') || '暂无'
|
||||
}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildLandmarkReferenceText(profile: ParsedProfile) {
|
||||
if (profile.landmarks.length === 0) {
|
||||
return '当前还没有场景设定。';
|
||||
}
|
||||
|
||||
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
|
||||
const landmarkById = new Map(
|
||||
profile.landmarks.map((landmark) => [landmark.id, landmark]),
|
||||
);
|
||||
|
||||
return profile.landmarks
|
||||
.slice(0, 12)
|
||||
.map((landmark, index) => {
|
||||
const sceneNpcNames = landmark.sceneNpcIds
|
||||
.map((npcId) => storyNpcById.get(npcId)?.name ?? '')
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
const connectionNames = landmark.connections
|
||||
.map((connection) => {
|
||||
const targetName =
|
||||
landmarkById.get(connection.targetLandmarkId)?.name ||
|
||||
connection.targetLandmarkId;
|
||||
return `${targetName}(${connection.relativePosition} / ${
|
||||
connection.summary || '无说明'
|
||||
})`;
|
||||
})
|
||||
.join('、');
|
||||
|
||||
return `${index + 1}. ${landmark.name} / 危险度:${
|
||||
landmark.dangerLevel || 'medium'
|
||||
} / 描述:${landmark.description || '未写'} / 画面:${
|
||||
landmark.visualDescription || '未写'
|
||||
} / 场景角色:${
|
||||
sceneNpcNames || '暂无'
|
||||
} / 连接:${connectionNames || '暂无'}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildPlayablePrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 名新的“可扮演角色”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须保留明确的协作价值、成长空间和入队理由。',
|
||||
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
|
||||
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "playableNpc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 22,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildStoryPrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 名新的“场景角色”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
|
||||
'- 角色应与具体场景、关系链或局势变化发生绑定。',
|
||||
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "storyNpc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildLandmarkPrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 个新的“场景”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
|
||||
'- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。',
|
||||
'- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
|
||||
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "landmark": {',
|
||||
' "name": "场景名",',
|
||||
' "description": "场景描述",',
|
||||
' "visualDescription": "场景画面描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme",',
|
||||
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
|
||||
' "connections": [',
|
||||
' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },',
|
||||
' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
61
server-node/src/prompts/customWorldOrchestratorPrompts.ts
Normal file
61
server-node/src/prompts/customWorldOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
|
||||
export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
|
||||
export function buildCustomWorldProfilePrompt(params: {
|
||||
generationSeedText: string;
|
||||
creatorIntentText?: string;
|
||||
generationMode: string;
|
||||
targets: {
|
||||
playableCount: number;
|
||||
storyCount: number;
|
||||
landmarkCount: number;
|
||||
};
|
||||
}) {
|
||||
return [
|
||||
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
|
||||
'必须严格输出单个 JSON 对象,不要 Markdown,不要解释。',
|
||||
'',
|
||||
`生成模式:${params.generationMode}`,
|
||||
`可扮演角色数量:${params.targets.playableCount}`,
|
||||
`场景角色数量:${params.targets.storyCount}`,
|
||||
`关键场景数量:${params.targets.landmarkCount}`,
|
||||
'',
|
||||
'创作者输入:',
|
||||
params.generationSeedText,
|
||||
params.creatorIntentText ? `\n结构化创作锚点:\n${params.creatorIntentText}` : '',
|
||||
'',
|
||||
'输出 JSON 字段要求:',
|
||||
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
|
||||
'- majorFactions: string[],coreConflicts: string[]',
|
||||
'- camp: { name, description, dangerLevel }',
|
||||
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
|
||||
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
|
||||
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
|
||||
'- connections 每项包含 targetLandmarkName, relativePosition, summary,targetLandmarkName 必须指向本次输出的其他场景名',
|
||||
'',
|
||||
'约束:',
|
||||
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
|
||||
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
|
||||
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
|
||||
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
|
||||
'- dangerLevel 使用 low、medium、high、extreme 之一。',
|
||||
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
|
||||
'- 不要预生成物品档案;items 如需输出,必须为空数组。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldProfileRepairPrompt(responseText: string) {
|
||||
return [
|
||||
'请修复下面的自定义世界 JSON。',
|
||||
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
|
||||
responseText,
|
||||
].join('\n\n');
|
||||
}
|
||||
104
server-node/src/prompts/customWorldSceneNpcPrompts.ts
Normal file
104
server-node/src/prompts/customWorldSceneNpcPrompts.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
type ParsedStoryNpc = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
};
|
||||
|
||||
type ParsedLandmark = {
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
};
|
||||
|
||||
type ParsedProfile = {
|
||||
name: string;
|
||||
settingText: string;
|
||||
storyNpcs: ParsedStoryNpc[];
|
||||
landmarks: ParsedLandmark[];
|
||||
};
|
||||
|
||||
export const CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT =
|
||||
'你是游戏世界编辑器的场景 NPC 生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。';
|
||||
|
||||
export function buildCustomWorldSceneNpcPrompt(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
otherNpcs: ParsedStoryNpc[],
|
||||
) {
|
||||
const sceneNpcSummary = sceneNpcs.length
|
||||
? sceneNpcs
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '当前场景还没有已加入 NPC。';
|
||||
|
||||
const reserveNpcSummary = otherNpcs.length
|
||||
? otherNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '暂无其他场景角色参考。';
|
||||
|
||||
const landmarkSummary = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
|
||||
`当前目标场景:${landmark.name}`,
|
||||
`场景描述:${landmark.description || '未填写'}`,
|
||||
`危险度:${landmark.dangerLevel || '中'}`,
|
||||
`当前场景已加入 NPC:\n${sceneNpcSummary}`,
|
||||
`其他可参考 NPC:\n${reserveNpcSummary}`,
|
||||
`世界内其他场景概览:\n${landmarkSummary}`,
|
||||
'请生成 1 名适合加入当前场景的新 NPC。',
|
||||
'要求:',
|
||||
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
|
||||
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
|
||||
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
|
||||
'- 返回 JSON,不要额外解释。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "npc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "头衔",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句角色描述",',
|
||||
' "backstory": "背景",',
|
||||
' "personality": "性格",',
|
||||
' "motivation": "动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
168
server-node/src/prompts/questPrompts.ts
Normal file
168
server-node/src/prompts/questPrompts.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
QuestGenerationContext,
|
||||
QuestOpportunity,
|
||||
QuestSceneSnapshot,
|
||||
} from '../modules/quest/runtimeQuestModule.js';
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map((moment) => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary
|
||||
?.map(
|
||||
(quest) =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active =
|
||||
context.activeCompanions?.map((companion) => companion.characterId).join('、') ||
|
||||
'无';
|
||||
const roster =
|
||||
context.rosterCompanions?.map((companion) => companion.characterId).join('、') ||
|
||||
'无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory =
|
||||
context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(
|
||||
scene: QuestSceneSnapshot | null,
|
||||
context: QuestGenerationContext,
|
||||
) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
return context.activeThreadIds?.length
|
||||
? context.activeThreadIds.join('、')
|
||||
: '暂无明确激活线程';
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask ?? '暂无'}`,
|
||||
`表层线:${profile.visibleLine ?? '暂无'}`,
|
||||
`当前压力:${profile.immediatePressure ?? '暂无'}`,
|
||||
profile.reactionHooks?.length
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const { context, scene, opportunity } = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name ?? '自定义世界'}: ${
|
||||
context.customWorldProfile.summary ?? '暂无摘要'
|
||||
}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName}(${context.issuerNpcId})`,
|
||||
`发布者身份:${context.issuerNpcContext || '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
43
server-node/src/prompts/runtimeItemPrompts.ts
Normal file
43
server-node/src/prompts/runtimeItemPrompts.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPromptText(params: {
|
||||
generationChannel: string;
|
||||
planBlocks: string[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.generationChannel}`,
|
||||
'以下每个物品都需要给出一条可编译的运行时物品意图。',
|
||||
...params.planBlocks,
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
33
server-node/src/prompts/storyOrchestratorPrompts.ts
Normal file
33
server-node/src/prompts/storyOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
type StoryRepairResponse = {
|
||||
storyText: string;
|
||||
encounter?: unknown;
|
||||
options: Array<{
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||||
你会收到一个已经解析过的剧情 JSON 对象。
|
||||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
|
||||
export function buildStoryLanguageRepairPrompt(response: StoryRepairResponse) {
|
||||
return [
|
||||
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
|
||||
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
|
||||
JSON.stringify(
|
||||
{
|
||||
storyText: response.storyText,
|
||||
encounter: response.encounter ?? null,
|
||||
options: response.options.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
].join('\n\n');
|
||||
}
|
||||
197
server-node/src/prompts/storyPromptBuilders.ts
Normal file
197
server-node/src/prompts/storyPromptBuilders.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return worldType || '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function describeCharacter(character: JsonRecord) {
|
||||
return [
|
||||
`主角:${readString(character.name) ?? '未知角色'}`,
|
||||
`称号:${readString(character.title) ?? '未知称号'}`,
|
||||
`描述:${readString(character.description) ?? '暂无'}`,
|
||||
`性格:${readString(character.personality) ?? '未显式提供'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeMonsters(monsters: JsonRecord[]) {
|
||||
if (monsters.length <= 0) {
|
||||
return '当前敌对目标:无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'当前敌对目标:',
|
||||
...monsters.slice(0, 4).map((monster) => {
|
||||
const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标';
|
||||
const hp = readNumber(monster.hp);
|
||||
const maxHp = Math.max(1, readNumber(monster.maxHp, hp));
|
||||
return `- ${name}(生命 ${hp}/${maxHp})`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: JsonRecord[]) {
|
||||
if (history.length <= 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'近期剧情:',
|
||||
...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeRequestOptions(options: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const available = options.availableOptions ?? [];
|
||||
const catalog = options.optionCatalog ?? [];
|
||||
|
||||
if (available.length > 0) {
|
||||
return [
|
||||
'固定可选项列表:',
|
||||
...available.map((option, index) => {
|
||||
const functionId = readString(option.functionId) ?? 'unknown';
|
||||
const actionText =
|
||||
readString(option.actionText) ??
|
||||
readString(option.text) ??
|
||||
'未提供文案';
|
||||
return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`;
|
||||
}),
|
||||
'必须保持数量不变,functionId 不变,可以重写 actionText。'.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (catalog.length > 0) {
|
||||
return [
|
||||
'当前局面可调用的交互选项目录:',
|
||||
...catalog.map((option, index) => {
|
||||
const functionId = readString(option.functionId) ?? 'unknown';
|
||||
const actionText =
|
||||
readString(option.actionText) ??
|
||||
readString(option.text) ??
|
||||
'未提供文案';
|
||||
return `- 第 ${index + 1} 项 / ${functionId}:${actionText}`;
|
||||
}),
|
||||
'functionId 只能从上面目录里选择。'.trim(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return '当前没有固定目录,请根据局势生成合理选项。';
|
||||
}
|
||||
|
||||
function hasNpcOptionCatalog(options: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
return (options.optionCatalog ?? []).some((option) =>
|
||||
(readString(option.functionId) ?? '').startsWith('npc_'),
|
||||
);
|
||||
}
|
||||
|
||||
function isPostNpcChatReevaluation(params: {
|
||||
choice?: string;
|
||||
context: JsonRecord;
|
||||
requestOptions?: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
readString(params.context.lastFunctionId) === 'npc_chat' &&
|
||||
hasNpcOptionCatalog(params.requestOptions ?? {}) &&
|
||||
Boolean(readString(params.choice))
|
||||
);
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象,不能输出解释、markdown 或代码块。
|
||||
输出格式必须严格符合:
|
||||
{
|
||||
"storyText": "剧情文本",
|
||||
"encounter": null,
|
||||
"options": [
|
||||
{
|
||||
"functionId": "预定义功能ID",
|
||||
"actionText": "选项显示文本"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
严格规则:
|
||||
- 所有文本必须是中文。
|
||||
- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。
|
||||
- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。
|
||||
- options 只允许输出 functionId 和 actionText。
|
||||
- 如果当前不是“继续推进后下一刻会遇到什么”的场景,encounter 必须保持为 null。`;
|
||||
|
||||
export function buildUserPrompt(params: {
|
||||
worldType: string;
|
||||
character: JsonRecord;
|
||||
monsters: JsonRecord[];
|
||||
history: JsonRecord[];
|
||||
context: JsonRecord;
|
||||
choice?: string;
|
||||
requestOptions?: {
|
||||
availableOptions?: Array<Record<string, unknown>>;
|
||||
optionCatalog?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}) {
|
||||
const sceneName = readString(params.context.sceneName) ?? '当前区域';
|
||||
const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。';
|
||||
const encounterName = readString(params.context.encounterName);
|
||||
const playerHp = readNumber(params.context.playerHp);
|
||||
const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp));
|
||||
const playerMana = readNumber(params.context.playerMana);
|
||||
const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana));
|
||||
const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗';
|
||||
const pendingSceneEncounter =
|
||||
params.context.pendingSceneEncounter === true ? '是' : '否';
|
||||
const postNpcChatReevaluation = isPostNpcChatReevaluation(params);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(params.worldType)}`,
|
||||
`场景:${sceneName}`,
|
||||
`场景描述:${sceneDescription}`,
|
||||
encounterName ? `当前面前对象:${encounterName}` : null,
|
||||
`当前状态:${inBattle}`,
|
||||
`玩家生命:${playerHp}/${playerMaxHp}`,
|
||||
`玩家灵力:${playerMana}/${playerMaxMana}`,
|
||||
`是否需要判断下一刻遭遇:${pendingSceneEncounter}`,
|
||||
describeCharacter(params.character),
|
||||
describeMonsters(params.monsters),
|
||||
describeStoryHistory(params.history),
|
||||
params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。',
|
||||
describeRequestOptions(params.requestOptions ?? {}),
|
||||
postNpcChatReevaluation
|
||||
? '当前这一步是刚结束一轮 NPC 交谈后,对眼前局势的再次判断。storyText 必须先落出刚才那段聊天带来的态度变化、气氛变化或新暴露的信息,再进入下一步局势。'
|
||||
: null,
|
||||
postNpcChatReevaluation
|
||||
? '如果输出 npc_ 开头的选项,这些 actionText 必须直接承接刚才聊到的话题、关系变化或对方态度,写成此刻自然浮现的回应,不要退回“继续交谈”“请求援手”“看看能交换什么”这类通用模板。'
|
||||
: null,
|
||||
postNpcChatReevaluation
|
||||
? '当前目录只是合法 function 范围,不代表都要出现;只保留此刻真正自然浮现、和刚才聊天结果有关的选项。'
|
||||
: null,
|
||||
params.context.pendingSceneEncounter === true
|
||||
? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter;否则 encounter 必须为 null。'
|
||||
: '当前这一步不是新的遭遇生成流程,encounter 必须为 null。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshot,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type CustomWorldPublicationStatus,
|
||||
type CustomWorldSessionRecord,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
DEFAULT_PLATFORM_THEME,
|
||||
SAVE_SNAPSHOT_VERSION,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppDatabase } from '../db.js';
|
||||
@@ -39,6 +41,7 @@ type SnapshotRow = QueryResultRow & {
|
||||
|
||||
type SettingsRow = QueryResultRow & {
|
||||
musicVolume: number;
|
||||
platformTheme: RuntimeSettings['platformTheme'];
|
||||
};
|
||||
|
||||
type CustomWorldEntryRow = QueryResultRow & {
|
||||
@@ -127,6 +130,23 @@ type ProfileWorldSnapshotMeta = {
|
||||
worldSubtitle: string;
|
||||
};
|
||||
|
||||
type ProfileSaveArchiveRow = QueryResultRow & {
|
||||
worldKey: string;
|
||||
ownerUserId: string | null;
|
||||
profileId: string | null;
|
||||
worldType: string | null;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
savedAt: string;
|
||||
bottomTab: string;
|
||||
gameState: unknown;
|
||||
currentStory: unknown;
|
||||
};
|
||||
|
||||
type ProfileSaveArchiveMeta = Omit<ProfileSaveArchiveSummary, 'lastPlayedAt'>;
|
||||
|
||||
export type RuntimeRepositoryPort = {
|
||||
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
|
||||
putSnapshot(
|
||||
@@ -136,6 +156,14 @@ export type RuntimeRepositoryPort = {
|
||||
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>;
|
||||
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>;
|
||||
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>;
|
||||
listProfileSaveArchives(userId: string): Promise<ProfileSaveArchiveSummary[]>;
|
||||
resumeProfileSaveArchive(
|
||||
userId: string,
|
||||
worldKey: string,
|
||||
): Promise<{
|
||||
entry: ProfileSaveArchiveSummary;
|
||||
snapshot: SavedSnapshot;
|
||||
} | null>;
|
||||
deleteSnapshot(userId: string): Promise<void>;
|
||||
getSettings(userId: string): Promise<RuntimeSettings>;
|
||||
putSettings(
|
||||
@@ -313,6 +341,10 @@ function normalizePlatformBrowseHistoryWriteEntry(
|
||||
};
|
||||
}
|
||||
|
||||
function readSavedStoryText(value: unknown) {
|
||||
return readString(asRecord(value)?.text);
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -600,6 +632,90 @@ function toProfilePlayedWorkSummary(
|
||||
};
|
||||
}
|
||||
|
||||
function toProfileSaveArchiveSummary(
|
||||
row: Pick<
|
||||
ProfileSaveArchiveRow,
|
||||
| 'worldKey'
|
||||
| 'ownerUserId'
|
||||
| 'profileId'
|
||||
| 'worldType'
|
||||
| 'worldName'
|
||||
| 'subtitle'
|
||||
| 'summaryText'
|
||||
| 'coverImageSrc'
|
||||
| 'savedAt'
|
||||
>,
|
||||
): ProfileSaveArchiveSummary {
|
||||
const subtitle = row.subtitle || '';
|
||||
return {
|
||||
worldKey: row.worldKey,
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldType: row.worldType,
|
||||
worldName: row.worldName || '未命名游戏',
|
||||
subtitle,
|
||||
summaryText: row.summaryText || subtitle || '继续推进上一次保存的故事。',
|
||||
coverImageSrc: row.coverImageSrc || null,
|
||||
lastPlayedAt: row.savedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProfileSaveArchiveMeta(
|
||||
snapshot: SavedSnapshot,
|
||||
): ProfileSaveArchiveMeta | null {
|
||||
const worldMeta = resolveProfileWorldSnapshotMeta(snapshot);
|
||||
if (!worldMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gameState = asRecord(snapshot.gameState);
|
||||
const continueGameDigest = readString(
|
||||
asRecord(gameState?.storyEngineMemory)?.continueGameDigest,
|
||||
);
|
||||
const currentStoryText = readSavedStoryText(snapshot.currentStory);
|
||||
const customWorldProfile = asRecord(gameState?.customWorldProfile);
|
||||
|
||||
if (customWorldProfile) {
|
||||
const profileId = readString(customWorldProfile.id) || 'custom-world';
|
||||
const metadata = extractCustomWorldLibraryMetadata(
|
||||
normalizeStoredProfile(profileId, customWorldProfile),
|
||||
);
|
||||
|
||||
return {
|
||||
worldKey: worldMeta.worldKey,
|
||||
ownerUserId: worldMeta.ownerUserId,
|
||||
profileId: worldMeta.profileId,
|
||||
worldType: worldMeta.worldType,
|
||||
worldName: worldMeta.worldTitle || metadata.worldName || '自定义世界',
|
||||
subtitle: metadata.subtitle || worldMeta.worldSubtitle || '',
|
||||
summaryText:
|
||||
continueGameDigest ||
|
||||
currentStoryText ||
|
||||
metadata.summaryText ||
|
||||
worldMeta.worldSubtitle ||
|
||||
'继续推进上一次保存的故事。',
|
||||
coverImageSrc: metadata.coverImageSrc,
|
||||
};
|
||||
}
|
||||
|
||||
const currentScenePreset = asRecord(gameState?.currentScenePreset);
|
||||
|
||||
return {
|
||||
worldKey: worldMeta.worldKey,
|
||||
ownerUserId: worldMeta.ownerUserId,
|
||||
profileId: worldMeta.profileId,
|
||||
worldType: worldMeta.worldType,
|
||||
worldName: worldMeta.worldTitle || '未命名游戏',
|
||||
subtitle: worldMeta.worldSubtitle || '',
|
||||
summaryText:
|
||||
continueGameDigest ||
|
||||
currentStoryText ||
|
||||
worldMeta.worldSubtitle ||
|
||||
'继续推进上一次保存的故事。',
|
||||
coverImageSrc: readString(currentScenePreset?.imageSrc) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
constructor(private readonly db: AppDatabase) {}
|
||||
|
||||
@@ -663,6 +779,29 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findProfileSaveArchive(userId: string, worldKey: string) {
|
||||
const result = await this.db.query<ProfileSaveArchiveRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_name AS "worldName",
|
||||
world_subtitle AS subtitle,
|
||||
summary_text AS "summaryText",
|
||||
cover_image_src AS "coverImageSrc",
|
||||
saved_at AS "savedAt",
|
||||
bottom_tab AS "bottomTab",
|
||||
game_state_json AS "gameState",
|
||||
current_story_json AS "currentStory"
|
||||
FROM profile_save_archives
|
||||
WHERE user_id = $1
|
||||
AND world_key = $2`,
|
||||
[userId, worldKey],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async upsertProfileDashboardState(
|
||||
userId: string,
|
||||
state: {
|
||||
@@ -686,6 +825,49 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
);
|
||||
}
|
||||
|
||||
private async upsertCurrentSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`INSERT INTO save_snapshots (
|
||||
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
saved_at = EXCLUDED.saved_at,
|
||||
bottom_tab = EXCLUDED.bottom_tab,
|
||||
game_state_json = EXCLUDED.game_state_json,
|
||||
current_story_json = EXCLUDED.current_story_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING version,
|
||||
saved_at AS "savedAt",
|
||||
game_state_json AS "gameState",
|
||||
bottom_tab AS "bottomTab",
|
||||
current_story_json AS "currentStory"`,
|
||||
[
|
||||
userId,
|
||||
snapshot.version,
|
||||
snapshot.savedAt,
|
||||
snapshot.bottomTab,
|
||||
snapshot.gameState,
|
||||
snapshot.currentStory,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
}
|
||||
|
||||
private async syncProfileDashboardFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
@@ -788,6 +970,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
});
|
||||
}
|
||||
|
||||
private async syncProfileSaveArchiveFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
) {
|
||||
const archiveMeta = resolveProfileSaveArchiveMeta(snapshot);
|
||||
if (!archiveMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncedAt = snapshot.savedAt || new Date().toISOString();
|
||||
|
||||
await this.db.query(
|
||||
`INSERT INTO profile_save_archives (
|
||||
user_id,
|
||||
world_key,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_type,
|
||||
world_name,
|
||||
world_subtitle,
|
||||
summary_text,
|
||||
cover_image_src,
|
||||
saved_at,
|
||||
bottom_tab,
|
||||
game_state_json,
|
||||
current_story_json,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (user_id, world_key) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
world_type = EXCLUDED.world_type,
|
||||
world_name = EXCLUDED.world_name,
|
||||
world_subtitle = EXCLUDED.world_subtitle,
|
||||
summary_text = EXCLUDED.summary_text,
|
||||
cover_image_src = EXCLUDED.cover_image_src,
|
||||
saved_at = EXCLUDED.saved_at,
|
||||
bottom_tab = EXCLUDED.bottom_tab,
|
||||
game_state_json = EXCLUDED.game_state_json,
|
||||
current_story_json = EXCLUDED.current_story_json,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[
|
||||
userId,
|
||||
archiveMeta.worldKey,
|
||||
archiveMeta.ownerUserId,
|
||||
archiveMeta.profileId,
|
||||
archiveMeta.worldType,
|
||||
archiveMeta.worldName,
|
||||
archiveMeta.subtitle,
|
||||
archiveMeta.summaryText,
|
||||
archiveMeta.coverImageSrc,
|
||||
syncedAt,
|
||||
snapshot.bottomTab,
|
||||
snapshot.gameState,
|
||||
snapshot.currentStory,
|
||||
syncedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private async syncCustomWorldProfileFromSnapshot(
|
||||
userId: string,
|
||||
snapshot: SavedSnapshot,
|
||||
@@ -883,45 +1126,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
bottomTab: payload.bottomTab,
|
||||
currentStory: payload.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = await this.db.query<SnapshotRow>(
|
||||
`INSERT INTO save_snapshots (
|
||||
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
saved_at = EXCLUDED.saved_at,
|
||||
bottom_tab = EXCLUDED.bottom_tab,
|
||||
game_state_json = EXCLUDED.game_state_json,
|
||||
current_story_json = EXCLUDED.current_story_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING version,
|
||||
saved_at AS "savedAt",
|
||||
game_state_json AS "gameState",
|
||||
bottom_tab AS "bottomTab",
|
||||
current_story_json AS "currentStory"`,
|
||||
[
|
||||
userId,
|
||||
snapshot.version,
|
||||
snapshot.savedAt,
|
||||
snapshot.bottomTab,
|
||||
snapshot.gameState,
|
||||
snapshot.currentStory,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
const persistedSnapshot = {
|
||||
version: row.version,
|
||||
savedAt: row.savedAt,
|
||||
gameState: row.gameState,
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot);
|
||||
|
||||
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
|
||||
await this.syncProfileSaveArchiveFromSnapshot(userId, persistedSnapshot);
|
||||
await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot);
|
||||
|
||||
return persistedSnapshot;
|
||||
@@ -993,6 +1201,50 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
} satisfies ProfilePlayStatsResponse;
|
||||
}
|
||||
|
||||
async listProfileSaveArchives(userId: string) {
|
||||
const result = await this.db.query<ProfileSaveArchiveRow>(
|
||||
`SELECT world_key AS "worldKey",
|
||||
owner_user_id AS "ownerUserId",
|
||||
profile_id AS "profileId",
|
||||
world_type AS "worldType",
|
||||
world_name AS "worldName",
|
||||
world_subtitle AS subtitle,
|
||||
summary_text AS "summaryText",
|
||||
cover_image_src AS "coverImageSrc",
|
||||
saved_at AS "savedAt",
|
||||
bottom_tab AS "bottomTab",
|
||||
game_state_json AS "gameState",
|
||||
current_story_json AS "currentStory"
|
||||
FROM profile_save_archives
|
||||
WHERE user_id = $1
|
||||
ORDER BY saved_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => toProfileSaveArchiveSummary(row));
|
||||
}
|
||||
|
||||
async resumeProfileSaveArchive(userId: string, worldKey: string) {
|
||||
const archive = await this.findProfileSaveArchive(userId, worldKey);
|
||||
if (!archive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
version: SAVE_SNAPSHOT_VERSION,
|
||||
savedAt: archive.savedAt,
|
||||
gameState: archive.gameState,
|
||||
bottomTab: archive.bottomTab,
|
||||
currentStory: archive.currentStory,
|
||||
} satisfies SavedSnapshot;
|
||||
const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot);
|
||||
|
||||
return {
|
||||
entry: toProfileSaveArchiveSummary(archive),
|
||||
snapshot: persistedSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSnapshot(userId: string) {
|
||||
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [
|
||||
userId,
|
||||
@@ -1001,9 +1253,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
|
||||
async getSettings(userId: string) {
|
||||
const result = await this.db.query<SettingsRow>(
|
||||
`SELECT music_volume AS "musicVolume"
|
||||
FROM runtime_settings
|
||||
WHERE user_id = $1`,
|
||||
`SELECT music_volume AS "musicVolume",
|
||||
platform_theme AS "platformTheme"
|
||||
FROM runtime_settings
|
||||
WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -1013,26 +1266,41 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
|
||||
typeof row?.musicVolume === 'number'
|
||||
? row.musicVolume
|
||||
: DEFAULT_MUSIC_VOLUME,
|
||||
platformTheme:
|
||||
row?.platformTheme === 'dark'
|
||||
? 'dark'
|
||||
: DEFAULT_PLATFORM_THEME,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
async putSettings(userId: string, settings: RuntimeSettings) {
|
||||
const nextSettings = {
|
||||
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
|
||||
platformTheme:
|
||||
settings.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME,
|
||||
} satisfies RuntimeSettings;
|
||||
|
||||
const result = await this.db.query<SettingsRow>(
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`INSERT INTO runtime_settings (user_id, music_volume, platform_theme, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
music_volume = EXCLUDED.music_volume,
|
||||
platform_theme = EXCLUDED.platform_theme,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING music_volume AS "musicVolume"`,
|
||||
[userId, nextSettings.musicVolume, new Date().toISOString()],
|
||||
RETURNING music_volume AS "musicVolume",
|
||||
platform_theme AS "platformTheme"`,
|
||||
[
|
||||
userId,
|
||||
nextSettings.musicVolume,
|
||||
nextSettings.platformTheme,
|
||||
new Date().toISOString(),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume,
|
||||
platformTheme:
|
||||
result.rows[0]?.platformTheme ?? nextSettings.platformTheme,
|
||||
} satisfies RuntimeSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,14 @@ import type {
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PLATFORM_THEMES,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
SavedGameSnapshotInput,
|
||||
@@ -89,6 +92,7 @@ const saveSnapshotSchema = z.object({
|
||||
|
||||
const settingsSchema = z.object({
|
||||
musicVolume: z.number().min(0).max(1),
|
||||
platformTheme: z.enum(PLATFORM_THEMES),
|
||||
});
|
||||
|
||||
const platformBrowseHistoryEntrySchema = z.object({
|
||||
@@ -184,6 +188,41 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
});
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-gallery',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
|
||||
asyncHandler(async (_request, response) => {
|
||||
sendApiResponse(response, {
|
||||
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
|
||||
} satisfies CustomWorldGalleryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const ownerUserId = readParam(request.params.ownerUserId);
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!ownerUserId || !profileId) {
|
||||
throw badRequest('ownerUserId and profileId are required');
|
||||
}
|
||||
|
||||
const entry =
|
||||
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
|
||||
ownerUserId,
|
||||
profileId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw notFound('public custom world not found');
|
||||
}
|
||||
|
||||
sendApiResponse(response, {
|
||||
entry,
|
||||
} satisfies CustomWorldGalleryDetailResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.use(requireAuth);
|
||||
router.use(
|
||||
'/runtime/custom-world/agent',
|
||||
@@ -313,6 +352,65 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
);
|
||||
});
|
||||
|
||||
routeCompatPaths('/profile/save-archives').forEach((path, index) => {
|
||||
router.get(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.saveArchives.list'
|
||||
: 'profile.saveArchives.list.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
sendApiResponse<ProfileSaveArchiveListResponse>(response, {
|
||||
entries: await context.runtimeRepository.listProfileSaveArchives(
|
||||
request.userId!,
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
[
|
||||
'/profile/save-archives/:worldKey',
|
||||
'/runtime/profile/save-archives/:worldKey',
|
||||
].forEach((path, index) => {
|
||||
router.post(
|
||||
path,
|
||||
routeMeta({
|
||||
operation:
|
||||
index === 0
|
||||
? 'profile.saveArchives.resume'
|
||||
: 'profile.saveArchives.resume.compat',
|
||||
}),
|
||||
asyncHandler(async (request, response) => {
|
||||
const worldKey =
|
||||
typeof request.params.worldKey === 'string'
|
||||
? request.params.worldKey.trim()
|
||||
: '';
|
||||
|
||||
if (!worldKey) {
|
||||
throw badRequest('worldKey 不能为空');
|
||||
}
|
||||
|
||||
const resumedArchive =
|
||||
await context.runtimeRepository.resumeProfileSaveArchive(
|
||||
request.userId!,
|
||||
worldKey,
|
||||
);
|
||||
|
||||
if (!resumedArchive) {
|
||||
throw notFound('指定存档不存在');
|
||||
}
|
||||
|
||||
sendApiResponse<ProfileSaveArchiveResumeResponse>(response, {
|
||||
entry: resumedArchive.entry,
|
||||
snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/llm/chat/completions',
|
||||
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
|
||||
@@ -450,42 +548,6 @@ export function createRuntimeRoutes(context: AppContext) {
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-gallery',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
|
||||
asyncHandler(async (_request, response) => {
|
||||
sendApiResponse(response, {
|
||||
entries:
|
||||
await context.runtimeRepository.listPublishedCustomWorldGallery(),
|
||||
} satisfies CustomWorldGalleryResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
|
||||
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const ownerUserId = readParam(request.params.ownerUserId);
|
||||
const profileId = readParam(request.params.profileId);
|
||||
if (!ownerUserId || !profileId) {
|
||||
throw badRequest('ownerUserId and profileId are required');
|
||||
}
|
||||
|
||||
const entry =
|
||||
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
|
||||
ownerUserId,
|
||||
profileId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw notFound('public custom world not found');
|
||||
}
|
||||
|
||||
sendApiResponse(response, {
|
||||
entry,
|
||||
} satisfies CustomWorldGalleryDetailResponse);
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/runtime/custom-world-library/:profileId',
|
||||
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),
|
||||
|
||||
@@ -3,6 +3,12 @@ import type {
|
||||
CustomWorldFoundationDraftLandmark,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import {
|
||||
buildCustomWorldAgentCharacterExpansionPrompt,
|
||||
buildCustomWorldAgentLandmarkExpansionPrompt,
|
||||
CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT,
|
||||
CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT,
|
||||
} from '../prompts/customWorldAgentPrompts.js';
|
||||
import {
|
||||
getWorldFoundationCardId,
|
||||
normalizeFoundationDraftProfile,
|
||||
@@ -438,22 +444,18 @@ async function requestCharacterSuggestionsFromLlm(params: {
|
||||
params.profile.summary;
|
||||
|
||||
const content = await params.llmClient.requestMessageContent({
|
||||
systemPrompt:
|
||||
'你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。',
|
||||
userPrompt: [
|
||||
`当前世界:${params.profile.name}`,
|
||||
`世界摘要:${params.profile.summary}`,
|
||||
`创作意图摘要:${creatorIntentSummary}`,
|
||||
`参考锚点:${anchorSummary}`,
|
||||
`已有角色:${getAllCharacters(params.profile)
|
||||
systemPrompt: CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT,
|
||||
userPrompt: buildCustomWorldAgentCharacterExpansionPrompt({
|
||||
worldName: params.profile.name,
|
||||
worldSummary: params.profile.summary,
|
||||
creatorIntentSummary,
|
||||
anchorSummary,
|
||||
existingNames: getAllCharacters(params.profile)
|
||||
.slice(0, 10)
|
||||
.map((entry) => entry.name)
|
||||
.join('、') || '暂无'}`,
|
||||
`数量:${params.count}`,
|
||||
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
|
||||
'返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。',
|
||||
'threadIds 必须优先引用现有线程 id。',
|
||||
].join('\n'),
|
||||
.map((entry) => entry.name),
|
||||
count: params.count,
|
||||
promptSeed: params.promptSeed,
|
||||
}),
|
||||
timeoutMs: 45000,
|
||||
debugLabel: 'custom-world-agent-generate-characters',
|
||||
});
|
||||
@@ -478,22 +480,18 @@ async function requestLandmarkSuggestionsFromLlm(params: {
|
||||
params.profile.summary;
|
||||
|
||||
const content = await params.llmClient.requestMessageContent({
|
||||
systemPrompt:
|
||||
'你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。',
|
||||
userPrompt: [
|
||||
`当前世界:${params.profile.name}`,
|
||||
`世界摘要:${params.profile.summary}`,
|
||||
`创作意图摘要:${creatorIntentSummary}`,
|
||||
`参考锚点:${anchorSummary}`,
|
||||
`已有地点:${params.profile.landmarks
|
||||
systemPrompt: CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT,
|
||||
userPrompt: buildCustomWorldAgentLandmarkExpansionPrompt({
|
||||
worldName: params.profile.name,
|
||||
worldSummary: params.profile.summary,
|
||||
creatorIntentSummary,
|
||||
anchorSummary,
|
||||
existingNames: params.profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map((entry) => entry.name)
|
||||
.join('、') || '暂无'}`,
|
||||
`数量:${params.count}`,
|
||||
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
|
||||
'返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。',
|
||||
'threadIds / characterIds 必须优先引用现有对象 id。',
|
||||
].join('\n'),
|
||||
.map((entry) => entry.name),
|
||||
count: params.count,
|
||||
promptSeed: params.promptSeed,
|
||||
}),
|
||||
timeoutMs: 45000,
|
||||
debugLabel: 'custom-world-agent-generate-landmarks',
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ import type {
|
||||
EightAnchorContent,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import {
|
||||
FOUNDATION_JSON_ONLY_SYSTEM_PROMPT,
|
||||
FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
} from '../prompts/customWorldAgentPrompts.js';
|
||||
import {
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
@@ -770,13 +774,6 @@ function buildChapter(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
|
||||
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3;
|
||||
const FOUNDATION_DRAFT_STORY_COUNT = 6;
|
||||
const FOUNDATION_DRAFT_LANDMARK_COUNT = 4;
|
||||
|
||||
@@ -47,6 +47,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
|
||||
@@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
|
||||
@@ -40,6 +40,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
|
||||
@@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
async getSettings() {
|
||||
return {
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
};
|
||||
},
|
||||
async putSettings(_userId, settings) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import {
|
||||
buildLandmarkPrompt,
|
||||
buildPlayablePrompt,
|
||||
buildStoryPrompt,
|
||||
CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT,
|
||||
} from '../prompts/customWorldEntityPrompts.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type CustomWorldEntityKind = 'playable' | 'story' | 'landmark';
|
||||
@@ -319,69 +325,6 @@ function normalizeProfile(value: unknown): ParsedProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
|
||||
if (roles.length === 0) {
|
||||
return emptyText;
|
||||
}
|
||||
|
||||
return roles
|
||||
.slice(0, 12)
|
||||
.map(
|
||||
(role, index) =>
|
||||
`${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${
|
||||
role.role || '未写'
|
||||
} / 描述:${role.description || '未写'} / 背景:${
|
||||
role.backstory || '未写'
|
||||
} / 性格:${role.personality || '未写'} / 动机:${
|
||||
role.motivation || '未写'
|
||||
} / 形象:${role.visualDescription || '未写'} / 动作表现:${
|
||||
role.actionDescription || '未写'
|
||||
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
|
||||
role.tags.join('、') || '暂无'
|
||||
}`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildLandmarkReferenceText(profile: ParsedProfile) {
|
||||
if (profile.landmarks.length === 0) {
|
||||
return '当前还没有场景设定。';
|
||||
}
|
||||
|
||||
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
|
||||
const landmarkById = new Map(
|
||||
profile.landmarks.map((landmark) => [landmark.id, landmark]),
|
||||
);
|
||||
|
||||
return profile.landmarks
|
||||
.slice(0, 12)
|
||||
.map((landmark, index) => {
|
||||
const sceneNpcNames = landmark.sceneNpcIds
|
||||
.map((npcId) => storyNpcById.get(npcId)?.name ?? '')
|
||||
.filter(Boolean)
|
||||
.join('、');
|
||||
const connectionNames = landmark.connections
|
||||
.map((connection) => {
|
||||
const targetName =
|
||||
landmarkById.get(connection.targetLandmarkId)?.name ||
|
||||
connection.targetLandmarkId;
|
||||
return `${targetName}(${connection.relativePosition} / ${
|
||||
connection.summary || '无说明'
|
||||
})`;
|
||||
})
|
||||
.join('、');
|
||||
|
||||
return `${index + 1}. ${landmark.name} / 危险度:${
|
||||
landmark.dangerLevel || 'medium'
|
||||
} / 描述:${landmark.description || '未写'} / 画面:${
|
||||
landmark.visualDescription || '未写'
|
||||
} / 场景角色:${
|
||||
sceneNpcNames || '暂无'
|
||||
} / 连接:${connectionNames || '暂无'}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildUniqueRoleName(existingNames: Set<string>, startIndex: number) {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
const index = startIndex + attempt;
|
||||
@@ -563,148 +506,6 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayablePrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 名新的“可扮演角色”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须保留明确的协作价值、成长空间和入队理由。',
|
||||
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
|
||||
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "playableNpc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 22,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildStoryPrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 名新的“场景角色”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
|
||||
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
|
||||
'- 角色应与具体场景、关系链或局势变化发生绑定。',
|
||||
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
|
||||
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "storyNpc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "称号",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句定位描述",',
|
||||
' "visualDescription": "角色形象描述",',
|
||||
' "actionDescription": "动作表现描述",',
|
||||
' "sceneVisualDescription": "角色关联场景画面描述",',
|
||||
' "backstory": "背景经历",',
|
||||
' "personality": "性格特点",',
|
||||
' "motivation": "当前动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildLandmarkPrompt(profile: ParsedProfile) {
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
|
||||
`世界摘要:${profile.summary || '未填写'}`,
|
||||
`世界基调:${profile.tone || '未填写'}`,
|
||||
`玩家主线目标:${profile.playerGoal || '未填写'}`,
|
||||
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
|
||||
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
|
||||
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
|
||||
'请基于上面全部上下文,生成 1 个新的“场景”。',
|
||||
'要求:',
|
||||
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
|
||||
'- 必须给出适合出现在这个新场景里的 sceneNpcNames,且只能从已有场景角色里选择至少 3 个名字。',
|
||||
'- 必须给出 connections,且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
|
||||
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
|
||||
'- 只返回 JSON,不要输出解释或 Markdown。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "landmark": {',
|
||||
' "name": "场景名",',
|
||||
' "description": "场景描述",',
|
||||
' "visualDescription": "场景画面描述",',
|
||||
' "dangerLevel": "low|medium|high|extreme",',
|
||||
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
|
||||
' "connections": [',
|
||||
' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },',
|
||||
' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) {
|
||||
const normalized = name.trim() || fallbackName;
|
||||
if (!existingNames.includes(normalized)) {
|
||||
@@ -1040,8 +841,7 @@ async function requestGeneratedEntity(
|
||||
: buildLandmarkPrompt(profile);
|
||||
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt:
|
||||
'你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。',
|
||||
systemPrompt: CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
timeoutMs: 45000,
|
||||
debugLabel: `custom-world-generate-${kind}`,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import {
|
||||
buildCustomWorldSceneNpcPrompt,
|
||||
CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT,
|
||||
} from '../prompts/customWorldSceneNpcPrompts.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type SceneNpcGenerationInput = {
|
||||
@@ -288,86 +292,6 @@ function buildFallbackDraft(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompt(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
otherNpcs: ParsedStoryNpc[],
|
||||
) {
|
||||
const sceneNpcSummary = sceneNpcs.length
|
||||
? sceneNpcs
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '当前场景还没有已加入 NPC。';
|
||||
|
||||
const reserveNpcSummary = otherNpcs.length
|
||||
? otherNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '暂无其他场景角色参考。';
|
||||
|
||||
const landmarkSummary = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
|
||||
`当前目标场景:${landmark.name}`,
|
||||
`场景描述:${landmark.description || '未填写'}`,
|
||||
`危险度:${landmark.dangerLevel || '中'}`,
|
||||
`当前场景已加入 NPC:\n${sceneNpcSummary}`,
|
||||
`其他可参考 NPC:\n${reserveNpcSummary}`,
|
||||
`世界内其他场景概览:\n${landmarkSummary}`,
|
||||
'请生成 1 名适合加入当前场景的新 NPC。',
|
||||
'要求:',
|
||||
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
|
||||
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
|
||||
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
|
||||
'- 返回 JSON,不要额外解释。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "npc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "头衔",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句角色描述",',
|
||||
' "backstory": "背景",',
|
||||
' "personality": "性格",',
|
||||
' "motivation": "动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sanitizeGeneratedNpc(
|
||||
rawValue: unknown,
|
||||
profile: ParsedProfile,
|
||||
@@ -571,9 +495,13 @@ export async function generateSceneNpcForLandmark(
|
||||
|
||||
try {
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt:
|
||||
'你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON,不要输出解释、前言或 markdown 代码块之外的额外内容。',
|
||||
userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs),
|
||||
systemPrompt: CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT,
|
||||
userPrompt: buildCustomWorldSceneNpcPrompt(
|
||||
profile,
|
||||
landmark,
|
||||
sceneNpcs,
|
||||
otherNpcs,
|
||||
),
|
||||
debugLabel: 'custom-world-scene-npc',
|
||||
});
|
||||
const parsed = parseJsonResponseText(content);
|
||||
|
||||
Reference in New Issue
Block a user