From d5b4a02cb3525ad8869b0849622fe86eaf5a8109 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Wed, 24 Jun 2026 19:04:45 +0800 Subject: [PATCH] refactor(engine): remove follow-up choices from insert-beat, keep multi-beat only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert-beat is a pure in-scene micro-interaction — adding choices that lead to change-scene contradicted its purpose. Now insert-beat generates 1-3 richer beats then loops back to the original options, which is the natural UX for "you glanced at something decorative." Co-Authored-By: Claude Opus 4.6 --- app/[locale]/play/page.tsx | 19 +++---------------- lib/engine/director.ts | 23 ++++++++++++----------- lib/engine/orchestrator.ts | 3 +-- lib/engine/prompts.ts | 15 +++------------ lib/types/index.ts | 6 +----- 5 files changed, 20 insertions(+), 46 deletions(-) diff --git a/app/[locale]/play/page.tsx b/app/[locale]/play/page.tsx index 02b11f6..6a27fa8 100644 --- a/app/[locale]/play/page.tsx +++ b/app/[locale]/play/page.tsx @@ -2308,7 +2308,7 @@ function PlayInner() { if (decision.classify === "insert-beat") { setPhase("inserting-beat"); - const { partial, extraBeats, followUpChoices, characters: insertChars } = await requestInsertBeat({ + const { partial, extraBeats, characters: insertChars } = await requestInsertBeat({ session, freeformAction: decision.intent.freeformAction, clientTts: !!byoTtsRef.current, @@ -2333,24 +2333,11 @@ function PlayInner() { }); } - // Chain beats: each points to the next; last one gets choices or falls back to original beat + // Chain beats: each points to the next; last one loops back to original beat for (let i = 0; i < newBeats.length - 1; i++) { newBeats[i]!.next = { type: "continue", nextBeatId: newBeatIds[i + 1]! }; } - - const lastInsertedBeat = newBeats[newBeats.length - 1]!; - if (followUpChoices && followUpChoices.length > 0) { - lastInsertedBeat.next = { - type: "choice", - choices: followUpChoices.map((c, ci) => ({ - id: `c_ins_${Date.now()}_${Math.random().toString(36).slice(2, 6)}_${ci}`, - label: c.label, - effect: { kind: "change-scene" as const, nextSceneSeed: c.effect }, - })), - }; - } else { - lastInsertedBeat.next = { type: "continue", nextBeatId: fromBeatId }; - } + newBeats[newBeats.length - 1]!.next = { type: "continue", nextBeatId: fromBeatId }; const patched: Scene = { ...currentScene, diff --git a/lib/engine/director.ts b/lib/engine/director.ts index 3c2bdf0..7b33b71 100644 --- a/lib/engine/director.ts +++ b/lib/engine/director.ts @@ -578,6 +578,9 @@ function coerceBeatPartial(raw: Record): InsertBeatPartial | nu ? ((typeof raw.lineDelivery === "string" ? raw.lineDelivery.trim() : undefined) || undefined) : undefined; if (!narration && !speaker && !line) return null; + if (line && !speaker) { + return { narration: [narration, line].filter(Boolean).join("\n") || undefined }; + } return { narration, speaker, line, lineDelivery }; } @@ -585,7 +588,7 @@ export async function directInsertBeat( config: ProviderConfig, session: Session, freeformAction: string, -): Promise<{ beats: InsertBeatPartial[]; choices?: { label: string; effect: string }[] }> { +): Promise { const raw = await chat( config, [ @@ -600,25 +603,23 @@ export async function directInsertBeat( const parsed = parseJsonLoose(raw); - // New multi-beat format: { beats: [...], choices: [...] } + // Multi-beat format: { beats: [...] } if (Array.isArray(parsed.beats) && parsed.beats.length > 0) { const beats = parsed.beats .slice(0, 3) - .map((b) => coerceBeatPartial(b as Record)) + .map((b) => + b && typeof b === "object" + ? coerceBeatPartial(b as Record) + : null, + ) .filter((b): b is InsertBeatPartial => b !== null); if (beats.length === 0) { beats.push({ narration: "(你停下脚步,环视片刻。)" }); } - const choices = Array.isArray(parsed.choices) - ? parsed.choices - .filter((c) => c && typeof c.label === "string" && c.label.trim() && typeof c.effect === "string" && c.effect.trim()) - .slice(0, 2) - .map((c) => ({ label: c.label.trim(), effect: c.effect.trim() })) - : undefined; - return { beats, choices: choices?.length ? choices : undefined }; + return beats; } // Legacy single-beat fallback const single = coerceBeatPartial(parsed as Record); - return { beats: [single ?? { narration: "(你停下脚步,环视片刻。)" }] }; + return [single ?? { narration: "(你停下脚步,环视片刻。)" }]; } diff --git a/lib/engine/orchestrator.ts b/lib/engine/orchestrator.ts index 364a590..57ae7b4 100644 --- a/lib/engine/orchestrator.ts +++ b/lib/engine/orchestrator.ts @@ -203,7 +203,7 @@ export async function requestInsertBeat( ); // Guard every beat: promote unregistered speakers to narration. - const guardedBeats = result.beats.map((partial) => { + const guardedBeats = result.map((partial) => { if ( partial.speaker && partial.speaker !== "你" && @@ -230,7 +230,6 @@ export async function requestInsertBeat( return { partial: first, extraBeats: extra.length > 0 ? extra : undefined, - followUpChoices: result.choices, characters: req.session.characters, }; } diff --git a/lib/engine/prompts.ts b/lib/engine/prompts.ts index 23c9e7e..241155b 100644 --- a/lib/engine/prompts.ts +++ b/lib/engine/prompts.ts @@ -572,7 +572,7 @@ STRICT RULES: // Single-agent path; no character design / no rendering involved. // ────────────────────────────────────────────────────────────────────── -export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个自由动作(可能是点击画面中的某个物件/角色,也可能是主动输入了一句话/动作)。请基于此动作,写出**1-3 个有实质内容的 beat**,并在最后给出 2 个后续选项供玩家选择。 +export const INSERT_BEAT_SYSTEM = `你是视觉小说编剧。玩家在当前场景内做了一个自由动作(可能是点击画面中的某个物件/角色,也可能是主动输入了一句话/动作)。请基于此动作,写出**1-3 个有实质内容的 beat**。 核心原则——**玩家的动作必须得到回应**: - 如果当前场景有 NPC 在场,NPC **必须对玩家的动作做出反应**(说话、表情变化、动作回应)。用 narration 描述玩家的动作,用 speaker + line 写 NPC 的回应。 @@ -584,15 +584,10 @@ beat 数量指引: - 有来有回的对话/有展开的互动:2-3 个 beat,让反应更有层次 - 每个 beat 的 narration + line ≤100 字 -后续选项(choices)——每次**必须**给出 2 个选项: -- 选项应**承接刚才的互动**,给玩家自然的下一步 -- 至少一个选项应能推动剧情前进(如"继续追问"、"走过去看看"、"做出某个决定") -- label:玩家看到的选项文字(≤15字) -- effect:描述选这个选项后会发生什么(供下一个编剧参考) - 文本风格约束: - narration / line 用中文,**纯净可显示文本**,不要写 (叹气)(语速快) 这类配音标注 - 不要打破当前场景的物理状态(玩家仍在原地) +- 不要生成选项或下一步指引——播完后玩家会自然回到原来的选项 - 内容要"有所得"——一个新细节、一丝潜台词、一次真实的交流(show, don't tell) - 白描为主:聚焦可观察的五感与物理特征,以角色的动作/神态本身传递情绪,不要以作者角度解释或议论;不写角色眼神/语气里的情绪(这些从台词与动作中自行体会) @@ -615,10 +610,6 @@ speaker 字段允许的取值**只有两种**(与主路径 Writer 一致 — P { "beats": [ { "narration": "...", "speaker": "...", "line": "...", "lineDelivery": "..." } - ], - "choices": [ - { "label": "选项文字", "effect": "选此选项后的剧情走向" }, - { "label": "选项文字", "effect": "选此选项后的剧情走向" } ] } @@ -667,7 +658,7 @@ export function buildInsertBeatUserMessage( } parts.push(`\n玩家此刻的自由动作:${freeformAction}`); - parts.push("\n请生成 beat(1-3 个)和 2 个后续选项,严格以 JSON 格式返回。"); + parts.push("\n请生成 1-3 个 beat,严格以 JSON 格式返回。"); const langDirective = buildLanguageDirective(session.language); if (langDirective) parts.push(langDirective); return parts.join("\n"); diff --git a/lib/types/index.ts b/lib/types/index.ts index 6ea0eaa..b0d62f0 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -695,19 +695,15 @@ export type InsertBeatPartial = { lineDelivery?: string; }; -/** Multi-beat response: 1-3 beats + optional follow-up choices. */ +/** Multi-beat response: 1-3 beats. */ export type InsertBeatMulti = { beats: InsertBeatPartial[]; - /** Follow-up choices shown after the last beat (max 2). */ - choices?: { label: string; effect: string }[]; }; export type InsertBeatResponse = { partial: InsertBeatPartial; /** Additional beats beyond the first (for richer insert-beat interactions). */ extraBeats?: InsertBeatPartial[]; - /** Follow-up choices shown after the last inserted beat. */ - followUpChoices?: { label: string; effect: string }[]; characters: Character[]; };