This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

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

View File

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

View File

@@ -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}有关吗`,
'你愿意再说清楚点吗',
];
}

View File

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

View File

@@ -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, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- 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', '设定上下文已整理,开始请求大模型推理。');

View File

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

View 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);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

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

View 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');
}

View 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');
}

View 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');
}

View 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, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- 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');
}

View 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');
}

View 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');
}

View 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');
}

View 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');
}

View 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');
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {

View File

@@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {

View File

@@ -40,6 +40,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {

View File

@@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() {
return {
musicVolume: 0.42,
platformTheme: 'light',
};
},
async putSettings(_userId, settings) {

View File

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

View File

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