chesspal 0.2.1 → 0.2.3

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.
@@ -1,12 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
- // What the board draws over its squares and pieces: the kings' badges at the end, the arrows, the helps' corners and pins, the control map's counts, the
3
- // bubble of a refused move, the choice of a promotion and the piece lifted off the board.
4
2
  import { useEffect, useRef, useState } from "preact/hooks";
5
3
  import { Piece } from "../pieces/pieces.js";
6
4
  /** The corner folded on a piece at risk, red on the player's and green on the other side's, in ink when no side is theirs (D-91); before its line in the card. */
7
5
  export function Notch({ tone }) {
8
6
  return (_jsx("span", { class: tone === undefined ? "help-notch" : `help-notch ${tone}`, "aria-hidden": "true", children: _jsxs("svg", { viewBox: "0 0 10 10", children: [_jsx("path", { d: "M0.5 0.5V9.5H9.5Z" }), tone === undefined ? null : _jsx("path", { class: "edge", d: "M0.5 0.5V9.5H9.5Z" }), _jsx("circle", { cx: "2.9", cy: "7.1", r: "1.1" })] }) }));
9
7
  }
8
+ /** The pushpin stuck in a pinned piece from the top left corner, its needle toward the piece, in the tone of its corner at risk (D-91); in ink in no side's. */
9
+ export function Pushpin({ tone }) {
10
+ return _jsx(Stuck, { kind: "help-pin", shape: pushpin, tone: tone });
11
+ }
12
+ /**
13
+ * The skewer stuck in the skewered piece, the one in front, as the pushpin in a pinned one (D-91): a stick in ink with a big bead and a small one in the tone,
14
+ * as the piece in front and the cheaper one behind it, so that it reads apart from the pushpin, all in the tone (Stefano); in the card's line, in its colour.
15
+ */
16
+ export function Skewer({ tone }) {
17
+ return (_jsx(Stuck, { kind: "help-skewer", shape: skewer, tone: tone, children: tone === undefined || tone === "neutral" ? null : skewerBeads.map(({ y, radius }) => _jsx("circle", { cx: "12", cy: y, r: radius }, y)) }));
18
+ }
10
19
  /** A king's badge in the corner of its square. */
11
20
  export function Badge({ kind }) {
12
21
  return (_jsx("span", { class: `badge ${kind}`, "aria-hidden": "true", children: kind === "mate" ? "#" : kind === "half" ? "½" : (_jsx("svg", { viewBox: "0 0 24 24", children: kind === "crown" ? _jsx("path", { d: "M4 18L3 7L8.5 11L12 5L15.5 11L21 7L20 18Z" }) : _jsx("path", { d: "M6 21V4M6 4.5C10 2.5 13 6.5 18 4.5V13.5C13 15.5 10 11.5 6 13.5" }) })) }));
@@ -46,13 +55,20 @@ export function Arrows({ arrows, at, explain, occupied }) {
46
55
  const starts = [...new Set(shapes.map(({ from }) => from))];
47
56
  return (_jsx("svg", { class: explain !== undefined ? "arrows explain" : occupied !== undefined ? "arrows drawn" : "arrows", viewBox: "0 0 8 8", "aria-hidden": "true", children: starts.map((start) => _jsx(ArrowGroup, { shapes: shapes.filter(({ from }) => from === start), edged: occupied !== undefined }, start)) }));
48
57
  }
49
- /** Each pin as a dashed line from the pinning piece through the pinned one to what stands behind it (D-78), over a halo that reads on every board. */
50
- export function Pins({ pins, at }) {
51
- return (_jsx("svg", { class: "helps-layer", viewBox: "0 0 8 8", "aria-hidden": "true", children: pins.map((pin) => {
58
+ /**
59
+ * Each pin as a dashed line from the pinning piece through the pinned one to what stands behind it (D-78), and each skewer as a dash-dot one, beads on its stick, over
60
+ * a halo that reads on every board. A pin and a skewer on the same stretch of a line stand apart on its two sides, so that both read.
61
+ */
62
+ export function PinsAndSkewers({ pins, skewers, at }) {
63
+ const lines = [...pins.map((line) => ({ kind: "pin", line, other: skewers })), ...skewers.map((line) => ({ kind: "skewer", line, other: pins }))];
64
+ return (_jsx("svg", { class: "helps-layer", viewBox: "0 0 8 8", "aria-hidden": "true", children: lines.map(({ kind, line, other }) => {
52
65
  // The line stops short of both ends, as an explaining arrow does: it hides neither piece, nor the corner of a pinner at risk.
53
- const [x0, y0] = towards(at(pin.by.square), at(pin.to.square), pinTrim);
54
- const [x1, y1] = towards(at(pin.to.square), at(pin.by.square), pinTrim);
55
- return (_jsxs("g", { class: "pin", children: [_jsx("line", { class: "halo", x1: x0, y1: y0, x2: x1, y2: y1 }), _jsx("line", { x1: x0, y1: y0, x2: x1, y2: y1 })] }, `${pin.by.square}${pin.piece.square}`));
66
+ const [startX, startY] = towards(at(line.by.square), at(line.to.square), pinTrim);
67
+ const [endX, endY] = towards(at(line.to.square), at(line.by.square), pinTrim);
68
+ const shift = other.some((each) => sharedSquares(each, line) > 1) ? (kind === "pin" ? 1 : -1) * sharedLineShift : 0;
69
+ const [dx, dy] = across([startX, startY], [endX, endY], shift);
70
+ const ends = { x1: startX + dx, y1: startY + dy, x2: endX + dx, y2: endY + dy };
71
+ return (_jsxs("g", { class: kind, children: [_jsx("line", { class: "halo", ...ends }), _jsx("line", { ...ends })] }, `${kind} ${line.by.square}${line.piece.square}`));
56
72
  }) }));
57
73
  }
58
74
  /** A square's counts on the control map, as a domino in its middle: how many of White's pieces reach it, and of Black's; none when no piece does. */
@@ -135,6 +151,24 @@ function ArrowGroup({ shapes, edged }) {
135
151
  ? shapes.map(({ key, shaft, head }) => (_jsxs("g", { children: [_jsx("path", { class: "edge", d: shaft }), _jsx("polygon", { class: "edge", points: head })] }, `edge${key}`)))
136
152
  : null, shapes.map(({ key, colour, shaft, head }) => (_jsxs("g", { class: colour, children: [_jsx("path", { d: shaft }), _jsx("polygon", { points: head })] }, key)))] }));
137
153
  }
154
+ /**
155
+ * A sign stuck in a piece from its square's top left corner, tilted toward the piece; edged in ink when coloured (Zoe). Its box holds the tilted sign and
156
+ * its halo only, so that it sits in the corner (Stefano).
157
+ */
158
+ function Stuck({ kind, shape, tone, children }) {
159
+ return (_jsx("span", { class: tone === undefined ? kind : `${kind} ${tone}`, "aria-hidden": "true", children: _jsx("svg", { viewBox: "2 0.5 18.5 22.5", children: _jsxs("g", { transform: "rotate(-32 12 12)", children: [_jsx("path", { d: shape }), tone === undefined || tone === "neutral" ? null : _jsx("path", { class: "edge", d: shape }), children] }) }) }));
160
+ }
161
+ /** How many of their three squares a pin and a skewer share: two when they lie on the same stretch of a line. */
162
+ function sharedSquares(one, other) {
163
+ const squares = [one.by.square, one.piece.square, one.to.square];
164
+ return [other.by.square, other.piece.square, other.to.square].filter((square) => squares.includes(square)).length;
165
+ }
166
+ /** A step of the given length across a line, always to the same side of it whichever way the line runs. */
167
+ function across(start, end, distance) {
168
+ const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
169
+ const way = end[0] < start[0] || (end[0] === start[0] && end[1] < start[1]) ? -1 : 1;
170
+ return [-(end[1] - start[1]) / length * distance * way, (end[0] - start[0]) / length * distance * way];
171
+ }
138
172
  /** A point `distance` squares from `point` toward `target`. */
139
173
  function towards(point, target, distance) {
140
174
  const length = Math.hypot(target[0] - point[0], target[1] - point[1]);
@@ -150,10 +184,18 @@ function ExplainIcon({ kind, piece }) {
150
184
  : _jsx("path", { d: "M4 8h15M15 4l4 4-4 4M20 16H5M9 12l-4 4 4 4" }) })) }));
