pi-roundtable 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/docs/plugins.md +100 -5
- package/examples/shared-services.test.ts +28 -0
- package/examples/shared-services.ts +31 -0
- package/package.json +6 -3
- package/src/cli/add-plugin.ts +4 -3
- package/src/cli/cli.ts +3 -0
- package/src/cli/templates.ts +21 -4
- package/src/core/contract/services.ts +7 -0
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/host.ts +7 -0
- package/src/core/http/listeners.ts +5 -25
- package/src/core/modules/delegation/web-research-worker.ts +14 -1
- package/src/core/plugin.ts +14 -0
- package/src/core/registry/contributions.ts +1 -0
- package/src/core/registry/services.ts +73 -1
- package/src/core/runtime/session-factory.ts +1 -1
- package/src/core/shared/package-dir.ts +8 -5
- package/src/core/shared/unix-server.ts +26 -0
- package/src/core/testing/partial.ts +21 -0
- package/src/core/testing/recording-logger.ts +44 -0
- package/src/core/testing/test-host.ts +14 -1
- package/src/discord/index.ts +1 -1
- package/src/kit/channels.ts +1 -1
- package/src/kit/domain.ts +1 -1
- package/src/kit/holds.ts +1 -1
- package/src/kit/index.ts +2 -1
- package/src/kit/judging.ts +1 -1
- package/src/kit/memory.ts +1 -1
- package/src/kit/mirror.ts +1 -1
- package/src/kit/presentation.ts +1 -1
- package/src/kit/process.ts +5 -0
- package/src/kit/shell.ts +1 -1
- package/src/kit/skills.ts +1 -1
- package/src/kit/support.ts +1 -1
- package/src/kit/threads.ts +1 -1
- package/src/kit/tools.ts +1 -1
- package/src/kit/worker.ts +1 -1
- package/src/testing.ts +16 -2
- package/templates/agents.ts +5 -0
- package/templates/official/codex-images/plugin.test.ts.tmpl +118 -0
- package/templates/official/codex-images/plugin.ts +215 -0
- package/templates/official/dice/plugin.test.ts.tmpl +105 -0
- package/templates/official/dice/plugin.ts +166 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Warning: this plugin uses ChatGPT's undocumented Codex backend, signed in with the owner's own
|
|
2
|
+
// ChatGPT subscription (the `openai-codex` login), to draw images. OpenAI has not published it
|
|
3
|
+
// as an API, so it can stop working without notice, and OpenAI's terms for the subscription
|
|
4
|
+
// apply to it. Use it only where you accept that risk.
|
|
5
|
+
|
|
6
|
+
import { definePlugin, PluginError, type ReferenceImage } from "pi-roundtable";
|
|
7
|
+
|
|
8
|
+
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
9
|
+
/** The Pi provider whose login pays for the images. */
|
|
10
|
+
const CODEX_PROVIDER = "openai-codex";
|
|
11
|
+
/** Routes the Codex request; the backend picks the image model itself. */
|
|
12
|
+
const CODEX_MODEL = "gpt-6-sol";
|
|
13
|
+
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
16
|
+
const MAX_IMAGE_BASE64 = Math.ceil(MAX_IMAGE_BYTES / 3) * 4;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Codex ran the image tool but returned no image, usually because it refused the prompt.
|
|
20
|
+
* Asking again with the same prompt fails again.
|
|
21
|
+
*/
|
|
22
|
+
export class ImageNotGeneratedError extends Error {
|
|
23
|
+
override name = "ImageNotGeneratedError";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The part of `fetch` the plugin uses, so a test can stand in for the network. */
|
|
27
|
+
export type CodexFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
28
|
+
|
|
29
|
+
export interface CodexImagesOptions {
|
|
30
|
+
/** The request function; default the global `fetch`. */
|
|
31
|
+
fetch?: CodexFetch;
|
|
32
|
+
/** The model that routes the request; default `gpt-6-sol`. */
|
|
33
|
+
model?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The ChatGPT account the login token belongs to, from the token's claims. */
|
|
37
|
+
function chatGptAccountId(token: string): string {
|
|
38
|
+
const payload = token.split(".")[1];
|
|
39
|
+
if (!payload) throw new Error("the Codex token is not a JWT");
|
|
40
|
+
let claims: Record<string, unknown>;
|
|
41
|
+
try {
|
|
42
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error("the Codex token's claims are not JSON");
|
|
45
|
+
}
|
|
46
|
+
const auth = claims[JWT_CLAIM_PATH] as
|
|
47
|
+
| { chatgpt_account_id?: unknown }
|
|
48
|
+
| undefined;
|
|
49
|
+
const id = auth?.chatgpt_account_id;
|
|
50
|
+
if (typeof id !== "string" || !id)
|
|
51
|
+
throw new Error("the Codex token has no ChatGPT account");
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function object(value: unknown): Record<string, unknown> {
|
|
56
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
57
|
+
? (value as Record<string, unknown>)
|
|
58
|
+
: {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reads the server-sent events until the response completes, and returns its one image. */
|
|
62
|
+
async function parseImageStream(response: Response): Promise<Uint8Array> {
|
|
63
|
+
if (!response.body) throw new Error("Codex returned no body");
|
|
64
|
+
let image: Uint8Array | undefined;
|
|
65
|
+
let completed = false;
|
|
66
|
+
const take = (value: unknown) => {
|
|
67
|
+
const item = object(value);
|
|
68
|
+
if (item.type !== "image_generation_call" || image) return;
|
|
69
|
+
if (
|
|
70
|
+
item.status !== "completed" ||
|
|
71
|
+
typeof item.result !== "string" ||
|
|
72
|
+
!item.result
|
|
73
|
+
) {
|
|
74
|
+
const { result: _result, ...detail } = item;
|
|
75
|
+
throw new ImageNotGeneratedError(
|
|
76
|
+
`Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (item.result.length > MAX_IMAGE_BASE64)
|
|
80
|
+
throw new Error("the image exceeds 32 MiB");
|
|
81
|
+
image = new Uint8Array(Buffer.from(item.result, "base64"));
|
|
82
|
+
};
|
|
83
|
+
const handle = (frame: string) => {
|
|
84
|
+
const data = frame
|
|
85
|
+
.split(/\r?\n/)
|
|
86
|
+
.filter((line) => line.startsWith("data:"))
|
|
87
|
+
.map((line) => line.slice(5).trim())
|
|
88
|
+
.join("\n");
|
|
89
|
+
if (!data || data === "[DONE]") return;
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(data);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
|
|
95
|
+
}
|
|
96
|
+
const event = object(parsed);
|
|
97
|
+
switch (event.type) {
|
|
98
|
+
case "error":
|
|
99
|
+
case "response.failed": {
|
|
100
|
+
const error = object(object(event.response).error ?? event.error);
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Codex failed: ${String(error.message ?? error.code ?? "unknown error")}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
case "response.incomplete":
|
|
106
|
+
throw new Error("the Codex response was incomplete");
|
|
107
|
+
case "response.output_item.done":
|
|
108
|
+
take(event.item);
|
|
109
|
+
break;
|
|
110
|
+
case "response.completed": {
|
|
111
|
+
const output = object(event.response).output;
|
|
112
|
+
if (!image && Array.isArray(output))
|
|
113
|
+
for (const item of output) take(item);
|
|
114
|
+
completed = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const decoder = new TextDecoder();
|
|
120
|
+
let buffer = "";
|
|
121
|
+
for await (const chunk of response.body) {
|
|
122
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
123
|
+
let match = /\r?\n\r?\n/.exec(buffer);
|
|
124
|
+
while (match && !completed) {
|
|
125
|
+
handle(buffer.slice(0, match.index));
|
|
126
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
127
|
+
match = /\r?\n\r?\n/.exec(buffer);
|
|
128
|
+
}
|
|
129
|
+
if (completed) break;
|
|
130
|
+
}
|
|
131
|
+
if (!completed && buffer.trim()) handle(buffer);
|
|
132
|
+
if (!completed) throw new Error("the Codex stream ended before completion");
|
|
133
|
+
if (!image)
|
|
134
|
+
throw new ImageNotGeneratedError("Codex answered without an image");
|
|
135
|
+
return image;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function generateImage(
|
|
139
|
+
prompt: string,
|
|
140
|
+
references: readonly ReferenceImage[],
|
|
141
|
+
token: string,
|
|
142
|
+
model: string,
|
|
143
|
+
fetchImpl: CodexFetch,
|
|
144
|
+
): Promise<Uint8Array> {
|
|
145
|
+
const response = await fetchImpl(CODEX_RESPONSES_URL, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
redirect: "error",
|
|
148
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
149
|
+
headers: {
|
|
150
|
+
authorization: `Bearer ${token}`,
|
|
151
|
+
"chatgpt-account-id": chatGptAccountId(token),
|
|
152
|
+
originator: "pi",
|
|
153
|
+
"openai-beta": "responses=experimental",
|
|
154
|
+
accept: "text/event-stream",
|
|
155
|
+
"content-type": "application/json",
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
model,
|
|
159
|
+
store: false,
|
|
160
|
+
stream: true,
|
|
161
|
+
instructions:
|
|
162
|
+
"You are generating bitmap image assets. For this request, call the image_generation tool exactly once. Do not answer with only text unless image generation is unavailable.",
|
|
163
|
+
input: [
|
|
164
|
+
{
|
|
165
|
+
role: "user",
|
|
166
|
+
content: [
|
|
167
|
+
{ type: "input_text", text: prompt },
|
|
168
|
+
...references.map((image) => ({
|
|
169
|
+
type: "input_image",
|
|
170
|
+
image_url: `data:${image.mimeType};base64,${image.data}`,
|
|
171
|
+
})),
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
tools: [{ type: "image_generation", output_format: "png" }],
|
|
176
|
+
tool_choice: "auto",
|
|
177
|
+
parallel_tool_calls: false,
|
|
178
|
+
text: { verbosity: "low" },
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const detail = (await response.text()).slice(0, 300);
|
|
183
|
+
throw new Error(`Codex answered ${response.status}: ${detail}`);
|
|
184
|
+
}
|
|
185
|
+
return parseImageStream(response);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const NO_LOGIN = `the host has no login for ${CODEX_PROVIDER}, the provider that pays for the images. Log in to the ${CODEX_PROVIDER} provider with Pi so the agent directory's auth.json holds it, or remove this plugin.`;
|
|
189
|
+
|
|
190
|
+
/** The plugin, with its request function and model replaceable for a test. */
|
|
191
|
+
export function createCodexImages(options: CodexImagesOptions = {}) {
|
|
192
|
+
const { fetch: fetchImpl = fetch, model = CODEX_MODEL } = options;
|
|
193
|
+
let apiKey: ((provider: string) => Promise<string | undefined>) | undefined;
|
|
194
|
+
// The login refreshes its token, so each image reads it again instead of keeping the first.
|
|
195
|
+
const token = async (): Promise<string> => {
|
|
196
|
+
const value = await apiKey?.(CODEX_PROVIDER);
|
|
197
|
+
if (!value) throw new PluginError(`plugin codex-images: ${NO_LOGIN}`);
|
|
198
|
+
return value;
|
|
199
|
+
};
|
|
200
|
+
return definePlugin({
|
|
201
|
+
name: "codex-images",
|
|
202
|
+
providers: {
|
|
203
|
+
// The images slot draws an agent's avatar from a prompt and reference pictures.
|
|
204
|
+
images: async (prompt, references) =>
|
|
205
|
+
generateImage(prompt, references, await token(), model, fetchImpl),
|
|
206
|
+
},
|
|
207
|
+
async setup(context) {
|
|
208
|
+
apiKey = context.apiKey;
|
|
209
|
+
await token();
|
|
210
|
+
return {};
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const codexImages = createCodexImages();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
3
|
+
import { createDice, dice, type Random, roll } from "./dice.ts";
|
|
4
|
+
|
|
5
|
+
/** A random source that answers with the faces given, as `[face, sides]` for each die in turn. */
|
|
6
|
+
function dieFaces(...rolled: [face: number, sides: number][]): Random {
|
|
7
|
+
const queue = rolled.map(([face, sides]) => (face - 1) / sides + 0.0001);
|
|
8
|
+
return () => {
|
|
9
|
+
const next = queue.shift();
|
|
10
|
+
if (next === undefined)
|
|
11
|
+
throw new Error("the test rolled more dice than it gave");
|
|
12
|
+
return next;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The faces given, for dice that all have `sides` sides. */
|
|
17
|
+
const faces = (sides: number, ...rolled: number[]): Random =>
|
|
18
|
+
dieFaces(...rolled.map((face): [number, number] => [face, sides]));
|
|
19
|
+
|
|
20
|
+
test("a die rolls from 1 to its sides, and a number adds itself", () => {
|
|
21
|
+
expect(roll("2d6+3", faces(6, 3, 5))).toBe("2d6+3: [3, 5] + 3 = 11");
|
|
22
|
+
expect(roll("d20", faces(20, 20))).toBe("d20: [20] = 20");
|
|
23
|
+
expect(roll(" 1D4 ", faces(4, 1))).toBe("1D4: [1] = 1");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("several groups add and subtract in order", () => {
|
|
27
|
+
expect(roll("2d6-1d4+2", dieFaces([4, 6], [2, 6], [1, 4]))).toBe(
|
|
28
|
+
"2d6-1d4+2: [4, 2] - [1] + 2 = 7",
|
|
29
|
+
);
|
|
30
|
+
expect(roll("-d6", faces(6, 2))).toBe("-d6: -[2] = -2");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("keep and drop rules remove dice and show them in parentheses", () => {
|
|
34
|
+
const six = (...rolled: number[]) => faces(6, ...rolled);
|
|
35
|
+
expect(roll("4d6k3", six(5, 1, 6, 3))).toBe("4d6k3: [5, (1), 6, 3] = 14");
|
|
36
|
+
expect(roll("4d6kh3", six(5, 1, 6, 3))).toBe("4d6kh3: [5, (1), 6, 3] = 14");
|
|
37
|
+
expect(roll("4d6kl1", six(5, 1, 6, 3))).toBe(
|
|
38
|
+
"4d6kl1: [(5), 1, (6), (3)] = 1",
|
|
39
|
+
);
|
|
40
|
+
expect(roll("4d6d1", six(5, 1, 6, 3))).toBe("4d6d1: [5, (1), 6, 3] = 14");
|
|
41
|
+
expect(roll("4d6dl1", six(5, 1, 6, 3))).toBe("4d6dl1: [5, (1), 6, 3] = 14");
|
|
42
|
+
expect(roll("4d6dh1", six(5, 1, 6, 3))).toBe("4d6dh1: [5, 1, (6), 3] = 9");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("fate dice add -1, 0, and +1 and default to four", () => {
|
|
46
|
+
const random = (() => {
|
|
47
|
+
const queue = [0.1, 0.5, 0.9, 0.9];
|
|
48
|
+
return () => queue.shift() ?? 0;
|
|
49
|
+
})();
|
|
50
|
+
expect(roll("dF", random)).toBe("dF: [-, 0, +, +] = 1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a bad expression is refused with what to fix", () => {
|
|
54
|
+
for (const bad of [
|
|
55
|
+
"",
|
|
56
|
+
" ",
|
|
57
|
+
"abc",
|
|
58
|
+
"2d",
|
|
59
|
+
"d1",
|
|
60
|
+
"0d6",
|
|
61
|
+
"2d6+",
|
|
62
|
+
"2d6++1",
|
|
63
|
+
"2d6 d4",
|
|
64
|
+
"4d6k4",
|
|
65
|
+
"4d6k0",
|
|
66
|
+
"4d6d9",
|
|
67
|
+
"1d6x",
|
|
68
|
+
])
|
|
69
|
+
expect(() => roll(bad, () => 0.5)).toThrow("Write an expression such as");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the caps on dice, sides, and length are refused", () => {
|
|
73
|
+
expect(() => roll("101d6", () => 0.5)).toThrow("more than 100 dice");
|
|
74
|
+
expect(() => roll("60d6+60d6", () => 0.5)).toThrow(
|
|
75
|
+
"more than 100 dice in all",
|
|
76
|
+
);
|
|
77
|
+
expect(() => roll("1d1001", () => 0.5)).toThrow("between 2 and 1000 sides");
|
|
78
|
+
expect(() => roll("d6+1000001", () => 0.5)).toThrow("is over 1000000");
|
|
79
|
+
expect(() => roll(`1+${"1+".repeat(120)}1`, () => 0.5)).toThrow(
|
|
80
|
+
"over 200 characters",
|
|
81
|
+
);
|
|
82
|
+
expect(roll("100d2", () => 0.99)).toContain("= 200");
|
|
83
|
+
expect(roll("1d1000", () => 0.9999)).toBe("1d1000: [1000] = 1000");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the roll_dice tool is for members and answers a bad expression as text", async () => {
|
|
87
|
+
const harness = await testPlugin(createDice(faces(6, 3, 5)));
|
|
88
|
+
expect(harness.tools).toEqual(["roll_dice"]);
|
|
89
|
+
expect(harness.tiers.minTier("roll_dice")).toBe("member");
|
|
90
|
+
expect(await harness.runTool("roll_dice", { expression: "2d6+3" })).toBe(
|
|
91
|
+
"2d6+3: [3, 5] + 3 = 11",
|
|
92
|
+
);
|
|
93
|
+
expect(await harness.runTool("roll_dice", { expression: "nope" })).toContain(
|
|
94
|
+
"is not a dice term",
|
|
95
|
+
);
|
|
96
|
+
await harness.stop();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("the ready-made plugin rolls with a real random source", async () => {
|
|
100
|
+
const harness = await testPlugin(dice);
|
|
101
|
+
expect(await harness.runTool("roll_dice", { expression: "d6" })).toMatch(
|
|
102
|
+
/^d6: \[[1-6]\] = [1-6]$/,
|
|
103
|
+
);
|
|
104
|
+
await harness.stop();
|
|
105
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { definePlugin, defineTool, ToolRefusal } from "pi-roundtable";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
/** Returns a number from 0 up to, not including, 1, like `Math.random`. */
|
|
5
|
+
export type Random = () => number;
|
|
6
|
+
|
|
7
|
+
const MAX_DICE = 100;
|
|
8
|
+
const MAX_SIDES = 1000;
|
|
9
|
+
const MAX_LENGTH = 200;
|
|
10
|
+
const MAX_MODIFIER = 1_000_000;
|
|
11
|
+
const FATE_DEFAULT = 4;
|
|
12
|
+
|
|
13
|
+
type KeepDrop = "k" | "kh" | "kl" | "d" | "dh" | "dl";
|
|
14
|
+
|
|
15
|
+
const TERM = /^(\d*)d(?:(f)|(\d+)(?:(kh|kl|dh|dl|k|d)(\d+))?)$/;
|
|
16
|
+
|
|
17
|
+
const refuse = (problem: string): never => {
|
|
18
|
+
throw new ToolRefusal(
|
|
19
|
+
`${problem} Write an expression such as 2d6+3, d20, 4d6k3, or 4dF.`,
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The indexes a keep or drop rule removes; `rolls` has more than `n` dice. */
|
|
24
|
+
function removed(
|
|
25
|
+
rolls: readonly number[],
|
|
26
|
+
rule: KeepDrop,
|
|
27
|
+
n: number,
|
|
28
|
+
): number[] {
|
|
29
|
+
const ascending = rolls
|
|
30
|
+
.map((value, index) => ({ value, index }))
|
|
31
|
+
.sort((a, b) => a.value - b.value || a.index - b.index)
|
|
32
|
+
.map((die) => die.index);
|
|
33
|
+
switch (rule) {
|
|
34
|
+
case "k":
|
|
35
|
+
case "kh":
|
|
36
|
+
return ascending.slice(0, rolls.length - n);
|
|
37
|
+
case "kl":
|
|
38
|
+
return ascending.slice(n);
|
|
39
|
+
case "d":
|
|
40
|
+
case "dl":
|
|
41
|
+
return ascending.slice(0, n);
|
|
42
|
+
case "dh":
|
|
43
|
+
return ascending.slice(rolls.length - n);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One term rolled: what it shows, and what it adds before its sign applies. */
|
|
48
|
+
interface Rolled {
|
|
49
|
+
shown: string;
|
|
50
|
+
value: number;
|
|
51
|
+
dice: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rollTerm(term: string, random: Random): Rolled {
|
|
55
|
+
if (/^\d+$/.test(term)) {
|
|
56
|
+
if (term.length > 7 || Number(term) > MAX_MODIFIER)
|
|
57
|
+
return refuse(`The number ${term} is over ${MAX_MODIFIER}.`);
|
|
58
|
+
return { shown: String(Number(term)), value: Number(term), dice: 0 };
|
|
59
|
+
}
|
|
60
|
+
const match = TERM.exec(term);
|
|
61
|
+
if (!match) return refuse(`"${term}" is not a dice term.`);
|
|
62
|
+
const [, countText, fate, sidesText, rule, nText] = match;
|
|
63
|
+
const count = countText ? Number(countText) : fate ? FATE_DEFAULT : 1;
|
|
64
|
+
if (count < 1) return refuse(`"${term}" rolls no dice.`);
|
|
65
|
+
if (count > MAX_DICE)
|
|
66
|
+
return refuse(`"${term}" rolls more than ${MAX_DICE} dice.`);
|
|
67
|
+
if (fate) {
|
|
68
|
+
const faces = Array.from(
|
|
69
|
+
{ length: count },
|
|
70
|
+
() => Math.floor(random() * 3) - 1,
|
|
71
|
+
);
|
|
72
|
+
const symbols = faces.map((face) =>
|
|
73
|
+
face > 0 ? "+" : face < 0 ? "-" : "0",
|
|
74
|
+
);
|
|
75
|
+
return {
|
|
76
|
+
shown: `[${symbols.join(", ")}]`,
|
|
77
|
+
value: faces.reduce((sum, face) => sum + face, 0),
|
|
78
|
+
dice: count,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const sides = Number(sidesText);
|
|
82
|
+
if (sides < 2 || sides > MAX_SIDES)
|
|
83
|
+
return refuse(`"${term}" needs between 2 and ${MAX_SIDES} sides.`);
|
|
84
|
+
const rolls = Array.from(
|
|
85
|
+
{ length: count },
|
|
86
|
+
() => Math.floor(random() * sides) + 1,
|
|
87
|
+
);
|
|
88
|
+
const dropped = new Set<number>();
|
|
89
|
+
if (rule) {
|
|
90
|
+
const n = Number(nText);
|
|
91
|
+
if (n < 1 || n >= count)
|
|
92
|
+
return refuse(
|
|
93
|
+
`"${term}" must keep or drop at least 1 and fewer than ${count} dice.`,
|
|
94
|
+
);
|
|
95
|
+
for (const index of removed(rolls, rule as KeepDrop, n)) dropped.add(index);
|
|
96
|
+
}
|
|
97
|
+
const shown = rolls.map((value, index) =>
|
|
98
|
+
dropped.has(index) ? `(${value})` : String(value),
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
shown: `[${shown.join(", ")}]`,
|
|
102
|
+
value: rolls.reduce(
|
|
103
|
+
(sum, value, index) => sum + (dropped.has(index) ? 0 : value),
|
|
104
|
+
0,
|
|
105
|
+
),
|
|
106
|
+
dice: count,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Rolls an expression of dice terms and numbers joined by `+` and `-`, and writes the result as
|
|
112
|
+
* text such as `2d6+3: [3, 5] + 3 = 11`. A term is `NdS`, an optional keep or drop rule
|
|
113
|
+
* (`k`/`kh` keep the highest, `kl` the lowest, `d`/`dl` drop the lowest, `dh` the highest, each
|
|
114
|
+
* with a count), `NdF` fate dice (default 4), or a whole number. Dropped dice show in parentheses.
|
|
115
|
+
* An invalid or oversized expression throws a `ToolRefusal` that says what to fix.
|
|
116
|
+
*/
|
|
117
|
+
export function roll(expression: string, random: Random = Math.random): string {
|
|
118
|
+
const compact = expression.replace(/\s+/g, "");
|
|
119
|
+
if (!compact) return refuse("The expression is empty.");
|
|
120
|
+
if (compact.length > MAX_LENGTH)
|
|
121
|
+
return refuse(`The expression is over ${MAX_LENGTH} characters.`);
|
|
122
|
+
const parts = [...compact.toLowerCase().matchAll(/([+-]?)([^+-]+)/g)];
|
|
123
|
+
if (parts.map((part) => part[0]).join("") !== compact.toLowerCase())
|
|
124
|
+
return refuse(`"${compact}" is not a sum of dice and numbers.`);
|
|
125
|
+
let total = 0;
|
|
126
|
+
let dice = 0;
|
|
127
|
+
let line = "";
|
|
128
|
+
for (const [index, [, sign = "", term = ""]] of parts.entries()) {
|
|
129
|
+
if (index > 0 && !sign)
|
|
130
|
+
return refuse(`"${compact}" is not a sum of dice and numbers.`);
|
|
131
|
+
const rolled = rollTerm(term, random);
|
|
132
|
+
dice += rolled.dice;
|
|
133
|
+
if (dice > MAX_DICE)
|
|
134
|
+
return refuse(`The expression rolls more than ${MAX_DICE} dice in all.`);
|
|
135
|
+
total += sign === "-" ? -rolled.value : rolled.value;
|
|
136
|
+
line +=
|
|
137
|
+
index === 0
|
|
138
|
+
? `${sign === "-" ? "-" : ""}${rolled.shown}`
|
|
139
|
+
: ` ${sign} ${rolled.shown}`;
|
|
140
|
+
}
|
|
141
|
+
return `${compact}: ${line} = ${total}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The plugin, with the random source replaceable for a test. */
|
|
145
|
+
export function createDice(random: Random = Math.random) {
|
|
146
|
+
return definePlugin({
|
|
147
|
+
name: "dice",
|
|
148
|
+
setup: () => ({
|
|
149
|
+
tools: [
|
|
150
|
+
defineTool({
|
|
151
|
+
name: "roll_dice",
|
|
152
|
+
description: `Roll dice and report each die and the total. The expression joins terms with + and -: NdS rolls N dice of S sides (2d6, d20), a count after k/kh keeps the highest dice (4d6k3), kl keeps the lowest, d/dl drops the lowest, dh drops the highest, NdF rolls N fate dice (default 4), and a plain number adds itself (2d6+1d4-1). At most ${MAX_DICE} dice in all and ${MAX_SIDES} sides per die. Dropped dice show in parentheses.`,
|
|
153
|
+
parameters: Type.Object({
|
|
154
|
+
expression: Type.String({
|
|
155
|
+
description: "For example 2d6+3 or 4d6k3",
|
|
156
|
+
}),
|
|
157
|
+
}),
|
|
158
|
+
minTier: "member",
|
|
159
|
+
run: ({ expression }) => roll(expression, random),
|
|
160
|
+
}),
|
|
161
|
+
],
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const dice = createDice();
|