refactor(engine): remove follow-up choices from insert-beat, keep multi-beat only

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 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-24 19:04:45 +08:00
parent b5f5ebc353
commit d5b4a02cb3
5 changed files with 20 additions and 46 deletions
+3 -16
View File
@@ -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,
+12 -11
View File
@@ -578,6 +578,9 @@ function coerceBeatPartial(raw: Record<string, unknown>): 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<InsertBeatPartial[]> {
const raw = await chat(
config,
[
@@ -600,25 +603,23 @@ export async function directInsertBeat(
const parsed = parseJsonLoose<InsertBeatMulti & InsertBeatPartial>(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<string, unknown>))
.map((b) =>
b && typeof b === "object"
? coerceBeatPartial(b as Record<string, unknown>)
: 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<string, unknown>);
return { beats: [single ?? { narration: "(你停下脚步,环视片刻。)" }] };
return [single ?? { narration: "(你停下脚步,环视片刻。)" }];
}
+1 -2
View File
@@ -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,
};
}
+3 -12
View File
@@ -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");
+1 -5
View File
@@ -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[];
};