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

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

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
+4
View File
@@ -512,6 +512,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
"设计.变量设计与更新规则",
"设计.生成规则",
"设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.故事推进",
"设计.正文组成",
"设计.监控栏",
],
@@ -525,6 +527,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
narrator: {
static: [
"设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.故事推进",
"设计.正文组成",
"设计.监控栏",
],
+63 -17
View File
@@ -206,6 +206,11 @@ export type ModuleCatalogEntry = {
* 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。
*/
params?: ModuleParamSpec[];
/**
* 可选:进料族。缺省(不写)= 工序编排与对话落盘都能见。
* recipe = 只进工序编排 DAG;dictate = 只进对话落盘何时落盘。
*/
intake?: RecipeIntakeFamily;
/** 来自 prompt.md ```meta:何时该选用(编排选型) */
when?: string;
/** 来自 prompt.md ```meta:何时不该选用 */
@@ -214,6 +219,27 @@ export type ModuleCatalogEntry = {
boundary?: string;
};
export function parseModuleIntake(raw: unknown): RecipeIntakeFamily | undefined {
if (raw === "recipe" || raw === "dictate") return raw;
return undefined;
}
/** 缺省 intake = 两族都可见 */
export function moduleVisibleForIntake(
module: Pick<ModuleCatalogEntry, "intake">,
family: RecipeIntakeFamily,
): boolean {
if (!module.intake) return true;
return module.intake === family;
}
export function catalogModulesForIntake(
catalog: ModuleCatalog,
family: RecipeIntakeFamily,
): ModuleCatalogEntry[] {
return catalog.modules.filter((m) => moduleVisibleForIntake(m, family));
}
/** 本步程序开场白正文(design-step 发出后写入,供 LLM 看见) */
export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白";
/** JSON:{ [步骤中文名]: "shown" | "answered" } */
@@ -924,6 +950,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
const auto = m.auto === true;
const kind = parseModuleNodeKind(m.kind);
const params = parseModuleParamSpecs(m.params);
const intake = parseModuleIntake(m.intake);
modules.push({
id,
name,
@@ -935,6 +962,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
...(kind ? { kind } : {}),
...(opening ? { opening } : {}),
...(params ? { params } : {}),
...(intake ? { intake } : {}),
});
}
if (modules.length === 0) return null;
@@ -1240,8 +1268,9 @@ function slugFromName(name: string): string {
美学纲领与交互范式: "aesthetics-interaction",
交互范式: "interaction",
美学纲领: "aesthetics",
叙事指南: "narrative",
叙事指南: "narrative-guide",
叙事指南与故事推进: "narrative",
故事推进: "story-progression",
实现机制: "mechanism",
舞台骨架: "world-blueprint",
世界蓝图与人文地理: "world-blueprint", // 旧称
@@ -1283,7 +1312,7 @@ export async function loadModuleCatalog(
/** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => {
const lines = catalogModulesForIntake(catalog, "recipe").map((m) => {
const flags = [
m.repeatable ? "〔可反复〕" : "",
m.closer ? "〔收口〕" : "",
@@ -1332,7 +1361,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
].join("\n");
}
/** Boss 直聘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */
/** 对话落盘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */
const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
"worker-spec",
"context-order",
@@ -1340,11 +1369,11 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
]);
/**
* 注入 Boss 直聘:能力何时落盘(meta when/when_not)+ insert 目标 tag。
* 注入对话落盘:能力何时落盘(meta when/when_not)+ insert 目标 tag。
* 不写 DAG / design-step 话术;不塞执行全文。
*/
export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => {
const lines = catalogModulesForIntake(catalog, "dictate").map((m) => {
const programSide = DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto;
const flags = [
m.repeatable ? "〔可反复〕" : "",
@@ -1366,11 +1395,13 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
});
return [
"【能力 · 何时落盘】",
"对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落、只短确认或问挡住的 1 点。",
"每轮通常只推进条件已成立的一刀;勿因用户一次说了很多就批量抢跑下游。",
"对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落该能力,改为短确认或问挡住的 1 点。",
"同轮可多次落盘:凡本轮材料已够且「何时用」成立的能力,都可 insert / declare_*(可并行);每个 insert 各回 reply_module,可见回复分段附带。",
"不抢跑:条件未成立的下游本轮不落,放进「建议下一刀」。",
"标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。",
"标〔收口〕的开场白:美学与必要上游齐后再写;未齐不硬塞。",
"不要把能力执行全文塞进聊天;规格只走 toolcall。",
"标〔收口〕的开场白:美学与必要上游齐后再写;未齐则先补上游。",
"标〔可反复〕的(生成规则/具体实例等):每条增殖用「基名#唯一id」拆分落盘(如 设计.具体实例#lei-ying);禁止反复 insert 基名覆盖旧条。改某一条则 insert 同一完整 tag。",
"能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。",
lines.join("\n"),
].join("\n");
}
@@ -1383,7 +1414,17 @@ function dictateLandHint(m: ModuleCatalogEntry): string {
return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」";
}
if (m.id === "opening-setup") {
return "insert 「设计.开场白」(Boss 直聘简化 tag;初值与开场同真相)";
return "insert 「设计.开场白」(对话落盘简化 tag;初值与开场同真相)";
}
if (m.artifact?.trim() && m.repeatable) {
const art = m.artifact.trim();
const slotHint =
m.id === "generation-rules"
? "rule_id"
: m.id === "concrete-instances"
? "batch_id或姓名"
: "唯一id";
return `insert 「${art}#${slotHint}」——可增殖:每条对象/批次各占一 tag;同完整 tag 才覆盖改写,禁止反复写基名「${art}」覆盖`;
}
if (m.artifact?.trim()) {
return `insert 「${m.artifact.trim()}」`;
@@ -1618,7 +1659,7 @@ export function formatRecipeCatalogForAgent(catalog: RecipeCatalog): string {
}
/**
* Boss 直聘 / 转述进料:把已选配方方法论收成短要点(进系统提示「本局配方」)。
* 对话落盘:把已选配方方法论收成短要点(进系统提示「本局配方」)。
* 不含 DAG steps / design-flow 选型话术。
*/
export function formatDictateRecipeBrief(detail: RecipeDetail): string {
@@ -1744,7 +1785,7 @@ export function formatFlowProgressForAgent(params: {
const catalog = params.catalog ?? null;
const filled = new Set(params.filledArtifactTags ?? []);
const repeatableNames = new Set(
(catalog?.modules ?? [])
(catalog ? catalogModulesForIntake(catalog, "recipe") : [])
.filter((m) => m.repeatable === true)
.map((m) => m.name),
);
@@ -1793,7 +1834,7 @@ export function formatFlowProgressForAgent(params: {
}
if (catalog) {
for (const mod of catalog.modules) {
for (const mod of catalogModulesForIntake(catalog, "recipe")) {
if (mod.repeatable === true) continue;
if (!mod.artifact || !filled.has(mod.artifact)) continue;
if (doneNames.has(mod.name)) continue;
@@ -1812,7 +1853,10 @@ export function formatFlowProgressForAgent(params: {
}
}
const repeatableList = (catalog?.modules ?? [])
const repeatableList = (catalog
? catalogModulesForIntake(catalog, "recipe")
: []
)
.filter((m) => m.repeatable === true)
.map((m) => m.name);
@@ -1940,7 +1984,9 @@ export function validateCreationFlow(
const errors: string[] = [];
const seenIds = new Set<string>();
const allowed = catalog
? new Set(catalog.modules.map((m) => m.name))
? new Set(
catalogModulesForIntake(catalog, "recipe").map((m) => m.name),
)
: null;
const reportedFreshDup = new Set<string>();
@@ -2224,7 +2270,7 @@ export function listCallableCatalogModules(params: {
(params.acceptedStepIds ?? []).map((id) => id.trim()).filter(Boolean),
);
const out: CreationFlowAvailableModule[] = [];
for (const mod of catalog.modules) {
for (const mod of catalogModulesForIntake(catalog, "recipe")) {
if (scheduled.has(mod.name)) continue;
if (!mod.repeatable) {
if (mod.artifact && filled.has(mod.artifact)) continue;
@@ -2598,7 +2644,7 @@ export function listRepeatableSpawns(
catalog: ModuleCatalog,
acceptedStepIds: readonly string[],
): CreationFlowSpawnView[] {
return catalog.modules
return catalogModulesForIntake(catalog, "recipe")
.filter((m) => m.repeatable === true && !m.closer)
.filter(
(m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)),
+254 -17
View File
@@ -12,11 +12,20 @@ import {
stringifyCreationFlow,
} from "./creation-flow.js";
import { parseContextFragment } from "./context-fragment.js";
import {
isPresentLikeObject,
normalizePresentPacket,
stringifyPresentPacket,
} from "./present-packet.js";
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
export const OPENING_OUTPUT_TAG = "输出.开场白";
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
export const OPENING_CURRENT_VARS_TAG = "变量.当前";
/** 本条开场自带的用户角色预设(名字+简介),不进全局列表 */
export const OPENING_PERSONA_TAG = "运行.开场用户角色";
/** opening = 本局用开场预设;global = 保持当前用户角色 */
export const OPENING_PERSONA_CHOICE_TAG = "运行.开场用户角色.选用";
export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
@@ -58,11 +67,17 @@ export type OpeningVariable = {
note?: string;
};
export type OpeningUserPersona = {
name: string;
description: string;
};
export type OpeningSealPayload = {
candidates: string[];
selectedIndex: number;
selectedText: string;
variables: OpeningVariable[];
persona: OpeningUserPersona | null;
};
function asTrimmed(v: unknown): string | undefined {
@@ -71,22 +86,96 @@ function asTrimmed(v: unknown): string | undefined {
return t || undefined;
}
function collectCandidateTexts(raw: unknown): string[] {
type OpeningCandidateEntry = {
text: string;
persona: OpeningUserPersona | null;
variables: OpeningVariable[];
};
function asOpeningBodyText(raw: unknown): string | undefined {
if (typeof raw === "string") {
const t = raw.trim();
if (!t) return undefined;
if (t.startsWith("{")) {
try {
const doc = JSON.parse(t) as unknown;
if (
isPresentLikeObject(doc) ||
(doc &&
typeof doc === "object" &&
!Array.isArray(doc) &&
(doc as Record<string, unknown>).blocks != null)
) {
return stringifyPresentPacket(
normalizePresentPacket(doc as Record<string, unknown>),
);
}
} catch {
/* 散文 */
}
}
return t;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
if (
isPresentLikeObject(row) ||
row.blocks != null ||
typeof row.shell === "string" ||
typeof row.shell_id === "string"
) {
return stringifyPresentPacket(normalizePresentPacket(row));
}
return undefined;
}
function variablesFromCandidate(row: Record<string, unknown>): OpeningVariable[] {
const fromRow = collectVariables(row.开场变量);
if (fromRow.length) return fromRow;
const meta = row.meta;
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
return collectVariables((meta as Record<string, unknown>).开场变量);
}
return [];
}
function collectCandidateEntries(raw: unknown): OpeningCandidateEntry[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
const out: OpeningCandidateEntry[] = [];
for (const item of raw) {
if (typeof item === "string" && item.trim()) {
out.push(item.trim());
const split = splitYamlFrontmatter(item);
out.push({
text: asOpeningBodyText(split.text) || split.text,
persona: split.persona,
variables: [],
});
continue;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const row = item as Record<string, unknown>;
const text =
asTrimmed(row.全文) ||
asTrimmed(row.text) ||
asTrimmed(row.开场白) ||
asTrimmed(row.内容);
if (text) out.push(text);
asOpeningBodyText(row.正文) ||
asOpeningBodyText(row.全文) ||
asOpeningBodyText(row.text) ||
asOpeningBodyText(row.开场白) ||
asOpeningBodyText(row.内容);
if (!text) continue;
const split = splitYamlFrontmatter(text);
const persona =
parseUserPersonaField(row.用户角色) ||
parseUserPersonaField(row.meta) ||
parseUserPersonaField(
row.meta && typeof row.meta === "object" && !Array.isArray(row.meta)
? (row.meta as Record<string, unknown>).用户角色
: undefined,
) ||
split.persona;
out.push({
text: asOpeningBodyText(split.text) || split.text,
persona,
variables: variablesFromCandidate(row),
});
}
}
return out;
@@ -111,6 +200,128 @@ function collectVariables(raw: unknown): OpeningVariable[] {
return out;
}
export function parseUserPersonaField(raw: unknown): OpeningUserPersona | null {
if (!raw) return null;
if (typeof raw === "string") return parseOpeningUserPersona(raw);
if (typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const nested = row.用户角色;
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
const fromNested = parseUserPersonaField(nested);
if (fromNested) return fromNested;
}
const name =
asTrimmed(row.名字) || asTrimmed(row.name) || asTrimmed(row.姓名) || "";
if (!name || name === "@玩家" || name === "用户" || name === "主角") return null;
const description =
asTrimmed(row.简介) ||
asTrimmed(row.description) ||
asTrimmed(row.人设) ||
asTrimmed(row.描述) ||
"";
return { name, description };
}
function splitYamlFrontmatter(raw: string): {
text: string;
persona: OpeningUserPersona | null;
} {
const text = raw.trim();
const fence = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!fence) return { text, persona: parseOpeningUserPersona(text) };
const persona =
parseUserPersonaField(parseSimpleYamlMap(fence[1] ?? "")) ||
parseOpeningUserPersona(fence[1] ?? "");
return { text: (fence[2] ?? "").trim(), persona };
}
/** 开场正文前的 YAML 式 meta:用户角色名字+简介,其余当正文。 */
export function splitOpeningDocument(raw: string | null | undefined): {
text: string;
persona: OpeningUserPersona | null;
} {
const text = raw?.trim() ?? "";
if (!text) return { text: "", persona: null };
const payload = parseOpeningSealPayload(text, 0);
if (payload?.selectedText) {
return { text: payload.selectedText, persona: payload.persona };
}
return splitYamlFrontmatter(text);
}
function parseSimpleYamlMap(raw: string): Record<string, unknown> {
const lines = raw.split(/\r?\n/);
const root: Record<string, unknown> = {};
let currentKey = "";
let currentObj: Record<string, string> | null = null;
for (const line of lines) {
const nested = line.match(/^[ \t]+([^\s:]+)\s*:\s*(.*)$/);
if (nested && currentObj) {
currentObj[nested[1]] = nested[2].trim();
continue;
}
const top = line.match(/^([^\s:]+)\s*:\s*(.*)$/);
if (!top) continue;
const key = top[1];
const val = top[2].trim();
if (val === "" || val === "|" || val === ">") {
currentKey = key;
currentObj = {};
root[key] = currentObj;
continue;
}
currentKey = "";
currentObj = null;
root[key] = val;
}
void currentKey;
return root;
}
export function parseOpeningUserPersona(
raw: string | null | undefined,
): OpeningUserPersona | null {
const text = raw?.trim() ?? "";
if (!text) return null;
try {
const doc = JSON.parse(text) as unknown;
const fromJson =
parseUserPersonaField(doc) ||
parseUserPersonaField(
doc && typeof doc === "object" && !Array.isArray(doc)
? (doc as Record<string, unknown>).用户角色
: undefined,
);
if (fromJson) return fromJson;
} catch {
/* not json */
}
const name =
text.match(/(?:用户角色\.)?名字[::]\s*([^\n]+)/)?.[1]?.trim() ||
text.match(/^名字[::]\s*([^\n]+)/m)?.[1]?.trim() ||
"";
if (!name || name === "@玩家" || name === "用户" || name === "主角") return null;
const description =
text.match(/(?:用户角色\.)?简介[::]\s*([^\n]+)/)?.[1]?.trim() ||
text.match(/^简介[::]\s*([^\n]+)/m)?.[1]?.trim() ||
"";
return { name, description };
}
export function serializeOpeningPersona(persona: OpeningUserPersona): string {
return JSON.stringify(
{ 名字: persona.name, 简介: persona.description },
null,
2,
);
}
export function readSealedOpeningPersona(
raw: string | null | undefined,
): OpeningUserPersona | null {
return parseUserPersonaField(raw) || parseOpeningUserPersona(raw);
}
function bodyRecord(fragmentBody: unknown): Record<string, unknown> | null {
if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) {
return null;
@@ -135,32 +346,58 @@ export function parseOpeningSealPayload(
})();
const primary =
asTrimmed(body?.开场白全文) ||
asTrimmed(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined);
asOpeningBodyText(body?.开场白全文) ||
asOpeningBodyText(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string"
? asOpeningBodyText(fragment.正文)
: undefined);
const fromList = collectCandidateTexts(body?.开场白候选);
const defaultPersona = parseUserPersonaField(body?.用户角色);
const sharedVariables = collectVariables(body?.开场变量);
const fromList = collectCandidateEntries(body?.开场白候选);
const candidates: string[] = [];
const personas: Array<OpeningUserPersona | null> = [];
const variableSets: OpeningVariable[][] = [];
const seen = new Set<string>();
const push = (text: string | undefined) => {
if (!text || seen.has(text)) return;
const push = (
text: string | undefined,
persona: OpeningUserPersona | null,
variables: OpeningVariable[],
) => {
if (!text) return;
if (seen.has(text)) {
const i = candidates.indexOf(text);
if (i >= 0) {
if (persona && !personas[i]) personas[i] = persona;
if (variables.length && !variableSets[i]?.length) {
variableSets[i] = variables;
}
}
return;
}
seen.add(text);
candidates.push(text);
personas.push(persona);
variableSets.push(variables);
};
push(primary);
for (const t of fromList) push(t);
push(primary, defaultPersona, sharedVariables);
for (const row of fromList) {
push(row.text, row.persona || defaultPersona, row.variables);
}
if (candidates.length === 0) return null;
const max = candidates.length - 1;
const idx = Number.isFinite(selectedIndex)
? Math.min(max, Math.max(0, Math.trunc(selectedIndex)))
: 0;
const selectedVars = variableSets[idx] ?? [];
return {
candidates,
selectedIndex: idx,
selectedText: candidates[idx] ?? candidates[0]!,
variables: collectVariables(body?.开场变量),
variables: selectedVars.length ? selectedVars : sharedVariables,
persona: personas[idx] ?? defaultPersona,
};
}
+117
View File
@@ -0,0 +1,117 @@
/**
* 游玩辅助面:表格 / 开场与角色 等 Tab 的视图截面。
*/
import type { Blackboard } from "../blackboard/blackboard.js";
import {
isTableCellEditable,
parseTableDoc,
} from "../blackboard/table-cells.js";
import {
OPENING_PERSONA_CHOICE_TAG,
OPENING_SETUP_ARTIFACT_TAG,
SLOT_OPENING_SELECTED_INDEX,
parseOpeningSealPayload,
splitOpeningDocument,
type OpeningUserPersona,
} from "./opening-seal.js";
import { CURRENT_VARS_TAG } from "./variable-catalog.js";
import { readSessionProtagonist } from "../persona/protagonist-bind.js";
import { presentReadablePlain } from "./present-packet.js";
export type PlayAuxTableRow = {
key: string;
value: unknown;
rev: number;
visibility: "visible" | "hidden";
editable: boolean;
note?: string;
};
export type PlayAuxOpening = {
index: number;
text: string;
persona: OpeningUserPersona | null;
};
export type PlayAuxView = {
table: { tag: string; rows: PlayAuxTableRow[] } | null;
openings: PlayAuxOpening[];
selectedOpeningIndex: number;
openingPersona: OpeningUserPersona | null;
openingPersonaChoice: "opening" | "global" | null;
};
function selectedOpeningIndex(slots: Record<string, unknown> | undefined): number {
const raw = slots?.[SLOT_OPENING_SELECTED_INDEX];
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(0, Math.trunc(raw));
if (typeof raw === "string" && raw.trim()) {
const n = Number(raw);
if (Number.isFinite(n)) return Math.max(0, Math.trunc(n));
}
return 0;
}
function collectOpenings(board: Blackboard): PlayAuxOpening[] {
const payload = parseOpeningSealPayload(
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
0,
);
if (payload?.candidates.length) {
return payload.candidates.map((text, index) => {
const one = parseOpeningSealPayload(
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
index,
);
return {
index,
text: presentReadablePlain(text) || text,
persona: one?.persona ?? payload.persona,
};
});
}
const split = splitOpeningDocument(board.getContentByTag("设计.开场白"));
if (split.text) {
return [{ index: 0, text: split.text, persona: split.persona }];
}
return [];
}
export function buildPlayAuxView(
board: Blackboard,
slots?: Record<string, unknown>,
): PlayAuxView {
const tableDoc = parseTableDoc(board.getContentByTag(CURRENT_VARS_TAG));
const table = tableDoc?.rows.length
? {
tag: CURRENT_VARS_TAG,
rows: tableDoc.rows.map((row) => ({
key: row.key,
value: row.value,
rev: row.rev,
visibility: row.visibility === "hidden" ? "hidden" : "visible",
editable: isTableCellEditable(row),
...(row.note ? { note: row.note } : {}),
})),
}
: null;
const openings = collectOpenings(board);
const idx = selectedOpeningIndex(slots);
const selectedOpeningIndexClamped = openings.length
? Math.min(idx, openings.length - 1)
: 0;
const sealed = readSessionProtagonist(board);
const choiceRaw = board.getContentByTag(OPENING_PERSONA_CHOICE_TAG)?.trim();
const openingPersonaChoice =
choiceRaw === "opening" || choiceRaw === "global" ? choiceRaw : null;
return {
table,
openings,
selectedOpeningIndex: selectedOpeningIndexClamped,
openingPersona: sealed
? { name: sealed.name, description: sealed.description }
: openings[selectedOpeningIndexClamped]?.persona ?? null,
openingPersonaChoice,
};
}
+20
View File
@@ -381,9 +381,29 @@ export function isPresentLikeObject(doc: unknown): boolean {
const row = doc as Record<string, unknown>;
if (row.schema === PRESENT_SCHEMA) return true;
if (isPresentShellId(row.shell) && row.blocks != null) return true;
if (isPresentShellId(row.shell_id) && row.blocks != null) return true;
return false;
}
/** 开场/卡片预览:取出 present.v1 主读正文;散文原样返回。 */
export function presentReadablePlain(raw: string | null | undefined): string {
const text = (raw ?? "").trim();
if (!text) return "";
const view = parsePresentPacket(text);
const body = view.packet.blocks.body;
if (typeof body === "string" && body.trim()) return body.trim();
if (body && typeof body === "object") {
try {
return JSON.stringify(body);
} catch {
return text;
}
}
const header = view.packet.blocks.header;
if (typeof header === "string" && header.trim()) return header.trim();
return text;
}
/**
* 从「设计.正文组成」context-fragment 解析壳适配。
*/
+23
View File
@@ -234,6 +234,29 @@ export function upsertValueMapEntry(
return { doc: { schema: VALUE_MAP_SCHEMA, maps } };
}
/** 按 id 移除一条映射;不存在则 error。返回被删条目供调用方清投影 tag */
export function removeValueMapEntry(
current: ValueMapDoc | null,
idRaw: string,
): {
doc: ValueMapDoc;
error?: string;
removed?: ValueMapEntry;
} {
const empty: ValueMapDoc = { schema: VALUE_MAP_SCHEMA, maps: [] };
const id = idRaw.trim();
if (!id) return { doc: current ?? empty, error: "id 不能为空" };
const maps = current?.maps ?? [];
const idx = maps.findIndex((m) => m.id === id);
if (idx < 0) return { doc: current ?? empty, error: `映射无 id ${id}` };
const removed = maps[idx]!;
const next = maps.filter((_, i) => i !== idx);
return {
doc: { schema: VALUE_MAP_SCHEMA, maps: next },
removed,
};
}
/** 按真值查一档正文;无命中返回 null */
export function lookupValueMapContent(
entry: ValueMapEntry,
+49 -2
View File
@@ -22,6 +22,8 @@ export type VariableCatalogField = {
initial: unknown;
/** 对用户可见(监控栏等);默认 true */
user_visible: boolean;
/** 游玩表上手改;默认跟可见性走 */
user_editable?: boolean;
note?: string;
};
@@ -84,11 +86,19 @@ export function parseVariableCatalog(raw: string | undefined | null): VariableCa
f.userVisible === false
? false
: true;
const editableRaw = f.user_editable ?? f.可手改 ?? f.userEditable;
const userEditable =
editableRaw === false
? false
: editableRaw === true
? true
: userVisible;
fields.push({
key,
type,
initial: coerceInitial(type, f.initial ?? f.初值 ?? f.value),
user_visible: userVisible,
user_editable: userEditable,
note: asString(f.note) ?? asString(f.备注),
});
}
@@ -108,6 +118,7 @@ export function serializeVariableCatalog(doc: VariableCatalogDoc): string {
type: f.type,
initial: f.initial,
user_visible: f.user_visible,
user_editable: f.user_editable ?? f.user_visible,
...(f.note ? { note: f.note } : {}),
})),
},
@@ -121,6 +132,7 @@ export type UpsertVariableInput = {
type?: string;
initial?: unknown;
user_visible?: boolean;
user_editable?: boolean;
note?: string;
};
@@ -136,6 +148,10 @@ export function upsertVariableField(
type,
initial: coerceInitial(type, input.initial),
user_visible: input.user_visible !== false,
user_editable:
input.user_editable !== undefined
? input.user_editable
: input.user_visible !== false,
note: input.note?.trim() || undefined,
};
const fields = [...(current?.fields ?? [])];
@@ -145,17 +161,44 @@ export function upsertVariableField(
return { doc: { schema: VARIABLE_CATALOG_SCHEMA, fields } };
}
/** 从目录移除一个真值字段;不存在则 error */
export function removeVariableField(
current: VariableCatalogDoc | null,
keyRaw: string,
): { doc: VariableCatalogDoc; error?: string; removed?: boolean } {
const empty: VariableCatalogDoc = {
schema: VARIABLE_CATALOG_SCHEMA,
fields: [],
};
const key = keyRaw.trim();
if (!key) return { doc: current ?? empty, error: "key 不能为空" };
const fields = current?.fields ?? [];
const next = fields.filter((f) => f.key !== key);
if (next.length === fields.length) {
return { doc: current ?? empty, error: `目录无字段 ${key}` };
}
return {
doc: { schema: VARIABLE_CATALOG_SCHEMA, fields: next },
removed: true,
};
}
/** 目录 → 表文档(初值);用于种 运行.初始变量 / 变量.当前 */
export function catalogToTableDoc(
catalog: VariableCatalogDoc,
actor: string,
): TableDoc {
const values: Record<string, unknown> = {};
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {};
const meta: Record<
string,
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
> = {};
for (const f of catalog.fields) {
values[f.key] = f.initial;
const editable = f.user_editable ?? f.user_visible;
meta[f.key] = {
visibility: f.user_visible ? "visible" : "hidden",
editable,
note: f.note,
};
}
@@ -192,12 +235,16 @@ export function seedVariablesFromCatalog(params: {
// 补缺键,不覆盖已有
const have = new Set(existingCurrent.rows.map((r) => r.key));
const patchValues: Record<string, unknown> = {};
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {};
const meta: Record<
string,
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
> = {};
for (const f of catalog.fields) {
if (have.has(f.key)) continue;
patchValues[f.key] = f.initial;
meta[f.key] = {
visibility: f.user_visible ? "visible" : "hidden",
editable: f.user_editable ?? f.user_visible,
note: f.note,
};
}
+3
View File
@@ -238,6 +238,8 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"世界.拓扑.*",
"设计.变量设计与更新规则",
"设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.故事推进",
"设计.正文组成",
"设计.监控栏",
],
@@ -265,6 +267,7 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"设计.worker集",
"设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.故事推进",
"设计.正文组成",
"设计.监控栏",
],