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:
@@ -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") {
|
||||
|
||||
@@ -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,不要调用工具。
|
||||
`;
|
||||
Reference in New Issue
Block a user