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:
@@ -4,7 +4,7 @@ role: core
|
||||
duty: >
|
||||
中立世界层:读用户输入、真值与 Progressive 投影,按规则推进并产出结构化裁决包
|
||||
(settlement.v1 → 运行.本轮.裁决);可提议变量变更;默认不写文学正文。
|
||||
when: 每轮用户输入后
|
||||
when: 每轮用户输入后首先上场
|
||||
suggested_context:
|
||||
static:
|
||||
- 设计.worker集
|
||||
@@ -14,7 +14,6 @@ suggested_context:
|
||||
- 用户.最新输入
|
||||
- 运行.事件流
|
||||
- 变量.当前
|
||||
- 运行.本轮.旁观
|
||||
- 上下文.角色态度
|
||||
- 大纲.当前章
|
||||
suggested_outputs:
|
||||
@@ -22,11 +21,10 @@ suggested_outputs:
|
||||
- 运行.事件流
|
||||
- 运行.本轮.变量变更
|
||||
prompt_excerpt: |
|
||||
你是中立主世界层(GM),不是文学作者。读真值、已投影上下文,以及本轮旁观维护包(若有)。
|
||||
你是中立主世界层(GM),不是文学作者。读真值、已投影上下文与对话历史。
|
||||
对用户行动作可复核裁决。「运行.本轮.裁决」必须是 JSON,schema 为 settlement.v1,至少尽量包含:
|
||||
player_action、resolved[]、visible_now、npc_moves[]、variable_changes[]、do_not_say[]、tone_hint。
|
||||
variable_changes 由 Runtime 合并进变量.当前——请写清 key 与 to/delta。
|
||||
叙事质量 intentionally 低——用户正文交给 narrator。
|
||||
需要真随机(骰子/抽签/比点)时:不要自己编结果;依赖已启用的机遇裁定(chance)
|
||||
写出的「运行.本轮.机遇」,或由编排器先 run_worker chance。
|
||||
需要真随机时:调用 chance 工具,在 requests 数组中一次性提交全部掷骰/抽取(每项带 id);禁止口算。
|
||||
不要把未投影的分档长文整表塞进输出;只写本轮可见后果。
|
||||
|
||||
@@ -37,6 +37,7 @@ export type DriverRunInput = {
|
||||
maxIterations?: number;
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onThinkingDone?: (text: string) => void;
|
||||
onOutputDelta?: (delta: string) => void;
|
||||
handleStep: (
|
||||
calls: DriverToolCall[],
|
||||
) => DriverStepOutcome | Promise<DriverStepOutcome>;
|
||||
@@ -109,6 +110,7 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
|
||||
];
|
||||
const streamCallbacks: StreamCallbacks = {
|
||||
onReasoningDelta: (delta) => input.onThinkingDelta?.(delta),
|
||||
onContentDelta: (delta) => input.onOutputDelta?.(delta),
|
||||
};
|
||||
const label = input.label ?? "driver";
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ import {
|
||||
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
|
||||
import {
|
||||
executeChance,
|
||||
executeChanceBatch,
|
||||
resolveChanceBatchRequest,
|
||||
resolveChanceRequest,
|
||||
} from "../skills/chance-tools.js";
|
||||
import {
|
||||
@@ -1412,21 +1414,31 @@ export class PhaseRuntime {
|
||||
).session;
|
||||
|
||||
const boardRaw = this.blackboard.getContentByTag("运行.机会请求");
|
||||
const request = resolveChanceRequest({
|
||||
const batch = resolveChanceBatchRequest({
|
||||
workerContext: effect.workerContext ?? null,
|
||||
blackboardRequestJson: boardRaw,
|
||||
});
|
||||
|
||||
const result = request
|
||||
? executeChance(request)
|
||||
: {
|
||||
schema: "chance.v1" as const,
|
||||
op: "roll" as const,
|
||||
ok: false,
|
||||
summary: "机遇失败:缺少请求(workerContext.chance 或 运行.机会请求)",
|
||||
detail: {},
|
||||
error: "missing_request",
|
||||
};
|
||||
const result = batch?.length
|
||||
? batch.length === 1
|
||||
? executeChance(batch[0]!)
|
||||
: executeChanceBatch(batch)
|
||||
: (() => {
|
||||
const single = resolveChanceRequest({
|
||||
workerContext: effect.workerContext ?? null,
|
||||
blackboardRequestJson: boardRaw,
|
||||
});
|
||||
if (single) return executeChance(single);
|
||||
return {
|
||||
schema: "chance.v1" as const,
|
||||
op: "roll" as const,
|
||||
ok: false,
|
||||
summary:
|
||||
"机遇失败:缺少请求(workerContext.chance.requests 或 运行.机会请求)",
|
||||
detail: {},
|
||||
error: "missing_request",
|
||||
};
|
||||
})();
|
||||
|
||||
const content = JSON.stringify(result, null, 2);
|
||||
const written = this.writeWorkerTagContent(
|
||||
|
||||
@@ -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,不要调用工具。
|
||||
`;
|
||||
+37
-7
@@ -1,5 +1,5 @@
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider, StreamCallbacks } from "../llm/client.js";
|
||||
import type { LlmProvider, StreamCallbacks, ChatMessage } from "../llm/client.js";
|
||||
import { supportsContentStream } from "../llm/stream-complete.js";
|
||||
import { loadWorkerSkillWithContext } from "../skills/loader.js";
|
||||
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
|
||||
@@ -21,6 +21,11 @@ import { resolveActivePreset } from "../preset/store.js";
|
||||
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
|
||||
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
|
||||
import { PRESENT_TAG } from "../skills/present-packet.js";
|
||||
import {
|
||||
gmChanceToolsEnabled,
|
||||
GM_CHANCE_HARNESS_INSTRUCTION,
|
||||
} from "../skills/gm-tools.js";
|
||||
import { runGmHarness } from "./gm-harness.js";
|
||||
import {
|
||||
mergeQuestionsPreferFragment,
|
||||
normalizeQuestions,
|
||||
@@ -38,6 +43,11 @@ import {
|
||||
serializeContextOrder,
|
||||
} from "../skills/context-order.js";
|
||||
|
||||
export type WorkerStreamCallbacks = {
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onOutputDelta?: (delta: string) => void;
|
||||
};
|
||||
|
||||
export type WorkerRunParams = {
|
||||
skillName: string;
|
||||
workerId: string;
|
||||
@@ -49,11 +59,6 @@ export type WorkerRunParams = {
|
||||
declared?: { worker: ParsedWorkerSkill; promptBody: string };
|
||||
};
|
||||
|
||||
export type WorkerStreamCallbacks = {
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onOutputDelta?: (delta: string) => void;
|
||||
};
|
||||
|
||||
export type WorkerRunResult = {
|
||||
outputs: Record<string, string>;
|
||||
summary: string;
|
||||
@@ -557,7 +562,19 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
|
||||
}
|
||||
|
||||
const systemContent = promptBody + WORKER_OUTPUT_INSTRUCTION;
|
||||
const useGmHarness =
|
||||
isPlayLayerActive(params.slots) &&
|
||||
worker.id === "world-simulator" &&
|
||||
gmChanceToolsEnabled(
|
||||
parseWorkerSetYaml(
|
||||
params.blackboard.getContentByTag("设计.worker集") ?? "",
|
||||
)?.play_slots,
|
||||
);
|
||||
|
||||
const systemContent =
|
||||
promptBody +
|
||||
WORKER_OUTPUT_INSTRUCTION +
|
||||
(useGmHarness ? GM_CHANCE_HARNESS_INSTRUCTION : "");
|
||||
const playPreset = isPlayLayerActive(params.slots)
|
||||
? resolveActivePreset(loadAppSettings().activePresetId)
|
||||
: null;
|
||||
@@ -589,6 +606,19 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
},
|
||||
];
|
||||
|
||||
if (useGmHarness) {
|
||||
const systemMsg = messages.find((m) => m.role === "system");
|
||||
const rest = messages.filter((m) => m.role !== "system") as ChatMessage[];
|
||||
const harness = await runGmHarness({
|
||||
llm: params.llm,
|
||||
system: systemMsg?.content ?? systemContent,
|
||||
messages: rest,
|
||||
stream: params.stream,
|
||||
caller: `worker:${worker.id}`,
|
||||
});
|
||||
return parseWorkerResponse(harness.content, worker.outputTags);
|
||||
}
|
||||
|
||||
const result = await completeWorkerPreferStream(
|
||||
params.llm,
|
||||
messages,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 主世界层(world-simulator)游玩期 tool harness:
|
||||
* 反复 completeWithTools 直到不再调用工具,最后一次正文视为裁决 JSON。
|
||||
*/
|
||||
import type { ChatMessage, LlmProvider } from "../llm/client.js";
|
||||
import {
|
||||
createLocalLlmDriver,
|
||||
type DriverToolCall,
|
||||
} from "../runtime/driver.js";
|
||||
import {
|
||||
GM_CHANCE_TOOL_NAME,
|
||||
GM_TOOL_DEFINITIONS,
|
||||
handleGmToolCall,
|
||||
} from "../skills/gm-tools.js";
|
||||
|
||||
export type GmHarnessStreamCallbacks = {
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onOutputDelta?: (delta: string) => void;
|
||||
};
|
||||
|
||||
export const DEFAULT_GM_HARNESS_MAX_ITERATIONS = 6;
|
||||
|
||||
export type GmHarnessParams = {
|
||||
llm: LlmProvider;
|
||||
system: string;
|
||||
messages: ChatMessage[];
|
||||
stream?: GmHarnessStreamCallbacks;
|
||||
maxIterations?: number;
|
||||
caller?: string;
|
||||
};
|
||||
|
||||
export type GmHarnessResult = {
|
||||
content: string;
|
||||
iterations: number;
|
||||
};
|
||||
|
||||
export async function runGmHarness(
|
||||
params: GmHarnessParams,
|
||||
): Promise<GmHarnessResult> {
|
||||
const driver = createLocalLlmDriver(params.llm);
|
||||
const label = "主世界层 harness";
|
||||
|
||||
const result = await driver.run({
|
||||
system: params.system,
|
||||
messages: params.messages,
|
||||
tools: GM_TOOL_DEFINITIONS,
|
||||
caller: params.caller ?? "worker:world-simulator",
|
||||
label,
|
||||
maxIterations: params.maxIterations ?? DEFAULT_GM_HARNESS_MAX_ITERATIONS,
|
||||
onThinkingDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
|
||||
onOutputDelta: (delta) => params.stream?.onOutputDelta?.(delta),
|
||||
onThinkingDone: () => {
|
||||
/* 思维链由 stream 增量展示 */
|
||||
},
|
||||
handleStep: (calls: DriverToolCall[]) => {
|
||||
const results: Array<{ callId: string; content: string }> = [];
|
||||
for (const call of calls) {
|
||||
if (call.name !== GM_CHANCE_TOOL_NAME) {
|
||||
results.push({
|
||||
callId: call.id,
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: `不允许的工具:${call.name}`,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const content =
|
||||
handleGmToolCall(call.name, call.arguments) ??
|
||||
JSON.stringify({ ok: false, error: "未知工具" });
|
||||
results.push({ callId: call.id, content });
|
||||
}
|
||||
return { kind: "continue", results };
|
||||
},
|
||||
});
|
||||
|
||||
if (result.stop.kind === "text") {
|
||||
return { content: result.stop.content, iterations: result.iterations };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${label} 以边界 tool 结束(${result.stop.call.name}),主世界层仅允许 ${GM_CHANCE_TOOL_NAME}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
executeChanceBatch,
|
||||
parseChanceBatchRequest,
|
||||
} from "../src/skills/chance-tools.js";
|
||||
import {
|
||||
executeGmChanceTool,
|
||||
gmChanceToolsEnabled,
|
||||
} from "../src/skills/gm-tools.js";
|
||||
|
||||
describe("chance batch", () => {
|
||||
it("parses requests array", () => {
|
||||
const items = parseChanceBatchRequest({
|
||||
requests: [
|
||||
{ id: "door", op: "roll", expression: "1d20" },
|
||||
{ id: "win", op: "compare", left: 15, right: 12 },
|
||||
],
|
||||
});
|
||||
expect(items?.map((x) => x.id)).toEqual(["door", "win"]);
|
||||
});
|
||||
|
||||
it("executeGmChanceTool returns batch schema", () => {
|
||||
const parsed = executeGmChanceTool(
|
||||
JSON.stringify({
|
||||
requests: [{ id: "x", op: "roll", expression: "1d6" }],
|
||||
}),
|
||||
);
|
||||
expect(parsed.schema).toBe("chance.batch.v1");
|
||||
expect(parsed.results[0]!.id).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gmChanceToolsEnabled", () => {
|
||||
it("defaults on when play_slots omitted", () => {
|
||||
expect(gmChanceToolsEnabled(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects chance=false", () => {
|
||||
expect(gmChanceToolsEnabled({ chance: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("enables when chance=true", () => {
|
||||
expect(gmChanceToolsEnabled({ chance: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../src/blackboard/table-cells.js";
|
||||
import {
|
||||
executeChance,
|
||||
executeChanceBatch,
|
||||
parseChanceRequest,
|
||||
} from "../src/skills/chance-tools.js";
|
||||
import { synthesizeContextOrderFromWorkers } from "../src/skills/context-order.js";
|
||||
@@ -185,15 +186,16 @@ describe("chance tools", () => {
|
||||
expect(result.detail.win).toBe(true);
|
||||
});
|
||||
|
||||
it("draws from pool", () => {
|
||||
const result = executeChance({
|
||||
op: "draw",
|
||||
pool: ["甲", "乙", "丙"],
|
||||
count: 2,
|
||||
unique: true,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect((result.detail.drawn as string[]).length).toBe(2);
|
||||
it("batch executes multiple requests with ids", () => {
|
||||
const batch = executeChanceBatch([
|
||||
{ id: "a", op: "roll", expression: "1d6" },
|
||||
{ id: "b", op: "compare", left: 10, right: 5, mode: "gte" },
|
||||
]);
|
||||
expect(batch.schema).toBe("chance.batch.v1");
|
||||
expect(batch.ok).toBe(true);
|
||||
expect(batch.results).toHaveLength(2);
|
||||
expect(batch.results[0]!.id).toBe("a");
|
||||
expect(batch.results[1]!.id).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user