Add gm harness with batch chance tools for play.

Main world layer can loop on the chance tool (requests array) until settlement JSON. Driver forwards output deltas; standalone chance worker accepts batch requests. play_slots.chance gates the harness.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 00:49:19 +08:00
co-authored by Cursor
parent 6b4156fd0f
commit 8360e885bb
9 changed files with 428 additions and 32 deletions
+104
View File
@@ -52,6 +52,19 @@ export type ChanceResult = {
error?: string;
};
export const CHANCE_BATCH_SCHEMA = "chance.batch.v1" as const;
export type ChanceBatchRequestItem = ChanceRequest & { id: string };
export type ChanceBatchResultItem = ChanceResult & { id: string };
export type ChanceBatchResult = {
schema: typeof CHANCE_BATCH_SCHEMA;
ok: boolean;
results: ChanceBatchResultItem[];
summary: string;
};
const DICE_RE = /^\s*(\d*)\s*[dD]\s*(\d+)\s*([+-]\s*\d+)?\s*$/;
function randInt(min: number, max: number): number {
@@ -305,9 +318,100 @@ function executePick(req: ChancePickRequest, reason?: string): ChanceResult {
}
/** 从黑板正文或 workerContext 解析请求 */
export function parseChanceBatchRequest(
raw: unknown,
): ChanceBatchRequestItem[] | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
const listRaw = row.requests ?? row.batch ?? row.items;
if (!Array.isArray(listRaw)) return null;
const out: ChanceBatchRequestItem[] = [];
for (const item of listRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const entry = item as Record<string, unknown>;
const id =
typeof entry.id === "string"
? entry.id.trim()
: typeof entry.name === "string"
? entry.name.trim()
: "";
if (!id) continue;
const { id: _drop, name: _n, ...rest } = entry;
const req = parseChanceRequest(rest);
if (!req) continue;
out.push({ ...req, id });
}
return out.length ? out : null;
}
/** 一次性执行多条随机请求;每项必须有稳定 id 供裁决引用 */
export function executeChanceBatch(
items: ChanceBatchRequestItem[],
): ChanceBatchResult {
const results: ChanceBatchResultItem[] = items.map((item) => {
const { id, ...req } = item;
const result = executeChance(req);
return { ...result, id };
});
const ok = results.every((r) => r.ok);
const summary = results.map((r) => `${r.id}: ${r.summary}`).join(";");
return {
schema: CHANCE_BATCH_SCHEMA,
ok,
results,
summary: ok ? summary : `部分失败:${summary}`,
};
}
/** 从黑板正文或 workerContext 解析请求(单条或批量) */
export function resolveChanceRequest(params: {
workerContext?: Record<string, unknown> | null;
blackboardRequestJson?: string | null;
}): ChanceRequest | null {
const batch = resolveChanceBatchRequest(params);
if (batch?.length === 1) {
const { id: _id, ...single } = batch[0]!;
return single;
}
return resolveChanceSingleRequest(params);
}
/** 批量解析:workerContext / 黑板 JSON */
export function resolveChanceBatchRequest(params: {
workerContext?: Record<string, unknown> | null;
blackboardRequestJson?: string | null;
}): ChanceBatchRequestItem[] | null {
const ctx = params.workerContext;
if (ctx && typeof ctx === "object") {
const nested = ctx.chance ?? ctx.request ?? ctx;
const batch = parseChanceBatchRequest(nested);
if (batch) return batch;
const single = parseChanceRequest(nested);
if (single) {
const id =
typeof (nested as Record<string, unknown>).id === "string"
? String((nested as Record<string, unknown>).id).trim()
: "chance-1";
return [{ ...single, id: id || "chance-1" }];
}
}
const raw = params.blackboardRequestJson?.trim();
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
const batch = parseChanceBatchRequest(parsed);
if (batch) return batch;
const single = parseChanceRequest(parsed);
if (single) return [{ ...single, id: "chance-1" }];
} catch {
return null;
}
return null;
}
function resolveChanceSingleRequest(params: {
workerContext?: Record<string, unknown> | null;
blackboardRequestJson?: string | null;
}): ChanceRequest | null {
const ctx = params.workerContext;
if (ctx && typeof ctx === "object") {
+119
View File
@@ -0,0 +1,119 @@
/**
* 主世界层 harness 可用工具(游玩期)。
* 仅挂载「本轮裁决缺了就没法写」的程序能力;转述/旁观/调度不在此列。
*/
import type { ToolDefinition } from "../llm/client.js";
import {
executeChanceBatch,
parseChanceBatchRequest,
type ChanceBatchResult,
} from "./chance-tools.js";
export const GM_CHANCE_TOOL_NAME = "chance" as const;
export const GM_TOOL_DEFINITIONS: ToolDefinition[] = [
{
type: "function",
function: {
name: GM_CHANCE_TOOL_NAME,
description:
"批量程序随机:掷骰、比点、抽签/加权抽取。需要多个随机时必须在 requests 数组中一次性全部提交;禁止口算或编造随机结果。",
parameters: {
type: "object",
properties: {
requests: {
type: "array",
description:
"本步所需的全部随机请求;每项必须有唯一 id,供后续 settlement 引用",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "本批内唯一标识,如 attack-roll、loot-pick",
},
op: {
type: "string",
enum: ["roll", "compare", "draw", "pick"],
},
expression: {
type: "string",
description: "roll 时:如 1d20、2d6+3",
},
left: { type: "number", description: "compare 时:左值" },
right: { type: "number", description: "compare 时:右值" },
mode: {
type: "string",
enum: ["gt", "gte", "lt", "lte", "eq"],
},
pool: {
type: "array",
items: { type: "string" },
description: "draw 时:候选池",
},
count: { type: "number" },
unique: { type: "boolean" },
items: {
type: "array",
description: "pick 时:{ id, weight? }[]",
},
reason: { type: "string" },
},
required: ["id", "op"],
},
},
},
required: ["requests"],
additionalProperties: false,
},
},
},
];
export function executeGmChanceTool(argumentsJson: string): ChanceBatchResult {
let args: unknown;
try {
args = JSON.parse(argumentsJson || "{}");
} catch {
return {
schema: "chance.batch.v1",
ok: false,
results: [],
summary: "机遇失败:工具参数不是合法 JSON",
};
}
const items = parseChanceBatchRequest(args);
if (!items?.length) {
return {
schema: "chance.batch.v1",
ok: false,
results: [],
summary: "机遇失败:requests 须为非空数组,且每项含 id 与 op",
};
}
return executeChanceBatch(items);
}
export function handleGmToolCall(
name: string,
argumentsJson: string,
): string | null {
if (name !== GM_CHANCE_TOOL_NAME) return null;
return JSON.stringify(executeGmChanceTool(argumentsJson), null, 2);
}
/** 游玩拓扑 play_slots.chance:启用主世界层机遇工具 harness */
export function gmChanceToolsEnabled(
playSlots: { chance?: boolean } | undefined,
): boolean {
if (!playSlots) return true;
return playSlots.chance !== false;
}
export const GM_CHANCE_HARNESS_INSTRUCTION = `
## 机遇工具(游玩期)
需要真随机(掷骰/比点/抽签/加权抽取)时:调用工具 \`chance\`,在 \`requests\` 数组中**一次性**列出本步全部随机需求(每项必须有唯一 \`id\`)。
禁止口算或编造随机结果。收到工具结果后,再在 settlement 的 \`resolved\` 等处引用对应 \`id\` 写出后果。
无随机需求时:直接输出 settlement.v1 JSON,不要调用工具。
`;