接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。

直聘插入按模块回馈,并记录请求骨架便于排查。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 07:40:08 +08:00
co-authored by Cursor
parent 9306c8e659
commit dd0a9e87d8
88 changed files with 6869 additions and 786 deletions
+55 -5
View File
@@ -11,6 +11,8 @@ import {
formatFlowProgressForAgent,
listCallableCatalogModules,
formatModuleCatalogForAgent,
formatModuleCatalogForDictate,
catalogModulesForIntake,
formatRecipeCatalogForAgent,
formatDictateRecipeBrief,
formatSelectedRecipeForAgent,
@@ -442,6 +444,7 @@ describe("creation-flow", () => {
expect(catalog?.modules.some((m) => m.name === "美学纲领与交互范式")).toBe(
true,
);
expect(catalog?.modules.some((m) => m.name === "主角设定")).toBe(true);
expect(catalog?.modules.some((m) => m.name === "交互范式")).toBe(false);
expect(catalog?.modules.some((m) => m.name === "美学纲领")).toBe(false);
expect(
@@ -480,16 +483,52 @@ describe("creation-flow", () => {
expect(block).toContain("role=prototype");
expect(block).toContain("编排参数");
expect(block).not.toContain("设计.美学纲领与交互范式");
expect(block).toContain("- 叙事指南与故事推进:");
expect(block).not.toMatch(/^- 叙事指南:/m);
expect(block).not.toMatch(/^- 故事推进:/m);
const dictateBlock = formatModuleCatalogForDictate(catalog!);
expect(dictateBlock).toMatch(/^- 叙事指南:/m);
expect(dictateBlock).toMatch(/^- 故事推进:/m);
expect(dictateBlock).not.toMatch(/^- 叙事指南与故事推进/m);
expect(
catalog?.modules.find((m) => m.name === "叙事指南与故事推进")?.intake,
).toBe("recipe");
expect(catalog?.modules.find((m) => m.name === "叙事指南")?.intake).toBe(
"dictate",
);
expect(catalog?.modules.find((m) => m.name === "故事推进")?.intake).toBe(
"dictate",
);
expect(
catalogModulesForIntake(catalog!, "recipe").some(
(m) => m.name === "叙事指南",
),
).toBe(false);
expect(
catalogModulesForIntake(catalog!, "dictate").some(
(m) => m.name === "叙事指南与故事推进",
),
).toBe(false);
const leftover = listCallableCatalogModules({ catalog: catalog! });
expect(leftover.some((m) => m.name === "叙事指南与故事推进")).toBe(true);
expect(leftover.some((m) => m.name === "叙事指南")).toBe(false);
expect(leftover.some((m) => m.name === "故事推进")).toBe(false);
const splitFlow = parseCreationFlow(`{
"steps": [{ "name": "叙事指南", "depends_on": [] }]
}`)!;
expect(validateCreationFlow(splitFlow, catalog).ok).toBe(false);
});
it("loads recipe catalog for user director selection UI", async () => {
const recipes = await loadRecipeCatalog("dialogue/world-simulator");
expect(recipes?.recipes.some((r) => r.name === "世界模拟器")).toBe(true);
expect(recipes?.recipes.some((r) => r.name === "回合推演")).toBe(true);
expect(recipes?.recipes.some((r) => r.name === "扩写助手")).toBe(true);
expect(recipes?.recipes.some((r) => r.name === "文本生成器")).toBe(false);
const block = formatRecipeCatalogForAgent(recipes!);
expect(block).toContain("须由用户手动选择");
expect(block).toContain("世界模拟器");
expect(block).toContain("回合推演");
const details = await loadAllRecipeDetails("dialogue/world-simulator");
expect(details.length).toBeGreaterThanOrEqual(2);
@@ -519,7 +558,9 @@ describe("creation-flow", () => {
expect(dictate?.recipes.some((r) => r.name === "文本生成器")).toBe(true);
expect(dictate?.recipes.some((r) => r.name === "交互式长文生成器")).toBe(true);
expect(dictate?.recipes.some((r) => r.name === "数据化跑团体验")).toBe(true);
expect(dictate?.recipes[0]?.id).toBe("数据化跑团体验");
expect(dictate?.recipes.some((r) => r.id === "快穿短局")).toBe(true);
expect(dictate?.recipes.some((r) => r.name === "快穿短局")).toBe(true);
expect(dictate?.recipes[0]?.id).toBe("快穿短局");
const entry = dictate!.recipes.find((r) => r.id === "文本生成器")!;
expect(entry.family).toBe("dictate");
const detail = await loadRecipeDetail("dialogue/world-simulator", entry);
@@ -540,6 +581,15 @@ describe("creation-flow", () => {
expect(formatDictateRecipeBrief(rpgDetail)).toContain("declare_variable");
expect(formatDictateRecipeBrief(rpgDetail)).toContain("何时落盘");
expect(formatDictateRecipeBrief(rpgDetail)).toMatch(/美学纲领|体验核心/);
const skip = dictate!.recipes.find((r) => r.id === "快穿短局")!;
const skipDetail = await loadRecipeDetail("dialogue/world-simulator", skip);
expect(skipDetail.seed).toBeNull();
expect(skipDetail.brief).toMatch(/一核|短局|主角/);
expect(skipDetail.core).toMatch(/一核|开场白|黑板/);
expect(skipDetail.when).toMatch(/快穿|一次性|短任务/);
expect(formatDictateRecipeBrief(skipDetail)).toMatch(/舞台骨架|生成规则|拓扑/);
expect(formatDictateRecipeBrief(skipDetail)).toMatch(/回合推演|数据化跑团体验/);
});
it("parses selected recipe ref", () => {
@@ -649,8 +699,8 @@ recipes:
{ selectedRecipeRef: "world-simulator" },
);
expect(selected.worker.outputTags).toContain("设计.创作流程");
expect(selected.promptBody).toContain("【用户已选配方 · 世界模拟器】");
expect(selected.promptBody).toContain("世界模拟器");
expect(selected.promptBody).toContain("【用户已选配方 · 回合推演】");
expect(selected.promptBody).toContain("回合推演");
expect(selected.promptBody).toContain("【能力");
expect(selected.promptBody).toContain("美学纲领与交互范式");
expect(selected.promptBody).toContain("核心思路");
+12 -4
View File
@@ -16,6 +16,8 @@ describe("dictate play-bind", () => {
expect(isDictateStyleBindTag("设计.正文组成")).toBe(true);
expect(isDictateStyleBindTag("设计.模仿范例")).toBe(true);
expect(isDictateStyleBindTag("设计.模仿要点")).toBe(true);
expect(isDictateStyleBindTag("设计.叙事指南")).toBe(true);
expect(isDictateStyleBindTag("设计.故事推进")).toBe(true);
expect(isDictateStyleBindTag("用户.需求")).toBe(false);
});
@@ -107,7 +109,13 @@ describe("dictate play-bind", () => {
metadata: { [DICTATE_ORDER_META_KEY]: -28 },
});
board.write({
tag: "设计.叙事指南与故事推进",
tag: "设计.叙事指南",
content: "冷硬短句,笔墨落在权力交换。",
source: "dictate",
metadata: { [DICTATE_ORDER_META_KEY]: -20 },
});
board.write({
tag: "设计.故事推进",
content: "用户输入用法:大纲扩写。禁止扮演停笔。",
source: "dictate",
metadata: { [DICTATE_ORDER_META_KEY]: -22 },
@@ -132,11 +140,11 @@ describe("dictate play-bind", () => {
const narrRefs = narr!.inserts.map((i) => i.ref);
expect(narrRefs).toContain("设计.模仿范例");
expect(narrRefs).toContain("设计.模仿要点");
expect(narrRefs).toContain("设计.叙事指南与故事推进");
expect(narrRefs).toContain("设计.叙事指南");
expect(narrRefs).toContain("设计.故事推进");
expect(narrRefs).not.toContain("用户.需求");
expect(JSON.parse(result.workerSetJson).play_slots.narrator).toBe(true);
// 落档不改写创作期叙事指南
expect(board.getContentByTag("设计.叙事指南与故事推进")).toBe(
expect(board.getContentByTag("设计.故事推进")).toBe(
"用户输入用法:大纲扩写。禁止扮演停笔。",
);
});
+476 -4
View File
@@ -2,16 +2,23 @@ import { describe, expect, it } from "vitest";
import {
buildDictateMessages,
buildDictateSystemPrompt,
buildDictateInsertFeedbackIndex,
defaultDictateOrder,
extractDictateDialogue,
formatDictateUserFacingBrief,
isAllowedProductTag,
isDictateModeValue,
resolveModuleForInsertTag,
runDictateTurn,
sortDictateProducts,
resolveRepeatableInsertTag,
lookupDictateInsertFeedback,
} from "../src/dictate/index.js";
import {
formatModuleCatalogForDictate,
loadModuleCatalog,
} from "../src/skills/creation-flow.js";
import type { AgentDriver } from "../src/runtime/driver.js";
describe("dictate context", () => {
it("assembles full dialogue + products with almost no filtering", () => {
@@ -78,21 +85,37 @@ describe("dictate context", () => {
expect(prompt).toContain("设计.开场白");
expect(prompt).toContain("输出.开场白");
expect(prompt).toContain("insert");
expect(prompt).toContain("delete");
expect(prompt).toContain("declare_variable");
expect(prompt).toContain("undeclare_variable");
expect(prompt).toContain("declare_map");
expect(prompt).toContain("remove_map");
expect(prompt).toContain("同 position 再 insert");
expect(prompt).toContain("数据化跑团体验");
expect(prompt).toMatch(/order/);
expect(prompt).toContain("仅允许 toolcall");
expect(prompt).toContain("设计.模仿范例");
expect(prompt).toContain("设计.模仿要点");
expect(prompt).toContain("设计.叙事指南与故事推进");
expect(prompt).toContain("设计.故事推进");
expect(prompt).toContain("设计.叙事指南");
expect(prompt).toContain("大纲扩写");
expect(prompt).toContain("禁止扮演停笔");
expect(prompt).toContain("扮演停笔关掉");
expect(prompt).toContain("只挂载这些产物");
expect(prompt).toContain("【能力 · 何时落盘】");
expect(prompt).toContain("设计.美学纲领与交互范式");
expect(prompt).toContain("每轮通常只推进一刀");
expect(prompt).toContain("勿因配方名叫数据化就抢跑 D");
expect(prompt).toContain("同轮可多次 insert");
expect(prompt).toContain("不抢跑未齐条件的下游");
expect(prompt).toContain("也不自动开跑 D");
expect(prompt).toContain("快穿短局");
expect(prompt).toContain("一核短局");
expect(prompt).toMatch(/舞台骨架/);
expect(prompt).toMatch(/覆盖 C/);
expect(prompt).toContain("设计.主角设定");
expect(prompt).toContain("reply_module");
expect(prompt).toContain("对用户可见回复");
expect(prompt).toContain("有 N 个 reply_module");
expect(prompt).toContain("〔可反复〕须拆分");
expect(prompt).toContain("设计.具体实例#");
});
it("formats module catalog for dictate with land tags and when", async () => {
@@ -107,7 +130,13 @@ describe("dictate context", () => {
expect(block).toContain("declare_variable");
expect(block).toContain("设计.开场白");
expect(block).toContain("〔落档程序〕");
expect(block).toContain("设计.生成规则#");
expect(block).toContain("设计.具体实例#");
expect(block).toContain("可增殖");
expect(block).not.toContain("role=prototype");
expect(block).toContain("落盘:insert 「设计.叙事指南」");
expect(block).toContain("落盘:insert 「设计.故事推进」");
expect(block).not.toMatch(/^- 叙事指南与故事推进/m);
});
it("sorts products by relative order: neg then 0 then pos", () => {
@@ -146,4 +175,447 @@ describe("dictate context", () => {
expect(body).toContain("order=-10");
expect(body).toContain("order=10");
});
it("resolves insert tag to module and builds user-facing brief", async () => {
const catalog = await loadModuleCatalog("dialogue/world-simulator");
expect(catalog).toBeTruthy();
const mod = resolveModuleForInsertTag(
"设计.美学纲领与交互范式",
catalog!,
);
expect(mod?.id).toBe("aesthetics-interaction");
expect(
resolveModuleForInsertTag("设计.开场白", catalog!)?.id,
).toBe("opening-setup");
expect(
resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id,
).toBe("concrete-instances");
expect(
resolveModuleForInsertTag("设计.生成规则#ex-girlfriend-card", catalog!)?.id,
).toBe("generation-rules");
expect(resolveModuleForInsertTag("设计.叙事指南", catalog!)?.id).toBe(
"narrative-guide",
);
expect(resolveModuleForInsertTag("设计.故事推进", catalog!)?.id).toBe(
"story-progression",
);
expect(resolveModuleForInsertTag("设计.文风", catalog!)?.id).toBe(
"narrative-guide",
);
const index = await buildDictateInsertFeedbackIndex({
catalog: catalog!,
skillPackRoot: "dialogue/world-simulator",
});
const brief = index.get("设计.美学纲领与交互范式");
expect(brief).toBeTruthy();
expect(brief!).toContain("# 模块 · 美学纲领与交互范式");
expect(brief!).toContain("回答时请附带本模块");
expect(brief!).toContain("本模块内容包括");
expect(brief!).toContain("## 写入用户回复时做什么");
expect(brief!).toContain("## 探测与追问(本模块口径)");
expect(brief!).toContain("展示优于提问");
expect(brief!).toMatch(/自评关注|交互范式|美学纲领/);
const alias = index.get("设计.开场白");
expect(alias).toContain("开场白与开场变量");
});
it("splits repeatable insert tags and rejects bare overwrite", async () => {
const families = new Set(["设计.生成规则", "设计.具体实例"]);
const instanceBody = JSON.stringify({
schema: "context-fragment.v1",
技能: "具体实例",
正文: {
rule_id: "ex-girlfriend-card",
batch_id: "lei-ying",
records: [{ 姓名: "雷樱" }],
},
});
const first = resolveRepeatableInsertTag({
tag: "设计.具体实例",
content: instanceBody,
existingTags: [],
repeatableFamilies: families,
});
expect("tag" in first && first.tag).toBe("设计.具体实例#lei-ying");
const second = resolveRepeatableInsertTag({
tag: "设计.具体实例",
content: JSON.stringify({
schema: "context-fragment.v1",
正文: { records: [{ 姓名: "谢明澜" }] },
}),
existingTags: ["设计.具体实例#lei-ying"],
repeatableFamilies: families,
});
expect("tag" in second && second.tag).toBe("设计.具体实例#谢明澜");
const overwriteBare = resolveRepeatableInsertTag({
tag: "设计.具体实例",
content: "不是JSON就抽不出槽",
existingTags: ["设计.具体实例#lei-ying"],
repeatableFamilies: families,
});
expect("error" in overwriteBare).toBe(true);
const catalog = await loadModuleCatalog("dialogue/world-simulator");
const index = await buildDictateInsertFeedbackIndex({
catalog: catalog!,
skillPackRoot: "dialogue/world-simulator",
});
expect(
lookupDictateInsertFeedback(index, "设计.具体实例#lei-ying"),
).toContain("具体实例");
});
it("runDictateTurn remaps bare repeatable insert to #slot", async () => {
const products: { tag: string; content: string }[] = [];
const body = JSON.stringify({
schema: "context-fragment.v1",
正文: {
batch_id: "lei-ying",
records: [{ 姓名: "雷樱" }],
},
});
const driver: AgentDriver = {
async run(input) {
const outcome = await input.handleStep([
{
id: "c1",
name: "insert",
arguments: JSON.stringify({
position: "设计.具体实例",
content: body,
}),
},
]);
expect(outcome.kind).toBe("continue");
if (outcome.kind === "continue") {
const payload = JSON.parse(outcome.results[0]!.content) as {
ok?: boolean;
position?: string;
error?: string;
};
expect(payload.ok).toBe(true);
expect(payload.position).toBe("设计.具体实例#lei-ying");
}
return {
iterations: 1,
stop: { kind: "text", content: "已落实例" },
};
},
};
const result = await runDictateTurn({
llm: {} as never,
dialogue: [{ role: "user", text: "再生成一张雷樱" }],
driver,
repeatableFamilies: ["设计.具体实例", "设计.生成规则"],
handlers: {
listProducts: () => products,
writeProduct: (tag, content) => {
products.push({ tag, content });
},
deleteProduct: () => false,
readTag: () => undefined,
writeTag: () => undefined,
deleteTag: () => false,
clearDialogue: () => undefined,
},
});
expect(result.wroteTags).toEqual(["设计.具体实例#lei-ying"]);
expect(products.map((p) => p.tag)).toEqual(["设计.具体实例#lei-ying"]);
});
it("insert tool result includes reply_module from lookup", async () => {
let capturedSpec: string | undefined;
const driver: AgentDriver = {
async run(input) {
const outcome = await input.handleStep([
{
id: "c1",
name: "insert",
arguments: JSON.stringify({
position: "设计.美学纲领与交互范式",
content: "傀儡帝体验核",
}),
},
]);
expect(outcome.kind).toBe("continue");
if (outcome.kind === "continue") {
const payload = JSON.parse(outcome.results[0]!.content) as {
ok?: boolean;
reply_module?: string;
};
expect(payload.ok).toBe(true);
expect(payload.reply_module).toContain("回答时请附带本模块");
capturedSpec = payload.reply_module;
}
return {
iterations: 1,
stop: { kind: "text", content: "已落美学;下一刀建议主角名片。" },
};
},
};
const products: { tag: string; content: string }[] = [];
const result = await runDictateTurn({
llm: {} as never,
dialogue: [{ role: "user", text: "傀儡帝恋爱" }],
driver,
handlers: {
listProducts: () => products,
writeProduct: (tag, content) => {
products.push({ tag, content });
},
deleteProduct: () => false,
readTag: () => undefined,
writeTag: () => undefined,
deleteTag: () => false,
clearDialogue: () => undefined,
lookupInsertFeedback: () =>
formatDictateUserFacingBrief({
tag: "设计.美学纲领与交互范式",
module: {
id: "aesthetics-interaction",
name: "美学纲领与交互范式",
declaration: "x",
artifact: "设计.美学纲领与交互范式",
},
promptMd: [
"```probe",
"缺其中一件:追问 1~2 点",
"```",
"```principles",
"展示优于提问、大胆优于保守",
"```",
"```output",
'{"自评":{"维度":[{"名":"交互范式"},{"名":"美学纲领"}]}}',
"```",
].join("\n"),
}),
},
});
expect(capturedSpec).toContain("探测与追问");
expect(result.wroteTags).toContain("设计.美学纲领与交互范式");
expect(result.reply).toContain("已落美学");
});
it("delete removes a product tag", async () => {
const products = new Map<string, string>([
["设计.开场白", "旧开场"],
["用户.需求", "雨夜"],
]);
const driver: AgentDriver = {
async run(input) {
const outcome = await input.handleStep([
{
id: "d1",
name: "delete",
arguments: JSON.stringify({
position: "设计.开场白",
reason: "重做开场",
}),
},
]);
expect(outcome.kind).toBe("continue");
if (outcome.kind === "continue") {
const payload = JSON.parse(outcome.results[0]!.content) as {
ok?: boolean;
deleted?: string;
};
expect(payload.ok).toBe(true);
expect(payload.deleted).toBe("设计.开场白");
}
return {
iterations: 1,
stop: { kind: "text", content: "已删开场白。" },
};
},
};
const result = await runDictateTurn({
llm: {} as never,
dialogue: [{ role: "user", text: "删掉开场白" }],
driver,
handlers: {
listProducts: () =>
[...products.entries()].map(([tag, content]) => ({ tag, content })),
writeProduct: (tag, content) => {
products.set(tag, content);
},
deleteProduct: (tag) => products.delete(tag),
readTag: () => undefined,
writeTag: () => undefined,
deleteTag: () => false,
clearDialogue: () => undefined,
},
});
expect(products.has("设计.开场白")).toBe(false);
expect(products.has("用户.需求")).toBe(true);
expect(result.deletedTags).toContain("设计.开场白");
});
it("undeclare_variable and remove_map prune catalog/map docs", async () => {
let catalogRaw =
'{"schema":"variable-catalog.v1","fields":[{"key":"好感","type":"number","initial":10,"user_visible":true},{"key":"章节","type":"number","initial":1,"user_visible":true}]}';
let mapRaw =
'{"schema":"value-map.v1","maps":[{"id":"aff","field":"好感","target_tag":"上下文.角色态度","bands":[{"min":0,"content":"冷"}]}]}';
let currentRaw =
'{"rows":[{"key":"好感","value":10,"rev":1},{"key":"章节","value":1,"rev":1}]}';
const projection = new Map<string, string>([
["上下文.角色态度", "冷"],
]);
const tags = new Map<string, string>([
["设计.变量目录", catalogRaw],
["设计.变量映射", mapRaw],
["变量.当前", currentRaw],
]);
const driver: AgentDriver = {
async run(input) {
const r1 = await input.handleStep([
{
id: "u1",
name: "undeclare_variable",
arguments: JSON.stringify({ key: "好感" }),
},
]);
expect(r1.kind).toBe("continue");
const r2 = await input.handleStep([
{
id: "r1",
name: "remove_map",
arguments: JSON.stringify({ id: "aff" }),
},
]);
expect(r2.kind).toBe("continue");
if (r2.kind === "continue") {
const payload = JSON.parse(r2.results[0]!.content) as {
cleared_target?: string | null;
};
expect(payload.cleared_target).toBe("上下文.角色态度");
}
return {
iterations: 2,
stop: { kind: "text", content: "已移除好感与映射。" },
};
},
};
const result = await runDictateTurn({
llm: {} as never,
dialogue: [{ role: "user", text: "去掉好感" }],
driver,
handlers: {
listProducts: () => [],
writeProduct: () => undefined,
deleteProduct: () => false,
readTag: (tag) => tags.get(tag) ?? projection.get(tag),
writeTag: (tag, content) => {
tags.set(tag, content);
if (tag === "设计.变量目录") catalogRaw = content;
if (tag === "设计.变量映射") mapRaw = content;
if (tag === "变量.当前") currentRaw = content;
},
deleteTag: (tag) => {
const a = tags.delete(tag);
const b = projection.delete(tag);
return a || b;
},
clearDialogue: () => undefined,
},
});
expect(catalogRaw).toContain("章节");
expect(catalogRaw).not.toContain('"好感"');
expect(currentRaw).not.toContain('"好感"');
expect(tags.has("设计.变量映射")).toBe(false);
expect(projection.has("上下文.角色态度")).toBe(false);
expect(result.deletedTags.some((t) => t.includes("好感"))).toBe(true);
expect(result.deletedTags.some((t) => t.includes("aff"))).toBe(true);
});
it("parallel inserts each get a reply_module and multi-module hint", async () => {
const driver: AgentDriver = {
async run(input) {
const outcome = await input.handleStep([
{
id: "c1",
name: "insert",
arguments: JSON.stringify({
position: "设计.美学纲领与交互范式",
content: "美学",
}),
},
{
id: "c2",
name: "insert",
arguments: JSON.stringify({
position: "设计.主角设定",
content: "主角",
}),
},
]);
expect(outcome.kind).toBe("continue");
if (outcome.kind === "continue") {
const a = JSON.parse(outcome.results[0]!.content) as {
reply_module?: string;
reply_hint?: string;
};
const b = JSON.parse(outcome.results[1]!.content) as {
reply_module?: string;
reply_hint?: string;
};
expect(a.reply_module).toContain("美学纲领");
expect(b.reply_module).toContain("主角设定");
expect(a.reply_hint).toMatch(/多个 reply_module|本步已带回模块/);
expect(b.reply_hint).toContain("本步已带回模块");
}
return {
iterations: 1,
stop: { kind: "text", content: "## 模块 · 美学\n\n## 模块 · 主角" },
};
},
};
await runDictateTurn({
llm: {} as never,
dialogue: [{ role: "user", text: "都要" }],
driver,
handlers: {
listProducts: () => [],
writeProduct: () => undefined,
deleteProduct: () => false,
readTag: () => undefined,
writeTag: () => undefined,
deleteTag: () => false,
clearDialogue: () => undefined,
lookupInsertFeedback: (tag) =>
tag.includes("美学")
? formatDictateUserFacingBrief({
tag,
module: {
id: "aesthetics-interaction",
name: "美学纲领与交互范式",
declaration: "x",
artifact: tag,
},
promptMd: "```probe\n美学追问\n```",
})
: formatDictateUserFacingBrief({
tag,
module: {
id: "protagonist",
name: "主角设定",
declaration: "x",
artifact: tag,
},
promptMd: "```probe\n主角追问\n```",
}),
},
});
});
});
+10 -3
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
displayCreationModeLabel,
displaySkillPackLabel,
displayStageLabel,
displayWorkerLabel,
formatWorkerDisplayTitle,
@@ -13,6 +15,13 @@ describe("display-labels", () => {
expect(displayStageLabel("play")).toBe("游玩");
});
it("maps creation intake modes and recipe display names", () => {
expect(displayCreationModeLabel("recipe")).toBe("工序编排");
expect(displayCreationModeLabel("dictate")).toBe("对话落盘");
expect(displaySkillPackLabel("world-simulator")).toBe("回合推演");
expect(displaySkillPackLabel("快穿短局")).toBe("快穿短局");
});
it("maps design workers", () => {
expect(displayWorkerLabel("design-flow")).toBe("创作 · 流程编排");
expect(displayWorkerLabel("design-step")).toBe("创作 · 执行步骤");
@@ -24,9 +33,7 @@ describe("display-labels", () => {
it("maps creation unit ids", () => {
expect(displayWorkerLabel("phase:core")).toBe("单位 · 核心");
expect(displayWorkerLabel("fixed:interaction")).toBe("技能 · 交互范式");
expect(displayWorkerLabel("fixed:aesthetics-interaction")).toBe(
"技能 · 美学纲领与交互范式",
);
expect(displayWorkerLabel("fixed:protagonist")).toBe("技能 · 主角设定");
expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述");
});
+112
View File
@@ -5,6 +5,7 @@ import {
parseOpeningSealPayload,
openingVariablesToTableDoc,
reopenCreationFlowRaw,
splitOpeningDocument,
} from "../src/skills/opening-seal.js";
const fragment = JSON.stringify({
@@ -63,6 +64,117 @@ describe("opening-seal", () => {
]);
});
it("reads 用户角色 from candidate meta and 正文", () => {
const raw = JSON.stringify({
schema: "context-fragment.v1",
技能: "开场白与开场变量",
正文: {
开场白全文: "@玩家站在债契前。",
开场白候选: [
{
meta: {
用户角色: { 名字: "沈聿", 简介: "本地放贷人;与这对母女相识但不熟。" },
},
正文: "@玩家站在债契前。",
},
{
meta: {
用户角色: { 名字: "阿泽", 简介: "刚穿进来的社畜。" },
},
正文: "门外有人喊 @玩家 的名字。",
},
],
开场变量: [{ 名: "债额", 值: 3 }],
},
});
const first = parseOpeningSealPayload(raw, 0);
expect(first?.persona).toEqual({
name: "沈聿",
description: "本地放贷人;与这对母女相识但不熟。",
});
const second = parseOpeningSealPayload(raw, 1);
expect(second?.selectedText).toContain("门外");
expect(second?.persona).toEqual({
name: "阿泽",
description: "刚穿进来的社畜。",
});
});
it("serializes present.v1 正文 and per-candidate 开场变量", () => {
const packet = {
schema: "present.v1",
shell: "chat_monitor",
blocks: {
monitor: { 债额: "三两" },
body: "@玩家站在债契前。",
},
meta: { suggested_actions: ["问债"] },
};
const raw = JSON.stringify({
schema: "context-fragment.v1",
技能: "开场白与开场变量",
正文: {
开场白全文: packet,
开场白候选: [
{
meta: {
用户角色: { 名字: "沈聿", 简介: "放贷人" },
开场变量: [{ 名: "债额", 值: 3, 依据: "桌上债契" }],
},
正文: packet,
},
{
meta: {
用户角色: { 名字: "阿泽", 简介: "社畜" },
开场变量: [{ 名: "口粮", 值: 1 }],
},
正文: {
schema: "present.v1",
shell: "chat_monitor",
blocks: { body: "门外有人喊 @玩家。" },
},
},
],
开场变量: [{ 名: "债额", 值: 3, 依据: "桌上债契" }],
},
});
const first = parseOpeningSealPayload(raw, 0);
expect(first?.candidates).toHaveLength(2);
const selected = JSON.parse(first!.selectedText);
expect(selected.schema).toBe("present.v1");
expect(selected.shell).toBe("chat_monitor");
expect(selected.blocks.body).toContain("@玩家");
expect(first?.persona).toEqual({ name: "沈聿", description: "放贷人" });
expect(first?.variables).toEqual([
{ name: "债额", value: 3, note: "桌上债契" },
]);
const second = parseOpeningSealPayload(raw, 1);
expect(second?.persona?.name).toBe("阿泽");
expect(second?.variables).toEqual([{ name: "口粮", value: 1 }]);
expect(JSON.parse(second!.selectedText).blocks.body).toContain("门外");
});
it("splits YAML frontmatter meta from opening 正文", () => {
const nested = splitOpeningDocument(`---
用户角色:
名字: 沈聿
简介: 本地放贷人
---
清晨,@玩家推开堂屋门。
`);
expect(nested.persona).toEqual({ name: "沈聿", description: "本地放贷人" });
expect(nested.text).toBe("清晨,@玩家推开堂屋门。");
const dotted = splitOpeningDocument(`---
用户角色.名字: 阿泽
用户角色.简介: 刚穿进来的社畜
---
门外有人喊 @玩家。
`);
expect(dotted.persona).toEqual({ name: "阿泽", description: "刚穿进来的社畜" });
expect(dotted.text).toBe("门外有人喊 @玩家。");
});
it("builds table rows from opening variables", () => {
const payload = parseOpeningSealPayload(fragment, 0);
const doc = openingVariablesToTableDoc(payload!.variables);
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { Blackboard } from "../src/blackboard/blackboard.js";
import { stringifyTableDoc, createTableFromValues } from "../src/blackboard/table-cells.js";
import { buildPlayAuxView } from "../src/skills/play-aux.js";
import { SLOT_OPENING_SELECTED_INDEX } from "../src/skills/opening-seal.js";
describe("play-aux view", () => {
it("splits visible and hidden table rows and lists opening candidates", () => {
const board = new Blackboard();
board.write({
tag: "变量.当前",
content: stringifyTableDoc(
createTableFromValues(
{ 口粮: 1, 暗线: "未揭" },
"worker:opening-setup",
{
口粮: { visibility: "visible", editable: true },
暗线: { visibility: "hidden", editable: false },
},
),
),
source: "runtime",
});
board.write({
tag: "设计.开场白与开场变量",
content: JSON.stringify({
schema: "context-fragment.v1",
技能: "开场白与开场变量",
正文: {
开场白全文: "@玩家站在债契前。",
开场白候选: [
{
meta: { 用户角色: { 名字: "沈聿", 简介: "放贷人" } },
正文: "@玩家站在债契前。",
},
{
meta: { 用户角色: { 名字: "阿泽", 简介: "社畜" } },
正文: "门外有人喊 @玩家。",
},
],
},
}),
source: "agent",
});
const view = buildPlayAuxView(board, { [SLOT_OPENING_SELECTED_INDEX]: 1 });
expect(view.table?.rows.find((r) => r.key === "口粮")?.editable).toBe(true);
expect(view.table?.rows.find((r) => r.key === "暗线")?.visibility).toBe("hidden");
expect(view.table?.rows.find((r) => r.key === "暗线")?.editable).toBe(false);
expect(view.openings).toHaveLength(2);
expect(view.selectedOpeningIndex).toBe(1);
expect(view.openings[1]?.persona?.name).toBe("阿泽");
});
it("previews present.v1 opening body instead of raw JSON", () => {
const board = new Blackboard();
board.write({
tag: "设计.开场白与开场变量",
content: JSON.stringify({
schema: "context-fragment.v1",
技能: "开场白与开场变量",
正文: {
开场白全文: {
schema: "present.v1",
shell: "chat_monitor",
blocks: { body: "@玩家站在债契前,堂屋很静。" },
},
},
}),
source: "agent",
});
const view = buildPlayAuxView(board);
expect(view.openings).toHaveLength(1);
expect(view.openings[0]?.text).toBe("@玩家站在债契前,堂屋很静。");
expect(view.openings[0]?.text).not.toContain("present.v1");
});
});
+14
View File
@@ -5,6 +5,7 @@ import {
parseShellAdaptationFromReplyFormat,
presentFromPlainText,
presentOutputFromFallback,
presentReadablePlain,
isPlayFinalVisibleWorker,
isPlayGmBodyWorker,
isPlayPresentWorker,
@@ -146,4 +147,17 @@ describe("play present worker", () => {
expect(view.packet.shell).toBe("chat_monitor");
expect(view.packet.blocks.body).toBe("她点了点头。");
});
it("presentReadablePlain extracts body from present.v1", () => {
expect(
presentReadablePlain(
JSON.stringify({
schema: "present.v1",
shell: "spotlight",
blocks: { body: "清晨,@玩家推开门。" },
}),
),
).toBe("清晨,@玩家推开门。");
expect(presentReadablePlain("门外有人砸门。")).toBe("门外有人砸门。");
});
});
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it, beforeEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { Blackboard } from "../src/blackboard/blackboard.js";
const MARKDOWN_SPEC = `# 主角设定:债主(@玩家)
## 名字
沈聿。母女只知道这个姓和这张脸,叫不出更多。
## 身份
本地放贷人之一,业务不大不小。
## 能做什么
手里握着母亲签下的借据。没有超自然金手指。
## 关键背景(用户补充)
许曼的几笔散债被沈聿合并到自己手里。
---
# 母女档案(NPC)
## 母亲:许曼(33岁)
不应被当成主角名字。
`;
describe("protagonist overlay", () => {
beforeEach(() => {
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
path.join(tmpdir(), "wa-protag-"),
);
});
it("parses context-fragment JSON", async () => {
const { parseProtagonistSpec } = await import(
"../src/persona/protagonist-bind.js"
);
const spec = parseProtagonistSpec(
JSON.stringify({
schema: "context-fragment.v1",
技能: "主角设定",
正文: {
名字: "林晚",
背景: "刚被退婚的庶女",
特殊设定: { 身份: "侯府庶女", 金手指: "看见好感数字", 其它: "无" },
},
}),
);
expect(spec?.name).toBe("林晚");
expect(spec?.cheat).toMatch(/好感/);
});
it("parses markdown headings used by dictate", async () => {
const { parseProtagonistSpec, formatProtagonistSummary } = await import(
"../src/persona/protagonist-bind.js"
);
const spec = parseProtagonistSpec(MARKDOWN_SPEC);
expect(spec?.name).toBe("沈聿");
expect(spec?.identity).toMatch(/放贷/);
expect(spec?.cheat).toMatch(/借据/);
expect(spec?.background).toMatch(/散债/);
expect(formatProtagonistSummary(spec!).length).toBeLessThan(80);
expect(formatProtagonistSummary(spec!)).not.toMatch(/\*\*/);
});
it("overlays play persona from the product without touching global cards", async () => {
const {
PROTAGONIST_TAG,
readSessionProtagonist,
overlayPlayPersona,
} = await import("../src/persona/protagonist-bind.js");
const { listPersonas, getActivePersona } = await import(
"../src/persona/store.js"
);
const board = new Blackboard();
board.write({
tag: PROTAGONIST_TAG,
content: JSON.stringify({
正文: {
名字: "林晚",
背景: "刚被退婚的庶女",
特殊设定: { 身份: "侯府庶女", 金手指: "看见好感数字" },
},
}),
source: "agent",
});
const before = listPersonas().map((p) => ({
id: p.id,
name: p.name,
description: p.description,
}));
const activeId = getActivePersona()?.id;
const view = readSessionProtagonist(board);
expect(view?.name).toBe("林晚");
expect(view?.summary).toMatch(/庶女|好感/);
expect(overlayPlayPersona("play", board)?.name).not.toBe("林晚");
const { OPENING_PERSONA_CHOICE_TAG } = await import(
"../src/skills/opening-seal.js"
);
board.write({
tag: OPENING_PERSONA_CHOICE_TAG,
content: "opening",
source: "user",
});
const play = overlayPlayPersona("play", board);
expect(play?.name).toBe("林晚");
expect(play?.description).toMatch(/庶女/);
expect(overlayPlayPersona("design", board)?.name).not.toBe("林晚");
board.write({
tag: OPENING_PERSONA_CHOICE_TAG,
content: "global",
source: "user",
});
expect(overlayPlayPersona("play", board)?.name).not.toBe("林晚");
expect(
listPersonas().map((p) => ({
id: p.id,
name: p.name,
description: p.description,
})),
).toEqual(before);
expect(getActivePersona()?.id).toBe(activeId);
});
it("rejects placeholder names", async () => {
const { parseProtagonistSpec } = await import(
"../src/persona/protagonist-bind.js"
);
expect(parseProtagonistSpec("名字:@玩家\n背景:无")).toBeNull();
expect(parseProtagonistSpec("名字:主角\n背景:无")).toBeNull();
expect(parseProtagonistSpec("## 名字\n(待用户给出)\n## 身份\n债主")).toBeNull();
});
it("parses prose fields", async () => {
const { parseProtagonistSpec } = await import(
"../src/persona/protagonist-bind.js"
);
const spec = parseProtagonistSpec(
"名字:阿泽\n背景:现代社畜刚穿进书里\n金手指:系统面板\n身份:炮灰男配",
);
expect(spec).toMatchObject({
name: "阿泽",
background: "现代社畜刚穿进书里",
cheat: "系统面板",
identity: "炮灰男配",
});
});
it("overlays opening meta 用户角色 when the user switches to that preset", async () => {
const { readSessionProtagonist, overlayPlayPersona } = await import(
"../src/persona/protagonist-bind.js"
);
const {
OPENING_PERSONA_TAG,
OPENING_PERSONA_CHOICE_TAG,
} = await import("../src/skills/opening-seal.js");
const { listPersonas } = await import("../src/persona/store.js");
const board = new Blackboard();
board.write({
tag: OPENING_PERSONA_TAG,
content: JSON.stringify({
名字: "沈聿",
简介: "本地放贷人;与这对母女相识但不熟。",
}),
source: "runtime",
});
board.write({
tag: OPENING_PERSONA_CHOICE_TAG,
content: "opening",
source: "user",
});
const before = listPersonas().map((p) => p.name);
const view = readSessionProtagonist(board);
expect(view?.name).toBe("沈聿");
expect(view?.description).toMatch(/放贷人/);
const play = overlayPlayPersona("play", board);
expect(play?.name).toBe("沈聿");
expect(play?.description).toMatch(/放贷人/);
expect(listPersonas().map((p) => p.name)).toEqual(before);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import type { ChatMessage, ToolDefinition } from "../src/llm/client.js";
import {
describeRequestAwaiting,
formatLlmAbortLine,
formatLlmFailLine,
formatLlmFinishLine,
formatLlmSendLine,
} from "../src/llm/request-log.js";
const insertTool: ToolDefinition = {
type: "function",
function: {
name: "insert",
description: "write",
parameters: {},
},
};
describe("describeRequestAwaiting", () => {
it("flags first user turn", () => {
const messages: ChatMessage[] = [
{ role: "system", content: "你是对话落盘创作助手" },
{ role: "user", content: "傀儡皇帝恋爱体验……很长一段用户原话" },
];
expect(describeRequestAwaiting(messages)).toBe("待答用户");
});
it("flags digesting insert without repeating content", () => {
const messages: ChatMessage[] = [
{ role: "system", content: "sys" },
{ role: "user", content: "用户原话" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "c1",
type: "function",
function: {
name: "insert",
arguments: "{\"position\":\"设计.美学纲领与交互范式\",\"content\":\"很长正文\"}",
},
},
],
},
{ role: "tool", content: "{\"ok\":true}", tool_call_id: "c1" },
];
expect(describeRequestAwaiting(messages)).toBe("待消化 写入产物");
const line = formatLlmSendLine({
kind: "tools-stream",
caller: "dictate_agent",
label: "对话落盘 第2轮",
messages,
tools: [insertTool],
});
expect(line).toContain("发送 对话落盘 第2轮 · 工具流式");
expect(line).toContain("待消化 写入产物");
expect(line).toContain("条=系统1+用户1+助手1+工具1");
expect(line).toContain("可调 写入产物");
expect(line).not.toContain("很长正文");
expect(line).not.toContain("用户原话");
});
});
describe("formatLlmSendLine", () => {
it("summarizes a dictate first round", () => {
const line = formatLlmSendLine({
kind: "tools-stream",
caller: "dictate_agent",
label: "对话落盘 第1轮",
messages: [
{ role: "system", content: "x".repeat(12000) },
{ role: "user", content: "写长文" },
],
tools: [insertTool],
});
expect(line).toBe(
"发送 对话落盘 第1轮 · 工具流式 待答用户 条=系统1+用户1 可调 写入产物 上下文≈1.2万字",
);
});
});
describe("formatLlmFinishLine", () => {
it("reports completion without dumping body", () => {
const line = formatLlmFinishLine(
{
kind: "tools-stream",
caller: "dictate_agent",
label: "对话落盘 第1轮",
messages: [],
},
7306,
{
content: "短确认",
reasoning: "",
model: "deepseek-v4-pro",
toolCalls: [{ id: "1", name: "insert", arguments: "SECRET_BODY" }],
usage: { promptTokens: 14385, completionTokens: 413, totalTokens: 14798 },
},
);
expect(line).toContain("完成 对话落盘 第1轮 · 工具流式");
expect(line).toContain("7306毫秒");
expect(line).toContain("模型=deepseek-v4-pro");
expect(line).toContain("调用=写入产物");
expect(line).toContain("用量=14385+413");
expect(line).not.toContain("SECRET_BODY");
expect(line).not.toContain("短确认");
});
});
describe("abort and fail lines", () => {
it("marks user abort separately from failure", () => {
expect(
formatLlmAbortLine(
{ kind: "tools-stream", caller: "dictate_agent", label: "对话落盘 第1轮" },
2100,
),
).toBe("中断 对话落盘 第1轮 · 工具流式 2100毫秒");
expect(
formatLlmFailLine(
{ kind: "stream", caller: "worker:design-step", label: "设计监控栏" },
10787,
new Error("fetch failed"),
),
).toBe("失败 执行单元 design-step · 设计监控栏 · 流式 10787毫秒 fetch failed");
});
});
+28
View File
@@ -48,4 +48,32 @@ describe("table-cells", () => {
expect(skipped[0]?.reason).toContain("rev-conflict");
expect(doc.rows.find((r) => r.key === "好感")?.value).toBe(10);
});
it("user patches require matching rev and skip hidden cells", async () => {
const { patchUserTableCells } = await import("../src/blackboard/table-cells.js");
const current = createTableFromValues(
{ 口粮: 1, 暗线: "未揭" },
"worker:a",
{ 口粮: { visibility: "visible" }, 暗线: { visibility: "hidden" } },
);
current.rows.find((r) => r.key === "口粮")!.rev = 2;
const { applied, skipped, doc } = patchUserTableCells(current, [
{ key: "口粮", value: 3, expectedRev: 1 },
{ key: "暗线", value: "揭了", expectedRev: 1 },
]);
expect(applied).toEqual([]);
expect(skipped.some((s) => s.key === "口粮" && s.reason.includes("rev"))).toBe(
true,
);
expect(skipped.some((s) => s.key === "暗线" && s.reason === "not-editable")).toBe(
true,
);
const ok = patchUserTableCells(current, [
{ key: "口粮", value: 3, expectedRev: 2 },
]);
expect(ok.applied).toEqual(["口粮"]);
expect(ok.doc.rows.find((r) => r.key === "口粮")?.value).toBe(3);
expect(ok.doc.rows.find((r) => r.key === "口粮")?.rev).toBe(3);
expect(doc.rows.find((r) => r.key === "口粮")?.value).toBe(1);
});
});
+25
View File
@@ -6,6 +6,7 @@ import {
reprojectValueMaps,
serializeValueMapDoc,
upsertValueMapEntry,
removeValueMapEntry,
VALUE_MAP_TAG,
} from "../src/skills/value-map.js";
import {
@@ -13,6 +14,7 @@ import {
seedVariablesFromCatalog,
serializeVariableCatalog,
upsertVariableField,
removeVariableField,
VARIABLE_CATALOG_TAG,
} from "../src/skills/variable-catalog.js";
import { stringifyTableDoc, createTableFromValues, parseTableDoc } from "../src/blackboard/table-cells.js";
@@ -47,6 +49,15 @@ describe("variable-catalog", () => {
expect(seeded.current).toContain("好感");
expect(seeded.initial).toContain("旧港");
});
it("removes a catalog field", () => {
const a = upsertVariableField(null, { key: "好感", type: "number", initial: 1 });
const b = upsertVariableField(a.doc, { key: "章节", type: "number", initial: 1 });
const { doc, error } = removeVariableField(b.doc, "好感");
expect(error).toBeUndefined();
expect(doc.fields.map((f) => f.key)).toEqual(["章节"]);
expect(removeVariableField(doc, "好感").error).toMatch(/无字段/);
});
});
describe("value-map", () => {
@@ -142,6 +153,20 @@ describe("value-map", () => {
expect(lookupValueMapContent(parsed!.maps[0]!, 1)).toBe("第一章");
expect(lookupValueMapContent(parsed!.maps[0]!, 9)).toBe("未定");
});
it("removes map by id", () => {
const { doc: withMap } = upsertValueMapEntry(null, {
id: "aff",
field: "好感",
target_tag: "上下文.角色态度",
bands: [{ min: 0, content: "冷" }],
});
const { doc, removed, error } = removeValueMapEntry(withMap, "aff");
expect(error).toBeUndefined();
expect(removed?.id).toBe("aff");
expect(doc.maps).toHaveLength(0);
expect(removeValueMapEntry(doc, "aff").error).toMatch(/无 id/);
});
});
describe("catalog tag constants", () => {