@datamoc/mw_games 0.20.0 → 0.22.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/audio/Channels.d.ts +19 -0
  3. package/dist/audio/Channels.d.ts.map +1 -1
  4. package/dist/audio/Channels.js +33 -4
  5. package/dist/audio/Channels.js.map +1 -1
  6. package/dist/audio/LoopTags.d.ts +37 -0
  7. package/dist/audio/LoopTags.d.ts.map +1 -0
  8. package/dist/audio/LoopTags.js +116 -0
  9. package/dist/audio/LoopTags.js.map +1 -0
  10. package/dist/audio/Midi.d.ts +5 -0
  11. package/dist/audio/Midi.d.ts.map +1 -1
  12. package/dist/audio/Midi.js +9 -1
  13. package/dist/audio/Midi.js.map +1 -1
  14. package/dist/audio/MidiRender.d.ts +3 -2
  15. package/dist/audio/MidiRender.d.ts.map +1 -1
  16. package/dist/audio/MidiRender.js +2 -2
  17. package/dist/audio/MidiRender.js.map +1 -1
  18. package/dist/audio/index.d.ts +3 -1
  19. package/dist/audio/index.d.ts.map +1 -1
  20. package/dist/audio/index.js +1 -0
  21. package/dist/audio/index.js.map +1 -1
  22. package/dist/board/bitboard.d.ts +132 -0
  23. package/dist/board/bitboard.d.ts.map +1 -0
  24. package/dist/board/bitboard.js +495 -0
  25. package/dist/board/bitboard.js.map +1 -0
  26. package/dist/board/chess.d.ts +16 -0
  27. package/dist/board/chess.d.ts.map +1 -1
  28. package/dist/board/chess.js +43 -0
  29. package/dist/board/chess.js.map +1 -1
  30. package/dist/board/index.d.ts +9 -1
  31. package/dist/board/index.d.ts.map +1 -1
  32. package/dist/board/index.js +5 -1
  33. package/dist/board/index.js.map +1 -1
  34. package/dist/board/openings.d.ts +58 -0
  35. package/dist/board/openings.d.ts.map +1 -0
  36. package/dist/board/openings.js +45 -0
  37. package/dist/board/openings.js.map +1 -0
  38. package/dist/board/tablebase.d.ts +118 -0
  39. package/dist/board/tablebase.d.ts.map +1 -0
  40. package/dist/board/tablebase.js +414 -0
  41. package/dist/board/tablebase.js.map +1 -0
  42. package/dist/board/tourney.d.ts +90 -0
  43. package/dist/board/tourney.d.ts.map +1 -0
  44. package/dist/board/tourney.js +1013 -0
  45. package/dist/board/tourney.js.map +1 -0
  46. package/dist/core/JavaRandom.d.ts +55 -0
  47. package/dist/core/JavaRandom.d.ts.map +1 -0
  48. package/dist/core/JavaRandom.js +81 -0
  49. package/dist/core/JavaRandom.js.map +1 -0
  50. package/dist/core/index.d.ts +2 -0
  51. package/dist/core/index.d.ts.map +1 -1
  52. package/dist/core/index.js +1 -0
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/mw_games.global.js +123 -116
  55. package/dist/rpg/EventRunner.d.ts +7 -0
  56. package/dist/rpg/EventRunner.d.ts.map +1 -1
  57. package/dist/rpg/EventRunner.js +6 -0
  58. package/dist/rpg/EventRunner.js.map +1 -1
  59. package/dist/two-d/render/FogLayer.d.ts +68 -0
  60. package/dist/two-d/render/FogLayer.d.ts.map +1 -0
  61. package/dist/two-d/render/FogLayer.js +83 -0
  62. package/dist/two-d/render/FogLayer.js.map +1 -0
  63. package/dist/two-d/render/LiquidLayer.d.ts +62 -0
  64. package/dist/two-d/render/LiquidLayer.d.ts.map +1 -0
  65. package/dist/two-d/render/LiquidLayer.js +102 -0
  66. package/dist/two-d/render/LiquidLayer.js.map +1 -0
  67. package/dist/two-d/render/RpgmAutotile.d.ts +5 -0
  68. package/dist/two-d/render/RpgmAutotile.d.ts.map +1 -1
  69. package/dist/two-d/render/RpgmAutotile.js +4 -3
  70. package/dist/two-d/render/RpgmAutotile.js.map +1 -1
  71. package/dist/two-d/render/TileMap.d.ts +14 -2
  72. package/dist/two-d/render/TileMap.d.ts.map +1 -1
  73. package/dist/two-d/render/TileMap.js +32 -2
  74. package/dist/two-d/render/TileMap.js.map +1 -1
  75. package/dist/two-d/render/index.d.ts +4 -0
  76. package/dist/two-d/render/index.d.ts.map +1 -1
  77. package/dist/two-d/render/index.js +2 -0
  78. package/dist/two-d/render/index.js.map +1 -1
  79. package/dist/two-d/ui/Label.d.ts.map +1 -1
  80. package/dist/two-d/ui/Label.js +11 -2
  81. package/dist/two-d/ui/Label.js.map +1 -1
  82. package/dist/two-d/ui/ListView.d.ts +24 -0
  83. package/dist/two-d/ui/ListView.d.ts.map +1 -1
  84. package/dist/two-d/ui/ListView.js +97 -4
  85. package/dist/two-d/ui/ListView.js.map +1 -1
  86. package/dist/two-d/ui/MessageLog.d.ts +62 -0
  87. package/dist/two-d/ui/MessageLog.d.ts.map +1 -0
  88. package/dist/two-d/ui/MessageLog.js +114 -0
  89. package/dist/two-d/ui/MessageLog.js.map +1 -0
  90. package/dist/two-d/ui/RadioGroup.d.ts +68 -0
  91. package/dist/two-d/ui/RadioGroup.d.ts.map +1 -0
  92. package/dist/two-d/ui/RadioGroup.js +177 -0
  93. package/dist/two-d/ui/RadioGroup.js.map +1 -0
  94. package/dist/two-d/ui/TextModel.d.ts +26 -1
  95. package/dist/two-d/ui/TextModel.d.ts.map +1 -1
  96. package/dist/two-d/ui/TextModel.js +56 -7
  97. package/dist/two-d/ui/TextModel.js.map +1 -1
  98. package/dist/two-d/ui/TextSharpness.d.ts +33 -0
  99. package/dist/two-d/ui/TextSharpness.d.ts.map +1 -0
  100. package/dist/two-d/ui/TextSharpness.js +56 -0
  101. package/dist/two-d/ui/TextSharpness.js.map +1 -0
  102. package/dist/two-d/ui/index.d.ts +5 -0
  103. package/dist/two-d/ui/index.d.ts.map +1 -1
  104. package/dist/two-d/ui/index.js +3 -0
  105. package/dist/two-d/ui/index.js.map +1 -1
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +14 -2
  109. package/sbom.cdx.json +6 -6
  110. package/tools/build-tablebases.d.mts +22 -0
  111. package/tools/build-tablebases.mjs +549 -0
  112. package/tools/compile-openings.d.mts +20 -0
  113. package/tools/compile-openings.mjs +130 -0
  114. package/tools/play-uci.d.mts +26 -0
  115. package/tools/play-uci.mjs +194 -0
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join, relative, basename } from 'node:path';
4
+ import { huffEncode } from '../src/board/tablebase.ts';
5
+ import { spawn } from '../src/threads/spawn.ts';
6
+
7
+ /**
8
+ * Generates pawnless endgame tablebases (WDL plus distance-to-mate) and ships
9
+ * them compiled as JSON a game checks in beside its own code.
10
+ *
11
+ * Each ending is white extras against a lone black king (`KQK`, `KBNK`, ...): a
12
+ * retrograde solve over every placement, verified against the known results
13
+ * (queens and rooks win, lone bishops and knights draw). Captures convert into
14
+ * the already-generated smaller tables, so 3-piece endings generate first and
15
+ * ride along as the 4-piece tasks' arguments.
16
+ *
17
+ * Only Node built-ins plus the framework's own rule shape are used, so this
18
+ * adds no dependency. Generation is the parallel half of item 390's data story
19
+ * beside the in-page search: one `threads.spawn` task per ending over a capped
20
+ * lane queue (each big ending holds hundreds of megabytes of arrays, so lanes
21
+ * default to 4, not the search's 8), the same tools/compress-dist.mjs shape the
22
+ * async search uses. A row that no longer parses fails the compile instead of
23
+ * silently dropping a line; a corrupt artifact fails the load, never misprobes.
24
+ *
25
+ * usage: node tools/build-tablebases.mjs <outDir> [ending ...] [--jobs=N]
26
+ * endings default to the shipped set: KQK KRK KBK KNK KBNK KBBK
27
+ */
28
+
29
+ /**
30
+ * Solves one ending by retrograde analysis: every placement with every side to
31
+ * move, wins/losses propagated from mates and stalemates until nothing changes,
32
+ * the rest draws. **Self-contained by construction:** `spawn` stringifies this
33
+ * function into a worker, so everything it touches is nested inside it; the
34
+ * async-equals-sync probe tests are what keep it honest against `tablebase.ts`.
35
+ *
36
+ * `spec` is `{ white: ['queen', ...] }`; `lower` carries the smaller tables a
37
+ * capture converts into (`{ [id]: { wdl: number[], dtm: number[] } }`, null for
38
+ * 3-piece endings where every capture leaves bare kings). Returns plain arrays
39
+ * so the result structured-clones straight into the writer below.
40
+ */
41
+ export function tablebaseTask(spec, lower) {
42
+ const extras = spec.white.map((kind) => ({ queen: 4, rook: 3, bishop: 2, knight: 1 })[kind]).sort((a, b) => b - a);
43
+ const kinds = [5, ...extras, 5];
44
+ const colors = [0, ...extras.map(() => 0), 1];
45
+ const count = kinds.length;
46
+ let positions = 1;
47
+ for (let i = 0; i < count; i++) positions *= 64;
48
+ positions *= 2;
49
+
50
+ const indexOf = (squares, side) => {
51
+ let index = squares[0];
52
+ for (let i = 1; i < squares.length; i++) index = index * 64 + squares[i];
53
+ return index * 2 + side;
54
+ };
55
+
56
+ const adjacent = (a, b) => Math.max(Math.abs((a & 7) - (b & 7)), Math.abs((a >> 3) - (b >> 3))) <= 1;
57
+
58
+ const KNIGHT_STEPS = [
59
+ [1, 2],
60
+ [2, 1],
61
+ [2, -1],
62
+ [1, -2],
63
+ [-1, -2],
64
+ [-2, -1],
65
+ [-2, 1],
66
+ [-1, 2],
67
+ ];
68
+ const KING_STEPS = [
69
+ [1, 0],
70
+ [1, 1],
71
+ [0, 1],
72
+ [-1, 1],
73
+ [-1, 0],
74
+ [-1, -1],
75
+ [0, -1],
76
+ [1, -1],
77
+ ];
78
+ const DIAGONALS = [
79
+ [1, 1],
80
+ [-1, 1],
81
+ [-1, -1],
82
+ [1, -1],
83
+ ];
84
+ const STRAIGHTS = [
85
+ [1, 0],
86
+ [0, 1],
87
+ [-1, 0],
88
+ [0, -1],
89
+ ];
90
+
91
+ const squaresOf = (index) => {
92
+ const squares = new Array(count);
93
+ let rest = Math.floor(index / 2);
94
+ for (let i = count - 1; i >= 0; i--) {
95
+ squares[i] = rest % 64;
96
+ rest = Math.floor(rest / 64);
97
+ }
98
+ return { squares, side: index % 2 };
99
+ };
100
+
101
+ const occupiedSet = (squares, skip) => {
102
+ const set = new Set();
103
+ for (let i = 0; i < squares.length; i++) {
104
+ if (i !== skip && squares[i] >= 0) set.add(squares[i]);
105
+ }
106
+ return set;
107
+ };
108
+
109
+ /** side `by` (0 white, 1 black) attacks `square` on this occupancy */
110
+ const attacked = (squares, square, by) => {
111
+ const file = square & 7;
112
+ const rank = square >> 3;
113
+ for (let i = 0; i < count; i++) {
114
+ if (colors[i] !== by || squares[i] < 0) continue;
115
+ const kind = kinds[i];
116
+ const f = squares[i] & 7;
117
+ const r = squares[i] >> 3;
118
+ if (kind === 1) {
119
+ for (const [df, dr] of KNIGHT_STEPS) {
120
+ if (f + df === file && r + dr === rank) return true;
121
+ }
122
+ } else if (kind === 5) {
123
+ if (Math.max(Math.abs(f - file), Math.abs(r - rank)) === 1) return true;
124
+ } else {
125
+ const rays = kind === 2 ? DIAGONALS : kind === 3 ? STRAIGHTS : [...DIAGONALS, ...STRAIGHTS];
126
+ for (const [df, dr] of rays) {
127
+ let a = f + df;
128
+ let b = r + dr;
129
+ while (a >= 0 && a < 8 && b >= 0 && b < 8) {
130
+ if (a === file && b === rank) return true;
131
+ let blocked = false;
132
+ for (let j = 0; j < count; j++) {
133
+ if ((squares[j] & 7) === a && squares[j] >> 3 === b) {
134
+ blocked = true;
135
+ break;
136
+ }
137
+ }
138
+ if (blocked) break;
139
+ a += df;
140
+ b += dr;
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return false;
146
+ };
147
+
148
+ const kingOf = (squares, side) => squares[side === 0 ? 0 : count - 1];
149
+
150
+ /** every move for `side`: `{ from: pieceIndex, to, capture: capturedIndex | -1 }` */
151
+ const genMoves = (squares, side) => {
152
+ const occupied = occupiedSet(squares, -1);
153
+ const out = [];
154
+ for (let i = 0; i < count; i++) {
155
+ if (colors[i] !== side) continue;
156
+ const kind = kinds[i];
157
+ const f = squares[i] & 7;
158
+ const r = squares[i] >> 3;
159
+ const slide = (steps) => {
160
+ for (const [df, dr] of steps) {
161
+ let a = f + df;
162
+ let b = r + dr;
163
+ while (a >= 0 && a < 8 && b >= 0 && b < 8) {
164
+ const to = b * 8 + a;
165
+ if (!occupied.has(to)) out.push({ from: i, to, capture: -1 });
166
+ else {
167
+ for (let j = 0; j < count; j++) {
168
+ if (squares[j] === to && colors[j] !== side && kinds[j] !== 5) {
169
+ out.push({ from: i, to, capture: j });
170
+ break;
171
+ }
172
+ }
173
+ break;
174
+ }
175
+ a += df;
176
+ b += dr;
177
+ }
178
+ }
179
+ };
180
+ if (kind === 1) {
181
+ for (const [df, dr] of KNIGHT_STEPS) {
182
+ const a = f + df;
183
+ const b = r + dr;
184
+ if (a < 0 || a > 7 || b < 0 || b > 7) continue;
185
+ const to = b * 8 + a;
186
+ const capture = captureAt(squares, side, to);
187
+ if (capture === -1 && occupied.has(to)) continue;
188
+ out.push({ from: i, to, capture });
189
+ }
190
+ } else if (kind === 5) {
191
+ for (const [df, dr] of KING_STEPS) {
192
+ const a = f + df;
193
+ const b = r + dr;
194
+ if (a < 0 || a > 7 || b < 0 || b > 7) continue;
195
+ const to = b * 8 + a;
196
+ const capture = captureAt(squares, side, to);
197
+ if (capture === -1 && occupied.has(to)) continue;
198
+ out.push({ from: i, to, capture });
199
+ }
200
+ } else if (kind === 2) slide(DIAGONALS);
201
+ else if (kind === 3) slide(STRAIGHTS);
202
+ else slide([...DIAGONALS, ...STRAIGHTS]);
203
+ }
204
+ return out;
205
+ };
206
+
207
+ /** captured piece index on `to`, -1 when empty, -2 when it holds a king (never generated) */
208
+ const captureAt = (squares, side, to) => {
209
+ for (let j = 0; j < count; j++) {
210
+ if (squares[j] === to) {
211
+ if (colors[j] === side) return -1;
212
+ return kinds[j] === 5 ? -2 : j;
213
+ }
214
+ }
215
+ return -1;
216
+ };
217
+
218
+ /** legal moves: captures verified like quiets, kings never adjacent afterwards */
219
+ const legalMoves = (squares, side) => {
220
+ const out = [];
221
+ for (const move of genMoves(squares, side)) {
222
+ if (move.capture === -2) continue;
223
+ const next = squares.slice();
224
+ next[move.from] = move.to;
225
+ if (move.capture >= 0) next[move.capture] = -1;
226
+ if (adjacent(next[0], next[count - 1])) continue;
227
+ if (attacked(next, kingOf(next, side), 1 - side)) continue;
228
+ out.push(move);
229
+ }
230
+ return out;
231
+ };
232
+
233
+ /** reduced ending id after piece `gone` is captured, or null for bare kings */
234
+ const reducedId = (gone) => {
235
+ const rest = kinds.filter((kind, i) => i !== 0 && i !== count - 1 && i !== gone && kind !== 5);
236
+ if (!rest.length) return null;
237
+ const letter = { 4: 'Q', 3: 'R', 2: 'B', 1: 'N' };
238
+ return `K${rest
239
+ .slice()
240
+ .sort((a, b) => b - a)
241
+ .map((kind) => letter[kind])
242
+ .join('')}K`;
243
+ };
244
+
245
+ /** index of these squares in the reduced ending's order */
246
+ const reducedIndex = (squares, side, gone) => {
247
+ const order = [0];
248
+ const rest = [];
249
+ for (let i = 1; i < count - 1; i++) {
250
+ if (i !== gone) rest.push(i);
251
+ }
252
+ rest.sort((a, b) => kinds[b] - kinds[a]);
253
+ const seq = [squares[0], ...rest.map((i) => squares[i]), squares[count - 1]];
254
+ let index = seq[0];
255
+ for (let i = 1; i < seq.length; i++) index = index * 64 + seq[i];
256
+ return index * 2 + (1 - side);
257
+ };
258
+
259
+ /** conversion value from the mover's view: 1 loss, 2 draw, 3 win, with distance */
260
+ const convert = (squares, side, gone) => {
261
+ const id = reducedId(gone);
262
+ if (id === null) return { value: 2, dtm: 0 };
263
+ const table = lower?.[id];
264
+ if (!table) throw new Error(`tablebaseTask needs ${id} to convert a capture`);
265
+ const index = reducedIndex(squares, side, gone);
266
+ const opponent = table.wdl[index];
267
+ const dtm = table.dtm[index] + 1;
268
+ if (opponent === 1) return { value: 3, dtm };
269
+ if (opponent === 3) return { value: 1, dtm };
270
+ return { value: 2, dtm: 0 };
271
+ };
272
+
273
+ const wdl = new Uint8Array(positions);
274
+ const dtm = new Uint16Array(positions);
275
+ const remaining = new Uint16Array(positions);
276
+ const queue = [];
277
+
278
+ /** predecessors: quiet un-moves of one enemy piece back onto an empty square */
279
+ const predecessors = (squares, side) => {
280
+ const out = [];
281
+ const enemy = 1 - side;
282
+ for (let i = 0; i < count; i++) {
283
+ if (colors[i] !== enemy) continue;
284
+ const kind = kinds[i];
285
+ const t = squares[i];
286
+ const rays =
287
+ kind === 2 ? DIAGONALS : kind === 3 ? STRAIGHTS : kind === 4 ? [...DIAGONALS, ...STRAIGHTS] : null;
288
+ const consume = (f) => {
289
+ if (squares.includes(f)) return;
290
+ //forward legality lives in the post-move occupancy, which is exactly
291
+ //this position: the destination is safe for a king, and the mover's
292
+ //own king stands safe (moving out of check is legal, so the
293
+ //pre-move square is never tested)
294
+ const ownKing = kind === 5 ? t : kingOf(squares, enemy);
295
+ if (attacked(squares, ownKing, side)) return;
296
+ const prev = squares.slice();
297
+ prev[i] = f;
298
+ if (adjacent(prev[0], prev[count - 1])) return;
299
+ out.push({ squares: prev, side: enemy });
300
+ };
301
+ if (rays) {
302
+ const f = t & 7;
303
+ const r = t >> 3;
304
+ for (const [df, dr] of rays) {
305
+ let a = f + df;
306
+ let b = r + dr;
307
+ while (a >= 0 && a < 8 && b >= 0 && b < 8) {
308
+ const from = b * 8 + a;
309
+ if (squares.includes(from)) break;
310
+ consume(from);
311
+ a += df;
312
+ b += dr;
313
+ }
314
+ }
315
+ } else {
316
+ const steps = kind === 1 ? KNIGHT_STEPS : KING_STEPS;
317
+ const f = t & 7;
318
+ const r = t >> 3;
319
+ for (const [df, dr] of steps) {
320
+ const a = f + df;
321
+ const b = r + dr;
322
+ if (a < 0 || a > 7 || b < 0 || b > 7) continue;
323
+ consume(b * 8 + a);
324
+ }
325
+ }
326
+ }
327
+ return out;
328
+ };
329
+
330
+ for (let index = 0; index < positions; index++) {
331
+ const { squares, side } = squaresOf(index);
332
+ const seen = new Set(squares);
333
+ if (seen.size !== count || adjacent(squares[0], squares[count - 1])) {
334
+ wdl[index] = 2;
335
+ continue;
336
+ }
337
+ const moves = legalMoves(squares, side);
338
+ let quiet = 0;
339
+ let draws = 0;
340
+ let won = false;
341
+ let wonDtm = 0;
342
+ let badDelay = 0;
343
+ for (const move of moves) {
344
+ if (move.capture === -1) {
345
+ quiet++;
346
+ continue;
347
+ }
348
+ const { value, dtm: distance } = convert(squares, side, move.capture);
349
+ if (value === 3) {
350
+ won = true;
351
+ wonDtm = distance;
352
+ break;
353
+ }
354
+ if (value === 2) draws++;
355
+ else if (distance > badDelay) badDelay = distance;
356
+ }
357
+ if (won) {
358
+ wdl[index] = 3;
359
+ dtm[index] = Math.min(65535, wonDtm);
360
+ queue.push(index);
361
+ } else if (moves.length === 0) {
362
+ if (attacked(squares, kingOf(squares, side), 1 - side)) {
363
+ wdl[index] = 1;
364
+ dtm[index] = 0;
365
+ } else {
366
+ wdl[index] = 2;
367
+ }
368
+ queue.push(index);
369
+ } else if (quiet === 0) {
370
+ if (draws > 0) {
371
+ wdl[index] = 2;
372
+ } else {
373
+ wdl[index] = 1;
374
+ dtm[index] = Math.min(65535, badDelay);
375
+ }
376
+ queue.push(index);
377
+ } else {
378
+ remaining[index] = quiet;
379
+ }
380
+ }
381
+
382
+ for (let head = 0; head < queue.length; head++) {
383
+ const index = queue[head];
384
+ const { squares, side } = squaresOf(index);
385
+ const value = wdl[index];
386
+ for (const pred of predecessors(squares, side)) {
387
+ const at = indexOf(pred.squares, pred.side);
388
+ if (wdl[at] !== 0) continue;
389
+ if (value === 1) {
390
+ wdl[at] = 3;
391
+ dtm[at] = Math.min(65535, dtm[index] + 1);
392
+ queue.push(at);
393
+ } else if (value === 3) {
394
+ remaining[at]--;
395
+ if (remaining[at] === 0) {
396
+ const { squares: ps, side: pside } = squaresOf(at);
397
+ const moves = legalMoves(ps, pside);
398
+ let delay = 0;
399
+ let drawn = false;
400
+ for (const move of moves) {
401
+ if (move.capture === -1) {
402
+ const next = ps.slice();
403
+ next[move.from] = move.to;
404
+ const child = indexOf(next, 1 - pside);
405
+ if (wdl[child] === 3 && dtm[child] + 1 > delay) delay = dtm[child] + 1;
406
+ } else {
407
+ const { value: converted } = convert(ps, pside, move.capture);
408
+ if (converted === 2) drawn = true;
409
+ }
410
+ }
411
+ if (drawn) {
412
+ wdl[at] = 2;
413
+ } else {
414
+ wdl[at] = 1;
415
+ dtm[at] = Math.min(65535, delay);
416
+ }
417
+ queue.push(at);
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ /** anything unreachable stays unknown: no forced result, so a draw */
424
+ for (let index = 0; index < positions; index++) {
425
+ if (wdl[index] === 0) wdl[index] = 2;
426
+ }
427
+ return { wdl: Array.from(wdl), dtm: Array.from(dtm), positions };
428
+ }
429
+
430
+ const SHIPPED = ['KQK', 'KRK', 'KBK', 'KNK', 'KBNK', 'KBBK'];
431
+ const LETTER_KIND = { Q: 'queen', R: 'rook', B: 'bishop', N: 'knight' };
432
+
433
+ export function specOf(id) {
434
+ const kinds = id
435
+ .slice(1, -1)
436
+ .split('')
437
+ .map((letter) => LETTER_KIND[letter]);
438
+ if (!kinds.length || kinds.some((kind) => !kind)) throw new Error(`not a tablebase ending: "${id}"`);
439
+ return { white: kinds };
440
+ }
441
+
442
+ function defaultJobs() {
443
+ try {
444
+ const parallelism = globalThis.process?.getBuiltinModule?.('node:os')?.availableParallelism?.();
445
+ if (typeof parallelism === 'number' && Number.isInteger(parallelism) && parallelism > 0) {
446
+ return Math.min(parallelism, 4);
447
+ }
448
+ } catch {
449
+ //generation must never fail for lack of a hint about its own host
450
+ }
451
+ return 2;
452
+ }
453
+
454
+ const isMain = process.argv[1] && import.meta.url.endsWith(basename(process.argv[1]));
455
+
456
+ if (isMain) {
457
+ const args = process.argv.slice(2);
458
+ const outDir = args.find((arg) => !arg.startsWith('--'));
459
+ const jobsArg = args.find((arg) => arg.startsWith('--jobs='));
460
+ const jobs = jobsArg ? Math.max(1, Math.floor(Number(jobsArg.slice(7)))) : defaultJobs();
461
+ if (!Number.isInteger(jobs)) {
462
+ console.error('jobs must be a positive integer, e.g. --jobs=4');
463
+ process.exit(1);
464
+ }
465
+ const wanted = args.filter((arg) => !arg.startsWith('--') && arg !== outDir);
466
+ if (!outDir) {
467
+ console.error('usage: node tools/build-tablebases.mjs <outDir> [ending ...] [--jobs=N]');
468
+ process.exit(1);
469
+ }
470
+ const ids = (wanted.length ? wanted : SHIPPED).map((id) => {
471
+ if (!/^K[QRBN]+K$/.test(id)) throw new Error(`not a tablebase ending: "${id}"`);
472
+ const strength = { Q: 0, R: 1, B: 2, N: 3 };
473
+ const canonical = `K${id
474
+ .slice(1, -1)
475
+ .split('')
476
+ .sort((a, b) => strength[a] - strength[b])
477
+ .join('')}K`;
478
+ if (id !== canonical) throw new Error(`not a canonical ending id (want "${canonical}"): "${id}"`);
479
+ return id;
480
+ });
481
+ //a capture converts into the ending without the taken piece, so requested
482
+ //4-piece endings pull in the 3-piece reductions they convert to
483
+ const withDeps = new Set(ids);
484
+ for (const id of ids) {
485
+ if (id.length !== 4) continue;
486
+ const extras = id.slice(1, -1).split('');
487
+ for (let gone = 0; gone < extras.length; gone++) {
488
+ withDeps.add(`K${extras.filter((_, i) => i !== gone).join('')}K`);
489
+ }
490
+ }
491
+ const three = [...withDeps].filter((id) => id.length === 3);
492
+ const four = [...withDeps].filter((id) => id.length === 4);
493
+ if (ids.some((id) => id.length > 4))
494
+ throw new Error('only 3-to-4-piece endings generate here; 5-piece stays out (see the roadmap)');
495
+ mkdirSync(outDir, { recursive: true });
496
+
497
+ const writeEnding = (id, result, seconds) => {
498
+ const wdl = huffEncode(Uint8Array.from(result.wdl));
499
+ const raw = Uint16Array.from(result.dtm);
500
+ const bytes = new Uint8Array(raw.length * 2);
501
+ for (let i = 0; i < raw.length; i++) {
502
+ bytes[2 * i] = raw[i] & 255;
503
+ bytes[2 * i + 1] = raw[i] >> 8;
504
+ }
505
+ const dtm = huffEncode(bytes);
506
+ const file = { version: 1, id, white: specOf(id).white, positions: result.positions, wdl, dtm };
507
+ writeFileSync(join(outDir, `${id}.json`), `${JSON.stringify(file, null, 2)}\n`);
508
+ console.log(
509
+ ` ${id}: ${result.positions} positions in ${seconds}s -> ${relative(process.cwd(), join(outDir, `${id}.json`))}`,
510
+ );
511
+ };
512
+ //4-piece endings hold hundreds of megabytes of arrays each, so their lanes
513
+ //stay narrow whatever --jobs says; 3-piece endings are megabytes and share wide
514
+ const lanes = async (items, solve, cap = jobs) => {
515
+ let next = 0;
516
+ const width = Math.max(1, Math.min(cap, items.length));
517
+ await Promise.all(
518
+ Array.from({ length: width }, async () => {
519
+ for (;;) {
520
+ const index = next++;
521
+ if (index >= items.length) return;
522
+ await solve(items[index]);
523
+ }
524
+ }),
525
+ );
526
+ };
527
+
528
+ //3-piece endings solve with no smaller table (every capture leaves bare
529
+ //kings); 4-piece tasks then receive them for their conversions
530
+ const lower = {};
531
+ await lanes(three, async (id) => {
532
+ const started = Date.now();
533
+ const result = await spawn(tablebaseTask, [specOf(id), null]);
534
+ lower[id] = { wdl: result.wdl, dtm: result.dtm };
535
+ writeEnding(id, result, ((Date.now() - started) / 1000).toFixed(1));
536
+ });
537
+ await lanes(
538
+ four,
539
+ async (id) => {
540
+ const started = Date.now();
541
+ const result = await spawn(tablebaseTask, [specOf(id), lower]);
542
+ writeEnding(id, result, ((Date.now() - started) / 1000).toFixed(1));
543
+ },
544
+ Math.min(jobs, 2),
545
+ );
546
+ console.log(
547
+ `\ntablebases in ${relative(process.cwd(), outDir)}. Load the endings a game needs with board.loadTablebaseEnding.`,
548
+ );
549
+ }
@@ -0,0 +1,20 @@
1
+ import type { OpeningBook } from '../src/board/openings.ts';
2
+
3
+ /** SAN tokens of one PGN line, without move numbers or results. */
4
+ export function sanTokens(pgn: string): string[];
5
+
6
+ /**
7
+ * Resolves one SAN token against the position into a UCI move (`e2e4`, `e7e8q`).
8
+ * Anything resolving to zero (or several) legal moves throws with the row it came
9
+ * from, because a silent guess would poison every position past it.
10
+ */
11
+ export function sanToUci(state: import('../src/board/chess.ts').ChessState, san: string, context: string): string;
12
+
13
+ /** A whole PGN line to UCI, played from the starting position. */
14
+ export function lineToUci(pgn: string, context: string): string[];
15
+
16
+ /**
17
+ * Compiles every TSV row in `dir` into a book: each position along each line keeps the
18
+ * continuation there, and the longest line through the position names it.
19
+ */
20
+ export function compileBook(dir: string): OpeningBook;
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
3
+ import { basename, join, relative } from 'node:path';
4
+ import { applyMove, legalMoves, positionKey, sq, squareName, startingChess } from '../src/board/chess.ts';
5
+ import { parseUciMove } from '../src/board/openings.ts';
6
+
7
+ /**
8
+ * Compiles opening tables (`eco`, `name`, `pgn` TSV rows, one per line after the header)
9
+ * into a position-keyed book a game checks in beside its own code.
10
+ *
11
+ * Each row's PGN is SAN, resolved against the real move generator and replayed from the
12
+ * starting position; every position along the line records the continuation, and the
13
+ * longest line ending at a position names it (first seen wins ties). The output is plain
14
+ * JSON (`{ version: 1, positions: { [key]: { eco?, name?, moves: [uci...] } } }`), keyed
15
+ * by `positionKey`, which is what `board.probeBook` reads back.
16
+ *
17
+ * Only Node built-ins plus the framework's own board rules are used, so this adds no
18
+ * dependency. Rows are vendored data (`data/openings/`, CC0), never fetched: re-vendoring
19
+ * is copying five files at a new commit, and a row that no longer parses fails the
20
+ * compile instead of silently dropping a line.
21
+ *
22
+ * usage: node tools/compile-openings.mjs <tsv folder> <book.json>
23
+ */
24
+
25
+ const PROMOTION_KIND = { q: 'queen', r: 'rook', b: 'bishop', n: 'knight' };
26
+ const PROMOTION_LETTER = { queen: 'q', rook: 'r', bishop: 'b', knight: 'n' };
27
+
28
+ /** SAN tokens of one PGN line, without move numbers or results */
29
+ export function sanTokens(pgn) {
30
+ return pgn
31
+ .split(/\s+/)
32
+ .map((token) => token.replace(/^\d+\.+/, ''))
33
+ .filter((token) => token && !/^(1-0|0-1|1\/2-1\/2|\*)$/.test(token));
34
+ }
35
+
36
+ /**
37
+ * Resolves one SAN token against the position into a UCI move (`e2e4`, `e7e8q`).
38
+ * Castling, disambiguation, captures, promotions and trailing check/mate marks are
39
+ * understood; anything resolving to zero (or several) legal moves throws with the row
40
+ * it came from, because a silent guess would poison every position past it.
41
+ */
42
+ export function sanToUci(state, san, context) {
43
+ const move = san.replace(/[+#?!]+$/, '');
44
+ if (/^O-O(-O)?$/.test(move)) {
45
+ const home = state.turn === 'white' ? 0 : 56;
46
+ return squareName(home + 4) + squareName(home + (move === 'O-O' ? 6 : 2));
47
+ }
48
+ const match = /^([KQRBN]?)([a-h]?[1-8]?)(x?)([a-h][1-8])(?:=([QRBN]))?$/.exec(move);
49
+ if (!match) throw new Error(`${context}: cannot parse '${san}'`);
50
+ const kind = match[1] === '' ? 'pawn' : { K: 'king', Q: 'queen', R: 'rook', B: 'bishop', N: 'knight' }[match[1]];
51
+ const to = sq(match[4]);
52
+ const promotion = match[5] ? PROMOTION_KIND[match[5].toLowerCase()] : undefined;
53
+ const candidates = legalMoves(state).filter(
54
+ (candidate) =>
55
+ state.board[candidate.from]?.kind === kind &&
56
+ candidate.to === to &&
57
+ (candidate.promotion ?? null) === (promotion ?? null) &&
58
+ [...match[2]].every((c) => squareName(candidate.from).includes(c)),
59
+ );
60
+ if (candidates.length !== 1) throw new Error(`${context}: '${san}' matches ${candidates.length} moves`);
61
+ const chosen = candidates[0];
62
+ return (
63
+ squareName(chosen.from) + squareName(chosen.to) + (chosen.promotion ? PROMOTION_LETTER[chosen.promotion] : '')
64
+ );
65
+ }
66
+
67
+ /** a whole PGN line to UCI, played from the starting position */
68
+ export function lineToUci(pgn, context) {
69
+ const state = startingChess();
70
+ const uci = [];
71
+ for (const san of sanTokens(pgn)) {
72
+ const move = sanToUci(state, san, context);
73
+ uci.push(move);
74
+ applyMove(state, parseUciMove(move));
75
+ }
76
+ return uci;
77
+ }
78
+
79
+ /**
80
+ * Compiles every TSV row in `dir` into a book: each position along each line keeps the
81
+ * continuation there, and the longest line ending at a position names it. Terminal
82
+ * positions keep entries of their own, so a line that stops early still classifies.
83
+ */
84
+ export function compileBook(dir) {
85
+ const positions = {};
86
+ for (const file of readdirSync(dir)
87
+ .filter((entry) => entry.endsWith('.tsv'))
88
+ .sort()) {
89
+ const rows = readFileSync(join(dir, file), 'utf8').split('\n');
90
+ rows.slice(1).forEach((row, index) => {
91
+ if (!row.trim()) return;
92
+ const [eco, name, pgn] = row.split('\t');
93
+ const context = `${file}:${index + 2} (${eco} ${name})`;
94
+ if (!eco || !name || !pgn) throw new Error(`${context}: malformed row`);
95
+ const uci = lineToUci(pgn, context);
96
+ const state = startingChess();
97
+ const keys = [positionKey(state)];
98
+ for (const move of uci) {
99
+ applyMove(state, parseUciMove(move));
100
+ keys.push(positionKey(state));
101
+ }
102
+ keys.forEach((key, ply) => {
103
+ let entry = positions[key];
104
+ if (!entry) entry = positions[key] = { length: 0, moves: [] };
105
+ if (ply === uci.length && uci.length > entry.length) {
106
+ entry.eco = eco;
107
+ entry.name = name;
108
+ entry.length = uci.length;
109
+ }
110
+ if (ply < uci.length && !entry.moves.includes(uci[ply])) entry.moves.push(uci[ply]);
111
+ });
112
+ });
113
+ }
114
+ for (const key of Object.keys(positions)) delete positions[key].length;
115
+ return { version: 1, positions };
116
+ }
117
+
118
+ const isMain = process.argv[1] && import.meta.url.endsWith(basename(process.argv[1]));
119
+
120
+ if (isMain) {
121
+ const [dir, out] = process.argv.slice(2);
122
+ if (!dir || !out) {
123
+ console.error('usage: node tools/compile-openings.mjs <tsv folder> <book.json>');
124
+ process.exit(1);
125
+ }
126
+ const book = compileBook(dir);
127
+ writeFileSync(out, `${JSON.stringify(book)}\n`);
128
+ const count = Object.keys(book.positions).length;
129
+ console.log(` ${count} positions from ${relative(process.cwd(), dir)} -> ${relative(process.cwd(), out)}`);
130
+ }