Files
writing-agent/src/skills/variable-catalog.ts
T
chenmoranzhiandCursor dd0a9e87d8 接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。
直聘插入按模块回馈,并记录请求骨架便于排查。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 07:40:08 +08:00

270 lines
8.4 KiB
TypeScript

/**
* 变量目录(创作期对话钉死;落档时种进 变量.当前)。
* schema: variable-catalog.v1
*/
import {
createTableFromValues,
parseTableDoc,
stringifyTableDoc,
type TableDoc,
} from "../blackboard/table-cells.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
export const VARIABLE_CATALOG_SCHEMA = "variable-catalog.v1" as const;
export const VARIABLE_CATALOG_TAG = "设计.变量目录";
export const CURRENT_VARS_TAG = "变量.当前";
export type VariableFieldType = "number" | "string" | "boolean" | "enum";
export type VariableCatalogField = {
key: string;
type: VariableFieldType;
initial: unknown;
/** 对用户可见(监控栏等);默认 true */
user_visible: boolean;
/** 游玩表上手改;默认跟可见性走 */
user_editable?: boolean;
note?: string;
};
export type VariableCatalogDoc = {
schema: typeof VARIABLE_CATALOG_SCHEMA;
fields: VariableCatalogField[];
};
function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function parseType(raw: unknown): VariableFieldType {
const s = asString(raw)?.toLowerCase();
if (s === "number" || s === "数字") return "number";
if (s === "boolean" || s === "布尔") return "boolean";
if (s === "enum" || s === "枚举") return "enum";
return "string";
}
function coerceInitial(type: VariableFieldType, raw: unknown): unknown {
if (type === "number") {
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
if (typeof raw === "string" && raw.trim() && !Number.isNaN(Number(raw))) {
return Number(raw);
}
return 0;
}
if (type === "boolean") {
if (typeof raw === "boolean") return raw;
if (raw === "true" || raw === "是" || raw === 1) return true;
if (raw === "false" || raw === "否" || raw === 0) return false;
return false;
}
if (raw === undefined || raw === null) return type === "string" ? "" : raw;
return raw;
}
export function parseVariableCatalog(raw: string | undefined | null): VariableCatalogDoc | null {
if (!raw?.trim()) return null;
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const row = parsed as Record<string, unknown>;
const fieldsRaw = row.fields ?? row.真值 ?? row.变量;
if (!Array.isArray(fieldsRaw)) return null;
const fields: VariableCatalogField[] = [];
for (const item of fieldsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const f = item as Record<string, unknown>;
const key = asString(f.key) ?? asString(f.名) ?? asString(f.name);
if (!key) continue;
const type = parseType(f.type ?? f.类型);
const userVisible =
f.user_visible === false ||
f.对用户可见 === false ||
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.备注),
});
}
if (!fields.length) return null;
return { schema: VARIABLE_CATALOG_SCHEMA, fields };
} catch {
return null;
}
}
export function serializeVariableCatalog(doc: VariableCatalogDoc): string {
return JSON.stringify(
{
schema: VARIABLE_CATALOG_SCHEMA,
fields: doc.fields.map((f) => ({
key: f.key,
type: f.type,
initial: f.initial,
user_visible: f.user_visible,
user_editable: f.user_editable ?? f.user_visible,
...(f.note ? { note: f.note } : {}),
})),
},
null,
2,
);
}
export type UpsertVariableInput = {
key: string;
type?: string;
initial?: unknown;
user_visible?: boolean;
user_editable?: boolean;
note?: string;
};
export function upsertVariableField(
current: VariableCatalogDoc | null,
input: UpsertVariableInput,
): { doc: VariableCatalogDoc; error?: string } {
const key = input.key.trim();
if (!key) return { doc: current ?? { schema: VARIABLE_CATALOG_SCHEMA, fields: [] }, error: "key 不能为空" };
const type = parseType(input.type);
const field: VariableCatalogField = {
key,
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 ?? [])];
const idx = fields.findIndex((f) => f.key === key);
if (idx >= 0) fields[idx] = field;
else fields.push(field);
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; 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,
};
}
return createTableFromValues(values, actor, meta);
}
/**
* 若 变量.当前 为空则用目录初值种入;已有格不覆盖。
* 同步补齐 运行.初始变量(仅当其为空)。
*/
export function seedVariablesFromCatalog(params: {
catalogRaw: string | undefined | null;
currentRaw: string | undefined | null;
initialRaw: string | undefined | null;
actor?: string;
}): {
current: string | null;
initial: string | null;
seededKeys: string[];
} {
const catalog = parseVariableCatalog(params.catalogRaw);
if (!catalog?.fields.length) {
return { current: null, initial: null, seededKeys: [] };
}
const actor = params.actor ?? "system:variable-catalog";
const fromCatalog = catalogToTableDoc(catalog, actor);
const seededKeys = fromCatalog.rows.map((r) => r.key);
const existingCurrent = parseTableDoc(params.currentRaw);
let currentOut: string | null = null;
if (!existingCurrent?.rows.length) {
currentOut = stringifyTableDoc(fromCatalog);
} else {
// 补缺键,不覆盖已有
const have = new Set(existingCurrent.rows.map((r) => r.key));
const patchValues: Record<string, unknown> = {};
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,
};
}
if (Object.keys(patchValues).length) {
const patch = createTableFromValues(patchValues, actor, meta);
const merged = {
rows: [...existingCurrent.rows, ...patch.rows].sort((a, b) =>
a.key.localeCompare(b.key),
),
};
currentOut = stringifyTableDoc(merged);
}
}
const existingInitial = parseTableDoc(params.initialRaw);
let initialOut: string | null = null;
if (!existingInitial?.rows.length) {
initialOut = stringifyTableDoc(fromCatalog);
}
return { current: currentOut, initial: initialOut, seededKeys };
}