pi-roundtable-drawing 0.7.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.
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Hand-drawn relationship maps: Rough.js on node-canvas, laid out with d3-force. Characters
3
+ * (player and non-player) and factions are the nodes; typed relationships are the edges.
4
+ */
5
+ import { type CanvasRenderingContext2D, createCanvas } from "canvas";
6
+ import {
7
+ forceCenter,
8
+ forceCollide,
9
+ forceLink,
10
+ forceManyBody,
11
+ forceSimulation,
12
+ type SimulationLinkDatum,
13
+ type SimulationNodeDatum,
14
+ } from "d3-force";
15
+ import rough from "roughjs";
16
+ import { DrawingError } from "./errors.ts";
17
+ import { type Random, roughSeed } from "./random.ts";
18
+ import { ensureFont, font } from "./style.ts";
19
+
20
+ export const NODE_TYPES = ["pc", "npc", "faction"] as const;
21
+ export const EDGE_TYPES = [
22
+ "romantic",
23
+ "entanglement",
24
+ "bond",
25
+ "faction",
26
+ "hostile",
27
+ ] as const;
28
+ export type NodeType = (typeof NODE_TYPES)[number];
29
+ export type EdgeType = (typeof EDGE_TYPES)[number];
30
+
31
+ export const MAX_NODES = 40;
32
+ export const MAX_EDGES = 80;
33
+ /** Longest title, node id, node label, and edge label, in characters. */
34
+ export const MAX_TITLE = 80;
35
+ export const MAX_TEXT = 60;
36
+
37
+ export interface MapNode {
38
+ id: string;
39
+ type: NodeType;
40
+ label?: string | undefined;
41
+ }
42
+
43
+ export interface MapEdge {
44
+ from: string;
45
+ to: string;
46
+ type: EdgeType;
47
+ label?: string | undefined;
48
+ }
49
+
50
+ export interface MapInput {
51
+ nodes: readonly MapNode[];
52
+ edges: readonly MapEdge[];
53
+ title?: string | undefined;
54
+ }
55
+
56
+ const COLORS = {
57
+ pc: { fill: "#FFE4E1", stroke: "#8B4513" },
58
+ npc: { fill: "#E6F3FF", stroke: "#4682B4" },
59
+ faction: { fill: "#F0FFF0", stroke: "#2E8B57" },
60
+ } as const;
61
+
62
+ const EDGE_STYLES: Record<
63
+ EdgeType,
64
+ {
65
+ stroke: string;
66
+ strokeWidth: number;
67
+ roughness: number;
68
+ dashGap?: number;
69
+ arrow: "target" | "both" | "none";
70
+ }
71
+ > = {
72
+ romantic: {
73
+ stroke: "#DC143C",
74
+ strokeWidth: 2.5,
75
+ roughness: 1.5,
76
+ arrow: "target",
77
+ },
78
+ entanglement: {
79
+ stroke: "#FF8C00",
80
+ strokeWidth: 2,
81
+ roughness: 1.2,
82
+ dashGap: 8,
83
+ arrow: "target",
84
+ },
85
+ bond: { stroke: "#808080", strokeWidth: 1.5, roughness: 1, arrow: "none" },
86
+ faction: {
87
+ stroke: "#2E8B57",
88
+ strokeWidth: 1.5,
89
+ roughness: 1,
90
+ dashGap: 6,
91
+ arrow: "target",
92
+ },
93
+ hostile: {
94
+ stroke: "#1C1C1C",
95
+ strokeWidth: 2.5,
96
+ roughness: 2.5,
97
+ arrow: "both",
98
+ },
99
+ };
100
+
101
+ const CANVAS_PADDING = 80;
102
+ const NODE_RADIUS = { pc: 40, npc: 30 } as const;
103
+ const FACTION_SIZE = { w: 80, h: 36 } as const;
104
+ /** Room between a node's text and the edge of its shape. */
105
+ const TEXT_PAD = 10;
106
+ const LABEL_PAD = 3;
107
+ /** The picture stays within what canvas can allocate. */
108
+ const MAX_SIDE = 16384;
109
+ const LAYOUT_SIZE = 1200;
110
+
111
+ interface SimNode extends SimulationNodeDatum {
112
+ id: string;
113
+ x: number;
114
+ y: number;
115
+ type: NodeType;
116
+ label?: string;
117
+ /** Half the width and height of the shape, grown to hold the node's text. */
118
+ halfW: number;
119
+ halfH: number;
120
+ }
121
+
122
+ interface SimLink extends SimulationLinkDatum<SimNode> {
123
+ type: EdgeType;
124
+ label?: string;
125
+ }
126
+
127
+ type RoughCanvas = ReturnType<typeof rough.canvas>;
128
+
129
+ function validate(input: MapInput): void {
130
+ if (input.nodes.length === 0)
131
+ throw new DrawingError("nodes cannot be empty. Give at least one node.");
132
+ if (input.nodes.length > MAX_NODES)
133
+ throw new DrawingError(
134
+ `${input.nodes.length} nodes is over the limit of ${MAX_NODES}. Draw a smaller map.`,
135
+ );
136
+ if (input.edges.length > MAX_EDGES)
137
+ throw new DrawingError(
138
+ `${input.edges.length} edges is over the limit of ${MAX_EDGES}. Draw a smaller map.`,
139
+ );
140
+ const ids = new Set<string>();
141
+ for (const node of input.nodes) {
142
+ if (node.id.trim() === "")
143
+ throw new DrawingError("A node has an empty id. Give every node a name.");
144
+ if (ids.has(node.id))
145
+ throw new DrawingError(
146
+ `Two nodes share the id ${JSON.stringify(node.id)}. Node ids must be unique.`,
147
+ );
148
+ ids.add(node.id);
149
+ }
150
+ for (const edge of input.edges) {
151
+ for (const end of [edge.from, edge.to])
152
+ if (!ids.has(end))
153
+ throw new DrawingError(
154
+ `An edge references the unknown node ${JSON.stringify(end)}. Add it to nodes or fix the edge.`,
155
+ );
156
+ if (edge.from === edge.to)
157
+ throw new DrawingError(
158
+ `An edge from ${JSON.stringify(edge.from)} to itself cannot be drawn. Connect two different nodes.`,
159
+ );
160
+ }
161
+ }
162
+
163
+ /** The text of a node, as it is drawn: the id, then the label under it. */
164
+ function nodeFonts(type: NodeType) {
165
+ return {
166
+ id: font(type === "pc" ? 16 : 14),
167
+ label: font(type === "pc" ? 12 : 11),
168
+ };
169
+ }
170
+
171
+ function shapeOf(
172
+ measure: CanvasRenderingContext2D,
173
+ n: MapNode,
174
+ ): { halfW: number; halfH: number } {
175
+ const fonts = nodeFonts(n.type);
176
+ measure.font = fonts.id;
177
+ let width = measure.measureText(n.id).width;
178
+ if (n.label) {
179
+ measure.font = fonts.label;
180
+ width = Math.max(width, measure.measureText(n.label).width);
181
+ }
182
+ if (n.type === "faction")
183
+ return {
184
+ halfW: Math.max(FACTION_SIZE.w, width + TEXT_PAD * 2) / 2,
185
+ halfH: FACTION_SIZE.h / 2,
186
+ };
187
+ const radius = NODE_RADIUS[n.type];
188
+ return { halfW: Math.max(radius, width / 2 + TEXT_PAD), halfH: radius };
189
+ }
190
+
191
+ function computeLayout(
192
+ input: MapInput,
193
+ random: Random,
194
+ measure: CanvasRenderingContext2D,
195
+ ): { nodes: SimNode[]; links: SimLink[] } {
196
+ const nodes: SimNode[] = input.nodes.map((n) => ({
197
+ id: n.id,
198
+ x: LAYOUT_SIZE / 2 + (random() - 0.5) * 200,
199
+ y: LAYOUT_SIZE / 2 + (random() - 0.5) * 200,
200
+ type: n.type,
201
+ ...shapeOf(measure, n),
202
+ ...(n.label ? { label: n.label } : {}),
203
+ }));
204
+ const links: SimLink[] = input.edges.map((e) => ({
205
+ source: e.from,
206
+ target: e.to,
207
+ type: e.type,
208
+ ...(e.label ? { label: e.label } : {}),
209
+ }));
210
+ const simulation = forceSimulation<SimNode>(nodes)
211
+ .randomSource(random)
212
+ .force(
213
+ "link",
214
+ forceLink<SimNode, SimLink>(links)
215
+ .id((d) => d.id)
216
+ .distance((link) => {
217
+ const [a, b] = [link.source, link.target].map((end) =>
218
+ typeof end === "object" ? (end as SimNode).halfW : 0,
219
+ );
220
+ return Math.max(160, (a ?? 0) + (b ?? 0) + 40);
221
+ }),
222
+ )
223
+ .force("charge", forceManyBody().strength(-400))
224
+ .force("center", forceCenter(LAYOUT_SIZE / 2, LAYOUT_SIZE / 2))
225
+ .force(
226
+ "collide",
227
+ forceCollide<SimNode>((n) => Math.max(60, n.halfW + 10)),
228
+ )
229
+ .stop();
230
+ for (let i = 0; i < 300; i++) simulation.tick();
231
+ return { nodes, links };
232
+ }
233
+
234
+ /** How far the edge of a node's shape is from its centre along the unit direction (ux, uy). */
235
+ function reach(node: SimNode, ux: number, uy: number): number {
236
+ if (node.type === "faction")
237
+ return Math.min(
238
+ ux === 0 ? Infinity : node.halfW / Math.abs(ux),
239
+ uy === 0 ? Infinity : node.halfH / Math.abs(uy),
240
+ );
241
+ // The shape is an ellipse.
242
+ return (
243
+ (node.halfW * node.halfH) / Math.hypot(node.halfH * ux, node.halfW * uy)
244
+ );
245
+ }
246
+
247
+ function drawArrowHead(
248
+ ctx: CanvasRenderingContext2D,
249
+ from: { x: number; y: number },
250
+ tip: { x: number; y: number },
251
+ color: string,
252
+ ): void {
253
+ const size = 12;
254
+ const angle = Math.atan2(tip.y - from.y, tip.x - from.x);
255
+ ctx.save();
256
+ ctx.fillStyle = color;
257
+ ctx.beginPath();
258
+ ctx.moveTo(tip.x, tip.y);
259
+ ctx.lineTo(
260
+ tip.x - size * Math.cos(angle - Math.PI / 6),
261
+ tip.y - size * Math.sin(angle - Math.PI / 6),
262
+ );
263
+ ctx.lineTo(
264
+ tip.x - size * Math.cos(angle + Math.PI / 6),
265
+ tip.y - size * Math.sin(angle + Math.PI / 6),
266
+ );
267
+ ctx.closePath();
268
+ ctx.fill();
269
+ ctx.restore();
270
+ }
271
+
272
+ function endOf(ref: SimLink["source"], byId: ReadonlyMap<string, SimNode>) {
273
+ const id = typeof ref === "object" ? ref.id : String(ref);
274
+ return byId.get(id);
275
+ }
276
+
277
+ function drawEdge(
278
+ rc: RoughCanvas,
279
+ ctx: CanvasRenderingContext2D,
280
+ link: SimLink,
281
+ byId: ReadonlyMap<string, SimNode>,
282
+ random: Random,
283
+ ): void {
284
+ const src = endOf(link.source, byId);
285
+ const tgt = endOf(link.target, byId);
286
+ if (!src || !tgt) return;
287
+ const style = EDGE_STYLES[link.type];
288
+ const dx = tgt.x - src.x;
289
+ const dy = tgt.y - src.y;
290
+ const dist = Math.hypot(dx, dy);
291
+ // The line runs between the edges of the two shapes, not through them.
292
+ let [from, to] = [
293
+ { x: src.x, y: src.y },
294
+ { x: tgt.x, y: tgt.y },
295
+ ];
296
+ if (dist > 0) {
297
+ const ux = dx / dist;
298
+ const uy = dy / dist;
299
+ const [out, into] = [reach(src, ux, uy) + 4, reach(tgt, -ux, -uy) + 4];
300
+ if (dist > out + into + 8) {
301
+ from = { x: src.x + ux * out, y: src.y + uy * out };
302
+ to = { x: tgt.x - ux * into, y: tgt.y - uy * into };
303
+ }
304
+ if (style.arrow === "target" || style.arrow === "both")
305
+ drawArrowHead(ctx, from, to, style.stroke);
306
+ if (style.arrow === "both") drawArrowHead(ctx, to, from, style.stroke);
307
+ }
308
+ rc.line(from.x, from.y, to.x, to.y, {
309
+ stroke: style.stroke,
310
+ strokeWidth: style.strokeWidth,
311
+ roughness: style.roughness,
312
+ seed: roughSeed(random),
313
+ ...(style.dashGap
314
+ ? { strokeLineDash: [style.dashGap, style.dashGap] }
315
+ : {}),
316
+ });
317
+
318
+ if (link.label) {
319
+ const mx = (src.x + tgt.x) / 2;
320
+ const my = (src.y + tgt.y) / 2;
321
+ ctx.save();
322
+ ctx.font = font(11);
323
+ const width = ctx.measureText(link.label).width;
324
+ const pad = LABEL_PAD;
325
+ ctx.fillStyle = "rgba(255,255,255,0.85)";
326
+ ctx.fillRect(
327
+ mx - width / 2 - pad,
328
+ my - 8 - pad,
329
+ width + pad * 2,
330
+ 16 + pad * 2,
331
+ );
332
+ ctx.fillStyle = style.stroke;
333
+ ctx.textAlign = "center";
334
+ ctx.textBaseline = "middle";
335
+ ctx.fillText(link.label, mx, my);
336
+ ctx.restore();
337
+ }
338
+ }
339
+
340
+ function drawNode(
341
+ rc: RoughCanvas,
342
+ ctx: CanvasRenderingContext2D,
343
+ node: SimNode,
344
+ random: Random,
345
+ ): void {
346
+ const colors = COLORS[node.type];
347
+ const seed = roughSeed(random);
348
+ const fill = {
349
+ fill: colors.fill,
350
+ fillStyle: "hachure",
351
+ fillWeight: 0.5,
352
+ stroke: colors.stroke,
353
+ seed,
354
+ };
355
+ if (node.type === "faction") {
356
+ rc.rectangle(
357
+ node.x - node.halfW,
358
+ node.y - node.halfH,
359
+ node.halfW * 2,
360
+ node.halfH * 2,
361
+ { ...fill, strokeWidth: 1.5, roughness: 1.2, hachureGap: 6 },
362
+ );
363
+ } else {
364
+ rc.ellipse(node.x, node.y, node.halfW * 2, node.halfH * 2, {
365
+ ...fill,
366
+ strokeWidth: 2,
367
+ roughness: 1.5,
368
+ hachureGap: 5,
369
+ });
370
+ }
371
+
372
+ ctx.save();
373
+ ctx.font = font(node.type === "pc" ? 16 : 14);
374
+ ctx.fillStyle = "#333";
375
+ ctx.textAlign = "center";
376
+ ctx.textBaseline = "middle";
377
+ ctx.fillText(node.id, node.x, node.y - (node.label ? 8 : 0));
378
+ if (node.label) {
379
+ ctx.font = font(node.type === "pc" ? 12 : 11);
380
+ ctx.fillStyle = "#666";
381
+ ctx.fillText(node.label, node.x, node.y + 12);
382
+ }
383
+ ctx.restore();
384
+ }
385
+
386
+ /**
387
+ * Draws the map as a PNG sized to its content. The layout and the hand-drawn wobble both come
388
+ * from `random`, so a seeded source draws the same map every time.
389
+ */
390
+ export function renderRelationshipMap(
391
+ input: MapInput,
392
+ random: Random = Math.random,
393
+ ): Uint8Array {
394
+ validate(input);
395
+ ensureFont();
396
+ // The shapes grow to hold their text, so the text is measured before the layout.
397
+ const measure = createCanvas(1, 1).getContext("2d");
398
+ const { nodes, links } = computeLayout(input, random, measure);
399
+
400
+ let minX = Infinity;
401
+ let minY = Infinity;
402
+ let maxX = -Infinity;
403
+ let maxY = -Infinity;
404
+ const include = (x0: number, y0: number, x1: number, y1: number) => {
405
+ minX = Math.min(minX, x0);
406
+ minY = Math.min(minY, y0);
407
+ maxX = Math.max(maxX, x1);
408
+ maxY = Math.max(maxY, y1);
409
+ };
410
+ for (const n of nodes)
411
+ include(n.x - n.halfW, n.y - n.halfH, n.x + n.halfW, n.y + n.halfH);
412
+ const byId = new Map(nodes.map((n) => [n.id, n]));
413
+ measure.font = font(11);
414
+ for (const link of links) {
415
+ const src = endOf(link.source, byId);
416
+ const tgt = endOf(link.target, byId);
417
+ if (!link.label || !src || !tgt) continue;
418
+ const half = measure.measureText(link.label).width / 2 + LABEL_PAD;
419
+ const [mx, my] = [(src.x + tgt.x) / 2, (src.y + tgt.y) / 2];
420
+ include(mx - half, my - 8 - LABEL_PAD, mx + half, my + 8 + LABEL_PAD);
421
+ }
422
+ let titleWidth = 0;
423
+ if (input.title) {
424
+ measure.font = font(20);
425
+ titleWidth = measure.measureText(input.title).width;
426
+ }
427
+ const titleOffset = input.title ? 40 : 0;
428
+ const width = Math.ceil(
429
+ Math.max(400, maxX - minX + CANVAS_PADDING * 2, titleWidth + 40),
430
+ );
431
+ const height = Math.ceil(
432
+ Math.max(300, maxY - minY + CANVAS_PADDING * 2 + titleOffset),
433
+ );
434
+ if (width > MAX_SIDE || height > MAX_SIDE)
435
+ throw new DrawingError(
436
+ `The map would be ${width} by ${height} px, which is too large to draw. Use shorter names or fewer nodes.`,
437
+ );
438
+ // The graph is centred; the title sits above it.
439
+ const offsetX = (width - (maxX - minX)) / 2 - minX;
440
+ const offsetY = CANVAS_PADDING + titleOffset - minY;
441
+ for (const n of nodes) {
442
+ n.x += offsetX;
443
+ n.y += offsetY;
444
+ }
445
+
446
+ const canvas = createCanvas(width, height);
447
+ const ctx = canvas.getContext("2d");
448
+ ctx.fillStyle = "#FFFEF5";
449
+ ctx.fillRect(0, 0, width, height);
450
+ if (input.title) {
451
+ ctx.save();
452
+ ctx.font = font(20);
453
+ ctx.fillStyle = "#333";
454
+ ctx.textAlign = "center";
455
+ ctx.fillText(input.title, width / 2, 35);
456
+ ctx.restore();
457
+ }
458
+
459
+ // SAFETY: Rough.js draws through the 2D context, which node-canvas provides in the browser's shape.
460
+ const rc = rough.canvas(
461
+ canvas as unknown as Parameters<typeof rough.canvas>[0],
462
+ );
463
+ for (const link of links) drawEdge(rc, ctx, link, byId, random);
464
+ for (const node of nodes) drawNode(rc, ctx, node, random);
465
+ return new Uint8Array(canvas.toBuffer("image/png"));
466
+ }
package/src/style.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { registerFont } from "canvas";
3
+
4
+ /** The family the bundled font is registered under; canvas needs it before the first draw. */
5
+ export const FONT_FAMILY = "Roundtable Drawing Sans";
6
+
7
+ const FONT_FILE = fileURLToPath(
8
+ new URL("../fonts/NotoSansTC-Bold.otf", import.meta.url),
9
+ );
10
+
11
+ /** The dark ground, the teal accents, and the text colors of the magic drawings and card spreads. */
12
+ export const PALETTE = {
13
+ background: "#111a1c",
14
+ surface: "#1a2628",
15
+ accent: "#1f7a72",
16
+ accentLight: "#5fc4b8",
17
+ text: "#f2f5f4",
18
+ muted: "#6f8583",
19
+ } as const;
20
+
21
+ let registered = false;
22
+
23
+ /** Registers the bundled font once per process. */
24
+ export function ensureFont(): void {
25
+ if (registered) return;
26
+ registerFont(FONT_FILE, { family: FONT_FAMILY, weight: "bold" });
27
+ registered = true;
28
+ }
29
+
30
+ /** A canvas font string in the bundled family. */
31
+ export const font = (px: number): string => `bold ${px}px "${FONT_FAMILY}"`;
@@ -0,0 +1,121 @@
1
+ import { Type } from "typebox";
2
+ import type { Deck } from "../cards/deck.ts";
3
+ import { groupsOf } from "../cards/deck.ts";
4
+ import { drawCards } from "../cards/draw.ts";
5
+ import {
6
+ autoLayout,
7
+ checkSpread,
8
+ MAX_GRID,
9
+ renderSpread,
10
+ type SpreadPosition,
11
+ } from "../cards/spread.ts";
12
+ import { DrawingError } from "../errors.ts";
13
+ import type { Random } from "../random.ts";
14
+ import {
15
+ type ImageToolEnv,
16
+ imageTool,
17
+ literals,
18
+ strictObject,
19
+ } from "./image-tool.ts";
20
+
21
+ const MAX_DRAW = 100;
22
+
23
+ function describeDeck(deck: Deck): string {
24
+ const groups = groupsOf(deck);
25
+ return `${deck.id}: ${deck.name}, ${deck.cards.length} cards${
26
+ groups.length > 0 ? ` (groups: ${groups.join(", ")})` : ""
27
+ }${deck.reversals ? "" : ", upright by default"}`;
28
+ }
29
+
30
+ /** `draw_cards`: draws from one of the operator's decks and attaches a picture of the spread to the reply. */
31
+ export function drawCardsTool(
32
+ decks: readonly Deck[],
33
+ env: ImageToolEnv,
34
+ random: Random,
35
+ ) {
36
+ const [first, ...rest] = decks;
37
+ if (!first) throw new DrawingError("draw_cards needs at least one deck.");
38
+ return imageTool(
39
+ {
40
+ name: "draw_cards",
41
+ description: `Draw cards from a full, freshly shuffled deck and attach a picture of the spread to your reply. Each draw is independent; pass exclude to leave cards out. Read the returned cards, not your own guess. Decks: ${decks.map(describeDeck).join("; ")}.`,
42
+ parameters: strictObject({
43
+ deck: literals(
44
+ [first, ...rest].map((deck) => deck.id),
45
+ "The deck to draw from.",
46
+ ),
47
+ count: Type.Integer({ minimum: 1, maximum: MAX_DRAW }),
48
+ group: Type.Optional(
49
+ Type.String({
50
+ description:
51
+ "Draw only from the cards of this group of the deck, when the deck has groups.",
52
+ }),
53
+ ),
54
+ exclude: Type.Optional(
55
+ Type.Array(Type.String(), {
56
+ description: "Card ids to leave out, as the deck lists them.",
57
+ }),
58
+ ),
59
+ allow_reversed: Type.Optional(
60
+ Type.Boolean({
61
+ description:
62
+ "Whether cards may come up reversed; the deck's own default when omitted.",
63
+ }),
64
+ ),
65
+ spread: Type.Optional(
66
+ Type.Array(
67
+ strictObject({
68
+ row: Type.Integer({ minimum: 0, maximum: MAX_GRID }),
69
+ col: Type.Integer({ minimum: 0, maximum: MAX_GRID }),
70
+ label: Type.String({
71
+ description: "What the position means, such as Past.",
72
+ }),
73
+ }),
74
+ {
75
+ description:
76
+ "One position per card for a named spread; omit for plain rows.",
77
+ },
78
+ ),
79
+ ),
80
+ question: Type.Optional(
81
+ Type.String({ description: "The question, shown on the picture." }),
82
+ ),
83
+ }),
84
+ draw: async (args) => {
85
+ const deck = decks.find((candidate) => candidate.id === args.deck);
86
+ if (!deck)
87
+ throw new DrawingError(
88
+ `Unknown deck ${JSON.stringify(args.deck)}. Use ${decks.map((d) => d.id).join(", ")}.`,
89
+ );
90
+ const positions: SpreadPosition[] =
91
+ args.spread ?? autoLayout(args.count);
92
+ // Checked before the draw, so a refused spread leaves the random source where it was.
93
+ checkSpread(positions, args.count);
94
+ const cards = drawCards({
95
+ deck,
96
+ count: args.count,
97
+ group: args.group,
98
+ exclude: args.exclude,
99
+ allowReversed: args.allow_reversed,
100
+ random,
101
+ });
102
+ const image = await renderSpread(deck, cards, positions, {
103
+ title: deck.name,
104
+ subtitle:
105
+ args.question ??
106
+ `${cards.length} card${cards.length === 1 ? "" : "s"}`,
107
+ });
108
+ const lines = cards.map((card, i) => {
109
+ const label = positions[i]?.label ? `${positions[i]?.label}: ` : "";
110
+ return `${i + 1}. ${label}${card.name}${card.reversed ? " (reversed)" : ""} [id: ${card.id}]`;
111
+ });
112
+ return {
113
+ stem: "cards",
114
+ image,
115
+ text: `Drew ${cards.length} from ${deck.id}; the spread picture is attached to your reply as {file}.\n${lines.join("\n")}`,
116
+ };
117
+ },
118
+ },
119
+ env,
120
+ );
121
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ defineTool,
3
+ REPLY_FILE_LIMITS,
4
+ type Tier,
5
+ type ToolContribution,
6
+ ToolRefusal,
7
+ type ToolTurn,
8
+ } from "pi-roundtable";
9
+ import { type Static, type TObject, type TProperties, Type } from "typebox";
10
+ import Value from "typebox/value";
11
+ import { DrawingError } from "../errors.ts";
12
+
13
+ /** A string that is one of `values`, which the model reads as an enum. */
14
+ export const literals = <T extends string>(
15
+ values: readonly T[],
16
+ description?: string,
17
+ ) =>
18
+ Type.Union(
19
+ values.map((value) => Type.Literal(value)),
20
+ description ? { description } : {},
21
+ );
22
+
23
+ /** An argument object that refuses a name it does not know, so a misspelled argument is a refusal rather than a default. */
24
+ export const strictObject = <Properties extends TProperties>(
25
+ properties: Properties,
26
+ ) => Type.Object(properties, { additionalProperties: false });
27
+
28
+ export const BACKGROUNDS = ["dark", "white", "transparent"] as const;
29
+
30
+ export const BACKGROUND = Type.Optional(literals(BACKGROUNDS, "Default dark."));
31
+
32
+ /** What a drawing tool produced: the picture, the name it is attached under, and what the model reads back. */
33
+ export interface Drawn {
34
+ /** The file name without its extension; the tool adds `.png`. */
35
+ stem: string;
36
+ image: Uint8Array;
37
+ text: string;
38
+ }
39
+
40
+ export interface ImageToolSpec<Schema extends TObject> {
41
+ name: string;
42
+ description: string;
43
+ parameters: Schema;
44
+ draw(args: Static<Schema>, turn: ToolTurn): Promise<Drawn> | Drawn;
45
+ }
46
+
47
+ export interface ImageToolEnv {
48
+ minTier: Tier;
49
+ }
50
+
51
+ /** The pi session checks a call against its schema; this check also covers a tool run without a session. */
52
+ function checkArguments<Schema extends TObject>(
53
+ schema: Schema,
54
+ args: unknown,
55
+ ): asserts args is Static<Schema> {
56
+ if (Value.Check(schema, args)) return;
57
+ const problems = Value.Errors(schema, args)
58
+ .slice(0, 3)
59
+ .map((error) => `${error.instancePath || "arguments"}: ${error.message}`);
60
+ throw new ToolRefusal(
61
+ `The arguments are not valid (${problems.join("; ")}). Fix them and call again.`,
62
+ );
63
+ }
64
+
65
+ /**
66
+ * A tool that draws one image and attaches it to the agent's reply as a file. A request the renderer
67
+ * cannot honour comes back to the model as a refusal it can correct.
68
+ */
69
+ export function imageTool<Schema extends TObject>(
70
+ spec: ImageToolSpec<Schema>,
71
+ env: ImageToolEnv,
72
+ ): ToolContribution {
73
+ return defineTool({
74
+ name: spec.name,
75
+ description: spec.description,
76
+ parameters: spec.parameters,
77
+ minTier: env.minTier,
78
+ run: async (args, turn) => {
79
+ checkArguments(spec.parameters, args);
80
+ let drawn: Drawn;
81
+ try {
82
+ drawn = await spec.draw(args, turn);
83
+ } catch (error) {
84
+ if (error instanceof DrawingError) throw new ToolRefusal(error.message);
85
+ throw error;
86
+ }
87
+ const name = `${drawn.stem}.png`;
88
+ // The host refuses a larger file too, but only with its own wording, and a model can act on this one.
89
+ if (drawn.image.byteLength > REPLY_FILE_LIMITS.maxFileBytes)
90
+ throw new ToolRefusal(
91
+ `The picture is ${(drawn.image.byteLength / 2 ** 20).toFixed(1)} MiB, over the ${REPLY_FILE_LIMITS.maxFileBytes / 2 ** 20} MiB a reply may carry. Ask for a smaller picture, such as fewer cards or a smaller size.`,
92
+ );
93
+ turn.attachFile({ name, data: drawn.image });
94
+ return drawn.text.replace("{file}", name);
95
+ },
96
+ });
97
+ }