有变量真值时固定开旁观填表,开场与游玩只填模型生成区。 Co-authored-by: Cursor <cursoragent@cursor.com>
275 lines
8.5 KiB
TypeScript
275 lines
8.5 KiB
TypeScript
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("prefers 设计.主角设定 over legacy opening meta", async () => {
|
||
const {
|
||
PROTAGONIST_TAG,
|
||
readSessionProtagonist,
|
||
ensureProtagonistFromPersona,
|
||
protagonistFragmentFromPersona,
|
||
protagonistTagForOpeningSlot,
|
||
} = await import("../src/persona/protagonist-bind.js");
|
||
|
||
const board = new Blackboard();
|
||
board.write({
|
||
tag: "设计.开场白",
|
||
content: `用户角色.名字: 旧名
|
||
用户角色.简介: 旧简介
|
||
@玩家推开门。`,
|
||
source: "agent",
|
||
});
|
||
board.write({
|
||
tag: PROTAGONIST_TAG,
|
||
content: protagonistFragmentFromPersona({
|
||
name: "李宗",
|
||
description: "男大学生",
|
||
}),
|
||
source: "agent",
|
||
});
|
||
expect(readSessionProtagonist(board)?.name).toBe("李宗");
|
||
|
||
const empty = new Blackboard();
|
||
expect(
|
||
ensureProtagonistFromPersona(empty, {
|
||
name: "阿泽",
|
||
description: "社畜",
|
||
}),
|
||
).toBe(true);
|
||
expect(readSessionProtagonist(empty)?.name).toBe("阿泽");
|
||
expect(
|
||
ensureProtagonistFromPersona(empty, {
|
||
name: "另一个",
|
||
description: "不覆盖",
|
||
}),
|
||
).toBe(false);
|
||
|
||
expect(protagonistTagForOpeningSlot("设计.开场白#dorm")).toBe(
|
||
"设计.主角设定#dorm",
|
||
);
|
||
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"
|
||
);
|
||
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);
|
||
});
|
||
});
|