引入上下文片段、固定槽与投影排序,并完善机遇裁定与游玩期 UI。

把创作产物收敛为可挂载片段与 play_slots/context_order,同步修订世界模拟器模块与运行时拼装。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 01:36:32 +08:00
parent 00dfcb6615
commit 94f67fa744
69 changed files with 7786 additions and 1283 deletions

View File

@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import {
parseContextFragment,
projectFragmentContent,
contextFragmentToView,
extractFragmentAskSidecar,
} from "../src/skills/context-fragment.js";
import {
parseContextOrder,
contextOrderToSegments,
slotOrderForRef,
applyContextOrderEdit,
mergeContextOrderIntoWorkerSetJson,
serializeContextOrder,
} from "../src/skills/context-order.js";
import { assembleWorkerContext } from "../src/skills/context-segments.js";
import { Blackboard } from "../src/blackboard/blackboard.js";
import { buildDeclaredWorkerSkill } from "../src/skills/declared-worker.js";
import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js";
describe("context-fragment.v1", () => {
it("parses public header and projects summary", () => {
const frag = parseContextFragment({
schema: "context-fragment.v1",
: "美学纲领与交互范式",
brief: "特权求生紧张",
mount: ["world-simulator", "narrator"],
: "stable",
: { : { : "紧张" } },
: [],
})!;
expect(frag.).toBe("美学纲领与交互范式");
expect(projectFragmentContent(JSON.stringify(frag), "summary")).toBe(
"特权求生紧张",
);
const view = contextFragmentToView(frag);
expect(view.ok).toBe(true);
expect(view.sections.some((s) => s.title === "概要")).toBe(true);
});
it("extracts 自评 + 追问 into ask sidecar", () => {
const side = extractFragmentAskSidecar({
schema: "context-fragment.v1",
brief: "孤立免疫",
: { : { : "…" } },
: {
: [
{ : "交互范式", 分数: 70, : "权限尚可" },
{ : "美学纲领", 分数: 55, : "边界未锁" },
],
: "暴力尺度未定",
},
: {
: "为贴近质感,还需确认:",
: [
{
: "暴力与血腥如何服务压迫感?",
: ["偶发锋利高光", "常态压抑少见血", "其它"],
: "远处尖叫,近处只留锈迹",
},
],
},
});
expect(side.assessment).toContain("交互范式 70%");
expect(side.assessment).toContain("薄弱点:暴力尺度未定");
expect(side.assessment).toContain("为贴近质感");
expect(side.questions).toHaveLength(1);
expect(side.questions[0]!.prompt).toContain("暴力与血腥");
expect(side.questions[0]!.prompt).toContain("示例:远处尖叫");
expect(side.questions[0]!.options?.map((o) => o.label)).toEqual([
"偶发锋利高光",
"常态压抑少见血",
"其它",
]);
});
});
describe("context-order.v1", () => {
it("maps flat order to segments (history after stable)", () => {
const doc = parseContextOrder({
schema: "context-order.v1",
slots: [
{
ref: "world-simulator",
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "设计.实现机制", projection: "summary" },
{ order: 2, ref: "对话.历史", projection: "summary" },
{ order: 3, ref: "用户.最新输入", projection: "full" },
],
},
],
})!;
const slot = slotOrderForRef(doc, "world-simulator")!;
const segs = contextOrderToSegments({
slot,
personaText: "中立主世界层",
});
expect(segs[0].inline).toContain("中立主世界层");
expect(segs[1].tags).toEqual(["设计.实现机制"]);
expect(segs[2].tags).toEqual(["对话.历史"]);
expect(segs[3].tier).toBe("dynamic");
});
it("reorders flat list and merges into worker set JSON", () => {
const doc = parseContextOrder({
schema: "context-order.v1",
slots: [
{
ref: "narrator",
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "设计.叙事指南", projection: "summary" },
{ order: 2, ref: "设计.美学纲领与交互范式", projection: "summary" },
{ order: 3, ref: "对话.历史", projection: "summary" },
{ order: 4, ref: "运行.本轮.裁决", projection: "full" },
],
},
],
})!;
const moved = applyContextOrderEdit(doc, {
action: "move",
slotRef: "narrator",
index: 2,
delta: -1,
});
expect(moved.slots[0]!.inserts.map((i) => i.ref)).toEqual([
"worker.persona",
"设计.美学纲领与交互范式",
"设计.叙事指南",
"对话.历史",
"运行.本轮.裁决",
]);
const afterHist = applyContextOrderEdit(moved, {
action: "set_anchor",
slotRef: "narrator",
index: 1,
anchor: "post_history",
});
const refs = afterHist.slots[0]!.inserts.map((i) => i.ref);
const histAt = refs.indexOf("对话.历史");
expect(refs.indexOf("设计.美学纲领与交互范式")).toBeGreaterThan(histAt);
const merged = mergeContextOrderIntoWorkerSetJson(
JSON.stringify({
play_slots: { gm: true, narrator: true, perspective: false },
workers: [],
}),
afterHist,
);
const again = JSON.parse(merged);
expect(again.context_order.schema).toBe("context-order.v1");
expect(serializeContextOrder(afterHist)).toContain("对话.历史");
});
it("declared worker prefers context_order segments", () => {
const parsed = parseWorkerSetYaml(
JSON.stringify({
play_slots: { gm: true, narrator: true, perspective: false },
context_order: {
schema: "context-order.v1",
slots: [
{
ref: "world-simulator",
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "对话.历史", projection: "summary" },
{ order: 2, ref: "用户.最新输入", projection: "full" },
],
},
],
},
workers: [],
}),
);
expect(parsed.parseError).toBeUndefined();
const entry = parsed.workers.find((w) => w.ref === "world-simulator")!;
const { worker } = buildDeclaredWorkerSkill({
skillPackName: "world-simulator",
entry,
template: {
id: "world-simulator",
label: "主世界层",
duty: "裁决",
suggested_context: { static: ["设计.worker集"], dynamic: ["用户.最新输入"] },
},
workerSet: parsed,
});
expect(worker.contextSegments?.some((s) => s.inline?.includes("裁决"))).toBe(
true,
);
expect(
worker.contextSegments?.some(
(s) => s.tier === "dynamic" && s.tags.includes("用户.最新输入"),
),
).toBe(true);
const bb = new Blackboard();
bb.write({
tag: "用户.最新输入",
content: "推门进去",
source: "test",
});
const text = assembleWorkerContext({
inputs: { "用户.最新输入": "推门进去" },
segments: worker.contextSegments,
blackboard: bb,
workerId: "world-simulator",
workerName: "主世界层",
outputTags: ["运行.本轮.裁决"],
});
expect(text).toContain("裁决");
expect(text).toContain("推门进去");
});
});

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
appendDialogueHistoryTurn,
projectDialogueHistory,
buildHistoryFallback,
isDialogueHistoryRef,
} from "../src/skills/dialogue-history.js";
import { assembleWorkerContext } from "../src/skills/context-segments.js";
import { Blackboard } from "../src/blackboard/blackboard.js";
import {
contextOrderToSegments,
parseContextOrder,
applyContextOrderEdit,
synthesizeContextOrderFromWorkers,
} from "../src/skills/context-order.js";
describe("dialogue history projection", () => {
it("recognizes history refs and projects by level", () => {
expect(isDialogueHistoryRef("对话.历史")).toBe(true);
expect(isDialogueHistoryRef("history")).toBe(true);
const long = Array.from({ length: 30 }, (_, i) => `## 用户\n\n第${i}`).join(
"\n\n",
);
const summary = projectDialogueHistory(long, "summary");
expect(summary).toContain("仅最近");
expect(projectDialogueHistory(long, "fixed")).toContain("不注入");
});
it("assembles history tag in flat segment order", () => {
const bb = new Blackboard();
bb.write({
tag: "对话.历史",
content: appendDialogueHistoryTurn("", { role: "用户", text: "推门" }),
source: "test",
});
bb.write({ tag: "用户.最新输入", content: "再看一眼", source: "test" });
const text = assembleWorkerContext({
inputs: {},
segments: [
{
id: "p",
tier: "static",
tags: [],
label: "## persona",
inline: "GM",
projection: "fixed",
},
{
id: "h",
tier: "static",
tags: ["对话.历史"],
label: "## 对话.历史",
projection: "full",
},
{
id: "u",
tier: "dynamic",
tags: ["用户.最新输入"],
label: "## 用户.最新输入",
projection: "full",
},
],
blackboard: bb,
workerId: "world-simulator",
workerName: "主世界层",
outputTags: [],
});
const iPersona = text.indexOf("GM");
const iHist = text.indexOf("推门");
const iUser = text.indexOf("再看一眼");
expect(iPersona).toBeGreaterThanOrEqual(0);
expect(iHist).toBeGreaterThan(iPersona);
expect(iUser).toBeGreaterThan(iHist);
});
it("falls back to event stream when history empty", () => {
expect(
buildHistoryFallback({
eventStream: "门开了",
latestUser: "走进去",
}),
).toContain("门开了");
});
});
describe("flat context order", () => {
it("synthesizes history tag and moves across it", () => {
const doc = synthesizeContextOrderFromWorkers(
[
{
ref: "narrator",
name: "叙事转述",
context: {
static: ["设计.叙事指南"],
dynamic: ["运行.本轮.裁决", "用户.最新输入"],
},
},
],
{ gm: true, narrator: true, perspective: false },
)!;
expect(doc.slots[0]!.inserts.some((i) => i.ref === "对话.历史")).toBe(true);
const histIdx = doc.slots[0]!.inserts.findIndex((i) => i.ref === "对话.历史");
const moved = applyContextOrderEdit(doc, {
action: "set_anchor",
slotRef: "narrator",
index: doc.slots[0]!.inserts.length - 1,
anchor: "pre_history",
});
const newHist = moved.slots[0]!.inserts.findIndex((i) => i.ref === "对话.历史");
const lastUser = moved.slots[0]!.inserts.findIndex(
(i) => i.ref === "用户.最新输入",
);
expect(lastUser).toBeLessThan(newHist);
expect(histIdx).toBeGreaterThanOrEqual(0);
});
it("maps to segments in list order including history", () => {
const doc = parseContextOrder({
schema: "context-order.v1",
slots: [
{
ref: "world-simulator",
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "对话.历史", projection: "summary" },
{ order: 2, ref: "用户.最新输入", projection: "full" },
],
},
],
})!;
const segs = contextOrderToSegments({
slot: doc.slots[0]!,
personaText: "裁决",
});
expect(segs[0]!.inline).toContain("裁决");
expect(segs[1]!.tags).toEqual(["对话.历史"]);
expect(segs[2]!.tags).toEqual(["用户.最新输入"]);
});
});

