pi-roundtable-drawing 0.7.4 → 0.7.6

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 CHANGED
@@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.6] - 2026-10-03
9
+
10
+ - `permissive` also accepts an empty node id, a sigil intention with no letters or only blanks, and an unknown sacred-geometry color.
11
+
12
+ ## [0.7.5] - 2026-10-03
13
+
14
+ ### Added
15
+
16
+ - `permissive` accepts what a looser host sends; `mapLimits` values must be whole numbers of at least 1.
17
+
8
18
  ## [0.7.4] - 2026-10-03
9
19
 
10
20
  - Add `cardPresentation.result` for the card draw result text and `mapLimits` for longer relationship-map text.
package/README.md CHANGED
@@ -83,7 +83,8 @@ export default {
83
83
  | `random` | `() => number` | `Math.random` | The random source of the map layouts and the card draws, returning a number from 0 up to, not including, 1. A seeded source makes a run reproducible: a new `seededRandom(seed)` given the same calls draws the same pictures. `seededRandom(seed)` is exported for that |
84
84
  | `minTier` | `"owner" \| "admin" \| "member"` | `member` | The lowest tier that may use the tools. The operator's `toolTiers` setting still wins |
85
85
  | `cardPresentation` | `CardPresentation` | package captions | Trusted operator-local headings, reversal suffixes and result text |
86
- | `mapLimits` | `{ title?, text? }` | 80 and 60 | Longest relationship-map title, and node id, node label or edge label, in characters, for a host whose callers already draw longer text; the renderer's size limit still applies |
86
+ | `mapLimits` | `{ title?, text? }` | 80 and 60 | Longest relationship-map title, and node id, node label or edge label, in characters, for a host whose callers already draw longer text; each is a whole number of at least 1 (anything else stops the start), and the renderer's size limit still applies |
87
+ | `permissive` | `boolean` | `false` | Accept what a looser host's callers already send: a card exclusion that is not a card of the deck is ignored, spread positions may share a cell, a relationship map may repeat or leave empty a node id and may draw an edge from a node to itself, a sigil may have an intention with no letters or only blanks, and a sacred-geometry color that canvas cannot parse is ignored. By default each is refused with a message the model can correct |
87
88
 
88
89
  The plugin is named `drawing`.
89
90
  `cardPresentation.heading(draw, turn)` returns `{ title, subtitle }`; `draw` holds the selected `deck`, `count`, and optional `question`, and `turn` carries the host-bound speaker/channel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-drawing",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Relationship maps, magic circles, sigils, sacred geometry, and card spreads drawn locally for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,7 +48,7 @@
48
48
  "devDependencies": {
49
49
  "@biomejs/biome": "2.5.15",
50
50
  "@types/bun": "1.4.2",
51
- "pi-roundtable": "0.7.4",
51
+ "pi-roundtable": "0.7.6",
52
52
  "typescript": "7.0.2"
53
53
  }
54
54
  }
package/src/cards/draw.ts CHANGED
@@ -15,11 +15,21 @@ export interface DrawParams {
15
15
  /** Whether cards may come up reversed; the deck's own default when absent. */
16
16
  allowReversed?: boolean | undefined;
17
17
  random: Random;
18
+ /** Ignore exclusions that are not cards of the deck, instead of refusing them. */
19
+ ignoreUnknownExclusions?: boolean | undefined;
18
20
  }
19
21
 
20
22
  /** Draws from the whole deck minus the exclusions; there is no persistent deck between draws. */
