把正文组成收成数据契约,程序投影与旁观维护不再靠模型临场猜。
有变量真值时固定开旁观填表,开场与游玩只填模型生成区。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
sanitizeReasoningEffort,
|
||||
sanitizeVerbosity,
|
||||
} from "../src/llm/generation-compat.js";
|
||||
import { buildRequestBody } from "../src/llm/client.js";
|
||||
import { buildRequestBody, extractMessageParts, openRouterSessionId } from "../src/llm/client.js";
|
||||
import type { LlmConfig } from "../src/config/env.js";
|
||||
|
||||
const stGeneration = {
|
||||
@@ -410,6 +410,24 @@ describe("fetchWithGenerationCompat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageParts", () => {
|
||||
it("keeps reply_user when arguments are an object and id is missing", () => {
|
||||
const parts = extractMessageParts({
|
||||
content: [{ type: "text", text: "同一次请求里的说明" }],
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "reply_user", arguments: { text: "形成了气氛" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parts.toolCalls).toEqual([
|
||||
{ id: "call-0", name: "reply_user", arguments: JSON.stringify({ text: "形成了气氛" }) },
|
||||
]);
|
||||
expect(parts.content).toBe("同一次请求里的说明");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRequestBody", () => {
|
||||
const messages = [{ role: "user" as const, content: "hi" }];
|
||||
|
||||
@@ -597,6 +615,24 @@ describe("buildRequestBody", () => {
|
||||
expect(body.messages).toBe(messages);
|
||||
});
|
||||
|
||||
it("writes session_id only for OpenRouter", () => {
|
||||
const openrouter: LlmConfig = {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "k",
|
||||
model: "google/gemini-3.8-flash",
|
||||
};
|
||||
const direct: LlmConfig = {
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
apiKey: "k",
|
||||
model: "deepseek-v4-pro",
|
||||
};
|
||||
const id = "9b76dcb6-0703-4b0d-ba75-faa985b532b4";
|
||||
expect(buildRequestBody(openrouter, messages, { sessionId: id }).session_id).toBe(id);
|
||||
expect(buildRequestBody(direct, messages, { sessionId: id }).session_id).toBeUndefined();
|
||||
expect(buildRequestBody(openrouter, messages, {}).session_id).toBeUndefined();
|
||||
expect(openRouterSessionId(openrouter.baseUrl, ` ${"a".repeat(300)} `)).toHaveLength(256);
|
||||
});
|
||||
|
||||
it("does not forward top_k from an imported ST preset", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import {
|
||||
applyDictatePlayBind,
|
||||
bindDictateProductsToPlaySpec,
|
||||
} from "../src/dictate/play-bind.js";
|
||||
import {
|
||||
applyMaintenanceStrategyToPlaySlots,
|
||||
normalizeMaintenanceAdvice,
|
||||
parseMaintenanceAdviceFromVariableDesign,
|
||||
resolveMaintenanceStrategyFromBoard,
|
||||
strategyFromAdvice,
|
||||
MAINTENANCE_STRATEGY_TAG,
|
||||
} from "../src/skills/maintenance-strategy.js";
|
||||
import { defaultPlaySlots } from "../src/skills/play-slots.js";
|
||||
import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js";
|
||||
import { VARIABLE_DESIGN_TAG } from "../src/skills/variable-design-compile.js";
|
||||
|
||||
function designWithAdvice(advice: string) {
|
||||
return JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "变量设计与更新规则",
|
||||
正文: {
|
||||
真值: [
|
||||
{
|
||||
id: "affection",
|
||||
名称: "好感",
|
||||
类型: "number",
|
||||
初始化: { 默认值: 0 },
|
||||
公开性: "public",
|
||||
},
|
||||
],
|
||||
维护复杂度: {
|
||||
建议: advice,
|
||||
依据: ["测试依据"],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("maintenance-strategy", () => {
|
||||
it("normalizes advice aliases", () => {
|
||||
expect(normalizeMaintenanceAdvice("auditor_bundle")).toBe("auditor_bundle");
|
||||
expect(normalizeMaintenanceAdvice("旁观")).toBe("auditor_bundle");
|
||||
expect(normalizeMaintenanceAdvice("inline_tool")).toBe("inline_tool");
|
||||
expect(normalizeMaintenanceAdvice("内联")).toBe("inline_tool");
|
||||
expect(normalizeMaintenanceAdvice("inline_structured")).toBe(
|
||||
"inline_structured",
|
||||
);
|
||||
expect(normalizeMaintenanceAdvice("none")).toBe("none");
|
||||
});
|
||||
|
||||
it("parses variable-design maintenance box", () => {
|
||||
const parsed = parseMaintenanceAdviceFromVariableDesign(
|
||||
designWithAdvice("auditor_bundle"),
|
||||
);
|
||||
expect(parsed?.advice).toBe("auditor_bundle");
|
||||
expect(parsed?.依据).toContain("测试依据");
|
||||
});
|
||||
|
||||
it("defaults to auditor_bundle when truths exist but advice missing", () => {
|
||||
const raw = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: { 真值: [{ id: "a", 类型: "number", 初始化: { 默认值: 1 } }] },
|
||||
});
|
||||
expect(parseMaintenanceAdviceFromVariableDesign(raw)?.advice).toBe(
|
||||
"auditor_bundle",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps advice to auditor/inline flags at strategyFromAdvice level", () => {
|
||||
expect(strategyFromAdvice("auditor_bundle")).toMatchObject({
|
||||
auditor: true,
|
||||
inline: false,
|
||||
});
|
||||
expect(strategyFromAdvice("inline_tool")).toMatchObject({
|
||||
auditor: false,
|
||||
inline: true,
|
||||
});
|
||||
expect(strategyFromAdvice("none")).toMatchObject({
|
||||
auditor: false,
|
||||
inline: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("forces auditor_bundle when truths exist even if design said inline", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: VARIABLE_DESIGN_TAG,
|
||||
content: designWithAdvice("inline_tool"),
|
||||
source: "test",
|
||||
});
|
||||
const strategy = resolveMaintenanceStrategyFromBoard(board);
|
||||
expect(strategy.advice).toBe("auditor_bundle");
|
||||
expect(strategy.auditor).toBe(true);
|
||||
expect(strategy.inline).toBe(false);
|
||||
expect(strategy.依据?.some((x) => x.includes("inline_tool"))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps auditor off when there are no truths", () => {
|
||||
const board = new Blackboard();
|
||||
const strategy = resolveMaintenanceStrategyFromBoard(board);
|
||||
expect(strategy.advice).toBe("none");
|
||||
expect(strategy.auditor).toBe(false);
|
||||
});
|
||||
|
||||
it("overrides play_slots.auditor from strategy", () => {
|
||||
const slots = applyMaintenanceStrategyToPlaySlots(defaultPlaySlots(), {
|
||||
schema: "maintenance-strategy.v1",
|
||||
advice: "auditor_bundle",
|
||||
auditor: true,
|
||||
inline: false,
|
||||
source: VARIABLE_DESIGN_TAG,
|
||||
});
|
||||
expect(slots.auditor).toBe(true);
|
||||
expect(slots.gm).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("play-bind applies maintenance strategy", () => {
|
||||
it("turns auditor on for auditor_bundle", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: VARIABLE_DESIGN_TAG,
|
||||
content: designWithAdvice("auditor_bundle"),
|
||||
source: "test",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.世界蓝图",
|
||||
content: "舞台",
|
||||
source: "test",
|
||||
});
|
||||
const result = bindDictateProductsToPlaySpec(board);
|
||||
const parsed = parseWorkerSetYaml(result.workerSetJson);
|
||||
expect(parsed?.play_slots?.auditor).toBe(true);
|
||||
const strategy = resolveMaintenanceStrategyFromBoard(board);
|
||||
expect(strategy.advice).toBe("auditor_bundle");
|
||||
});
|
||||
|
||||
it("still opens auditor when design said inline_tool", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: VARIABLE_DESIGN_TAG,
|
||||
content: designWithAdvice("inline_tool"),
|
||||
source: "test",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.世界蓝图",
|
||||
content: "舞台",
|
||||
source: "test",
|
||||
});
|
||||
applyDictatePlayBind(board);
|
||||
const worker = parseWorkerSetYaml(
|
||||
board.getContentByTag("设计.worker集") ?? "",
|
||||
);
|
||||
expect(worker?.play_slots?.auditor).toBe(true);
|
||||
const strategyRaw = board.getContentByTag(MAINTENANCE_STRATEGY_TAG);
|
||||
expect(strategyRaw).toContain("auditor_bundle");
|
||||
expect(strategyRaw).toContain('"auditor": true');
|
||||
expect(strategyRaw).toContain("inline_tool");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatTableValue, parseTableDraft } from "../web/play-aux.js";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { stringifyTableDoc, createTableFromValues } from "../src/blackboard/table-cells.js";
|
||||
import { buildPlayAuxView } from "../src/skills/play-aux.js";
|
||||
@@ -72,6 +73,8 @@ describe("play-aux view", () => {
|
||||
expect(view.openings).toHaveLength(1);
|
||||
expect(view.openings[0]?.text).toBe("@玩家站在债契前,堂屋很静。");
|
||||
expect(view.openings[0]?.text).not.toContain("present.v1");
|
||||
expect(view.openings[0]?.document).toContain("present.v1");
|
||||
expect(view.openings[0]?.document).toContain("@玩家站在债契前,堂屋很静。");
|
||||
});
|
||||
|
||||
it("lists dictate 设计.开场白#slot openings with same-slot 主角设定", () => {
|
||||
@@ -109,6 +112,45 @@ describe("play-aux view", () => {
|
||||
expect(gate?.persona?.name).toBe("阿泽");
|
||||
});
|
||||
|
||||
it("prefers slotted 设计.开场白#场景短码 over the bare card and the legacy bundle", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
tag: "设计.开场白",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
shell: "prose",
|
||||
blocks: { body: "这张没有短码。" },
|
||||
}),
|
||||
source: "agent",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.开场白#opening-cycle2",
|
||||
content: JSON.stringify({
|
||||
schema: "present.v1",
|
||||
shell: "status_card",
|
||||
blocks: { status_card: "天数|阶段" },
|
||||
}),
|
||||
source: "agent",
|
||||
});
|
||||
board.write({
|
||||
tag: "设计.开场白与开场变量",
|
||||
content: JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "开场白与开场变量",
|
||||
正文: { 开场白全文: "@玩家站在债契前。" },
|
||||
}),
|
||||
source: "agent",
|
||||
});
|
||||
const view = buildPlayAuxView(board);
|
||||
expect(view.openings).toHaveLength(1);
|
||||
expect(view.openings[0]?.tag).toBe("设计.开场白#opening-cycle2");
|
||||
expect(view.openings[0]?.slot).toBe("opening-cycle2");
|
||||
expect(view.openings[0]?.document).toContain("status_card");
|
||||
expect(view.openings[0]?.document).toContain("天数|阶段");
|
||||
// 无 body 时预览回退整包;选用仍走 document,不会压丢 status_card
|
||||
expect(view.openings[0]?.document).toBe(view.openings[0]?.text);
|
||||
});
|
||||
|
||||
it("lists proliferated zero-layer openings for play swipe", () => {
|
||||
const board = new Blackboard();
|
||||
board.write({
|
||||
@@ -126,3 +168,38 @@ describe("play-aux view", () => {
|
||||
expect(view.selectedOpeningIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("play table drafts", () => {
|
||||
it("keeps numbers, booleans, and JSON values in their original types", () => {
|
||||
expect(parseTableDraft(1, "12")).toEqual({ ok: true, value: 12 });
|
||||
expect(parseTableDraft(1, "nope").ok).toBe(false);
|
||||
expect(parseTableDraft(false, "是")).toEqual({ ok: true, value: true });
|
||||
expect(parseTableDraft(["基础侦察术"], '["基础侦察术","追踪"]')).toEqual({
|
||||
ok: true,
|
||||
value: ["基础侦察术", "追踪"],
|
||||
});
|
||||
expect(parseTableDraft(["基础侦察术"], "[")).toEqual({ ok: false, error: "JSON 不合法" });
|
||||
expect(parseTableDraft("空", " 还在 ")).toEqual({ ok: true, value: "还在" });
|
||||
});
|
||||
|
||||
it("pretty-prints long structured values and keeps short ones on one line", () => {
|
||||
expect(formatTableValue(["基础侦察术"])).toBe('["基础侦察术"]');
|
||||
const long = { a: "x".repeat(40), b: [1, 2, 3] };
|
||||
expect(formatTableValue(long)).toContain("\n");
|
||||
expect(formatTableValue(10)).toBe("10");
|
||||
});
|
||||
|
||||
it("parses JSON strings so nested matrices are editable as objects", () => {
|
||||
const matrix =
|
||||
'{"塞琳娜":{"认知度":"有所耳闻","亲密度":-10},"崔岚":{"认知度":"有所耳闻","亲密度":-10}}';
|
||||
expect(formatTableValue(matrix)).toContain("\n");
|
||||
expect(formatTableValue(matrix)).toContain("塞琳娜");
|
||||
expect(parseTableDraft(matrix, formatTableValue(matrix))).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
塞琳娜: { 认知度: "有所耳闻", 亲密度: -10 },
|
||||
崔岚: { 认知度: "有所耳闻", 亲密度: -10 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+148
-1
@@ -4,6 +4,7 @@ import {
|
||||
PRESENT_FRAME_SANDBOX,
|
||||
buildPresentSrcdoc,
|
||||
isFrontendBundle,
|
||||
presentFrameSlotHtml,
|
||||
presentSourceBytes,
|
||||
previewPacket,
|
||||
} from "../web/present-frame.js";
|
||||
@@ -24,7 +25,8 @@ describe("present frame srcdoc", () => {
|
||||
});
|
||||
expect(srcdoc).toContain(`<div id="app"></div>`);
|
||||
expect(srcdoc).toContain(`#app { color: #e1e7f0; }`);
|
||||
expect(srcdoc).toContain("present.onData");
|
||||
expect(srcdoc).toContain("present-host");
|
||||
expect(srcdoc).toContain("applyPacket");
|
||||
expect(srcdoc).toContain("示例正文");
|
||||
expect(srcdoc).toContain(`default-src 'none'`);
|
||||
expect(srcdoc.indexOf("window.present")).toBeLessThan(srcdoc.indexOf("data.blocks.body"));
|
||||
@@ -54,6 +56,112 @@ describe("present frame srcdoc", () => {
|
||||
expect(PRESENT_BYTE_LIMIT).toBe(60 * 1024);
|
||||
});
|
||||
|
||||
it("sizes the frame to content and fills contract slots", () => {
|
||||
const srcdoc = buildPresentSrcdoc({
|
||||
html: `<article data-block="body"></article>`,
|
||||
css: `html,body{height:100vh;overflow:auto}`,
|
||||
js: `present.onData(function () {});`,
|
||||
packet: { schema: "present.v1", blocks: { body: "正文", forum_feed: ["一帖"] } },
|
||||
slots: [
|
||||
{ id: "body", type: "markdown" },
|
||||
{ id: "forum_feed", type: "list" },
|
||||
],
|
||||
frameId: "pf3",
|
||||
});
|
||||
expect(srcdoc).not.toContain("100vh");
|
||||
expect(srcdoc).not.toContain("overflow:auto");
|
||||
expect(srcdoc).toContain("overflow:hidden!important");
|
||||
expect(srcdoc).toContain("fillSlots");
|
||||
expect(srcdoc).toContain("isProgramOwned");
|
||||
expect(srcdoc).toContain("forum_feed");
|
||||
expect(srcdoc).toContain('"type":"list"');
|
||||
});
|
||||
|
||||
it("turns markdown newlines into paragraphs even if the shell wrote textContent first", () => {
|
||||
const srcdoc = buildPresentSrcdoc({
|
||||
html: `<article id="body" data-block="body"></article>`,
|
||||
css: "",
|
||||
js: `present.onData(function (data) {
|
||||
document.getElementById("body").textContent = data.blocks.body;
|
||||
});`,
|
||||
packet: {
|
||||
schema: "present.v1",
|
||||
blocks: { body: "第一段\\n\\n第二段 **加粗**" },
|
||||
},
|
||||
slots: [{ id: "body", type: "markdown" }],
|
||||
frameId: "pf4",
|
||||
});
|
||||
// packet JSON keeps real newlines; template sample above used escaped for the test string —
|
||||
// rebuild with actual newlines:
|
||||
const withBreaks = buildPresentSrcdoc({
|
||||
html: `<article id="body" data-block="body"></article>`,
|
||||
css: "",
|
||||
js: `present.onData(function (data) {
|
||||
document.getElementById("body").textContent = data.blocks.body;
|
||||
});`,
|
||||
packet: {
|
||||
schema: "present.v1",
|
||||
blocks: { body: "第一段\n\n第二段 **加粗**" },
|
||||
},
|
||||
slots: [{ id: "body", type: "markdown" }],
|
||||
frameId: "pf4b",
|
||||
});
|
||||
const match = withBreaks.match(/function markdown\(raw\) \{[\s\S]*?\n \}/);
|
||||
expect(match).toBeTruthy();
|
||||
const markdown = new Function(`${match![0]}; return markdown;`)();
|
||||
const html = markdown("第一段\n\n第二段 **加粗**");
|
||||
expect(html).toContain("<p>第一段</p>");
|
||||
expect(html).toContain("<p>第二段 <strong>加粗</strong></p>");
|
||||
expect(withBreaks).toContain("isProgramOwned");
|
||||
expect(srcdoc).toContain("isProgramOwned");
|
||||
});
|
||||
|
||||
it("applies program fill after shell onData so textContent cannot keep a wall of text", () => {
|
||||
const srcdoc = buildPresentSrcdoc({
|
||||
html: `<article id="body" data-block="body"></article>`,
|
||||
css: "",
|
||||
js: `present.onData(function (data) {
|
||||
document.getElementById("body").textContent = data.blocks.body;
|
||||
});`,
|
||||
packet: { schema: "present.v1", blocks: { body: "a\\nb" } },
|
||||
slots: [{ id: "body", type: "markdown" }],
|
||||
frameId: "pf5",
|
||||
});
|
||||
const sealIdx = srcdoc.indexOf("present.__seal");
|
||||
const ownedIdx = srcdoc.indexOf("isProgramOwned");
|
||||
expect(ownedIdx).toBeGreaterThan(-1);
|
||||
expect(sealIdx).toBeGreaterThan(-1);
|
||||
// applyPacket: renderCb then fillSlots
|
||||
const applyIdx = srcdoc.indexOf("function applyPacket");
|
||||
const applyChunk = srcdoc.slice(applyIdx, applyIdx + 450);
|
||||
expect(applyChunk.indexOf("renderCb")).toBeLessThan(applyChunk.lastIndexOf("fillSlots"));
|
||||
expect(srcdoc).toContain('t === "kv" || t === "table" || t === "topology"');
|
||||
});
|
||||
|
||||
it("renders list objects by text/label instead of JSON.stringify", () => {
|
||||
const srcdoc = buildPresentSrcdoc({
|
||||
html: `<ol data-block="options"></ol>`,
|
||||
css: "",
|
||||
js: `present.onData(function () {});`,
|
||||
packet: { schema: "present.v1", blocks: { options: [] } },
|
||||
slots: [{ id: "options", type: "list" }],
|
||||
frameId: "pf6",
|
||||
});
|
||||
const match = srcdoc.match(/function listItemText\(item\) \{[\s\S]*?\n \}/);
|
||||
expect(match).toBeTruthy();
|
||||
const listItemText = new Function(`${match![0]}; return listItemText;`)();
|
||||
expect(listItemText({ type: "继续推进1", text: "顺着旧话试探" })).toBe("顺着旧话试探");
|
||||
expect(listItemText({ label: "换方向" })).toBe("换方向");
|
||||
expect(listItemText("纯字符串")).toBe("纯字符串");
|
||||
expect(listItemText('{"type":"x","text":"从字符串JSON取出"}')).toBe("从字符串JSON取出");
|
||||
});
|
||||
|
||||
it("keeps the previous frame height so a remount does not collapse the page", () => {
|
||||
expect(presentFrameSlotHtml("pf9", 1280)).toContain('style="min-height:1280px"');
|
||||
expect(presentFrameSlotHtml("pf9")).not.toContain("min-height");
|
||||
expect(presentFrameSlotHtml("pf9", 4)).not.toContain("min-height");
|
||||
});
|
||||
|
||||
it("normalizes sample data into present.v1 blocks", () => {
|
||||
expect(previewPacket({ schema: "present.v1", blocks: { body: "x" } })).toEqual({
|
||||
schema: "present.v1",
|
||||
@@ -61,4 +169,43 @@ describe("present frame srcdoc", () => {
|
||||
});
|
||||
expect(previewPacket(null)).toEqual({ schema: "present.v1", blocks: {} });
|
||||
});
|
||||
|
||||
it("accepts Chinese block ids and expands nested matrix JSON into a table", () => {
|
||||
const srcdoc = buildPresentSrcdoc({
|
||||
html: `<section data-block="卷宗表"></section><article data-block="正文"></article>`,
|
||||
css: "",
|
||||
js: "present.onData(function () {});",
|
||||
packet: {
|
||||
schema: "present.v1",
|
||||
blocks: {
|
||||
卷宗表: {
|
||||
source: "heroines_matrix",
|
||||
rows: [
|
||||
{ name: "塞琳娜", 亲密度: -10, 服从度: 10 },
|
||||
{ name: "崔岚", 亲密度: -10, 服从度: 10 },
|
||||
],
|
||||
},
|
||||
正文: "第一段\n\n第二段",
|
||||
},
|
||||
},
|
||||
slots: [
|
||||
{ id: "卷宗表", type: "table" },
|
||||
{ id: "正文", type: "markdown" },
|
||||
],
|
||||
frameId: "pf-zh",
|
||||
});
|
||||
expect(srcdoc).toContain("function isBlockId");
|
||||
expect(srcdoc).toContain("present-data-table");
|
||||
expect(srcdoc).toContain("matrixMapToRows");
|
||||
expect(srcdoc).not.toContain("/^[A-Za-z_]");
|
||||
|
||||
const writeMatch = srcdoc.match(/function writeDataTable\(el, rows, columns\) \{[\s\S]*?\n \}/);
|
||||
expect(writeMatch).toBeTruthy();
|
||||
const idMatch = srcdoc.match(/function isBlockId\(id\) \{[\s\S]*?\n \}/);
|
||||
expect(idMatch).toBeTruthy();
|
||||
const isBlockId = new Function(`${idMatch![0]}; return isBlockId;`)();
|
||||
expect(isBlockId("正文")).toBe(true);
|
||||
expect(isBlockId("body")).toBe(true);
|
||||
expect(isBlockId('bad"id')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,39 @@ describe("protagonist overlay", () => {
|
||||
expect(protagonistTagForOpeningSlot("设计.开场白")).toBe(PROTAGONIST_TAG);
|
||||
});
|
||||
|
||||
it("binds present.v1 meta.用户角色 to the slotted card and follows opening rewrites", async () => {
|
||||
const {
|
||||
ensureProtagonistFromPersona,
|
||||
personaBoundToOpening,
|
||||
protagonistTagForOpeningSlot,
|
||||
} = await import("../src/persona/protagonist-bind.js");
|
||||
const { splitOpeningDocument } = await import("../src/skills/opening-seal.js");
|
||||
|
||||
const openingTag = "设计.开场白#debt-hall";
|
||||
const card = (简介: string) =>
|
||||
JSON.stringify({
|
||||
schema: "present.v1",
|
||||
blocks: { body: "清晨,@玩家推开堂屋门。" },
|
||||
meta: { 用户角色: { 名字: "沈聿", 简介 } },
|
||||
});
|
||||
|
||||
const board = new Blackboard();
|
||||
const personaTag = protagonistTagForOpeningSlot(openingTag);
|
||||
for (const 简介 of ["本地放贷人。", "持有这家债契的放贷人。"]) {
|
||||
const content = card(简介);
|
||||
const split = splitOpeningDocument(content);
|
||||
expect(split.persona?.name).toBe("沈聿");
|
||||
expect(split.text).toBe(content);
|
||||
ensureProtagonistFromPersona(board, split.persona, personaTag, {
|
||||
overwrite: true,
|
||||
});
|
||||
board.write({ tag: openingTag, content, source: "agent" });
|
||||
expect(personaBoundToOpening(board, openingTag, content)?.description).toContain(
|
||||
简介,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("overlays opening meta 用户角色 when the user switches to that preset", async () => {
|
||||
const { readSessionProtagonist, overlayPlayPersona } = await import(
|
||||
"../src/persona/protagonist-bind.js"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePresentPacket } from "../src/skills/present-packet.js";
|
||||
import {
|
||||
openingContractError,
|
||||
parseReplyDataContract,
|
||||
presentSchemaFromContract,
|
||||
} from "../src/skills/reply-contract.js";
|
||||
|
||||
const replyFormat = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
数据契约: {
|
||||
schema: "present.v1",
|
||||
blocks: {
|
||||
status_card: { 类型: "kv", 来源: "program_projection", 职责: "属性" },
|
||||
body: { 类型: "markdown", 来源: "model_generated", 职责: "主读正文" },
|
||||
forum_feed: { 类型: "list", 来源: "model_generated", 职责: "论坛" },
|
||||
danmu_stream: { 类型: "list", 来源: "model_generated", 职责: "弹幕" },
|
||||
},
|
||||
},
|
||||
frontend: { html: `<div id="app"></div>`, css: "body{}", js: "present.onData(function(){})" },
|
||||
},
|
||||
});
|
||||
|
||||
describe("reply data contract", () => {
|
||||
it("keeps model blocks and drops program projections from the fill schema", () => {
|
||||
const contract = parseReplyDataContract(replyFormat);
|
||||
expect(contract?.modelBlocks.map((block) => block.id)).toEqual([
|
||||
"body",
|
||||
"forum_feed",
|
||||
"danmu_stream",
|
||||
]);
|
||||
expect(contract?.programBlocks.map((block) => block.id)).toEqual(["status_card"]);
|
||||
expect(contract?.frontend?.html).toContain("app");
|
||||
const built = presentSchemaFromContract(contract!);
|
||||
const blocks = (
|
||||
built.schema.properties as { blocks: { required: string[] } }
|
||||
).blocks;
|
||||
expect(blocks.required).toEqual(["body", "forum_feed", "danmu_stream"]);
|
||||
expect(built.loose).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an old shell packet that only fills body", () => {
|
||||
const contract = parseReplyDataContract(replyFormat)!;
|
||||
const error = openingContractError(
|
||||
JSON.stringify({
|
||||
schema: "present.v1",
|
||||
shell: "prose",
|
||||
blocks: { body: "清晨,@玩家推开门。" },
|
||||
}),
|
||||
contract,
|
||||
);
|
||||
expect(error).toContain("forum_feed");
|
||||
expect(error).toContain("danmu_stream");
|
||||
});
|
||||
|
||||
it("accepts present.v1 that fills every model block", () => {
|
||||
const contract = parseReplyDataContract(replyFormat)!;
|
||||
expect(
|
||||
openingContractError(
|
||||
JSON.stringify({
|
||||
schema: "present.v1",
|
||||
blocks: {
|
||||
body: "清晨,@玩家推开门。",
|
||||
forum_feed: ["有人愿意修外墙吗?"],
|
||||
danmu_stream: [],
|
||||
},
|
||||
}),
|
||||
contract,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves contract blocks when normalizing a present packet", () => {
|
||||
const view = parsePresentPacket(
|
||||
JSON.stringify({
|
||||
schema: "present.v1",
|
||||
blocks: {
|
||||
body: "正文",
|
||||
forum_feed: ["一帖"],
|
||||
danmu_stream: ["一条"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(view.packet.blocks.forum_feed).toEqual(["一帖"]);
|
||||
expect(view.packet.blocks.danmu_stream).toEqual(["一条"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
catalogFieldsFromVariableDesign,
|
||||
mergeCatalogFromVariableDesign,
|
||||
syncVariableDesignToRuntime,
|
||||
VARIABLE_DESIGN_TAG,
|
||||
} from "../src/skills/variable-design-compile.js";
|
||||
import {
|
||||
parseVariableCatalog,
|
||||
VARIABLE_CATALOG_TAG,
|
||||
} from "../src/skills/variable-catalog.js";
|
||||
import {
|
||||
mergePresentWithProjections,
|
||||
parseStatusBarProjectionBindings,
|
||||
projectPresentBlocks,
|
||||
} from "../src/skills/present-project.js";
|
||||
import { parseReplyDataContract } from "../src/skills/reply-contract.js";
|
||||
import { parsePresentPacket } from "../src/skills/present-packet.js";
|
||||
import { createTableFromValues, stringifyTableDoc } from "../src/blackboard/table-cells.js";
|
||||
|
||||
const designRaw = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
技能: "变量设计与更新规则",
|
||||
正文: {
|
||||
真值: [
|
||||
{
|
||||
id: "current_target_name",
|
||||
名称: "当前目标",
|
||||
类型: "string",
|
||||
初始化: { 默认值: "沈清欢" },
|
||||
公开性: "public",
|
||||
},
|
||||
{
|
||||
id: "target_dignity",
|
||||
名称: "尊严",
|
||||
类型: "number",
|
||||
初始化: { 默认值: 100 },
|
||||
公开性: "public",
|
||||
},
|
||||
{
|
||||
id: "secret_flag",
|
||||
名称: "隐藏标记",
|
||||
类型: "boolean",
|
||||
初始化: { 默认值: false },
|
||||
公开性: "hidden",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe("variable-design-compile", () => {
|
||||
it("parses truths into catalog fields", () => {
|
||||
const fields = catalogFieldsFromVariableDesign(designRaw);
|
||||
expect(fields.map((f) => f.key)).toEqual([
|
||||
"current_target_name",
|
||||
"target_dignity",
|
||||
"secret_flag",
|
||||
]);
|
||||
expect(fields.find((f) => f.key === "target_dignity")?.initial).toBe(100);
|
||||
expect(fields.find((f) => f.key === "secret_flag")?.user_visible).toBe(false);
|
||||
});
|
||||
|
||||
it("merges into an existing catalog without dropping declare fields", () => {
|
||||
const existing = parseVariableCatalog(
|
||||
JSON.stringify({
|
||||
schema: "variable-catalog.v1",
|
||||
fields: [
|
||||
{
|
||||
key: "extra_declared",
|
||||
type: "number",
|
||||
initial: 1,
|
||||
user_visible: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const merged = mergeCatalogFromVariableDesign(existing, designRaw)!;
|
||||
expect(merged.doc.fields.map((f) => f.key).sort()).toEqual([
|
||||
"current_target_name",
|
||||
"extra_declared",
|
||||
"secret_flag",
|
||||
"target_dignity",
|
||||
].sort());
|
||||
expect(merged.added).toEqual(
|
||||
expect.arrayContaining(["current_target_name", "target_dignity", "secret_flag"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs design → catalog → 变量.当前", () => {
|
||||
const sync = syncVariableDesignToRuntime({
|
||||
designRaw,
|
||||
catalogRaw: null,
|
||||
currentRaw: null,
|
||||
initialRaw: null,
|
||||
});
|
||||
expect(sync.catalog).toContain("current_target_name");
|
||||
expect(sync.current).toContain("沈清欢");
|
||||
expect(sync.initial).toContain("target_dignity");
|
||||
expect(sync.seededKeys).toEqual(
|
||||
expect.arrayContaining(["current_target_name", "target_dignity", "secret_flag"]),
|
||||
);
|
||||
expect(VARIABLE_DESIGN_TAG).toBe("设计.变量设计与更新规则");
|
||||
expect(VARIABLE_CATALOG_TAG).toBe("设计.变量目录");
|
||||
});
|
||||
|
||||
it("does not overwrite existing current cells when reseeding", () => {
|
||||
const first = syncVariableDesignToRuntime({
|
||||
designRaw,
|
||||
catalogRaw: null,
|
||||
currentRaw: null,
|
||||
initialRaw: null,
|
||||
});
|
||||
const patched = createTableFromValues(
|
||||
{ current_target_name: "已改名", target_dignity: 100, secret_flag: false },
|
||||
"user",
|
||||
);
|
||||
const second = syncVariableDesignToRuntime({
|
||||
designRaw,
|
||||
catalogRaw: first.catalog,
|
||||
currentRaw: stringifyTableDoc(patched),
|
||||
initialRaw: first.initial,
|
||||
});
|
||||
expect(second.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("present-project", () => {
|
||||
const replyFormat = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
数据契约: {
|
||||
schema: "present.v1",
|
||||
blocks: {
|
||||
status_card: { 类型: "kv", 来源: "program_projection", 职责: "属性" },
|
||||
body: { 类型: "markdown", 来源: "model_generated", 职责: "正文" },
|
||||
},
|
||||
},
|
||||
frontend: { html: "<div id='app'></div>", css: "", js: "present.onData(function(){})" },
|
||||
},
|
||||
});
|
||||
|
||||
const statusBar = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
语义区域: [
|
||||
{
|
||||
id: "status_card",
|
||||
内容类型: "kv",
|
||||
来源: {
|
||||
方式: "program_projection",
|
||||
ref: "变量.当前",
|
||||
字段: ["current_target_name", "target_dignity"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it("reads projection bindings from status bar", () => {
|
||||
const bindings = parseStatusBarProjectionBindings(statusBar);
|
||||
expect(bindings).toEqual([
|
||||
{
|
||||
id: "status_card",
|
||||
type: "kv",
|
||||
ref: "变量.当前",
|
||||
fields: ["current_target_name", "target_dignity"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects selected fields into program blocks", () => {
|
||||
const contract = parseReplyDataContract(replyFormat)!;
|
||||
const current = stringifyTableDoc(
|
||||
createTableFromValues(
|
||||
{ current_target_name: "沈清欢", target_dignity: 100, other: 1 },
|
||||
"system",
|
||||
),
|
||||
);
|
||||
const projected = projectPresentBlocks({
|
||||
contract,
|
||||
statusBarRaw: statusBar,
|
||||
currentVarsRaw: current,
|
||||
});
|
||||
expect(projected.status_card).toEqual({
|
||||
current_target_name: "沈清欢",
|
||||
target_dignity: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to all current vars when status bar has no binding", () => {
|
||||
const contract = parseReplyDataContract(replyFormat)!;
|
||||
const current = stringifyTableDoc(
|
||||
createTableFromValues({ a: 1, b: "x" }, "system"),
|
||||
);
|
||||
const projected = projectPresentBlocks({
|
||||
contract,
|
||||
currentVarsRaw: current,
|
||||
});
|
||||
expect(projected.status_card).toEqual({ a: 1, b: "x" });
|
||||
});
|
||||
|
||||
it("expands JSON-string matrices into table rows for program projection", () => {
|
||||
const reply = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
数据契约: {
|
||||
schema: "present.v1",
|
||||
blocks: {
|
||||
heroines: { 类型: "table", 来源: "program_projection", 职责: "矩阵" },
|
||||
body: { 类型: "markdown", 来源: "model_generated", 职责: "正文" },
|
||||
},
|
||||
},
|
||||
frontend: { html: "<div></div>", css: "", js: "present.onData(function(){})" },
|
||||
},
|
||||
});
|
||||
const status = JSON.stringify({
|
||||
schema: "context-fragment.v1",
|
||||
正文: {
|
||||
语义区域: [
|
||||
{
|
||||
id: "heroines",
|
||||
内容类型: "table",
|
||||
来源: {
|
||||
方式: "program_projection",
|
||||
ref: "变量.当前",
|
||||
字段: ["heroines_matrix"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const matrix = {
|
||||
塞琳娜: { 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 },
|
||||
崔岚: { 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 },
|
||||
};
|
||||
const current = stringifyTableDoc(
|
||||
createTableFromValues(
|
||||
{ heroines_matrix: JSON.stringify(matrix) },
|
||||
"system",
|
||||
),
|
||||
);
|
||||
const projected = projectPresentBlocks({
|
||||
contract: parseReplyDataContract(reply)!,
|
||||
statusBarRaw: status,
|
||||
currentVarsRaw: current,
|
||||
});
|
||||
expect(projected.heroines).toEqual({
|
||||
source: "heroines_matrix",
|
||||
rows: [
|
||||
{ name: "塞琳娜", 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 },
|
||||
{ name: "崔岚", 认知度: "有所耳闻", 亲密度: -10, 服从度: 10 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges projections into a plain body present packet", () => {
|
||||
const merged = mergePresentWithProjections(
|
||||
"一段正文",
|
||||
{ status_card: { hp: 10 } },
|
||||
"prose",
|
||||
);
|
||||
const view = parsePresentPacket(merged);
|
||||
expect(view.packet.blocks.body).toBe("一段正文");
|
||||
expect(view.packet.blocks.status_card).toEqual({ hp: 10 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user