重构世界模拟器为模块化配方架构,完善创作编排、会话运行时与 Web UI,并清理过时技能。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
275
src/blackboard/table-side-effects.ts
Normal file
275
src/blackboard/table-side-effects.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 表边沿副作用:prev 不满足且 now 满足才触发;once 规则记 fired。
|
||||
* 规则来自 设计.worker集.tables.side_effects。
|
||||
*/
|
||||
import type { Blackboard } from "./blackboard.js";
|
||||
import type { TableDoc } from "./table-cells.js";
|
||||
|
||||
export type SideEffectOp = "eq" | "neq" | "gte" | "lte" | "gt" | "lt" | "truthy" | "changed";
|
||||
|
||||
export type SideEffectMode = "once" | "edge" | "every_edge";
|
||||
|
||||
export type SideEffectAction =
|
||||
| { type: "write_tag"; tag: string; content: string }
|
||||
| { type: "replace_tag"; tag: string; content: string }
|
||||
| { type: "queue_worker"; workerId: string; note?: string };
|
||||
|
||||
export type SideEffectRule = {
|
||||
id: string;
|
||||
field: string;
|
||||
op: SideEffectOp;
|
||||
value?: unknown;
|
||||
/** once = 边沿 + 记 fired;edge/every_edge = 仅边沿,可反复跨边沿 */
|
||||
mode: SideEffectMode;
|
||||
action: SideEffectAction;
|
||||
};
|
||||
|
||||
export type FiredRegistry = Record<string, { at: string; round?: number }>;
|
||||
|
||||
export const SIDE_EFFECT_FIRED_TAG = "运行.表副作用.fired";
|
||||
|
||||
export function parseSideEffectRules(tables: unknown): SideEffectRule[] {
|
||||
if (!tables || typeof tables !== "object" || Array.isArray(tables)) return [];
|
||||
const raw = (tables as { side_effects?: unknown }).side_effects;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: SideEffectRule[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const id = typeof row.id === "string" ? row.id.trim() : "";
|
||||
const field = typeof row.field === "string" ? row.field.trim() : "";
|
||||
if (!id || !field) continue;
|
||||
const op = normalizeOp(row.op);
|
||||
const mode = normalizeMode(row.mode);
|
||||
const action = parseAction(row.action);
|
||||
if (!action) continue;
|
||||
out.push({ id, field, op, value: row.value, mode, action });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeOp(raw: unknown): SideEffectOp {
|
||||
const s = typeof raw === "string" ? raw.trim().toLowerCase() : "eq";
|
||||
const allowed: SideEffectOp[] = [
|
||||
"eq",
|
||||
"neq",
|
||||
"gte",
|
||||
"lte",
|
||||
"gt",
|
||||
"lt",
|
||||
"truthy",
|
||||
"changed",
|
||||
];
|
||||
return (allowed.includes(s as SideEffectOp) ? s : "eq") as SideEffectOp;
|
||||
}
|
||||
|
||||
function normalizeMode(raw: unknown): SideEffectMode {
|
||||
const s = typeof raw === "string" ? raw.trim().toLowerCase() : "once";
|
||||
if (s === "edge" || s === "every_edge") return "every_edge";
|
||||
return "once";
|
||||
}
|
||||
|
||||
function parseAction(raw: unknown): SideEffectAction | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const a = raw as Record<string, unknown>;
|
||||
const type = typeof a.type === "string" ? a.type.trim() : "";
|
||||
if (type === "write_tag" || type === "replace_tag") {
|
||||
const tag = typeof a.tag === "string" ? a.tag.trim() : "";
|
||||
const content = typeof a.content === "string" ? a.content : "";
|
||||
if (!tag) return null;
|
||||
return { type, tag, content };
|
||||
}
|
||||
if (type === "queue_worker") {
|
||||
const workerId =
|
||||
typeof a.workerId === "string"
|
||||
? a.workerId.trim()
|
||||
: typeof a.worker_id === "string"
|
||||
? a.worker_id.trim()
|
||||
: "";
|
||||
if (!workerId) return null;
|
||||
return {
|
||||
type: "queue_worker",
|
||||
workerId,
|
||||
note: typeof a.note === "string" ? a.note : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function tableValueMap(doc: TableDoc | null | undefined): Map<string, unknown> {
|
||||
const map = new Map<string, unknown>();
|
||||
for (const row of doc?.rows ?? []) {
|
||||
map.set(row.key, row.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function conditionSatisfied(
|
||||
values: Map<string, unknown>,
|
||||
rule: SideEffectRule,
|
||||
prevValues?: Map<string, unknown>,
|
||||
): boolean {
|
||||
const now = values.get(rule.field);
|
||||
if (rule.op === "changed") {
|
||||
if (!prevValues) return false;
|
||||
return !sameValue(prevValues.get(rule.field), now);
|
||||
}
|
||||
return compareOp(now, rule.op, rule.value);
|
||||
}
|
||||
|
||||
function compareOp(actual: unknown, op: SideEffectOp, expected: unknown): boolean {
|
||||
switch (op) {
|
||||
case "eq":
|
||||
return sameValue(actual, expected);
|
||||
case "neq":
|
||||
return !sameValue(actual, expected);
|
||||
case "truthy":
|
||||
return Boolean(actual);
|
||||
case "gte":
|
||||
case "lte":
|
||||
case "gt":
|
||||
case "lt": {
|
||||
const a = toNumber(actual);
|
||||
const b = toNumber(expected);
|
||||
if (a == null || b == null) return false;
|
||||
if (op === "gte") return a >= b;
|
||||
if (op === "lte") return a <= b;
|
||||
if (op === "gt") return a > b;
|
||||
return a < b;
|
||||
}
|
||||
case "changed":
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toNumber(v: unknown): number | null {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() && !Number.isNaN(Number(v))) {
|
||||
return Number(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameValue(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a == null && b == null;
|
||||
if (typeof a === "number" || typeof b === "number") {
|
||||
const na = toNumber(a);
|
||||
const nb = toNumber(b);
|
||||
return na != null && nb != null && na === nb;
|
||||
}
|
||||
return String(a) === String(b);
|
||||
}
|
||||
|
||||
export type SideEffectTrigger = {
|
||||
rule: SideEffectRule;
|
||||
reason: "edge";
|
||||
};
|
||||
|
||||
/**
|
||||
* 对本轮 prev→now 快照算边沿;同 round 同 ruleId 最多一次。
|
||||
* once:已 fired 则跳过;触发后写入 nextFired。
|
||||
* every_edge:每次边沿都触发,不记 fired。
|
||||
*/
|
||||
export function evaluateSideEffects(params: {
|
||||
prev: TableDoc | null;
|
||||
next: TableDoc;
|
||||
rules: SideEffectRule[];
|
||||
fired: FiredRegistry;
|
||||
round?: number;
|
||||
}): {
|
||||
triggers: SideEffectTrigger[];
|
||||
nextFired: FiredRegistry;
|
||||
queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }>;
|
||||
} {
|
||||
const prevMap = tableValueMap(params.prev);
|
||||
const nextMap = tableValueMap(params.next);
|
||||
const nextFired: FiredRegistry = { ...params.fired };
|
||||
const triggers: SideEffectTrigger[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queuedWorkers: Array<{ workerId: string; ruleId: string; note?: string }> =
|
||||
[];
|
||||
|
||||
for (const rule of params.rules) {
|
||||
if (seen.has(rule.id)) continue;
|
||||
if (rule.mode === "once" && nextFired[rule.id]) continue;
|
||||
|
||||
const was = conditionSatisfied(prevMap, rule, undefined);
|
||||
const now = conditionSatisfied(nextMap, rule, prevMap);
|
||||
// 边沿:prev 不满足、now 满足(changed 用 prev/now 值差)
|
||||
const edge =
|
||||
rule.op === "changed"
|
||||
? conditionSatisfied(nextMap, rule, prevMap)
|
||||
: !was && now;
|
||||
if (!edge) continue;
|
||||
|
||||
seen.add(rule.id);
|
||||
triggers.push({ rule, reason: "edge" });
|
||||
if (rule.mode === "once") {
|
||||
nextFired[rule.id] = {
|
||||
at: new Date().toISOString(),
|
||||
round: params.round,
|
||||
};
|
||||
}
|
||||
if (rule.action.type === "queue_worker") {
|
||||
queuedWorkers.push({
|
||||
workerId: rule.action.workerId,
|
||||
ruleId: rule.id,
|
||||
note: rule.action.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { triggers, nextFired, queuedWorkers };
|
||||
}
|
||||
|
||||
export function parseFiredRegistry(raw: string | undefined | null): FiredRegistry {
|
||||
if (!raw?.trim()) return {};
|
||||
try {
|
||||
const doc = JSON.parse(raw) as unknown;
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {};
|
||||
const out: FiredRegistry = {};
|
||||
for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
|
||||
if (!k.trim()) continue;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
const row = v as Record<string, unknown>;
|
||||
out[k] = {
|
||||
at: typeof row.at === "string" ? row.at : new Date().toISOString(),
|
||||
round: typeof row.round === "number" ? row.round : undefined,
|
||||
};
|
||||
} else if (v === true) {
|
||||
out[k] = { at: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyFiredRegistry(fired: FiredRegistry): string {
|
||||
return JSON.stringify(fired, null, 2);
|
||||
}
|
||||
|
||||
/** 执行 write_tag / replace_tag;queue_worker 只返回,由上层调度 */
|
||||
export function applySideEffectTagActions(params: {
|
||||
blackboard: Blackboard;
|
||||
triggers: SideEffectTrigger[];
|
||||
source?: string;
|
||||
}): { writtenTags: string[] } {
|
||||
const writtenTags: string[] = [];
|
||||
const source = params.source ?? "system:table-side-effect";
|
||||
for (const { rule } of params.triggers) {
|
||||
const action = rule.action;
|
||||
if (action.type !== "write_tag" && action.type !== "replace_tag") continue;
|
||||
params.blackboard.write({
|
||||
tag: action.tag,
|
||||
content: action.content,
|
||||
source,
|
||||
});
|
||||
writtenTags.push(action.tag);
|
||||
}
|
||||
return { writtenTags };
|
||||
}
|
||||
Reference in New Issue
Block a user