接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。
直聘插入按模块回馈,并记录请求骨架便于排查。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -512,6 +512,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
|
||||
"设计.变量设计与更新规则",
|
||||
"设计.生成规则",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
@@ -525,6 +527,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
|
||||
narrator: {
|
||||
static: [
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
|
||||
+63
-17
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 解析壳适配。
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
"世界.拓扑.*",
|
||||
"设计.变量设计与更新规则",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
@@ -265,6 +267,7 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
"设计.worker集",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user