151
185
  }
152
186
  const promotionPieces = ["q", "n", "r", "b"];
187
+ // A pushpin standing up, as the bubble's pin: its cap, its body widening into the collar, and its needle. Tilted less than a diagonal, so it never lies along a pin's line.
188
+ const pushpin = "M8.6 2.2H15.4Q16.6 2.2 16.6 3.4V4.6Q16.6 5.8 15.4 5.8H14.8V9.4L18.4 13.1V14.9H13V20L12 22.6L11 20V14.9H5.6V13.1L9.2 9.4V5.8H8.6Q7.4 5.8 7.4 4.6V3.4Q7.4 2.2 8.6 2.2Z";
189
+ // A skewer standing up, in the pushpin's box: a round end, a big bead, a small one, a stretch of stick between them, and its point.
190
+ const skewer = "M10.9 2.6A1.1 1.1 0 0 1 13.1 2.6V3.75A4.2 4.2 0 0 1 13.1 11.85V13A3.1 3.1 0 0 1 13.1 18.8V20.2L12 23.4L10.9 20.2V18.8A3.1 3.1 0 0 1 10.9 13V11.85A4.2 4.2 0 0 1 10.9 3.75Z";
191
+ // The skewer's beads, on its stick: the big one and the small one, as its shape draws them.
192
+ const skewerBeads = [{ y: 7.8, radius: 4.2 }, { y: 15.9, radius: 3.1 }];
153
193
  // How much an arrow that explains is cut, in squares: off the culprit at its start, and before an occupied square at its point.
154
194
  const explainTrim = { start: 0.36, end: 0.4 };
155
195
  // How much a pin's line is cut at each end, in squares.
156
196
  const pinTrim = 0.35;
197
+ // How far a pin and a skewer on the same stretch of a line stand off it, each on its side, in squares: a little more than a line with its halo.
198
+ const sharedLineShift = 0.08;
157
199
  // An arrow's head, in squares: how long, and how far each side of its line.
158
200
  const arrowHead = { length: 0.42, width: 0.3 };
159
201
  // The helps' point, smaller in step with their thinner shaft (Stefano).
@@ -49,6 +49,74 @@
49
49
  stroke-width: 0.5;
50
50
  }
51
51
 
