pi-roundtable 0.4.0 → 0.5.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 +12 -0
- package/README.zh-TW.md +1 -1
- package/docs/plugins.md +1 -1
- package/package.json +4 -4
- package/src/core/builtin/skills.ts +6 -1
- package/src/core/config/config.ts +4 -1
- package/src/core/speakers.ts +4 -1
- package/src/core/tool-set.snapshot.json +5 -0
- package/templates/official/codex-images/plugin.ts +65 -57
- package/templates/official/dice/plugin.ts +70 -41
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-10-01
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent` are no longer pinned to one version: they take `>=0.99.2 <1`, so a host picks up a newer model table with `bun update @earendil-works/pi-ai @earendil-works/pi-coding-agent`, or its own direct dependency, without waiting for a pi-roundtable release. Pinned to 0.87.1 before, the host's model list stopped at the models that version knew. 0.99.2 is the lowest version the tests run against, and the lockfile keeps CI on it, so a host that updates further runs on versions this package was not tested with.
|
|
13
|
+
- `pi-mcp-adapter` is 4.0.0 (was 2.37.0). Version 2.37.0 declared support for pi-ai up to 0.87 only; 4.0.0 declares 0.99. It stays pinned. Checked by connecting a session built the way the core builds one to a local MCP server that requires a bearer token: the tool registered and a call returned its result.
|
|
14
|
+
- A judge or agent model that the newer table lists but the host has no login for fails with the provider's "Provider is not configured" error, where an unlisted model fails with "is not available".
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Agents had no `skill_list` tool, although the `agent_create` description, the built-in `writing-skills` skill and the skill errors all tell them to call it. The `skills` addon now mounts it for agent sessions, at the member tier like its entry in the tool tiers. A host that mounted `skillListExtension` itself for agents (the kit still exports it) now offers the tool twice and should drop its own copy.
|
|
19
|
+
|
|
8
20
|
## [0.4.0] - 2026-10-01
|
|
9
21
|
|
|
10
22
|
### Added
|
package/README.zh-TW.md
CHANGED
package/docs/plugins.md
CHANGED
|
@@ -289,7 +289,7 @@ They are on by default, so a bot that says nothing about them has all three.
|
|
|
289
289
|
| Addon | Plugin | Switch in `roundtable.config.ts` | What it adds | When it is off |
|
|
290
290
|
|---|---|---|---|---|
|
|
291
291
|
| Memory | `memory` (provides `MEMORY`) | `memory: false` | The memory table, the `memory_add`, `memory_search` and `memory_remove` tools, and the memory block of every system prompt | No memory tools and no block; the table is left as it is |
|
|
292
|
-
| Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_link`, `skill_create`, `agent_skills`, and the rest), and the skills every agent carries, `writing-skills` included | No skill tools and no skills in any session; `agent_get` has no skills line; `agent_create` leaves out its `skills` parameter and refuses a call that passes some with `Skills are off on this host`; the tables are left as they are |
|
|
292
|
+
| Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_list`, `skill_link`, `skill_create`, `agent_skills`, and the rest), and the skills every agent carries, `writing-skills` included | No skill tools and no skills in any session; `agent_get` has no skills line; `agent_create` leaves out its `skills` parameter and refuses a call that passes some with `Skills are off on this host`; the tables are left as they are |
|
|
293
293
|
| Discord administration | `discord-admin` | `discord: { admin: false }` | The `discord_*` tools that read and manage the server, for the owner | No `discord_*` tools; the channel executor that remote MCP uses is the connection's, so it stays |
|
|
294
294
|
|
|
295
295
|
A switched-off addon's tables and rows are never touched: turning it on again finds them as they were.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A plugin-driven Pi agent server for Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,11 +45,11 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@babel/parser": "7.29.9",
|
|
48
|
-
"@earendil-works/pi-ai": "0.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
48
|
+
"@earendil-works/pi-ai": ">=0.99.2 <1",
|
|
49
|
+
"@earendil-works/pi-coding-agent": ">=0.99.2 <1",
|
|
50
50
|
"canvas": "3.2.3",
|
|
51
51
|
"discord.js": "14.27.0",
|
|
52
|
-
"pi-mcp-adapter": "
|
|
52
|
+
"pi-mcp-adapter": "4.0.0",
|
|
53
53
|
"pino": "10.3.1",
|
|
54
54
|
"typebox": "1.3.34",
|
|
55
55
|
"unpdf": "1.8.1"
|
|
@@ -4,6 +4,7 @@ import { SkillStore } from "../modules/skills/skill-store.ts";
|
|
|
4
4
|
import {
|
|
5
5
|
SKILL_LIST_TOOL,
|
|
6
6
|
SKILL_TOOLS,
|
|
7
|
+
skillListExtension,
|
|
7
8
|
skillToolsExtension,
|
|
8
9
|
} from "../modules/skills/skill-tools.ts";
|
|
9
10
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
@@ -57,6 +58,7 @@ export function skillsPlugin(
|
|
|
57
58
|
services.provide(SKILLS, built);
|
|
58
59
|
return {
|
|
59
60
|
sessionTools: [
|
|
61
|
+
agentOnly("skill-list", () => skillListExtension(built)),
|
|
60
62
|
agentOnly("skill-tools", () =>
|
|
61
63
|
skillToolsExtension(built, (name) => {
|
|
62
64
|
// The agent server is set up after this plugin and read when a session is built.
|
|
@@ -64,7 +66,10 @@ export function skillsPlugin(
|
|
|
64
66
|
}),
|
|
65
67
|
),
|
|
66
68
|
],
|
|
67
|
-
agentSelection: () => ({
|
|
69
|
+
agentSelection: () => ({
|
|
70
|
+
tools: [SKILL_LIST_TOOL, ...SKILL_TOOLS],
|
|
71
|
+
groups: [],
|
|
72
|
+
}),
|
|
68
73
|
toolTiers: SKILL_TIERS,
|
|
69
74
|
};
|
|
70
75
|
},
|
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
/** How prompts refer back to the owner. */
|
|
28
28
|
export type Pronouns = "he" | "she" | "they";
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Who holds a tier besides the owner: user ids, role ids, or everyone. `everyone` works under any
|
|
32
|
+
* tier it is written in, so under `admins` it makes every author an admin.
|
|
33
|
+
*/
|
|
31
34
|
export interface TierConfig {
|
|
32
35
|
users?: readonly string[];
|
|
33
36
|
roles?: readonly string[];
|
package/src/core/speakers.ts
CHANGED
|
@@ -75,7 +75,10 @@ export interface SpeakerPolicy {
|
|
|
75
75
|
resolve(author: SpeakerFacts): Speaker | undefined;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Who holds one tier: user ids, role ids, or everyone. `everyone` works under any tier it is
|
|
80
|
+
* written in, so under `admins` it makes every author an admin.
|
|
81
|
+
*/
|
|
79
82
|
export interface TierMembers {
|
|
80
83
|
users?: readonly string[];
|
|
81
84
|
roles?: readonly string[];
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"skill_group_delete",
|
|
103
103
|
"skill_group_set",
|
|
104
104
|
"skill_link",
|
|
105
|
+
"skill_list",
|
|
105
106
|
"skill_unlink",
|
|
106
107
|
"skill_update"
|
|
107
108
|
],
|
|
@@ -125,6 +126,7 @@
|
|
|
125
126
|
"skill_group_delete",
|
|
126
127
|
"skill_group_set",
|
|
127
128
|
"skill_link",
|
|
129
|
+
"skill_list",
|
|
128
130
|
"skill_unlink",
|
|
129
131
|
"skill_update",
|
|
130
132
|
"write"
|
|
@@ -187,6 +189,7 @@
|
|
|
187
189
|
"skill_group_delete",
|
|
188
190
|
"skill_group_set",
|
|
189
191
|
"skill_link",
|
|
192
|
+
"skill_list",
|
|
190
193
|
"skill_unlink",
|
|
191
194
|
"skill_update"
|
|
192
195
|
],
|
|
@@ -209,6 +212,7 @@
|
|
|
209
212
|
"skill_group_delete",
|
|
210
213
|
"skill_group_set",
|
|
211
214
|
"skill_link",
|
|
215
|
+
"skill_list",
|
|
212
216
|
"skill_unlink",
|
|
213
217
|
"skill_update",
|
|
214
218
|
"write"
|
|
@@ -280,6 +284,7 @@
|
|
|
280
284
|
"skill_group_delete": "admin",
|
|
281
285
|
"skill_group_set": "admin",
|
|
282
286
|
"skill_link": "admin",
|
|
287
|
+
"skill_list": "member",
|
|
283
288
|
"skill_unlink": "admin",
|
|
284
289
|
"skill_update": "admin",
|
|
285
290
|
"web_search": "member",
|
|
@@ -35,7 +35,7 @@ export interface CodexImagesOptions {
|
|
|
35
35
|
|
|
36
36
|
/** The ChatGPT account the login token belongs to, from the token's claims. */
|
|
37
37
|
function chatGptAccountId(token: string): string {
|
|
38
|
-
const payload = token.split(".")
|
|
38
|
+
const [, payload] = token.split(".");
|
|
39
39
|
if (!payload) throw new Error("the Codex token is not a JWT");
|
|
40
40
|
let claims: Record<string, unknown>;
|
|
41
41
|
try {
|
|
@@ -58,43 +58,64 @@ function object(value: unknown): Record<string, unknown> {
|
|
|
58
58
|
: {};
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/** The one image an `image_generation_call` item carries; anything else is not an image. */
|
|
62
|
+
function imageOf(value: unknown): Uint8Array | undefined {
|
|
63
|
+
const item = object(value);
|
|
64
|
+
if (item.type !== "image_generation_call") return undefined;
|
|
65
|
+
if (
|
|
66
|
+
item.status !== "completed" ||
|
|
67
|
+
typeof item.result !== "string" ||
|
|
68
|
+
!item.result
|
|
69
|
+
) {
|
|
70
|
+
const { result: _result, ...detail } = item;
|
|
71
|
+
throw new ImageNotGeneratedError(
|
|
72
|
+
`Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (item.result.length > MAX_IMAGE_BASE64)
|
|
76
|
+
throw new Error("the image exceeds 32 MiB");
|
|
77
|
+
return new Uint8Array(Buffer.from(item.result, "base64"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The frames of a server-sent event stream: the text between blank lines. */
|
|
81
|
+
async function* frames(
|
|
82
|
+
body: ReadableStream<Uint8Array>,
|
|
83
|
+
): AsyncGenerator<string> {
|
|
84
|
+
const decoder = new TextDecoder();
|
|
85
|
+
let buffer = "";
|
|
86
|
+
for await (const chunk of body) {
|
|
87
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
88
|
+
let match = /\r?\n\r?\n/.exec(buffer);
|
|
89
|
+
while (match) {
|
|
90
|
+
yield buffer.slice(0, match.index);
|
|
91
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
92
|
+
match = /\r?\n\r?\n/.exec(buffer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (buffer.trim()) yield buffer;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The event a frame carries, or undefined for a frame without data. */
|
|
99
|
+
function eventOf(frame: string): Record<string, unknown> | undefined {
|
|
100
|
+
const data = frame
|
|
101
|
+
.split(/\r?\n/)
|
|
102
|
+
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trim()] : []))
|
|
103
|
+
.join("\n");
|
|
104
|
+
if (!data || data === "[DONE]") return undefined;
|
|
105
|
+
try {
|
|
106
|
+
return object(JSON.parse(data));
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Reads the events until the response completes, and returns its one image. */
|
|
62
113
|
async function parseImageStream(response: Response): Promise<Uint8Array> {
|
|
63
114
|
if (!response.body) throw new Error("Codex returned no body");
|
|
64
115
|
let image: Uint8Array | undefined;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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) {
|
|
116
|
+
for await (const frame of frames(response.body)) {
|
|
117
|
+
const event = eventOf(frame);
|
|
118
|
+
switch (event?.type) {
|
|
98
119
|
case "error":
|
|
99
120
|
case "response.failed": {
|
|
100
121
|
const error = object(object(event.response).error ?? event.error);
|
|
@@ -105,34 +126,21 @@ async function parseImageStream(response: Response): Promise<Uint8Array> {
|
|
|
105
126
|
case "response.incomplete":
|
|
106
127
|
throw new Error("the Codex response was incomplete");
|
|
107
128
|
case "response.output_item.done":
|
|
108
|
-
|
|
129
|
+
image ??= imageOf(event.item);
|
|
109
130
|
break;
|
|
110
131
|
case "response.completed": {
|
|
111
132
|
const output = object(event.response).output;
|
|
112
|
-
if (
|
|
113
|
-
for (const item of output)
|
|
114
|
-
|
|
115
|
-
|
|
133
|
+
if (Array.isArray(output))
|
|
134
|
+
for (const item of output) image ??= imageOf(item);
|
|
135
|
+
if (!image)
|
|
136
|
+
throw new ImageNotGeneratedError("Codex answered without an image");
|
|
137
|
+
return image;
|
|
116
138
|
}
|
|
139
|
+
default:
|
|
140
|
+
break;
|
|
117
141
|
}
|
|
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
142
|
}
|
|
131
|
-
|
|
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;
|
|
143
|
+
throw new Error("the Codex stream ended before completion");
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
async function generateImage(
|
|
@@ -41,6 +41,8 @@ function removed(
|
|
|
41
41
|
return ascending.slice(0, n);
|
|
42
42
|
case "dh":
|
|
43
43
|
return ascending.slice(rolls.length - n);
|
|
44
|
+
default:
|
|
45
|
+
return rule satisfies never;
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -51,49 +53,53 @@ interface Rolled {
|
|
|
51
53
|
dice: number;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
56
|
+
/** A plain number term. */
|
|
57
|
+
function rollNumber(term: string): Rolled {
|
|
58
|
+
if (term.length > 7 || Number(term) > MAX_MODIFIER)
|
|
59
|
+
return refuse(`The number ${term} is over ${MAX_MODIFIER}.`);
|
|
60
|
+
return { shown: String(Number(term)), value: Number(term), dice: 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How a fate die shows: + for 1, - for -1, and 0. */
|
|
64
|
+
function fateSymbol(face: number): string {
|
|
65
|
+
if (face > 0) return "+";
|
|
66
|
+
if (face < 0) return "-";
|
|
67
|
+
return "0";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `count` fate dice, each one of -1, 0, or +1. */
|
|
71
|
+
function rollFate(count: number, random: Random): Rolled {
|
|
72
|
+
const faces = Array.from(
|
|
73
|
+
{ length: count },
|
|
74
|
+
() => Math.floor(random() * 3) - 1,
|
|
75
|
+
);
|
|
76
|
+
const symbols = faces.map(fateSymbol);
|
|
77
|
+
return {
|
|
78
|
+
shown: `[${symbols.join(", ")}]`,
|
|
79
|
+
value: faces.reduce((sum, face) => sum + face, 0),
|
|
80
|
+
dice: count,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `count` dice of `sides` sides, with an optional keep or drop rule that removes some of them. */
|
|
85
|
+
function rollPool(
|
|
86
|
+
term: string,
|
|
87
|
+
count: number,
|
|
88
|
+
sides: number,
|
|
89
|
+
rule: { kind: KeepDrop; n: number } | undefined,
|
|
90
|
+
random: Random,
|
|
91
|
+
): Rolled {
|
|
82
92
|
if (sides < 2 || sides > MAX_SIDES)
|
|
83
93
|
return refuse(`"${term}" needs between 2 and ${MAX_SIDES} sides.`);
|
|
84
94
|
const rolls = Array.from(
|
|
85
95
|
{ length: count },
|
|
86
96
|
() => Math.floor(random() * sides) + 1,
|
|
87
97
|
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
}
|
|
98
|
+
if (rule && (rule.n < 1 || rule.n >= count))
|
|
99
|
+
return refuse(
|
|
100
|
+
`"${term}" must keep or drop at least 1 and fewer than ${count} dice.`,
|
|
101
|
+
);
|
|
102
|
+
const dropped = new Set(rule ? removed(rolls, rule.kind, rule.n) : []);
|
|
97
103
|
const shown = rolls.map((value, index) =>
|
|
98
104
|
dropped.has(index) ? `(${value})` : String(value),
|
|
99
105
|
);
|
|
@@ -107,6 +113,31 @@ function rollTerm(term: string, random: Random): Rolled {
|
|
|
107
113
|
};
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/** How many dice a term rolls: the count it writes, else 4 for fate dice and 1 for the rest. */
|
|
117
|
+
function diceCount(countText: string | undefined, fate: string | undefined) {
|
|
118
|
+
if (countText) return Number(countText);
|
|
119
|
+
return fate ? FATE_DEFAULT : 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rollTerm(term: string, random: Random): Rolled {
|
|
123
|
+
if (/^\d+$/.test(term)) return rollNumber(term);
|
|
124
|
+
const match = TERM.exec(term);
|
|
125
|
+
if (!match) return refuse(`"${term}" is not a dice term.`);
|
|
126
|
+
const [, countText, fate, sidesText, rule, nText] = match;
|
|
127
|
+
const count = diceCount(countText, fate);
|
|
128
|
+
if (count < 1) return refuse(`"${term}" rolls no dice.`);
|
|
129
|
+
if (count > MAX_DICE)
|
|
130
|
+
return refuse(`"${term}" rolls more than ${MAX_DICE} dice.`);
|
|
131
|
+
if (fate) return rollFate(count, random);
|
|
132
|
+
return rollPool(
|
|
133
|
+
term,
|
|
134
|
+
count,
|
|
135
|
+
Number(sidesText),
|
|
136
|
+
rule ? { kind: rule as KeepDrop, n: Number(nText) } : undefined,
|
|
137
|
+
random,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
110
141
|
/**
|
|
111
142
|
* Rolls an expression of dice terms and numbers joined by `+` and `-`, and writes the result as
|
|
112
143
|
* text such as `2d6+3: [3, 5] + 3 = 11`. A term is `NdS`, an optional keep or drop rule
|
|
@@ -133,10 +164,8 @@ export function roll(expression: string, random: Random = Math.random): string {
|
|
|
133
164
|
if (dice > MAX_DICE)
|
|
134
165
|
return refuse(`The expression rolls more than ${MAX_DICE} dice in all.`);
|
|
135
166
|
total += sign === "-" ? -rolled.value : rolled.value;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
? `${sign === "-" ? "-" : ""}${rolled.shown}`
|
|
139
|
-
: ` ${sign} ${rolled.shown}`;
|
|
167
|
+
const lead = sign === "-" ? "-" : "";
|
|
168
|
+
line += index === 0 ? `${lead}${rolled.shown}` : ` ${sign} ${rolled.shown}`;
|
|
140
169
|
}
|
|
141
170
|
return `${compact}: ${line} = ${total}`;
|
|
142
171
|
}
|