169
tests/play-slots.test.ts Normal file
View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import {
expandPlaySlotsToWorkers,
mergeWorkersWithPlaySlots,
onDemandRefsFromPlaySlots,
parsePlaySlots,
refsFromPlaySlots,
} from "../src/skills/play-slots.js";
import {
deriveOnDemandWorkerScope,
deriveRunWorkerScope,
parseWorkerSetYaml,
} from "../src/skills/worker-set-parse.js";
import { parseSettlementPacket } from "../src/skills/settlement-packet.js";
import {
executeChance,
parseChanceRequest,
} from "../src/skills/chance-tools.js";
describe("play_slots", () => {
it("expands default gm+narrator", () => {
const slots = parsePlaySlots({ gm: true, narrator: true, perspective: false })!;
expect(refsFromPlaySlots(slots)).toEqual(["world-simulator", "narrator"]);
const workers = expandPlaySlotsToWorkers(slots);
expect(workers.map((w) => w.ref)).toEqual(["world-simulator", "narrator"]);
expect(workers[0].acceptance).toBe("continue");
expect(workers[1].acceptance).toBe("review");
});
it("orders perspective before gm", () => {
const slots = parsePlaySlots({
gm: true,
narrator: true,
perspective: true,
})!;
expect(refsFromPlaySlots(slots)).toEqual([
"role-decide",
"world-simulator",
"narrator",
]);
});
it("merges play_slots into worker set JSON", () => {
const parsed = parseWorkerSetYaml(
JSON.stringify({
form_summary: "恋爱升温",
play_slots: { gm: true, narrator: true, perspective: false },
workers: [],
}),
);
expect(parsed?.play_slots?.gm).toBe(true);
expect(parsed?.workers).toHaveLength(2);
expect(deriveRunWorkerScope(parsed)).toEqual([
"world-simulator",
"narrator",
]);
});
it("preserves custom context on merge", () => {
const slots = parsePlaySlots({ gm: true, narrator: true })!;
const merged = mergeWorkersWithPlaySlots(
[
{
ref: "world-simulator",
name: "自定义世界",
context: { dynamic: ["变量.当前"] },
outputs: ["运行.本轮.裁决"],
},
],
slots,
);
expect(merged[0].name).toBe("自定义世界");
expect(merged[0].context?.dynamic).toEqual(["变量.当前"]);
expect(merged[1].ref).toBe("narrator");
});
it("chance is on-demand and not in turn pipeline", () => {
const slots = parsePlaySlots({
gm: true,
narrator: true,
perspective: false,
chance: true,
})!;
expect(refsFromPlaySlots(slots)).toEqual(["world-simulator", "narrator"]);
expect(onDemandRefsFromPlaySlots(slots)).toEqual(["chance"]);
const workers = expandPlaySlotsToWorkers(slots);
expect(workers.map((w) => w.ref)).toEqual([
"world-simulator",
"narrator",
"chance",
]);
expect(workers.find((w) => w.ref === "chance")?.invocation).toBe(
"on_demand",
);
const parsed = parseWorkerSetYaml(
JSON.stringify({
play_slots: {
gm: true,
narrator: true,
perspective: false,
chance: true,
},
workers: [],
}),
);
expect(deriveRunWorkerScope(parsed)).toEqual([
"world-simulator",
"narrator",
]);
expect(deriveOnDemandWorkerScope(parsed)).toEqual(["chance"]);
});
});
describe("chance tools", () => {
it("parses and rolls dice expression", () => {
const req = parseChanceRequest({ op: "roll", expression: "2d6+1" });
expect(req?.op).toBe("roll");
const result = executeChance(req!);
expect(result.ok).toBe(true);
expect(result.detail.total).toBeTypeOf("number");
});
it("compares scores", () => {
const result = executeChance({
op: "compare",
left: 15,
right: 12,
mode: "gte",
});
expect(result.ok).toBe(true);
expect(result.detail.win).toBe(true);
});
it("draws from pool", () => {
const result = executeChance({
op: "draw",
pool: ["甲", "乙", "丙"],
count: 2,
unique: true,
});
expect(result.ok).toBe(true);
expect((result.detail.drawn as string[]).length).toBe(2);
});
});
describe("settlement packet", () => {
it("parses settlement.v1 into sections", () => {
const view = parseSettlementPacket(
JSON.stringify({
schema: "settlement.v1",
player_action: "送画册",
resolved: ["林晚收下"],
visible_now: "短谢,未邀留下",
variable_changes: [{ key: "好感", from: 10, to: 18, delta: 8 }],
do_not_say: ["好感数字"],
}),
);
expect(view.ok).toBe(true);
expect(view.sections.map((s) => s.title)).toContain("玩家行动");
expect(view.sections.map((s) => s.title)).toContain("变量变更");
});
it("treats prose as visible_now fallback", () => {
const view = parseSettlementPacket("林晚点头收下了画册。");
expect(view.ok).toBe(true);
expect(view.packet?.visible_now).toContain("画册");
});
});