52
+ /* A pinned piece: a pushpin stuck in it from the top left corner, in the notch's colours and on its halo; it takes the place of the rank's number there.
53
+ A skewered piece, the one in front: a skewer stuck in it the same way, beside the pushpin when the piece is pinned too */
54
+ .help-pin,
55
+ .help-skewer {
56
+ position: absolute;
57
+ left: 1px;
58
+ top: 1px;
59
+ z-index: 2;
60
+ display: block;
61
+ height: clamp(15px, 3.6cqw, 26px);
62
+ aspect-ratio: 18.5 / 22.5;
63
+ pointer-events: none;
64
+ }
65
+
66
+ .help-pin svg,
67
+ .help-skewer svg {
68
+ display: block;
69
+ width: 100%;
70
+ height: 100%;
71
+ overflow: visible;
72
+ }
73
+
74
+ .help-pin path,
75
+ .help-skewer path {
76
+ fill: var(--help-ink);
77
+ stroke: var(--help-halo);
78
+ stroke-width: 2.6;
79
+ stroke-linejoin: round;
80
+ paint-order: stroke;
81
+ }
82
+
83
+ .help-pin.threat path {
84
+ fill: var(--badge-blunder);
85
+ }
86
+
87
+ .help-pin.chance path {
88
+ fill: var(--badge-best);
89
+ }
90
+
91
+ /* The skewer's stick stays in ink and its beads take the tone, so that it reads apart from the pushpin (Stefano) */
92
+ .help-skewer circle {
93
+ stroke: var(--help-ink);
94
+ stroke-width: 0.9;
95
+ }
96
+
97
+ .help-skewer.threat circle {
98
+ fill: var(--badge-blunder);
99
+ }
100
+
101
+ .help-skewer.chance circle {
102
+ fill: var(--badge-best);
103
+ }
104
+
105
+ .help-pin path.edge,
106
+ .help-skewer path.edge {
107
+ fill: none;
108
+ stroke: var(--help-ink);
109
+ stroke-width: 1;
110
+ }
111
+
112
+ .square:has(> .help-pin) > .help-skewer {
113
+ left: clamp(9px, 3cqw, 22px);
114
+ }
115
+
116
+ .square:has(> .help-pin, > .help-skewer) > .coordinate.rank {
117
+ visibility: hidden;
118
+ }
119
+
52
120
  /* A pin: a thin dashed ink line from the pinner to what stands behind, under the arrows; without a head, never an arrow */
53
121
  .helps-layer {
54
122
  position: absolute;
@@ -73,6 +141,11 @@
73
141
  stroke-linecap: round;
74
142
  }
75
143
 
144
+ /* A skewer's line: a dash and a dot, beads on its stick, so that it reads apart from a pin's even dashes */
145
+ .helps-layer .skewer line:not(.halo) {
146
+ stroke-dasharray: 0.22 0.11 0 0.11;
147
+ }
148
+
76
149
  /* The map (Zoe, after the round): every square on one neutral ground, then light where only White reaches, dark where only Black does, half and half
77
150
  when both do; the two counts together in the middle, as a domino. The drawings and the pieces step back */
78
151
  .board.mapping ~ .arrows,
@@ -383,6 +456,18 @@
383
456
  fill: var(--card);
384
457
  }
385
458
 