21
23
  export function drawCards(params: DrawParams): DrawnCard[] {
22
- const { deck, count, group, exclude, allowReversed, random } = params;
24
+ const {
25
+ deck,
26
+ count,
27
+ group,
28
+ exclude,
29
+ allowReversed,
30
+ random,
31
+ ignoreUnknownExclusions,
32
+ } = params;
23
33
  if (!Number.isInteger(count) || count < 1)
24
34
  throw new DrawingError(
25
35
  `count must be a whole number of at least 1; got ${count}.`,
@@ -35,7 +45,7 @@ export function drawCards(params: DrawParams): DrawnCard[] {
35
45
  const known = new Set(deck.cards.map((card) => card.id));
36
46
  const excluded = new Set(exclude ?? []);
37
47
  for (const id of excluded)
38
- if (!known.has(id))
48
+ if (!known.has(id) && !ignoreUnknownExclusions)
39
49
  throw new DrawingError(
40
50
  `exclude names ${JSON.stringify(id)}, which is not a card of deck ${deck.id}. Use the card ids the deck lists.`,
41
51
  );
@@ -43,6 +43,7 @@ export function autoLayout(count: number): SpreadPosition[] {
43
43
  export function checkSpread(
44
44
  positions: readonly SpreadPosition[],
45
45
  count: number,
46
+ allowSharedCells = false,
46
47
  ): void {
47
48
  if (positions.length !== count)
48
49
  throw new DrawingError(
@@ -55,7 +56,7 @@ export function checkSpread(
55
56
  `Spread positions need a row and a column that are whole numbers from 0; got row ${row}, column ${col}.`,
56
57
  );
57
58
  const cell = `${row}:${col}`;
58
- if (cells.has(cell))
59
+ if (cells.has(cell) && !allowSharedCells)
59
60
  throw new DrawingError(
60
61
  `Two cards share row ${row}, column ${col}. Give each card its own position.`,
61
62
  );
@@ -113,8 +114,9 @@ export async function renderSpread(
113
114
  positions: readonly SpreadPosition[],
114
115
  heading: { title: string; subtitle: string },
115
116
  reversedSuffix = " (reversed)",
117
+ allowSharedCells = false,
116
118
  ): Promise<Uint8Array> {
117
- checkSpread(positions, cards.length);
119
+ checkSpread(positions, cards.length, allowSharedCells);
118
120
  ensureFont();
119
121
  const faces = await Promise.all(
120
122
  cards.map(async (card) => {
package/src/magic.ts CHANGED
@@ -257,6 +257,8 @@ export interface SigilOptions {
257
257
  complexity: "simple" | "elaborate";
258
258
  style: "traditional" | "modern";
259
259
  background: Background;
260
+ /** Draw what a looser caller sends: an intention with no letters, a blank one, or an unknown color. */
261
+ permissive?: boolean;
260
262
  }
261
263
 
262
264
  const SIGIL_SIZE = 512;
@@ -283,9 +285,13 @@ function planetaryRadius(char: string): number {
283
285
  }
284
286
 
285
287
  export function renderSigil(options: SigilOptions): Uint8Array {
286
- if (options.intention.trim() === "")
288
+ if (options.intention.trim() === "" && !options.permissive)
287
289
  throw new DrawingError("The intention is empty. Give a short phrase.");
288
- if (options.method !== "geometric" && letters(options.intention).length === 0)
290
+ if (
291
+ options.method !== "geometric" &&
292
+ letters(options.intention).length === 0 &&
293
+ !options.permissive
294
+ )
289
295
  throw new DrawingError(
290
296
  `The ${options.method} method draws from letters, and the intention has none. Write it in words, or use the geometric method.`,
291
297
  );
@@ -403,6 +409,8 @@ export interface GeometryOptions {
403
409
  /** CSS colors cycled per layer; the palette's line color when empty. */
404
410
  colors: readonly string[];
405
411
  background: Background;
412
+ /** Ignore a color canvas cannot parse, as it would, instead of refusing it. */
413
+ permissive?: boolean;
406
414
  }
407
415
 
408
416
  const GEOMETRY_SIZE = 1024;
@@ -433,7 +441,7 @@ export function renderSacredGeometry(options: GeometryOptions): Uint8Array {
433
441
  `At most ${MAX_LAYERS} colors are used, one for each layer. Give fewer.`,
434
442
  );
435
443
  for (const color of options.colors)
436
- if (!isCssColor(color))
444
+ if (!options.permissive && !isCssColor(color))
437
445
  throw new DrawingError(
438
446
  `${JSON.stringify(color)} is not a CSS color. Use a name such as gold or a code such as #c0a060.`,
439
447
  );
package/src/plugin.ts CHANGED
@@ -56,6 +56,13 @@ export interface DrawingOptions {
56
56
  cardPresentation?: CardPresentation;
57
57
  /** Longer map text than the defaults, for a host whose callers already draw longer labels. */
58
58
  mapLimits?: RelationshipMapLimits;
59
+ /**
60
+ * Accept what a looser host's callers already send: a card exclusion that is not a card of the
61
+ * deck is ignored, spread positions may share a cell, and a relationship map may repeat or leave
62
+ * empty a node id and may draw an edge from a node to itself. Off by default, where each is
63
+ * refused with a message the model can correct.
64
+ */
65
+ permissive?: boolean;
59
66
  }
60
67
 
61
68
  function readDecks(deckDir: string): Deck[] {
@@ -77,6 +84,11 @@ export function drawing(options: DrawingOptions = {}) {
77
84
  return definePlugin({
78
85
  name: "drawing",
79
86
  setup: () => {
87
+ for (const [name, limit] of Object.entries(options.mapLimits ?? {}))
88
+ if (!Number.isSafeInteger(limit) || limit < 1)
89
+ throw new PluginError(
90
+ `plugin drawing: mapLimits.${name} must be a whole number of at least 1; got ${limit}.`,
91
+ );
80
92
  // Read at setup, so a wrong deck directory stops the start with a message that names this plugin.
81
93
  const decks = options.deckDir ? readDecks(options.deckDir) : [];
82
94
  const env: ImageToolEnv = {
@@ -84,12 +96,25 @@ export function drawing(options: DrawingOptions = {}) {
84
96
  };
85
97
  return {
86
98
  tools: [
87
- relationshipMapTool(env, random, options.mapLimits),
99
+ relationshipMapTool(
100
+ env,
101
+ random,
102
+ options.mapLimits,
103
+ options.permissive,
104
+ ),
88
105
  magicCircleTool(env),
89
- sigilTool(env),
90
- sacredGeometryTool(env),
106
+ sigilTool(env, options.permissive),
107
+ sacredGeometryTool(env, options.permissive),
91
108
  ...(decks.length > 0
92
- ? [drawCardsTool(decks, env, random, options.cardPresentation)]
109
+ ? [
110
+ drawCardsTool(
111
+ decks,
112
+ env,
113
+ random,
114
+ options.cardPresentation,
115
+ options.permissive,
116
+ ),
117
+ ]
93
118
  : []),
94
119
  ],
95
120
  };
@@ -126,7 +126,7 @@ interface SimLink extends SimulationLinkDatum<SimNode> {
126
126
 
127
127
  type RoughCanvas = ReturnType<typeof rough.canvas>;
128
128
 
129
- function validate(input: MapInput): void {
129
+ function validate(input: MapInput, permissive: boolean): void {
130
130
  if (input.nodes.length === 0)
131
131
  throw new DrawingError("nodes cannot be empty. Give at least one node.");
132
132
  if (input.nodes.length > MAX_NODES)
@@ -139,9 +139,9 @@ function validate(input: MapInput): void {
139
139
  );
140
140
  const ids = new Set<string>();
141
141
  for (const node of input.nodes) {
142
- if (node.id.trim() === "")
142
+ if (node.id.trim() === "" && !permissive)
143
143
  throw new DrawingError("A node has an empty id. Give every node a name.");
144
- if (ids.has(node.id))
144
+ if (ids.has(node.id) && !permissive)
145
145
  throw new DrawingError(
146
146
  `Two nodes share the id ${JSON.stringify(node.id)}. Node ids must be unique.`,
147
147
  );
@@ -153,7 +153,7 @@ function validate(input: MapInput): void {
153
153
  throw new DrawingError(
154
154
  `An edge references the unknown node ${JSON.stringify(end)}. Add it to nodes or fix the edge.`,
155
155
  );
156
- if (edge.from === edge.to)
156
+ if (edge.from === edge.to && !permissive)
157
157
  throw new DrawingError(
158
158
  `An edge from ${JSON.stringify(edge.from)} to itself cannot be drawn. Connect two different nodes.`,
159
159
  );
@@ -390,8 +390,10 @@ function drawNode(
390
390
  export function renderRelationshipMap(
391
391
  input: MapInput,
392
392
  random: Random = Math.random,
393
+ /** Draw maps with repeated or empty node ids and edges from a node to itself instead of refusing them. */
394
+ permissive = false,
393
395
  ): Uint8Array {
394
- validate(input);
396
+ validate(input, permissive);
395
397
  ensureFont();
396
398
  // The shapes grow to hold their text, so the text is measured before the layout.
397
399
  const measure = createCanvas(1, 1).getContext("2d");
@@ -34,6 +34,7 @@ export function drawCardsTool(
34
34
  env: ImageToolEnv,
35
35
  random: Random,
36
36
  presentation: CardPresentation = {},
37
+ permissive = false,
37
38
  ) {
38
39
  const [first, ...rest] = decks;
39
40
  if (!first) throw new DrawingError("draw_cards needs at least one deck.");
@@ -92,7 +93,7 @@ export function drawCardsTool(
92
93
  const positions: SpreadPosition[] =
93
94
  args.spread ?? autoLayout(args.count);
94
95
  // Checked before the draw, so a refused spread leaves the random source where it was.
95
- checkSpread(positions, args.count);
96
+ checkSpread(positions, args.count, permissive);
96
97
  const cards = drawCards({
97
98
  deck,
98
99
  count: args.count,
@@ -100,6 +101,7 @@ export function drawCardsTool(
100
101
  exclude: args.exclude,
101
102
  allowReversed: args.allow_reversed,
102
103
  random,
104
+ ignoreUnknownExclusions: permissive,
103
105
  });
104
106
  const heading = presentation.heading?.(
105
107
  { deck, count: cards.length, question: args.question },
@@ -116,6 +118,7 @@ export function drawCardsTool(
116
118
  positions,
117
119
  heading,
118
120
  presentation.reversedSuffix,
121
+ permissive,
119
122
  );
120
123
  const lines = cards.map((card, i) => {
121
124
  const label = positions[i]?.label ? `${positions[i]?.label}: ` : "";
@@ -65,7 +65,7 @@ export function magicCircleTool(env: ImageToolEnv) {
65
65
  );
66
66
  }
67
67
 
68
- export function sigilTool(env: ImageToolEnv) {
68
+ export function sigilTool(env: ImageToolEnv, permissive = false) {
69
69
  return imageTool(
70
70
  {
71
71
  name: "sigil_generate",
@@ -86,6 +86,7 @@ export function sigilTool(env: ImageToolEnv) {
86
86
  complexity: args.complexity ?? "elaborate",
87
87
  style: args.style ?? "traditional",
88
88
  background: args.background ?? "dark",
89
+ permissive,
89
90
  }),
90
91
  text: "The sigil is attached to your reply as {file}.",
91
92
  }),
@@ -94,7 +95,7 @@ export function sigilTool(env: ImageToolEnv) {
94
95
  );
95
96
  }
96
97
 
97
- export function sacredGeometryTool(env: ImageToolEnv) {
98
+ export function sacredGeometryTool(env: ImageToolEnv, permissive = false) {
98
99
  return imageTool(
99
100
  {
100
101
  name: "sacred_geometry_generate",
@@ -131,6 +132,7 @@ export function sacredGeometryTool(env: ImageToolEnv) {
131
132
  rotation: args.rotation ?? 0,
132
133
  colors: args.colors ?? [],
133
134
  background: args.background ?? "dark",
135
+ permissive,
134
136
  }),
135
137
  text: "The sacred geometry is attached to your reply as {file}.",
136
138
  }),
@@ -21,6 +21,7 @@ export function relationshipMapTool(
21
21
  env: ImageToolEnv,
22
22
  random: Random,
23
23
  limits: RelationshipMapLimits = {},
24
+ permissive = false,
24
25
  ) {
25
26
  const maxTitle = limits.title ?? MAX_TITLE;
26
27
  const maxText = limits.text ?? MAX_TEXT;
@@ -34,7 +35,7 @@ export function relationshipMapTool(
34
35
  nodes: Type.Array(
35
36
  strictObject({
36
37
  id: Type.String({
37
- minLength: 1,
38
+ minLength: permissive ? 0 : 1,
38
39
  maxLength: maxText,
39
40
  description: "Character or faction name.",
40
41
  }),
@@ -63,7 +64,7 @@ export function relationshipMapTool(
63
64
  }),
64
65
  draw: (args) => ({
65
66
  stem: "relationship-map",
66
- image: renderRelationshipMap(args, random),
67
+ image: renderRelationshipMap(args, random, permissive),
67
68
  text: "The relationship map is attached to your reply as {file}.",
68
69
  }),
69
70
  },