View File

@@ -85,4 +85,38 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.outputs["设计.worker集.草稿"]).toContain("narrator");
expect(result.askUser).toBeUndefined();
});
it("lifts fragment 追问/自评 into askUser + askAssessment", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
"设计.美学纲领与交互范式": {
schema: "context-fragment.v1",
brief: "孤立免疫",
: { : { : "特权与惊惶" } },
: {
: [{ : "美学纲领", 分数: 60, : "边界未锁" }],
: "尺度",
},
: {
: "还需确认:",
: [
{
: "血腥如何用?",
: ["少而锋利", "常态压抑"],
: "锈迹即可",
},
],
},
},
},
summary: "美学纲领 · 孤立免疫",
}),
["设计.美学纲领与交互范式"],
);
expect(result.askUser?.length).toBe(1);
expect(result.askUser?.[0]?.options?.length).toBe(2);
expect(result.askAssessment).toContain("美学纲领 60%");
expect(result.askAssessment).toContain("还需确认");
});
});

View File

@@ -47,7 +47,9 @@ describe("formatWorkerSetForUser", () => {
expect(view.headline).toContain("西幻");
expect(view.playModeLabel).toBe("行动–反应循环");
expect(view.workers).toHaveLength(2);
expect(view.workers[0].displayName).toBe("世界模拟");
expect(view.workers[0].displayName).toBe("世界");
expect(view.playSlots?.some((s) => s.id === "gm" && s.enabled)).toBe(true);
expect(view.contextOrder?.slots?.length).toBeGreaterThan(0);
expect(view.creationUnits?.map((u) => u.id)).toEqual(
expect.arrayContaining([
"fixed:aesthetics", // sample 含 narrator.presentation
@@ -121,9 +123,13 @@ workers:
expect(view.interactionParadigm).toContain("旁观");
expect(view.coreWorker).toBe("world-simulator");
expect(view.reasoning).toContain("信息隔绝");
expect(view.workers[0].roleLabel).toBe("核心");
expect(view.workers[0].rationale).toContain("裁决");
expect(view.workers[1].mergeConsidered).toContain("NPC");
// 推断 play_slots 后顺序为 perspective → gm角色用槽位中文名
const gm = view.workers.find((w) => w.id === "world-simulator");
const perspective = view.workers.find((w) => w.id === "role-decide");
expect(gm?.roleLabel).toBe("主世界层");
expect(gm?.rationale).toContain("裁决");
expect(perspective?.roleLabel).toBe("角色视角");
expect(perspective?.mergeConsidered).toContain("NPC");
});
it("merges default context when explicit context omitted", () => {
@@ -134,9 +140,14 @@ workers:
when: 世界机之后
`)!;
const view = formatWorkerSetForUser(parsed)!;
expect(view.workers[0].context.staticTags.some((t) => t.tag.includes("变量.目录"))).toBe(
true,
);
expect(
view.workers[0].context.staticTags.some(
(t) =>
t.tag.includes("变量.目录") ||
t.tag.includes("变量设计") ||
t.tag.includes("设计.变量"),
),
).toBe(true);
expect(view.workers[0].context.explicit).toBe(false);
expect(view.workers[0].writes).toContain("变量.当前");
});