459
+ /* The skewer of the board, in the line's colour and without its halo */
460
+ .help-rows .help-skewer {
461
+ left: 7px;
462
+ top: 4px;
463
+ height: 17px;
464
+ }
465
+
466
+ .help-rows .help-skewer path {
467
+ fill: var(--row);
468
+ stroke: none;
469
+ }
470
+
386
471
  .help-rows .row-icon {
387
472
  position: absolute;
388
473
  left: 6px;
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-ru
3
3
  // the coach's words (D-88).
4
4
  import { Fragment } from "preact";
5
5
  import { useState } from "preact/hooks";
6
- import { Notch } from "../board/marks.js";
6
+ import { Notch, Skewer } from "../board/marks.js";
7
7
  import { BadgeIcon, NoteIcon } from "../game/badges.js";
8
8
  import { FoldButton, useCardFold } from "../game/card-fold.js";
9
9
  import { Figurines } from "../game/move-list.js";
@@ -70,7 +70,7 @@ function Judged({ ply, text }) {
70
70
  function Tactics({ helps, opened, onOpen, onPoint, text }) {
71
71
  const words = text.helpsCard;
72
72
  const point = pointing(onPoint);
73
- const calm = !helps.lines.some((line) => line.kind === "risk" || line.kind === "pin");
73
+ const calm = !helps.lines.some((line) => line.kind === "risk" || line.kind === "pin" || line.kind === "skewer");
74
74
  return (_jsxs("ul", { class: "help-rows", children: [calm
75
75
  ? (_jsxs("li", { class: "safe", children: [_jsxs(RowIcon, { children: [_jsx("circle", { cx: "8", cy: "8", r: "6.5" }), _jsx("path", { d: "M5.2 8.2l2 2 3.8-4" })] }), words.nothingAtRisk] }))
76
76
  : null, helps.lines.map((line) => {
@@ -86,11 +86,14 @@ function pointing(onPoint) {
86
86
  onPointerLeave: (event) => event.pointerType === "mouse" && onPoint([]),
87
87
  });
88
88
  }
89
- /** A line's sign: the corner of a piece at risk, a pin's link, a check's plus, the loose pieces' i; its colour is the line's. */
89
+ /** A line's sign: the corner of a piece at risk, a pin's link, the skewer of the board, a check's plus, the loose pieces' i; its colour is the line's. */
90
90
  function LineIcon({ kind }) {
91
91
  if (kind === "risk") {
92
92
  return _jsx(Notch, {});
93
93
  }
94
+ if (kind === "skewer") {
95
+ return _jsx(Skewer, {});
96
+ }
94
97
  if (kind === "loose") {
95
98
  return _jsx(InfoIcon, {});
96
99
  }
@@ -115,6 +118,8 @@ function LineDetails({ line, helps, point, text }) {
115
118
  return (_jsx("ul", { class: "line-details", children: line.items.map((risk) => (_jsxs("li", { ...point(riskSquares(risk)), children: [text.piece(risk.piece.piece, risk.piece.side), " ", _jsx(Squares, { squares: [risk.piece.square] }), " \u00B7 ", words.attackers, ": ", _jsx(Squares, { squares: risk.piece.attackers }), risk.why === "undefended" ? null : (_jsxs(_Fragment, { children: [" ", "\u00B7 ", words.defenders, ": ", _jsx(Squares, { squares: risk.piece.defenders })] })), " \u00B7 ", words[risk.why]] }, risk.piece.square))) }));
116
119
  case "pin":
117
120
  return (_jsx("ul", { class: "line-details", children: line.items.map((pin) => (_jsxs("li", { ...point(pinSquares(pin)), children: [text.piece(pin.piece.piece, pin.piece.side), " ", _jsx(Squares, { squares: [pin.piece.square] }), " ", words.pinnedTo(pin.to.piece), " ", _jsx(Squares, { squares: [pin.to.square] }), ", ", words.pinnedBy(pin.by.piece), " ", _jsx(Squares, { squares: [pin.by.square] })] }, pin.piece.square))) }));
121
+ case "skewer":
122
+ return (_jsx("ul", { class: "line-details", children: line.items.map((skewer) => (_jsxs("li", { ...point(pinSquares(skewer)), children: [text.piece(skewer.piece.piece, skewer.piece.side), " ", _jsx(Squares, { squares: [skewer.piece.square] }), " ", words.skeweredBefore(skewer.to.piece), " ", _jsx(Squares, { squares: [skewer.to.square] }), ", ", words.pinnedBy(skewer.by.piece), " ", _jsx(Squares, { squares: [skewer.by.square] })] }, skewer.piece.square))) }));
118
123
  case "check":
119
124
  return (_jsx("p", { class: "line-details", children: line.items.map((check) => (_jsx("span", { class: "help-move", children: _jsx(Figurines, { san: check, side: line.side }) }, check))) }));
120
125
  case "loose":
@@ -7,6 +7,7 @@ export function helpsOf(game, you) {
7
7
  const toMove = game.position.turn;
8
8
  const atRisk = [...risks(facts.undefended, "undefended"), ...risks(facts.attackedByCheaper, "cheaper"), ...risks(facts.outnumbered, "outnumbered")];
9
9
  const pins = facts.pins.map((pin) => ({ piece: on(pin.square), by: on(pin.by), to: on(pin.to) }));
10
+ const skewers = facts.skewers.map((skewer) => ({ piece: on(skewer.square), by: on(skewer.by), to: on(skewer.to) }));
10
11
  const checks = facts.checks;
11
12
  const otherChecks = facts.otherChecks ?? [];
12
13
  const loose = facts.loose.map(on);
@@ -15,10 +16,11 @@ export function helpsOf(game, you) {
15
16
  you,
16
17
  atRisk,
17
18
  pins,
19
+ skewers,
18
20
  checks,
19
21
  otherChecks,
20
22
  loose,
21
- lines: tacticLines({ toMove, atRisk, pins, checks, otherChecks, loose }, you),
23
+ lines: tacticLines({ toMove, atRisk, pins, skewers, checks, otherChecks, loose }, you),
22
24
  strategy: game.profile,
23
25
  sides: you === "black" ? ["black", "white"] : ["white", "black"],
24
26
  };
@@ -28,12 +30,23 @@ export function riskOn(helps, square) {
28
30
  const risk = helps.atRisk.find((risk) => risk.piece.square === square);
29
31
  return risk === undefined ? undefined : toneOf(risk.piece.side, helps.you);
30
32
  }
31
- /** The squares a line of the tactics lights on the board: its pieces, and those that attack or pin them; none for the checks. */
33
+ /** How the helps mark a square's pinned piece: in the tone of a piece at risk, red when it is the player's, green when it is the other side's (D-91). */
34
+ export function pinOn(helps, square) {
35
+ const pin = helps.pins.find((pin) => pin.piece.square === square);
36
+ return pin === undefined ? undefined : toneOf(pin.piece.side, helps.you);
37
+ }
38
+ /** How the helps mark a square's skewered piece, the one in front: in the tone of a piece at risk, as a pinned one (D-91). */
39
+ export function skewerOn(helps, square) {
40
+ const skewer = helps.skewers.find((skewer) => skewer.piece.square === square);
41
+ return skewer === undefined ? undefined : toneOf(skewer.piece.side, helps.you);
42
+ }
43
+ /** The squares a line of the tactics lights on the board: its pieces, and those that attack, pin or skewer them; none for the checks. */
32
44
  export function squaresOf(line) {
33
45
  switch (line.kind) {
34
46
  case "risk":
35
47
  return line.items.flatMap(riskSquares);
36
48
  case "pin":
49
+ case "skewer":
37
50
  return line.items.flatMap(pinSquares);
38
51
  case "check":
39
52
  return [];
@@ -45,7 +58,7 @@ export function squaresOf(line) {
45
58
  export function riskSquares(risk) {
46
59
  return [risk.piece.square, ...risk.piece.attackers];
47
60
  }
48
- /** A pinned piece, the one that pins it and the one behind it. */
61
+ /** A pinned or skewered piece, the one that pins or skewers it and the one behind it. */
49
62
  export function pinSquares(pin) {
50
63
  return [pin.piece.square, pin.by.square, pin.to.square];
51
64
  }
@@ -88,12 +101,13 @@ const cells = {
88
101
  castled: castlingOf,
89
102
  kingAttacks: (side) => side.king?.attacksNear ?? 0,
90
103
  };
91
- // The tactics' lines that have something: with the player's side, their pieces at risk and pinned and the other side's checks, then the same the other way
92
- // round; without it, White's then Black's; the loose pieces last.
104
+ // The tactics' lines that have something: with the player's side, their pieces at risk, pinned and skewered and the other side's checks, then the same the
105
+ // other way round; without it, White's then Black's; the loose pieces last.
93
106
  function tacticLines(facts, you) {
94
107
  const sideLines = (side) => [
95
108
  { kind: "risk", side, tone: toneOf(side, you), items: facts.atRisk.filter((risk) => risk.piece.side === side) },
96
109
  { kind: "pin", side, tone: toneOf(side, you), items: facts.pins.filter((pin) => pin.piece.side === side) },
110
+ { kind: "skewer", side, tone: toneOf(side, you), items: facts.skewers.filter((skewer) => skewer.piece.side === side) },
97
111
  ];
98
112
  const checkLine = (side) => ({
99
113
  kind: "check",
package/dist/page/text.js CHANGED
@@ -274,16 +274,18 @@ const english = {
274
274
  outnumbered: "more attackers than defenders",
275
275
  pinnedTo: (letter) => `pinned to the ${englishNames[letter]}`,
276
276
  pinnedBy: (letter) => `by the ${englishNames[letter]}`,
277
+ skeweredBefore: (letter) => `ahead of the ${englishNames[letter]}`,
277
278
  nothingAtRisk: "No piece at risk, no pins.",
278
279
  line: (kind, side, you, toMove) => {
279
280
  const name = side === "white" ? "White" : "Black";
281
+ const pieces = { risk: "pieces at risk", pin: "pinned pieces", skewer: "skewered pieces" };
280
282
  if (you === undefined) {
281
- return kind === "risk" ? `${name}'s pieces at risk` : kind === "pin" ? `${name}'s pinned pieces` : `${name}'s checks${toMove ? "" : `, if it were ${name}'s move`}`;
283
+ return kind === "check" ? `${name}'s checks${toMove ? "" : `, if it were ${name}'s move`}` : `${name}'s ${pieces[kind]}`;
282
284
  }
283
285
  if (kind === "check") {
284
286
  return side === you ? `Checks you ${toMove ? "can" : "could"} give` : `Checks the opponent ${toMove ? "can" : "could"} give you`;
285
287
  }
286
- return `${side === you ? "Your" : "The opponent's"} ${kind === "risk" ? "pieces at risk" : "pinned pieces"}`;
288
+ return `${side === you ? "Your" : "The opponent's"} ${pieces[kind]}`;
287
289
  },
288
290
  loose: "Pieces neither attacked nor defended",
289
291
  yours: "yours",
@@ -594,16 +596,18 @@ const italian = {
594
596
  outnumbered: "più attaccanti che difensori",
595
597
  pinnedTo: (letter) => `davanti ${italianWith("a", letter)}`,
596
598
  pinnedBy: (letter) => italianWith("da", letter),
599
+ skeweredBefore: (letter) => `con dietro ${italianThe[letter]}`,
597
600
  nothingAtRisk: "Nessun pezzo in pericolo, nessuna inchiodatura.",
598
601
  line: (kind, side, you, toMove) => {
599
602
  const name = side === "white" ? "Bianco" : "Nero";
603
+ const what = { risk: "in pericolo", pin: "inchiodati", skewer: "infilati" };
600
604
  if (you === undefined) {
601
- return kind === "risk" ? `Pezzi del ${name} in pericolo` : kind === "pin" ? `Pezzi del ${name} inchiodati` : `Scacchi del ${name}${toMove ? "" : ", se toccasse a lui"}`;
605
+ return kind === "check" ? `Scacchi del ${name}${toMove ? "" : ", se toccasse a lui"}` : `Pezzi del ${name} ${what[kind]}`;
602
606
  }
603
607
  if (kind === "check") {
604
608
  return side === you ? `Scacchi che ${toMove ? "puoi" : "potresti"} dare` : `Scacchi che l'avversario ${toMove ? "può" : "potrebbe"} darti`;
605
609
  }
606
- return `${side === you ? "I tuoi pezzi" : "I pezzi avversari"} ${kind === "risk" ? "in pericolo" : "inchiodati"}`;
610
+ return `${side === you ? "I tuoi pezzi" : "I pezzi avversari"} ${what[kind]}`;
607
611
  },
608
612
  loose: "Pezzi né attaccati né difesi",
609
613
  yours: "tuoi",
@@ -6,16 +6,19 @@ export interface Client {
6
6
  }
7
7
  /** Who made the call with these metadata: the model and effort the host ties to it, else the host's name; undefined without a host. */
8
8
  export declare function callerOf(meta: Record<string, unknown> | undefined, client: Client | undefined, folder: string, wait?: number): Promise<Caller | undefined>;
9
- /** Who in Claude Code made a tool call: the chat itself, or one of its agents by its type. */
9
+ /** Who in Claude Code or Codex made a tool call: the chat itself, or one of its agents by its type. */
10
10
  export type CallingAgent = {
11
11
  chat: true;
12
12
  } | {
13
13
  agent: string;
14
14
  };
15
15
  /**
16
- * Who in Claude Code made a tool call: the chat, when its line is in the chat's transcript, or the agent whose file has it, by the type Claude Code notes
17
- * beside that file (Priya). Undefined when it cannot be told: another host, no chat noted, no line, or no type.
16
+ * Who in Claude Code or Codex made a tool call: the chat, when its line is in the chat's transcript, or the agent whose file has it, by the type Claude Code
17
+ * notes beside that file (Priya); in Codex, as the call's metadata and the agent's own session say (D-98). Undefined when it cannot be told: another host,
18
+ * no chat noted, no line, or no type.
18
19
  */
19
20
  export declare function callingAgent(meta: Record<string, unknown> | undefined, folder: string, wait?: number): Promise<CallingAgent | undefined>;
21
+ /** Whether a call comes from one of Codex's agents, as its metadata say: its own process starts no board page (D-98). */
22
+ export declare function fromCodexAgent(meta: Record<string, unknown> | undefined): boolean;
20
23
  /** Whether an agent's tool may answer this caller: one of its agents, or a caller that cannot be told, since the tool's description is the first guard. */
21
24
  export declare function allowedCaller(caller: CallingAgent | undefined, agents: readonly string[]): boolean;
@@ -1,6 +1,7 @@
1
1
  // Who makes a tool call (D-81): only what the host ties to that call, never what the AI says of itself nor a setting the chat may have changed.
2
2
  // Codex sends the model and the effort with each call; Claude Code writes them into the chat's transcript, on the line of the call.
3
3
  import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { basename, dirname, join } from "node:path";
5
6
  import { lastChatStart } from "../games/index.js";
6
7
  /** Who made the call with these metadata: the model and effort the host ties to it, else the host's name; undefined without a host. */
@@ -20,10 +21,15 @@ export async function callerOf(meta, client, folder, wait = 50) {
20
21
  return client === undefined ? undefined : { annotator: client.title ?? client.name, hostOnly: true };
21
22
  }
22
23
  /**
23
- * Who in Claude Code made a tool call: the chat, when its line is in the chat's transcript, or the agent whose file has it, by the type Claude Code notes
24
- * beside that file (Priya). Undefined when it cannot be told: another host, no chat noted, no line, or no type.
24
+ * Who in Claude Code or Codex made a tool call: the chat, when its line is in the chat's transcript, or the agent whose file has it, by the type Claude Code
25
+ * notes beside that file (Priya); in Codex, as the call's metadata and the agent's own session say (D-98). Undefined when it cannot be told: another host,
26
+ * no chat noted, no line, or no type.
25
27
  */
26
28
  export async function callingAgent(meta, folder, wait = 50) {
29
+ const turn = objectOf(meta?.["x-codex-turn-metadata"]);
30
+ if (turn !== undefined) {
31
+ return await codexCaller(turn, wait);
32
+ }
27
33
  const call = meta?.["claudecode/toolUseId"];
28
34
  const transcript = lastChatStart(folder)?.transcript;
29
35
  const found = typeof call === "string" && transcript !== undefined ? await foundCall(transcript, call, wait) : undefined;
@@ -36,6 +42,10 @@ export async function callingAgent(meta, folder, wait = 50) {
36
42
  const type = objectOf(textOf(found.file.replace(/\.jsonl$/, ".meta.json")))?.agentType;
37
43
  return typeof type === "string" ? { agent: type } : undefined;
38
44
  }
45
+ /** Whether a call comes from one of Codex's agents, as its metadata say: its own process starts no board page (D-98). */
46
+ export function fromCodexAgent(meta) {
47
+ return objectOf(meta?.["x-codex-turn-metadata"])?.thread_source === "subagent";
48
+ }
39
49
  /** Whether an agent's tool may answer this caller: one of its agents, or a caller that cannot be told, since the tool's description is the first guard. */
40
50
  export function allowedCaller(caller, agents) {
41
51
  return caller === undefined || ("agent" in caller && agents.includes(caller.agent));
@@ -80,6 +90,81 @@ function callerIn(text, call) {
80
90
  }
81
91
  return undefined;
82
92
  }
93
+ /**
94
+ * Who in Codex made a call: the chat, or an agent by the role its session notes first, before its first call (Priya). An agent started with the chat's
95
+ * own turns sees the opponent's plan, so it counts as the chat (D-88).
96
+ */
97
+ async function codexCaller(turn, wait) {
98
+ if (turn.thread_source !== "subagent") {
99
+ return turn.thread_source === "user" ? { chat: true } : undefined;
100
+ }
101
+ const thread = turn.thread_id;
102
+ if (typeof thread !== "string" || !/^[0-9a-f-]{36}$/i.test(thread)) {
103
+ return undefined;
104
+ }
105
+ const known = codexThreads.get(thread);
106
+ if (known !== undefined) {
107
+ return known;
108
+ }
109
+ for (let attempt = 0; attempt < codexAttempts; attempt++) {
110
+ const start = headOf(codexSession(thread));
111
+ const role = /"agent_role":"([^"]+)"/.exec(start)?.[1];
112
+ if (role !== undefined) {
113
+ const caller = start.includes('"forked_from_id":"') ? { chat: true } : { agent: role };
114
+ codexThreads.set(thread, caller);
115
+ return caller;
116
+ }
117
+ await new Promise((done) => setTimeout(done, wait));
118
+ }
119
+ return undefined;
120
+ }
121
+ // Who each agent's thread is, read once: it never changes, and an agent calls a tool many times (Federico).
122
+ const codexThreads = new Map();
123
+ // The agent's session is written as it starts: a few tries are enough.
124
+ const codexAttempts = 10;
125
+ // Its first line holds Codex's long prompt, but the role comes in its first two kilobytes (Priya).
126
+ const headBytes = 16 * 1024;
127
+ /**
128
+ * The file of a Codex session by its thread: in the day's folder of its start, which its UUIDv7 holds, or the days beside it around midnight; none when
129
+ * it is not there.
130
+ */
131
+ function codexSession(thread) {
132
+ const started = Number.parseInt(thread.replaceAll("-", "").slice(0, 12), 16);
133
+ const sessions = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
134
+ for (const day of [0, -1, 1]) {
135
+ const date = new Date(started + day * 86_400_000);
136
+ const folder = join(sessions, String(date.getFullYear()), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0"));
137
+ try {
138
+ const name = readdirSync(folder).find((each) => each.endsWith(`-${thread}.jsonl`));
139
+ if (name !== undefined) {
140
+ return join(folder, name);
141
+ }
142
+ }
143
+ catch {
144
+ // No session that day.
145
+ }
146
+ }
147
+ return "";
148
+ }
149
+ function headOf(file) {
150
+ if (file === "") {
151
+ return "";
152
+ }
153
+ try {
154
+ const descriptor = openSync(file, "r");
155
+ try {
156
+ const buffer = Buffer.alloc(headBytes);
157
+ const read = readSync(descriptor, buffer, 0, headBytes, 0);
158
+ return buffer.subarray(0, read).toString("utf8");
159
+ }
160
+ finally {
161
+ closeSync(descriptor);
162
+ }
163
+ }
164
+ catch {
165
+ return "";
166
+ }
167
+ }
83
168
  function withEffort(model, effort) {
84
169
  return typeof effort === "string" && effort !== "" ? { annotator: model, effort } : { annotator: model };
85
170
  }
@@ -3,11 +3,16 @@ import { type AnyCommand, type Context } from "../commands/index.ts";
3
3
  /** What the host reads as the server starts, before any tool: where to begin, and the rule of every move (D-27). Short, since hosts may cut it. */
4
4
  export declare const serverInstructions: string;
5
5
  /** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
6
- export declare function createMcpServer(context?: Context, list?: AnyCommand[]): McpServer;
6
+ export declare function createMcpServer(context?: Context, list?: AnyCommand[], beforeCall?: (meta: Record<string, unknown> | undefined) => Promise<void>): McpServer;
7
7
  /**
8
8
  * Serves the tools over standard input and output, as AI hosts expect: stdout carries the protocol alone, so nothing else may print there. It ends when the
9
9
  * host goes and its input closes, once the calls still running have answered: a move half made would be lost.
10
10
  */
11
11
  export declare function startMcpServer(context?: Context, list?: AnyCommand[]): Promise<void>;
12
+ /**
13
+ * The board page started once, before the chat's first call, the status every chat opens with, which then gives its address: the process of a Codex
14
+ * agent, which has one of its own, starts none, which would stand beside the chat's and could keep the doorbell from it (D-98, Priya).
15
+ */
16
+ export declare function lazyPage(start: () => Promise<void>): (meta: Record<string, unknown> | undefined) => Promise<void>;
12
17
  /** The opponent's name for the page, from the host's (D-97): another host than Claude's and Codex is unnamed. */
13
18
  export declare function opponentOf(host: string | undefined): string | undefined;
@@ -5,31 +5,39 @@ import { z } from "zod";
5
5
  import { aiMove, commands, defaultContext, inputProblems, isBoolean, PositionChanged } from "../commands/index.js";
6
6
  import { aiNameOf } from "../core/index.js";
7
7
  import { endOf, loadGame, noteAiHost, noteAiModel } from "../games/index.js";
8
- import { hasAgent } from "../host-files/index.js";
8
+ import { agentTools, hasAgent } from "../host-files/index.js";
9
9
  import { gameFolder } from "../profiles/index.js";
10
10
  import { version } from "../version.js";
11
11
  import { pagePorts } from "./bell.js";
12
- import { allowedCaller, callerOf, callingAgent } from "./caller.js";
12
+ import { allowedCaller, callerOf, callingAgent, fromCodexAgent } from "./caller.js";
13
13
  import { chatHasRead, startPage } from "./page/page.js";
14
14
  /** What the host reads as the server starts, before any tool: where to begin, and the rule of every move (D-27). Short, since hosts may cut it. */
15
15
  export const serverInstructions = "Chesspal keeps the chess for you: the rules, the board, the clock, the games and the player's memory. Call `status` at the start of every "
16
16
  + "chat. Every move goes through Chesspal's tools: never keep the board in your head or invent a position.";
17
17
  /** The MCP server for an AI host: every command of the registry as a tool, with its description and parameters (D-5). */
18
- export function createMcpServer(context = defaultContext(), list = commands) {
18
+ export function createMcpServer(context = defaultContext(), list = commands, beforeCall) {
19
19
  const server = new McpServer({ name: "chesspal", version }, { instructions: serverInstructions });
20
20
  // The server keeps what it told the chat, the instructions a tool gives once, and the position it showed: none yet (D-71).
21
21
  context.told ??= new Set();
22
22
  context.positionSeen ??= null;
23
23
  // The host the chat runs in, as it introduced itself: it names the AI on the page and in the game's PGN (D-97).
24
24
  context.host ??= () => server.server.getClientVersion()?.name;
25
- const forOneHost = [];
25
+ const forSomeHosts = [];
26
26
  for (const command of list.filter((each) => each.cliOnly !== true)) {
27
27
  const tool = server.registerTool(command.name, toolConfig(command), async (input, extra) => {
28
+ await beforeCall?.(extra._meta);
29
+ const codexAgent = fromCodexAgent(extra._meta);
30
+ const calling = command.byAgents !== undefined || codexAgent ? await callingAgent(extra._meta, context.folder) : undefined;
28
31
  // An agent's tool called by the chat, or by another agent, would give what only that agent may see (Priya).
29
- if (command.byAgents !== undefined && !allowedCaller(await callingAgent(extra._meta, context.folder), command.byAgents)) {
32
+ if (command.byAgents !== undefined && !allowedCaller(calling, command.byAgents)) {
30
33
  // It names no agent to start: the chat that asked would start one itself (Priya).
31
34
  return { content: [{ type: "text", text: `${command.name} is not yours to call: the player reads what it gives on the board page. Nothing changed.` }], isError: true };
32
35
  }
36
+ // Codex gives its agents every tool: one of Chesspal's keeps to its own, and never plays or talks for the opponent (D-98, Federico).
37
+ const own = calling !== undefined && "agent" in calling ? agentTools(calling.agent) : undefined;
38
+ if (codexAgent && own !== undefined && !own.includes(command.name)) {
39
+ return { content: [{ type: "text", text: `${command.name} is not yours to call: you work only with ${own.join(", ")}. Nothing changed.` }], isError: true };
40
+ }
33
41
  // Each call knows who makes it, for the commands that write it down (D-81).
34
42
  const caller = () => callerOf(extra._meta, server.server.getClientVersion(), context.folder);
35
43
  const result = await track(runTool(command, input, context, caller, extra._meta));
@@ -38,15 +46,15 @@ export function createMcpServer(context = defaultContext(), list = commands) {
38
46
  }
39
47
  return result;
40
48
  });
41
- if (command.host !== undefined) {
49
+ if (command.hosts !== undefined) {
42
50
  tool.disable();
43
- forOneHost.push({ host: command.host, tool });
51
+ forSomeHosts.push({ hosts: command.hosts, tool });
44
52
  }
45
53
  }
46
- // A tool for one host is offered once the host has said its name, before it lists the tools.
54
+ // A tool for some hosts is offered once the host has said its name, before it lists the tools.
47
55
  server.server.oninitialized = () => {
48
56
  const host = server.server.getClientVersion()?.name;
49
- for (const { tool } of forOneHost.filter((each) => each.host === host)) {
57
+ for (const { tool } of forSomeHosts.filter((each) => host !== undefined && each.hosts.includes(host))) {
50
58
  tool.enable();
51
59
  }
52
60
  };
@@ -65,10 +73,25 @@ export async function startMcpServer(context = defaultContext(), list = commands
65
73
  };
66
74
  process.stdin.on("end", leave);
67
75
  process.stdin.on("close", leave);
68
- const server = createMcpServer(context, list);
69
- context.page = await pageOf(context, () => opponentOf(context.host?.()));
76
+ const pageFirst = lazyPage(async () => {
77
+ context.page = await pageOf(context, () => opponentOf(context.host?.()));
78
+ });
79
+ const server = createMcpServer(context, list, pageFirst);
70
80
  await server.connect(new StdioServerTransport());
71
81
  }
82
+ /**
83
+ * The board page started once, before the chat's first call, the status every chat opens with, which then gives its address: the process of a Codex
84
+ * agent, which has one of its own, starts none, which would stand beside the chat's and could keep the doorbell from it (D-98, Priya).
85
+ */
86
+ export function lazyPage(start) {
87
+ let page;
88
+ return async (meta) => {
89
+ if (!fromCodexAgent(meta)) {
90
+ page ??= start();
91
+ await page;
92
+ }
93
+ };
94
+ }
72
95
  /** The opponent's name for the page, from the host's (D-97): another host than Claude's and Codex is unnamed. */
73
96
  export function opponentOf(host) {
74
97
  return aiNameOf(host);
@@ -76,11 +99,10 @@ export function opponentOf(host) {
76
99
  /** The board page of the player's folder, served by this process for as long as the chat lasts (D-47); without it the game goes on in the chat. */
77
100
  async function pageOf(context, opponent) {
78
101
  try {
79
- const { url, heard, chatCalls, ponders, open, askNewGame } = await startPage(context, opponent);
102
+ const { url, heard, chatCalls, open, askNewGame } = await startPage(context, opponent);
80
103
  // Words the AI is given change no file: the pages hear of them from the commands.
81
104
  context.heard = heard;
82
105
  context.calls = chatCalls;
83
- context.ponders = ponders;
84
106
  context.pageOpen = open;
85
107
  context.askNewGame = askNewGame;
86
108
  if (!pagePorts(context.folder).includes(Number(new URL(url).port))) {
@@ -159,7 +181,7 @@ async function runTool(command, input, context, caller, meta) {
159
181
  * goes on, so that no call of the chat's waits for it while it plays; none for the chat, or when it cannot be told.
160
182
  */
161
183
  async function sharingAgent(command, meta, context) {
162
- const agents = command.sharedWith?.filter((agent) => hasAgent(context.folder, agent)) ?? [];
184
+ const agents = command.sharedWith?.filter((agent) => hasAgent(context.folder, agent, context.host?.())) ?? [];
163
185
  if (agents.length === 0 || gameGoingOn(context)) {
164
186
  return undefined;
165
187
  }