RemiFabre commited on
Commit
5d90ebb
·
1 Parent(s): c60cebf

Deploy browser player — run4/ckpt-037888 elo 2361.0

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,35 @@
1
  ---
2
- title: Faience
3
- emoji: 🌖
4
- colorFrom: gray
5
- colorTo: red
6
  sdk: static
7
- pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: "Faïence"
3
+ emoji: "🀄"
4
+ colorFrom: blue
5
+ colorTo: yellow
6
  sdk: static
7
+ pinned: true
8
+ thumbnail: https://remifabre-faience.static.hf.space/social.png
9
+ short_description: "Azul rules vs a self-play neural net, all in your browser"
10
  ---
11
 
12
+ <!-- The card for the Faïence Space. This file lives in the ludometer repo at
13
+ web/player/space/README.md; scripts/deploy_player.sh copies it to the
14
+ Space root at deploy time (the space/ directory itself is never shipped
15
+ to visitors). -->
16
+
17
+ Faïence is a free, open-source implementation of the rules of *Azul*, the
18
+ tile-laying game by Michael Kiesling: a fan project for research, with its own
19
+ code and artwork, not affiliated with or endorsed by the game's publishers.
20
+
21
+ Your opponent is a neural network that learned the game from scratch by
22
+ self-play. The net and its tree search run entirely in your browser: your
23
+ browser downloads the model once and everything after that is local. No
24
+ server plays for it, no account, no ads, nothing to buy.
25
+
26
+ It began as a machine-learning experiment: Ludometer, a research framework
27
+ that measures how good a board game is from the shape of an AI's learning
28
+ curve. The games people play here are the research material: when a game
29
+ ends, the page sends an anonymous record (moves, deals, net, score; sharing
30
+ can be switched off in Settings) to the public dataset
31
+ [RemiFabre/faience-games](https://huggingface.co/datasets/RemiFabre/faience-games),
32
+ where it becomes training data.
33
+
34
+ Code, training logs and methodology:
35
+ [RemiFabre/ludometer](https://github.com/RemiFabre/ludometer) (MIT).
css/style.css ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Page shell for the in-browser player.
2
+ *
3
+ * The table itself — tiles, the two boards, the status band, the log, the inline
4
+ * scoring panels and the tile flights — lives in ui/board.css, which is a copy of
5
+ * web/play/ui/board.css (see web/play/ui/PORTING.md). The hosted player and the
6
+ * local GUI therefore draw the *same* table from two different back ends: a Flask
7
+ * server there, a Web Worker running ONNX in the tab here.
8
+ *
9
+ * This file is only what belongs to *this* page: the linen ground, the header and
10
+ * its setup bar, the engine (model download) strip, the column layout, the supply
11
+ * and "how this works" boxes, and a harder look at small screens — this one is
12
+ * meant to be playable on a phone.
13
+ *
14
+ * Layout, top to bottom: header · engine strip · status band · the playfield
15
+ * (the factories on the left, the two identical boards — you above, AI below —
16
+ * beside them; <body data-boards="stack">, the + in settings, restores the big
17
+ * boards under the factories) · inline scoring · the move log and the about
18
+ * box · the bottom bar (the AI's clock and the tile totals). Nothing is ever
19
+ * covered by anything: there is no overlay and no toast on this page.
20
+ */
21
+
22
+ @import url("../ui/board.css");
23
+
24
+ * { box-sizing: border-box; }
25
+
26
+ body {
27
+ margin: 0;
28
+ min-height: 100vh;
29
+ color: var(--ink);
30
+ font: 15px/1.5 var(--font-body);
31
+ background-color: var(--linen);
32
+ /* woven linen: two hairline threads plus a warm vignette */
33
+ background-image:
34
+ linear-gradient(var(--linen-thread) 1px, rgba(0, 0, 0, 0) 1px),
35
+ linear-gradient(90deg, var(--linen-thread) 1px, rgba(0, 0, 0, 0) 1px),
36
+ radial-gradient(120% 90% at 50% 0%, var(--linen-warm) 0%, var(--linen) 55%, var(--linen-dark) 100%);
37
+ background-size: 7px 7px, 7px 7px, 100% 100%;
38
+ }
39
+
40
+ h1, h2, h3 { margin: 0; font-weight: 400; }
41
+ button, input, select { font: inherit; color: inherit; }
42
+
43
+ /* ------------------------------------------------------------------ top bar */
44
+ .topbar {
45
+ display: flex;
46
+ flex-wrap: wrap;
47
+ align-items: flex-end;
48
+ gap: 14px 24px;
49
+ padding: 12px clamp(14px, 3vw, 34px);
50
+ background: linear-gradient(var(--topbar-top), var(--topbar-bottom));
51
+ border-bottom: 1px solid var(--grout);
52
+ box-shadow: 0 6px 18px -14px rgba(60, 44, 20, .6);
53
+ }
54
+ .brand { display: flex; align-items: baseline; gap: 12px; margin-right: auto; }
55
+ .brand-name {
56
+ font-family: var(--font-display);
57
+ font-size: 28px;
58
+ letter-spacing: .16em;
59
+ text-transform: uppercase;
60
+ /* the title is glazed like a tile: cobalt with a highlight along the top */
61
+ background: linear-gradient(var(--c0-hi), var(--c0) 55%, var(--c0-lo));
62
+ -webkit-background-clip: text;
63
+ background-clip: text;
64
+ color: transparent;
65
+ }
66
+ .brand-sub { font-family: var(--font-label); font-size: 12.5px; letter-spacing: .08em; color: var(--ink-soft); }
67
+
68
+ .setup { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 14px; }
69
+ .field { display: flex; flex-direction: column; gap: 4px; }
70
+ .field-label {
71
+ font-family: var(--font-label);
72
+ font-size: 11px;
73
+ letter-spacing: .16em;
74
+ text-transform: uppercase;
75
+ color: var(--ink-faint);
76
+ }
77
+ .field select, .field input {
78
+ padding: 6px 9px;
79
+ min-width: 132px;
80
+ background: var(--field-bg);
81
+ border: 1px solid var(--grout);
82
+ border-radius: 2px;
83
+ box-shadow: 0 1px 0 rgba(255, 255, 255, .8) inset;
84
+ }
85
+ .field-narrow input { min-width: 78px; width: 88px; }
86
+ .field-narrow select { min-width: 132px; }
87
+ .check { display: flex; align-items: center; gap: 7px; padding-bottom: 6px; font-size: 14px; }
88
+ .check.coach { padding: 5px 10px 5px 8px; border: 1px solid var(--grout); border-radius: 2px; background: var(--field-bg); }
89
+ .check.coach.off { opacity: .55; }
90
+
91
+ .btn {
92
+ padding: 7px 15px;
93
+ border: 1px solid var(--grout);
94
+ border-radius: 2px;
95
+ background: linear-gradient(var(--btn-top), var(--btn-bottom));
96
+ cursor: pointer;
97
+ font-family: var(--font-label);
98
+ font-size: 13px;
99
+ letter-spacing: .1em;
100
+ text-transform: uppercase;
101
+ transition: transform .12s ease, box-shadow .12s ease, background .12s ease;
102
+ }
103
+ .btn:hover { background: linear-gradient(var(--btn-top-hover), var(--btn-bottom-hover)); }
104
+ .btn:active { transform: translateY(1px); }
105
+ .btn-primary {
106
+ color: var(--btn-primary-ink);
107
+ border-color: var(--c0-lo);
108
+ background: linear-gradient(var(--btn-primary-top), var(--c0) 60%, var(--btn-primary-bottom));
109
+ box-shadow: 0 1px 0 rgba(255, 255, 255, .35) inset, 0 2px 6px -3px rgba(20, 40, 80, .8);
110
+ }
111
+ .btn-primary:hover { background: linear-gradient(var(--btn-primary-top-hover), var(--btn-primary-bottom-hover)); }
112
+ .btn:disabled { opacity: .45; cursor: not-allowed; transform: none; }
113
+
114
+ :where(a, button, input, select, [tabindex]):focus-visible {
115
+ outline: 2px solid var(--c4-lo);
116
+ outline-offset: 2px;
117
+ }
118
+
119
+ /* ------------------------------------------------------- engine status bar */
120
+ /* The net is a 13 MB download, so its state is not a detail to hide in a corner:
121
+ * it gets its own strip under the header, and the Deal button stays disabled
122
+ * until the worker says it is ready. */
123
+ .engine-bar {
124
+ display: flex;
125
+ flex-wrap: wrap;
126
+ align-items: center;
127
+ gap: 8px 12px;
128
+ padding: 8px clamp(14px, 3vw, 34px);
129
+ background: linear-gradient(var(--engine-top), var(--engine-bottom));
130
+ border-bottom: 1px solid var(--grout);
131
+ font-family: var(--font-label);
132
+ font-size: 12px;
133
+ letter-spacing: .06em;
134
+ color: var(--ink-soft);
135
+ }
136
+ .engine-dot {
137
+ width: 9px;
138
+ height: 9px;
139
+ border-radius: 50%;
140
+ background: var(--c1);
141
+ box-shadow: 0 0 0 3px var(--c1-wash);
142
+ animation: kiln 1.2s ease-in-out infinite;
143
+ }
144
+ @keyframes kiln {
145
+ 0%, 100% { opacity: .35; }
146
+ 50% { opacity: 1; }
147
+ }
148
+ .engine-bar.ready .engine-dot { background: var(--c4); box-shadow: 0 0 0 3px var(--c4-wash); animation: none; }
149
+ .engine-bar.failed .engine-dot { background: var(--c2); box-shadow: 0 0 0 3px var(--c2-wash); animation: none; }
150
+ .engine-text { flex: 1 1 auto; min-width: 200px; }
151
+ .engine-badge {
152
+ padding: 3px 9px;
153
+ border-radius: 999px;
154
+ background: var(--badge-bg);
155
+ border: 1px solid var(--badge-edge);
156
+ text-transform: uppercase;
157
+ letter-spacing: .12em;
158
+ font-size: 11px;
159
+ color: var(--c0-ink);
160
+ white-space: nowrap;
161
+ }
162
+
163
+ /* -------------------------------------------------------------------- table */
164
+ .table {
165
+ display: flex;
166
+ flex-direction: column;
167
+ gap: clamp(10px, 1.4vw, 18px);
168
+ padding: clamp(12px, 1.8vw, 22px) clamp(12px, 3vw, 34px) 44px;
169
+ max-width: 1560px;
170
+ margin: 0 auto;
171
+ }
172
+
173
+ .middle-actions {
174
+ display: flex;
175
+ flex-wrap: wrap;
176
+ align-items: center;
177
+ justify-content: center;
178
+ gap: 12px;
179
+ margin-top: 10px;
180
+ }
181
+
182
+ /* The playfield. Two ways to draw it, picked in the settings panel and
183
+ * defaulted per device (see ui/layout.js):
184
+ * stack (the +, a desktop's default) — the classic table: big boards, side
185
+ * by side below the factories;
186
+ * side (the −, a phone's default) — the whole game on one screen: the
187
+ * factories on the left, both boards, smaller, stacked beside them.
188
+ * The base rules below are the stack look, so a desktop paints correctly
189
+ * before any script runs; the phone media query flips its own default. */
190
+ .playfield {
191
+ display: grid;
192
+ grid-template-columns: minmax(0, 1fr);
193
+ gap: clamp(10px, 1.4vw, 18px);
194
+ align-items: start;
195
+ }
196
+ .boards {
197
+ display: grid;
198
+ grid-template-columns: repeat(2, minmax(0, 1fr));
199
+ gap: clamp(10px, 1.4vw, 18px);
200
+ align-items: start;
201
+ }
202
+
203
+ body[data-boards="side"] .playfield {
204
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr);
205
+ }
206
+ body[data-boards="side"] .boards {
207
+ grid-template-columns: minmax(0, 1fr);
208
+ gap: clamp(8px, 1vw, 14px);
209
+ }
210
+ /* side layout: both boards must fit beside the factories, so they shrink */
211
+ body[data-boards="side"] .board { --tile: clamp(13px, 2.1vw, 36px); }
212
+
213
+ .below {
214
+ display: grid;
215
+ grid-template-columns: minmax(0, 1.7fr) minmax(240px, .8fr);
216
+ gap: clamp(10px, 1.4vw, 18px);
217
+ align-items: start;
218
+ }
219
+ .below-side { display: flex; flex-direction: column; gap: clamp(10px, 1.4vw, 18px); }
220
+
221
+ /* ------------------------------------------------------------- game record */
222
+ /* Shown only once a game is over: the finished game, offered as a file. Quiet
223
+ * by construction, because it is an invitation and not a demand. */
224
+ .record-bar {
225
+ display: flex;
226
+ flex-wrap: wrap;
227
+ align-items: center;
228
+ gap: 10px 18px;
229
+ }
230
+ .record-bar[hidden] { display: none; }
231
+ .record-note {
232
+ flex: 1 1 320px;
233
+ margin: 0;
234
+ font-size: 13.5px;
235
+ color: var(--ink-soft);
236
+ }
237
+ .record-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
238
+ .record-link {
239
+ font-family: var(--font-label);
240
+ font-size: 12px;
241
+ letter-spacing: .06em;
242
+ color: var(--c0);
243
+ }
244
+ .record-bar.done .record-note { color: var(--ink); }
245
+
246
+ /* --------------------------------------------------------------- bottom bar */
247
+ /* The quiet end of the page: the AI's clock lives here (moved off the top bar,
248
+ * where it crowded a phone), next to one line of totals. #lid-row inside the
249
+ * totals is where discarded tiles fly — a target, so it keeps a real box. */
250
+ .bottom-bar {
251
+ display: flex;
252
+ flex-wrap: wrap;
253
+ align-items: flex-end;
254
+ gap: 12px 22px;
255
+ }
256
+ .counts {
257
+ display: flex;
258
+ flex-wrap: wrap;
259
+ align-items: center;
260
+ gap: 6px 16px;
261
+ padding-bottom: 8px;
262
+ font-family: var(--font-label);
263
+ font-size: 12px;
264
+ letter-spacing: .06em;
265
+ color: var(--ink-soft);
266
+ }
267
+ #lid-row { border-radius: 3px; padding: 2px 4px; transition: background .3s ease; }
268
+ #lid-row.receiving { background: var(--lid-receiving); }
269
+
270
+ /* --------------------------------------------------------------- about box */
271
+ .about p { margin: 0 0 8px; font-size: 13.5px; color: var(--ink-soft); }
272
+ .about p:last-child { margin-bottom: 0; }
273
+ .about a { color: var(--c0); }
274
+ .about-meta {
275
+ font-family: var(--font-label);
276
+ font-size: 11.5px;
277
+ letter-spacing: .04em;
278
+ color: var(--ink-faint);
279
+ }
280
+
281
+ body.locked .board-grid, body.locked .factories, body.locked .center-dish { cursor: progress; }
282
+ .hidden { display: none !important; }
283
+
284
+ /* -------------------------------------------------------------- responsive */
285
+ @media (max-width: 1080px) {
286
+ .below { grid-template-columns: minmax(0, 1fr); }
287
+ .status { flex-wrap: wrap; }
288
+ }
289
+
290
+ @media (max-width: 720px) {
291
+ /* Phone layout: the boards are the page, one above the other so neither of
292
+ * them has to shrink below a playable tile. Every control keeps a 44px target. */
293
+ .topbar { gap: 6px 10px; padding: 8px 10px; }
294
+ .brand { width: 100%; margin-right: 0; gap: 8px; }
295
+ .brand-name { font-size: 19px; }
296
+ .brand-sub { font-size: 11px; }
297
+ /* seed, "you go first" and Deal share one row: the band, smaller */
298
+ .setup { width: 100%; gap: 6px 8px; align-items: center; flex-wrap: nowrap; }
299
+ .setup .btn { flex: 0 0 auto; padding: 10px 11px; white-space: nowrap; }
300
+ .setup .field { flex: 0 1 100px; }
301
+ .setup .field .field-label { display: none; }
302
+ .setup .check { flex: 0 0 auto; }
303
+ .field select, .field input { min-width: 0; padding: 9px 8px; }
304
+ .field-narrow select, .field-narrow input { width: auto; min-width: 0; }
305
+ .btn { padding: 10px 14px; min-height: 44px; }
306
+ .engine-bar { padding: 5px 10px; font-size: 11px; gap: 4px 10px; }
307
+ .engine-text { min-width: 0; }
308
+ /* the settings and the navigator are controls, so they get a tap target too */
309
+ .gear, .speed, .nav-btn { min-height: 44px; }
310
+ .settings-panel { gap: 8px 12px; }
311
+ .check { padding-bottom: 0; min-height: 44px; }
312
+ .check input { width: 20px; height: 20px; }
313
+
314
+ .table { padding: 8px 8px 32px; gap: 8px; }
315
+ .panel { padding: 11px 11px 13px; }
316
+ .scoring-grid { grid-template-columns: minmax(0, 1fr); }
317
+ .log { max-height: 190px; }
318
+
319
+ /* the status band earns its keep in one small line, not a headline */
320
+ .status { padding: 8px 12px 10px; gap: 10px; }
321
+ .status-headline { font-size: 15px; }
322
+ .status-detail { font-size: 10.5px; margin-top: 2px; min-height: 0; }
323
+ .status-side { min-width: 40px; }
324
+ .status-side-score { font-size: 19px; }
325
+ .status-dash { font-size: 15px; }
326
+
327
+ /* stack (+): the old phone table — one big board under the other */
328
+ body[data-boards="stack"] .boards { grid-template-columns: minmax(0, 1fr); }
329
+ body[data-boards="stack"] .board { --tile: clamp(26px, 9vw, 38px); }
330
+ body[data-boards="stack"] .factories { gap: 10px 12px; }
331
+ body[data-boards="stack"] .factory { --dish-size: calc(var(--tile) * 2 + 22px); }
332
+ body[data-boards="stack"] .middle-table { --tile: clamp(24px, 8vw, 32px); }
333
+ body[data-boards="stack"] .board-grid { column-gap: 8px; }
334
+ body[data-boards="stack"] .line { padding: 0 2px; }
335
+ body[data-boards="stack"] .line .row-note { font-size: 10.5px; line-height: 1.15; }
336
+
337
+ /* side (−, the default): factories left, both boards beside them. Small
338
+ * tiles are the accepted price of seeing the whole game at once. */
339
+ body:not([data-boards="stack"]) .playfield {
340
+ grid-template-columns: minmax(0, 1.04fr) minmax(0, .96fr);
341
+ gap: 6px;
342
+ }
343
+ body:not([data-boards="stack"]) .boards { grid-template-columns: minmax(0, 1fr); gap: 6px; }
344
+ body:not([data-boards="stack"]) .board {
345
+ --tile: clamp(11px, calc((48vw - 50px) / 10), 38px);
346
+ padding: 7px 6px 8px;
347
+ gap: 6px;
348
+ }
349
+ body:not([data-boards="stack"]) .board-grid { column-gap: 3px; }
350
+ body:not([data-boards="stack"]) .line { padding: 0 1px; gap: 2px; }
351
+ body:not([data-boards="stack"]) .line .row-note { display: none; }
352
+ body:not([data-boards="stack"]) .wall-row { gap: 2px; padding: 0 3px; }
353
+ body:not([data-boards="stack"]) .board-head { column-gap: 6px; padding-bottom: 5px; }
354
+ body:not([data-boards="stack"]) .board-who { font-size: 10px; letter-spacing: .1em; }
355
+ body:not([data-boards="stack"]) .board-score { font-size: 18px; }
356
+ body:not([data-boards="stack"]) .board-notes { font-size: 9.5px; min-height: 12px; }
357
+ body:not([data-boards="stack"]) .floor { padding: 3px 2px; gap: 2px; }
358
+ body:not([data-boards="stack"]) .floor .pen { font-size: 8.5px; }
359
+ body:not([data-boards="stack"]) .middle-panel { padding: 8px 6px 10px; }
360
+ body:not([data-boards="stack"]) .middle-table { --tile: clamp(17px, 5.6vw, 32px); }
361
+ body:not([data-boards="stack"]) .factories { gap: 8px 8px; }
362
+ body:not([data-boards="stack"]) .factory { --dish-size: calc(var(--tile) * 2 + 16px); }
363
+ body:not([data-boards="stack"]) .center-dish { padding: 8px 10px; margin-top: 8px; }
364
+ }
365
+
366
+ @media (max-width: 420px) {
367
+ body[data-boards="stack"] .board { --tile: clamp(23px, 10vw, 34px); }
368
+ body[data-boards="stack"] .middle-table { --tile: clamp(21px, 8.5vw, 28px); }
369
+ .engine-badge { font-size: 10px; letter-spacing: .08em; }
370
+ .status { gap: 8px; }
371
+ }
372
+
373
+ /* A coarse pointer never hovers, so the hover lift just makes taps feel laggy. */
374
+ @media (hover: none) {
375
+ button.tile:hover { transform: none; }
376
+ button.line.open:hover { background: var(--c4-wash); filter: none; }
377
+ }
378
+
379
+ /* ========================================================== corner buttons ==
380
+ * Two small round buttons pinned to the bottom-right, the way every board-game
381
+ * site pins its "?" — one scrolls to the What-this-is panel, one opens the
382
+ * public tally. Fixed, but tiny: they float over the linen, never the table. */
383
+ .corner {
384
+ position: fixed;
385
+ right: 14px;
386
+ bottom: 14px;
387
+ z-index: 20;
388
+ display: flex;
389
+ flex-direction: column;
390
+ gap: 8px;
391
+ }
392
+ .corner-btn {
393
+ width: 42px;
394
+ height: 42px;
395
+ display: grid;
396
+ place-items: center;
397
+ padding: 0;
398
+ border: 1px solid var(--grout);
399
+ border-radius: 50%;
400
+ background: linear-gradient(var(--btn-top), var(--btn-bottom));
401
+ box-shadow: var(--shadow-tile);
402
+ cursor: pointer;
403
+ color: var(--ink-soft);
404
+ font-family: var(--font-display);
405
+ font-size: 21px;
406
+ line-height: 1;
407
+ text-decoration: none;
408
+ }
409
+ .corner-btn:hover {
410
+ background: linear-gradient(var(--btn-top-hover), var(--btn-bottom-hover));
411
+ color: var(--ink);
412
+ }
413
+ .corner-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; }
414
+
415
+ /* the "?" button's landing: the panel it scrolled to glows for a breath */
416
+ .about.lit { box-shadow: var(--shadow-panel), 0 0 0 3px var(--c4-wash); }
417
+ .about { transition: box-shadow .5s ease; }
index.html CHANGED
@@ -1,19 +1,202 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Faïence: a tile game against a neural net, in your browser</title>
7
+ <meta name="description" content="Faïence is a free, open-source implementation of the rules of Azul (Michael Kiesling). Play against a neural net trained by self-play. It runs entirely in your browser, no server, no install.">
8
+ <!-- the card a shared link unfolds into -->
9
+ <meta property="og:type" content="website">
10
+ <meta property="og:title" content="Faïence: a tile game against a neural net">
11
+ <meta property="og:description" content="A free, open-source implementation of the rules of Azul. The net and its tree search run entirely in your browser: no server, no install, no account.">
12
+ <meta property="og:url" content="https://remifabre-faience.static.hf.space/">
13
+ <meta property="og:image" content="https://remifabre-faience.static.hf.space/social.png">
14
+ <meta property="og:image:width" content="1200">
15
+ <meta property="og:image:height" content="630">
16
+ <meta name="twitter:card" content="summary_large_image">
17
+ <meta name="color-scheme" content="light">
18
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%2317509e'/%3E%3Crect x='6' y='6' width='9' height='9' rx='1.5' fill='%2331b8d1'/%3E%3Crect x='17' y='6' width='9' height='9' rx='1.5' fill='%23d99a12'/%3E%3Crect x='6' y='17' width='9' height='9' rx='1.5' fill='%23b23a26'/%3E%3Crect x='17' y='17' width='9' height='9' rx='1.5' fill='%23faf6ec'/%3E%3C/svg%3E">
19
+ <link rel="stylesheet" href="css/style.css">
20
+ </head>
21
+ <body>
22
+
23
+ <header class="topbar">
24
+ <div class="brand">
25
+ <span class="brand-name">Faïence</span>
26
+ <span class="brand-sub" id="matchup">a tile game for two</span>
27
+ </div>
28
+
29
+ <form class="setup" id="setup" autocomplete="off">
30
+ <label class="field field-narrow">
31
+ <span class="field-label">Seed</span>
32
+ <input type="text" id="seed" inputmode="numeric" placeholder="random">
33
+ </label>
34
+ <label class="check">
35
+ <input type="checkbox" id="first" checked>
36
+ <span>You go first</span>
37
+ </label>
38
+ <button type="submit" class="btn btn-primary" id="deal" disabled>Deal tiles</button>
39
+ </form>
40
+ </header>
41
+
42
+ <!-- the net is a 13 MB download: its state gets its own strip, never a pop-up -->
43
+ <div class="engine-bar" id="engine-bar">
44
+ <span class="engine-dot" id="engine-dot" aria-hidden="true"></span>
45
+ <span class="engine-text" id="engine-text">Loading the net…</span>
46
+ <span class="engine-badge">runs entirely in your browser</span>
47
+ </div>
48
+
49
+ <main class="table">
50
+
51
+ <!-- one glance = whole game state; nothing on this page ever covers the board -->
52
+ <section id="status"></section>
53
+
54
+ <!-- the panel opens inline and pushes the table down; this page has no pop-ups -->
55
+ <section id="settings" aria-label="Settings"></section>
56
+
57
+ <!-- the playfield, drawn two ways (settings − / +, defaulted per device in
58
+ ui/layout.js): "stack" = big boards below the factories, a desktop's
59
+ default; "side" = factories left, both boards beside them, a phone's. -->
60
+ <div class="playfield">
61
+
62
+ <section class="panel middle-panel">
63
+ <h2 class="panel-title">The middle of the table</h2>
64
+ <!-- the tiles you are holding while you choose a row; a picture of the
65
+ selection, gone the moment you change your mind -->
66
+ <div class="hand" id="hand" hidden aria-hidden="true">
67
+ <span class="hand-label">In hand</span>
68
+ <div class="hand-tiles" id="hand-tiles"></div>
69
+ </div>
70
+ <div id="middle"></div>
71
+ <div class="middle-actions">
72
+ <span class="brand-sub" id="prompt">Loading the net. The tiles will be dealt in a moment.</span>
73
+ <!-- move navigation: pure replay of positions the page already has -->
74
+ <div id="nav" aria-label="Move navigation"></div>
75
+ <button type="button" class="btn" id="hint" disabled>Suggest a move</button>
76
+ <button type="button" class="btn hidden" id="cancel">Clear selection</button>
77
+ <!-- confirm mode: while a move is placed, this row's usual controls make
78
+ way for the banner — same row, same height, nothing on the page moves -->
79
+ <div class="confirm-bar" id="confirm-bar" hidden aria-live="polite">
80
+ <strong class="confirm-title">Validate this move</strong>
81
+ <span class="confirm-detail" id="confirm-detail"></span>
82
+ <button type="button" class="btn btn-primary" id="confirm">Play this move</button>
83
+ <button type="button" class="btn" id="confirm-cancel">Cancel</button>
84
+ </div>
85
+ </div>
86
+ </section>
87
+
88
+ <div class="boards">
89
+ <section id="board-human" aria-label="Your board"></section>
90
+ <section id="board-ai" aria-label="The AI's board"></section>
91
+ </div>
92
+
93
+ </div><!-- /.playfield -->
94
+
95
+ <section id="scoring" hidden aria-live="polite"></section>
96
+
97
+ <!-- Appears when a game ends. With sharing on (the default, and the switch
98
+ is in Settings) the game was just sent to the training pile by
99
+ js/upload.js; app.js writes the note accordingly. The buttons offer the
100
+ same record as a file, for the curious and for players who share
101
+ nothing. -->
102
+ <section class="panel record-bar" id="record" hidden>
103
+ <p class="record-note" id="record-note">Finished, and shared: the moves,
104
+ the tiles that were dealt, and which net you played are on their way to
105
+ the public training pile. Thank you. You can keep a copy too, or turn
106
+ sharing off in Settings.</p>
107
+ <div class="record-actions">
108
+ <button type="button" class="btn" id="record-save">Save this game</button>
109
+ <button type="button" class="btn" id="record-copy">Copy as text</button>
110
+ <a class="record-link" id="record-dataset"
111
+ href="https://huggingface.co/datasets/RemiFabre/faience-games"
112
+ target="_blank" rel="noopener">See the public pile</a>
113
+ </div>
114
+ </section>
115
+
116
+ <div class="below">
117
+ <section class="panel">
118
+ <div class="board-head">
119
+ <h2 class="panel-title" id="log-title">Move log</h2>
120
+ <label class="check coach off" id="coach-field" title="Score your moves with the AI's own search">
121
+ <input type="checkbox" id="coach">
122
+ <span>Coach mode</span>
123
+ </label>
124
+ </div>
125
+ <p class="coach-legend" id="coach-legend" hidden></p>
126
+ <ol class="log" id="log" aria-labelledby="log-title"></ol>
127
+ </section>
128
+
129
+ <div class="below-side">
130
+ <section class="panel about" id="about" aria-labelledby="about-title">
131
+ <h2 class="panel-title" id="about-title">What this is</h2>
132
+ <p>Faïence is a free, open-source implementation of the rules of
133
+ <em>Azul</em>, the tile-laying game by Michael Kiesling. It is a fan
134
+ project for research, with its own code and artwork, not affiliated
135
+ with or endorsed by the game's publishers.</p>
136
+ <p>All of it is open source (MIT):
137
+ <a href="https://github.com/RemiFabre/ludometer">code and training logs on GitHub</a>.
138
+ If you like this work, a star on the repo (or a hello in the issues)
139
+ is always appreciated.</p>
140
+ <p>Faïence began as a machine-learning experiment: Ludometer, a research
141
+ framework that measures how good a board game is from the shape of an
142
+ AI's learning curve. Somewhere along the way the experiment turned into
143
+ a pleasant opponent, so we are sharing it, playable and free. Nobody
144
+ earns a cent from this page: no ads, no account, nothing to buy.</p>
145
+ <p>The opponent is a neural network that learned the game from scratch
146
+ by playing against itself. Your browser downloads it once and runs it
147
+ locally.</p>
148
+ <p id="share-note">This is a research project, and the games are the
149
+ research material: when you play, you help the net train. So when a
150
+ game ends (or is abandoned), the page sends an anonymous record of it
151
+ to be used as training data: the moves, the tiles that were dealt,
152
+ which net you played, and the score. No name, no account, no
153
+ location. This is experimental, and there is a switch in Settings if
154
+ you would rather not.
155
+ <a href="https://huggingface.co/datasets/RemiFabre/faience-games"
156
+ target="_blank" rel="noopener">Every record is public</a>, so you can
157
+ read everything we collect.</p>
158
+ <p id="tally-note">Beyond that, the only thing that leaves your
159
+ browser is an anonymous, cookie-free tally of visits, games dealt,
160
+ and how finished games went. <a href="https://faience.goatcounter.com"
161
+ target="_blank" rel="noopener">It is public too</a>.</p>
162
+ <p class="about-meta" id="about-meta"></p>
163
+ </section>
164
+ </div>
165
+ </div>
166
+
167
+ <!-- the quiet end of the page: where discarded tiles go. #counts keeps only
168
+ totals (never per-colour counts) and holds #lid-row, the landing target
169
+ for every tile that melts into the lid. The "AI thinks for" clock is
170
+ parsed here, then seated in the settings panel by app.js. -->
171
+ <section class="panel bottom-bar" aria-label="Tile counts">
172
+ <label class="field field-narrow" id="think-field">
173
+ <span class="field-label">AI thinks for</span>
174
+ <select id="think">
175
+ <option value="0">Instant (no search)</option>
176
+ <option value="3">3 seconds</option>
177
+ <option value="5" selected>5 seconds</option>
178
+ <option value="10">10 seconds</option>
179
+ </select>
180
+ </label>
181
+ <div class="counts" id="counts"></div>
182
+ </section>
183
+
184
+ </main>
185
+
186
+ <!-- the two round corner buttons: what is this, and the public tally -->
187
+ <nav class="corner" aria-label="About and statistics">
188
+ <a class="corner-btn" id="corner-stats" href="https://faience.goatcounter.com"
189
+ target="_blank" rel="noopener" title="Public play statistics: everything this page records">
190
+ <svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="10" width="3.4" height="7" rx="1"/><rect x="8.3" y="6" width="3.4" height="11" rx="1"/><rect x="13.6" y="3" width="3.4" height="14" rx="1"/></svg>
191
+ </a>
192
+ <button type="button" class="corner-btn" id="corner-about" title="What is this?">?</button>
193
+ </nav>
194
+
195
+ <div class="fly-layer" id="fly" aria-hidden="true"></div>
196
+ <!-- score pop-ups get their own layer: a number is not a tile, and nothing that
197
+ counts tile flights should ever count these -->
198
+ <div class="pop-layer" id="pops" aria-hidden="true"></div>
199
+
200
+ <script type="module" src="js/app.js"></script>
201
+ </body>
202
  </html>
js/analytics.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* An honest, cookie-free tally of who plays.
2
+ *
3
+ * GoatCounter is built for exactly this arrangement: no cookies, no
4
+ * fingerprinting, no personal data, nothing stored in the visitor's browser —
5
+ * so no consent banner is needed — and the dashboard is public, so a player
6
+ * can read everything the page records; the About panel links straight to it.
7
+ * The pings go to GoatCounter's /count endpoint directly (their count.js is
8
+ * never loaded: this page ships every byte it runs). Empty COUNT_URL and the
9
+ * module produces zero network traffic; app.js then also removes the About
10
+ * panel's tally note and the stats button, so the page never points at a
11
+ * tally it does not keep.
12
+ *
13
+ * What gets counted (each a single GET of a 1×1 gif, fired and forgotten):
14
+ * pageview the page was opened
15
+ * game-start tiles were dealt
16
+ * game-end/<model>/<result> how a finished game went, per net —
17
+ * result is human-wins | net-wins | draw,
18
+ * with the score line as the hit's title
19
+ *
20
+ * The dashboard should be made PUBLIC in GoatCounter's settings: the page links
21
+ * to it, so every player can see exactly what is recorded. That link appears
22
+ * automatically once COUNT_URL is set.
23
+ */
24
+
25
+ export const COUNT_URL = "https://faience.goatcounter.com/count";
26
+
27
+ /** Whether the tally is on at all — the About panel reads this. */
28
+ export const analyticsOn = () => !!COUNT_URL;
29
+
30
+ /** The public dashboard the About panel links to — the /count host itself. */
31
+ export function statsUrl() {
32
+ if (!COUNT_URL) return "";
33
+ try {
34
+ return new URL(COUNT_URL).origin;
35
+ } catch (err) {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Count one event. Never throws, never waits, does nothing while COUNT_URL is
42
+ * empty. `pageview` is special-cased to count as a visit rather than an event;
43
+ * `extra.title` becomes the hit's title (detail on the dashboard, e.g. a score
44
+ * line), while the path stays coarse so the counts aggregate.
45
+ */
46
+ export function track(name, extra) {
47
+ if (!COUNT_URL) return;
48
+ try {
49
+ // development and the headless test suite play thousands of games; none of
50
+ // them are players, and none of them may touch the public tally
51
+ const host = location.hostname;
52
+ if (host === "localhost" || host === "127.0.0.1" || host === "") return;
53
+ const page = name === "pageview";
54
+ const path = page ? location.pathname : name;
55
+ const url =
56
+ COUNT_URL +
57
+ "?p=" + encodeURIComponent(path) +
58
+ (page ? "" : "&e=true") +
59
+ (extra && extra.title ? "&t=" + encodeURIComponent(extra.title) : "") +
60
+ "&rnd=" + Date.now().toString(36);
61
+ new Image().src = url; // a GET the browser fires and forgets; no reply is read
62
+ } catch (err) {
63
+ /* an ad blocker or a locked-down browser; the game plays on */
64
+ }
65
+ }
js/app.js ADDED
@@ -0,0 +1,1509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Azul against the net — the table, in your browser.
2
+ *
3
+ * The reusable half of this page lives in ../ui/: the boards, the status band,
4
+ * the move log, the inline scoring panels and the tile flights are the same
5
+ * framework-free modules the local GUI uses (a copy of web/play/ui/, see its
6
+ * PORTING.md). This file is the part that is specific to *this* page — the part
7
+ * the local GUI hands to a Flask server: it owns the game (`GameSession`, a port
8
+ * of the server's session object), asks a Web Worker running ONNX for the AI's
9
+ * moves, and decides what the status band says.
10
+ *
11
+ * How a turn plays out — and why there is no overlay, sheet or toast anywhere on
12
+ * this page:
13
+ *
14
+ * 1. you click a colour and the selection is acted out at once: your tiles fly
15
+ * to a small "hand" tray on the middle panel and the dish's leftovers fly to
16
+ * the centre — a preview of the move, undone the instant you change your
17
+ * mind (Escape, the Clear button, or another colour);
18
+ * 2. you click a row and the held tiles continue from the hand to your board.
19
+ * With confirm mode on (the default) the page simply draws the position the
20
+ * move would leave — real tiles in their real places, the new ones glowing —
21
+ * and a banner under the middle asks for the word: "Play this move" commits
22
+ * it, Cancel goes all the way back to before the pick. The page is never
23
+ * *between* positions: every view is a fully drawn state, and the flights
24
+ * are transient garnish over it (a lesson learned from floating clones that
25
+ * scrolled apart from the table). Nothing ever waits for the coach — with
26
+ * coach mode on, the rating runs in the background *after* the turn and
27
+ * fills in on the move's log entry;
28
+ * 3. if that ended the round, full pattern lines fly to the wall — each tile
29
+ * popping the points it earns as it lands, the floor line popping its cost —
30
+ * and the round's scoring appears *inline* under the boards;
31
+ * 4. the AI thinks against the clock in the status band (on your CPU, so the
32
+ * band also counts the positions it has visited), then its move plays out on
33
+ * the right-hand board exactly the same way.
34
+ *
35
+ * Input is locked from the moment a move is played until the last tile lands, and
36
+ * for no longer than that.
37
+ *
38
+ * When the AI moves twice in a row across a round boundary, both moves are
39
+ * animated: each move carries the position it was played from (`state_before`),
40
+ * so the refilled table the second move starts on is drawn before it plays.
41
+ *
42
+ * ← and → walk back through the game — pure replay of positions this page has
43
+ * already produced (see ../ui/history.js), with no re-search and no effect on
44
+ * the live game.
45
+ */
46
+ "use strict";
47
+
48
+ import { animated, flyTiles, initSpeed, scaled, sleep } from "../ui/animate.js";
49
+ import { createBoard, createMiddle } from "../ui/board.js";
50
+ import { confirmOn } from "../ui/confirm.js";
51
+ import {
52
+ CENTER,
53
+ COLORS,
54
+ CUM_PENALTY,
55
+ FLOOR,
56
+ markerChip,
57
+ node,
58
+ poolCount,
59
+ tileEl,
60
+ } from "../ui/dom.js";
61
+ import { bindHistoryKeys, createHistory } from "../ui/history.js";
62
+ import { renderLog } from "../ui/log.js";
63
+ import { popScore } from "../ui/popups.js";
64
+ import { clearScoring, renderFinalPanel, renderRoundPanel } from "../ui/scoring.js";
65
+ import { buildRecord, copyRecord, downloadRecord } from "./record.js";
66
+ import { setSharing, shareOnLeave, shareRecord, sharingOn, warmCollector } from "./upload.js";
67
+ import { createSettings } from "../ui/settings.js";
68
+ import { createStatus } from "../ui/status.js";
69
+ import { analyticsOn, statsUrl, track } from "./analytics.js";
70
+ import { GameSession } from "./game.js";
71
+ import { BACKENDS } from "./net.js";
72
+ import { describeAction } from "./report.js";
73
+
74
+ const el = (id) => document.getElementById(id);
75
+ const ui = {
76
+ matchup: el("matchup"), setup: el("setup"), seed: el("seed"), first: el("first"),
77
+ think: el("think"), deal: el("deal"), engineBar: el("engine-bar"),
78
+ engineText: el("engine-text"), aboutMeta: el("about-meta"), middle: el("middle"),
79
+ prompt: el("prompt"), hint: el("hint"), cancel: el("cancel"), fly: el("fly"),
80
+ scoring: el("scoring"), log: el("log"), coach: el("coach"),
81
+ coachField: el("coach-field"), coachLegend: el("coach-legend"), counts: el("counts"),
82
+ record: el("record"), recordNote: el("record-note"),
83
+ recordSave: el("record-save"), recordCopy: el("record-copy"),
84
+ settings: el("settings"), nav: el("nav"),
85
+ hand: el("hand"), handTiles: el("hand-tiles"), pops: el("pops"),
86
+ confirm: el("confirm"), confirmBar: el("confirm-bar"),
87
+ confirmDetail: el("confirm-detail"), confirmCancel: el("confirm-cancel"),
88
+ };
89
+
90
+ let session = null; // the GameSession that owns the rules
91
+ let S = null; // its last snapshot, what the page draws from
92
+ let sel = null; // {source, color} — tiles you are holding
93
+ let suggestion = null; // {source, color, dest} from the policy head
94
+ let busy = false; // a move is being computed, or is still playing out
95
+ let meta = null; // model/model_meta.json
96
+ let backend = null; // {name, batch, margin, rate} — what the worker settled on
97
+ let engineReady = false;
98
+ let liveSims = 0; // positions the current search has visited
99
+ let coachOn = false; // rate my moves with the AI's own search
100
+ let notice = ""; // a passing message, shown in the band, never over the board
101
+ let noticeTimer = null;
102
+ let proposal = null; // {id, move} — a placed move waiting for "Play this move"
103
+ let committing = null; // the move whose held view stays up while its tiles fly
104
+ let analysis = null; // the coach reading the position while you think (see below)
105
+ let navToken = 0; // invalidates a history-step animation when another step lands
106
+
107
+ initSpeed(); // before anything can animate: the stored speed, or 1×
108
+ const status = createStatus(el("status"));
109
+ const settings = createSettings(ui.settings, { popups: true, confirm: true, boards: true });
110
+ // the AI's clock is a setting like any other, so it sits with the other knobs
111
+ settings.panel.append(node("i", "settings-gap"), el("think-field"));
112
+
113
+ /* Sharing games sits with the other knobs too. On by default, and the About
114
+ * panel says so: this is a research project, and finished (or abandoned)
115
+ * games are the research material. The switch is honoured by js/upload.js
116
+ * everywhere a record could leave the page. */
117
+ settings.panel.append(
118
+ node("i", "settings-gap"),
119
+ node("span", "settings-label", "Share played games")
120
+ );
121
+ const shareGroup = node("div", "speeds");
122
+ shareGroup.setAttribute("role", "group");
123
+ shareGroup.setAttribute("aria-label", "Share played games");
124
+ const shareButtons = [true, false].map((value) => {
125
+ const b = node("button", "flag", value ? "On" : "Off");
126
+ b.type = "button";
127
+ b.title = value
128
+ ? "When a game ends it is sent, anonymously, to the public training pile: " +
129
+ "the moves, the tiles that were dealt, which net played, and the score. Nothing else."
130
+ : "Nothing is sent. Games stay in this tab unless you save them yourself.";
131
+ b.addEventListener("click", () => {
132
+ setSharing(value);
133
+ syncShare();
134
+ say(value ? "sharing on: played games become training data" : "sharing off: nothing is sent");
135
+ });
136
+ shareGroup.appendChild(b);
137
+ return b;
138
+ });
139
+ settings.panel.append(shareGroup);
140
+ function syncShare() {
141
+ shareButtons.forEach((b, i) => {
142
+ b.setAttribute("aria-pressed", String((i === 0) === sharingOn()));
143
+ });
144
+ }
145
+ syncShare();
146
+ const middle = createMiddle(ui.middle, { onPick: pick });
147
+ const boards = {
148
+ human: createBoard(el("board-human"), { seat: 0, interactive: true, onPlay: route }),
149
+ ai: createBoard(el("board-ai"), { seat: 1 }),
150
+ };
151
+ const nav = createHistory(ui.nav, {
152
+ log: () => (S && S.log) || [],
153
+ enabled: () => !busy,
154
+ onChange: onNavChange,
155
+ });
156
+
157
+ /* The coach's own clock, as in ludometer/gui/coach.py — but unlike the Python
158
+ * GUI it runs *after* the turn, in the background, so your move lands the
159
+ * moment you click it. It is still kept shorter than the opponent's budget. */
160
+ const COACH_THINK_S = 2;
161
+ const COACH_MAX_THINK_S = 3;
162
+ const COACH_LEGEND =
163
+ "Coach mode rates your move with the AI's own search: 0.00 = the move it would " +
164
+ "have played, −1 ≈ a whole win thrown away. The coach reads the position while " +
165
+ "you think, so the verdict usually lands with your move.";
166
+
167
+ /* ------------------------------------------------------------------ plumbing */
168
+ /** A passing message. It goes in the status band's detail line — never a pop-up. */
169
+ function say(message) {
170
+ notice = message || "";
171
+ if (noticeTimer) clearTimeout(noticeTimer);
172
+ if (notice) {
173
+ noticeTimer = setTimeout(() => {
174
+ notice = "";
175
+ if (S) renderStatus(nav.frame());
176
+ }, 6000);
177
+ }
178
+ if (S) renderStatus(nav.frame());
179
+ }
180
+
181
+ function setBusy(on) {
182
+ const changed = busy !== on;
183
+ busy = on;
184
+ document.body.classList.toggle("locked", on);
185
+ ui.deal.disabled = on || !engineReady;
186
+ ui.hint.disabled = on || !engineReady || !S || !S.your_turn;
187
+ nav.draw(); // browsing the history is off while tiles are in the air
188
+ // the tiles you may pick up depend on this, and unlocking has to give them
189
+ // back — safe here because tiles only ever fly while busy
190
+ if (changed && S) render();
191
+ }
192
+
193
+ /* ---------------------------------------------------------------- the worker */
194
+ /* One worker, kept for the life of the page: it holds the 13 MB session, which
195
+ * is far too expensive to rebuild per move. Requests are matched by id. */
196
+ const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
197
+ let nextId = 1;
198
+ const pending = new Map();
199
+
200
+ worker.onmessage = (event) => {
201
+ const msg = event.data;
202
+ const entry = pending.get(msg.id);
203
+ if (!entry) return;
204
+ if (msg.type === "loading" || msg.type === "progress") {
205
+ if (entry.onProgress) entry.onProgress(msg);
206
+ return;
207
+ }
208
+ pending.delete(msg.id);
209
+ if (msg.type === "error") entry.reject(new Error(msg.message));
210
+ else entry.resolve(msg);
211
+ };
212
+
213
+ worker.onerror = (event) => {
214
+ engineFailed(event.message || "the worker crashed");
215
+ };
216
+
217
+ function ask(message, onProgress) {
218
+ const id = nextId++;
219
+ return new Promise((resolve, reject) => {
220
+ pending.set(id, { resolve, reject, onProgress });
221
+ worker.postMessage({ ...message, id });
222
+ });
223
+ }
224
+
225
+ function engineFailed(reason) {
226
+ engineReady = false;
227
+ ui.engineBar.classList.remove("ready");
228
+ ui.engineBar.classList.add("failed");
229
+ ui.engineText.textContent = "The net could not be loaded: " + reason;
230
+ ui.deal.disabled = true;
231
+ ui.hint.disabled = true;
232
+ status.set({
233
+ headline: "The net could not be loaded",
234
+ detail: reason + ". Reload the page to try again.",
235
+ tone: "end",
236
+ });
237
+ }
238
+
239
+ async function bootEngine() {
240
+ status.set({ headline: "Loading the net", detail: "it runs in this tab, so it downloads once", tone: "idle" });
241
+ try {
242
+ meta = await (await fetch("model/model_meta.json", { cache: "no-cache" })).json();
243
+ } catch (err) {
244
+ meta = null;
245
+ }
246
+ describeModel();
247
+ const sizeMB = meta && meta.onnx_bytes ? (meta.onnx_bytes / 1e6).toFixed(1) : "13";
248
+ ui.engineText.textContent = `Downloading the net (${sizeMB} MB, cached after the first visit)…`;
249
+ let ready = null;
250
+ try {
251
+ ready = await ask(
252
+ {
253
+ type: "init",
254
+ // In preference order. The worker takes the first one that actually
255
+ // works in this browser and tells us which that was; a browser with no
256
+ // WebGPU never downloads the WebGPU runtime at all.
257
+ backends: ["webgpu", "wasm"].map((name) => ({
258
+ name,
259
+ ep: BACKENDS[name].ep,
260
+ module: new URL(BACKENDS[name].module, import.meta.url).href,
261
+ wasm: new URL(BACKENDS[name].wasm, import.meta.url).href,
262
+ })),
263
+ modelUrl: new URL("../model/model.onnx", import.meta.url).href,
264
+ },
265
+ (msg) => {
266
+ if (msg.type !== "loading") return;
267
+ // GitHub Pages gzips the .onnx, so content-length is the *compressed*
268
+ // size while `received` counts decompressed bytes: prefer the true size
269
+ // from the metadata, and never show more than 100%.
270
+ const total = (meta && meta.onnx_bytes) || msg.total;
271
+ if (!total) return;
272
+ const pct = Math.min(100, Math.round((msg.received / total) * 100));
273
+ ui.engineText.textContent = `Downloading the net: ${pct}% of ${(total / 1e6).toFixed(1)} MB`;
274
+ status.set({
275
+ headline: `Downloading the net: ${pct}%`,
276
+ detail: "nothing is sent anywhere; the net plays from your own machine",
277
+ tone: "idle",
278
+ });
279
+ }
280
+ );
281
+ } catch (err) {
282
+ engineFailed(err.message);
283
+ return;
284
+ }
285
+ engineReady = true;
286
+ backend = { name: ready.backend, batch: ready.batch, margin: !!ready.margin, rate: null };
287
+ ui.engineBar.classList.add("ready");
288
+ ui.engineText.textContent = engineLine();
289
+ describeModel();
290
+ ui.deal.disabled = false;
291
+ newGame();
292
+ }
293
+
294
+ /** Where the net is running, in the words a player would use. */
295
+ function backendLabel() {
296
+ if (!backend) return "your CPU";
297
+ return backend.name === "webgpu" ? "your GPU (WebGPU)" : "your CPU (WebAssembly)";
298
+ }
299
+
300
+ function engineLine() {
301
+ const where = `searching on ${backendLabel()}`;
302
+ if (!meta) return `Net ready: ${where}.`;
303
+ const elo = typeof meta.elo === "number" ? `${meta.elo >= 0 ? "+" : ""}${Math.round(meta.elo)} Elo` : "unrated";
304
+ const params = meta.num_params ? `${(meta.num_params / 1e6).toFixed(1)}M parameters` : "";
305
+ const rate =
306
+ backend && backend.rate ? ` · ${Math.round(backend.rate).toLocaleString()} positions/s` : "";
307
+ return `${meta.run}/${meta.checkpoint} · ${elo} on our internal ladder · ${params} · ${where}${rate}`;
308
+ }
309
+
310
+ function describeModel() {
311
+ if (!meta) {
312
+ ui.aboutMeta.textContent = "";
313
+ return;
314
+ }
315
+ const bits = [
316
+ `${meta.run}/${meta.checkpoint}`,
317
+ meta.games ? `${Number(meta.games).toLocaleString()} self-play games` : null,
318
+ meta.num_params ? `${meta.num_params.toLocaleString()} parameters` : null,
319
+ meta.onnx_bytes ? `${(meta.onnx_bytes / 1e6).toFixed(1)} MB ONNX` : null,
320
+ meta.exported_at ? `exported ${meta.exported_at.slice(0, 10)}` : null,
321
+ // What the search is actually running on, and how hard: this is the number
322
+ // the thinking-time selector is really spending, so it belongs on the page
323
+ // rather than in a console log.
324
+ backend
325
+ ? `${backend.name === "webgpu" ? "WebGPU" : "WebAssembly"}, ${backend.batch} positions per pass`
326
+ : null,
327
+ backend && backend.rate ? `${Math.round(backend.rate).toLocaleString()} positions/s here` : null,
328
+ backend && backend.margin ? "margin head: decisive play" : null,
329
+ ].filter(Boolean);
330
+ ui.aboutMeta.textContent = bits.join(" · ");
331
+ }
332
+
333
+ /* ---------------------------------------------------------------- the boards */
334
+ function aiLabel() {
335
+ return S && S.agent_name ? "AI · " + S.agent_name : "AI";
336
+ }
337
+
338
+ /** Fix the two boards' seats to this game's seating (you are always on the left). */
339
+ function seatBoards() {
340
+ boards.human = createBoard(el("board-human"), {
341
+ seat: S.human_seat, interactive: true, onPlay: route,
342
+ });
343
+ boards.ai = createBoard(el("board-ai"), { seat: S.ai_seat });
344
+ }
345
+
346
+ /**
347
+ * Draw the page.
348
+ *
349
+ * There are two things it can be showing: the live game, or a position out of
350
+ * the history. Browsing is read-only by construction — the board is drawn from
351
+ * the frame with no legal actions and no selection, so there is nothing to
352
+ * click even before the buttons are disabled.
353
+ */
354
+ /**
355
+ * Draw the page. The page is *always* a fully drawn position — never a limbo of
356
+ * floating clones (those scrolled apart from the table and collided with real
357
+ * tiles; see the hand tray and confirm mode below). Three positions can be on
358
+ * show: the live game; the live game with the selection lifted into the hand
359
+ * tray (`heldView`); or, in confirm mode, the position the placed move would
360
+ * leave (`placedView`), its new tiles glowing until you validate or cancel.
361
+ * Browsing the history is read-only by construction, as before.
362
+ */
363
+ function render() {
364
+ if (!S) return;
365
+ const frame = nav.frame();
366
+ const live = !frame;
367
+ if (!live && proposal) proposal = null; // browsing abandons a placed move
368
+ const placed = live && proposal ? proposal.move : null;
369
+ const heldInfo = placed || !live ? null : sel ? pickInfo(sel.source, sel.color) : committing;
370
+ const base = live ? S.state : frame.state;
371
+ const st = placed ? placedView(placed) : base;
372
+ const mid = placed ? st : heldInfo ? heldView(heldInfo) : base;
373
+ renderStatus(frame);
374
+ syncBanner();
375
+ middle.render({
376
+ state: mid,
377
+ legalActions: live && !placed ? S.human_legal_actions : [],
378
+ canPick: live && S.your_turn && !base.is_terminal && !busy && !placed,
379
+ selection: null, // the hand tray *is* the selection; dimming forty tiles says less
380
+ });
381
+ renderHand(heldInfo);
382
+ boards.human.render({
383
+ state: st,
384
+ legalActions: live && !placed ? S.human_legal_actions : [],
385
+ selection: live && !placed ? sel : null,
386
+ title: "You",
387
+ toMove: live && !base.is_terminal && base.current_player === S.human_seat,
388
+ highlightRow: live && suggestion ? suggestion.dest : undefined,
389
+ });
390
+ if (placed) glowPlacement(placed);
391
+ boards.ai.render({
392
+ state: st,
393
+ title: aiLabel(),
394
+ toMove: live && !base.is_terminal && base.current_player === S.ai_seat,
395
+ });
396
+ renderCounts(st);
397
+ renderLog(ui.log, (live ? S.log : frame.log) || []);
398
+ ui.cancel.classList.toggle("hidden", !live || !sel || !!placed);
399
+ ui.hint.disabled = busy || !engineReady || !live || !S.your_turn || !!placed;
400
+ syncCoach();
401
+ if (live && !placed && suggestion) {
402
+ const dish = middle.sourceEl(suggestion.source);
403
+ if (dish) {
404
+ dish.classList.add("picked");
405
+ setTimeout(() => dish.classList.remove("picked"), 1600);
406
+ }
407
+ }
408
+ startAnalysis(); // with coach mode on and your turn, the coach starts reading
409
+ }
410
+
411
+ /** Draw one position and nothing else — a history frame, or a step in a turn. */
412
+ function drawPosition(st) {
413
+ if (!st) return;
414
+ middle.render({ state: st, legalActions: [], canPick: false, selection: null });
415
+ boards.human.render({ state: st, title: "You", toMove: false });
416
+ boards.ai.render({ state: st, title: aiLabel(), toMove: false });
417
+ renderCounts(st);
418
+ }
419
+
420
+ function renderStatus(frame) {
421
+ const st = frame ? frame.state : S.state;
422
+ const info = S.opponent_info || {};
423
+ const rating = typeof info.elo === "number" ? " (" + Math.round(info.elo) + " Elo)" : "";
424
+ ui.matchup.textContent = "you versus " + S.agent_name + rating + " · seed " + S.seed;
425
+ status.setScore(st.scores[S.human_seat], st.scores[S.ai_seat], "You", "AI");
426
+
427
+ if (frame) {
428
+ status.set({
429
+ headline: "Viewing move " + frame.ply + " of " + frame.of,
430
+ detail: "← and → step through the game · End, or Latest, returns to play",
431
+ tone: "history",
432
+ });
433
+ ui.prompt.textContent = "This is a recorded position. The live game is untouched.";
434
+ return;
435
+ }
436
+
437
+ if (st.is_terminal) {
438
+ const mine = st.scores[S.human_seat];
439
+ const theirs = st.scores[S.ai_seat];
440
+ const verb = mine > theirs ? "You won " : theirs > mine ? "The AI won " : "A draw, ";
441
+ status.set({
442
+ headline: verb + mine + "–" + theirs,
443
+ detail: "The final scoring is below; the board stays exactly as it ended.",
444
+ tone: "end",
445
+ });
446
+ ui.prompt.textContent = "Deal again for another game.";
447
+ return;
448
+ }
449
+ if (S.your_turn) {
450
+ if (proposal) {
451
+ status.set({
452
+ headline: "Your move is placed",
453
+ detail: "This is the position it would leave. Validate or cancel below.",
454
+ tone: "you",
455
+ });
456
+ ui.prompt.textContent = "";
457
+ return;
458
+ }
459
+ status.set({
460
+ headline: sel
461
+ ? "Your turn: pick a row for your " + COLORS[sel.color] + " tiles"
462
+ : "Your turn: pick a colour",
463
+ detail: turnDetail(),
464
+ tone: "you",
465
+ });
466
+ ui.prompt.textContent = sel
467
+ ? "Or press Escape to put them back."
468
+ : "Take every tile of one colour from a factory, or from the middle.";
469
+ } else {
470
+ status.set({ headline: "The AI is choosing a move", detail: turnDetail(), tone: "ai" });
471
+ ui.prompt.textContent = "";
472
+ }
473
+ }
474
+
475
+ /** The line under the headline: where the game is, and what the AI just did. */
476
+ function turnDetail() {
477
+ const st = S.state;
478
+ const bits = ["Round " + (st.round + 1), st.tiles_left + " tiles left on the table"];
479
+ const last = S.last_ai_move;
480
+ if (last) {
481
+ bits.push("AI " + last.text);
482
+ if (last.search_text) bits.push(last.search_text);
483
+ }
484
+ if (notice) bits.push(notice);
485
+ return bits.join(" · ");
486
+ }
487
+
488
+ /* One quiet line of totals — never per-colour counts, Rémi retired those. The
489
+ * lid keeps a real element (#lid-row) because discarded tiles fly to it. */
490
+ function renderCounts(st) {
491
+ const sum = (counts) => counts.reduce((a, b) => a + b, 0);
492
+ ui.counts.innerHTML = "";
493
+ ui.counts.appendChild(node("span", "count", "Bag " + sum(st.bag)));
494
+ const lid = node("span", "count", "Lid " + sum(st.lid));
495
+ lid.id = "lid-row";
496
+ ui.counts.appendChild(lid);
497
+ ui.counts.appendChild(node("span", "count", st.tiles_left + " on the table"));
498
+ }
499
+
500
+ /* -------------------------------------------------------------- game control */
501
+ /** Seconds the AI may think per move — 0 means "policy head, no search". */
502
+ function currentThinkTime() {
503
+ const seconds = Number(ui.think.value);
504
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
505
+ }
506
+
507
+ /** The coach's budget for an opponent that thinks for `thinkTimeS` seconds. */
508
+ function coachBudget() {
509
+ return Math.min(currentThinkTime() || COACH_THINK_S, COACH_MAX_THINK_S);
510
+ }
511
+
512
+ /** The AI's brain, handed to the session: one search request to the worker. */
513
+ async function think(state, onThinking) {
514
+ const budgetS = session ? session.thinkTimeS : 0;
515
+ liveSims = 0;
516
+ const reply = await ask(
517
+ { type: budgetS > 0 ? "search" : "policy", setup: state.toSetup(), budgetS },
518
+ (msg) => {
519
+ if (msg.type !== "progress") return;
520
+ liveSims = msg.sims;
521
+ if (onThinking) onThinking(msg);
522
+ }
523
+ );
524
+ // The worker measures its own rate; the first real search is the first honest
525
+ // positions/s this machine has produced, so the page stops guessing at it.
526
+ if (backend && reply.search && reply.search.rate && !backend.rate) {
527
+ backend.rate = reply.search.rate;
528
+ ui.engineText.textContent = engineLine();
529
+ describeModel();
530
+ }
531
+ return { action: reply.action, search: reply.search };
532
+ }
533
+
534
+ function newGame(event) {
535
+ if (event) event.preventDefault();
536
+ if (!engineReady) {
537
+ say("the net is still loading");
538
+ return;
539
+ }
540
+ let seed = Math.floor(Math.random() * (1 << 30));
541
+ const typed = ui.seed.value.trim();
542
+ if (typed) {
543
+ if (!/^-?\d+$/.test(typed)) {
544
+ say("the seed must be a whole number");
545
+ return;
546
+ }
547
+ seed = Number(typed) >>> 0;
548
+ }
549
+ clearScoring(ui.scoring);
550
+ hideRecord();
551
+ // dealing over a live game abandons it; an abandoned game is position data
552
+ // worth keeping too, flagged unfinished (docs/HUGGINGFACE.md D5)
553
+ if (session && !session.state.isTerminal) shareRecord(currentRecord());
554
+ session = new GameSession({
555
+ seed,
556
+ humanPlaysFirst: ui.first.checked,
557
+ agentName: meta ? meta.checkpoint : "the net",
558
+ opponentInfo: meta ? { checkpoint: meta.checkpoint, elo: meta.elo, run: meta.run } : {},
559
+ thinkTimeS: currentThinkTime(),
560
+ think,
561
+ });
562
+ adopt({ reseat: true });
563
+ say("new tiles dealt");
564
+ track("game-start");
565
+ // the AI opens when you took the second seat
566
+ if (session.aiTurn) resumeIfPending();
567
+ }
568
+
569
+ function adopt(options) {
570
+ const first = !S || (options && options.reseat);
571
+ S = session.snapshot();
572
+ sel = null;
573
+ suggestion = null;
574
+ proposal = null;
575
+ committing = null; // the real position takes over from any derived view
576
+ if (first) {
577
+ seatBoards();
578
+ nav.reset();
579
+ }
580
+ noteFrames((options && options.moves) || []);
581
+ render();
582
+ }
583
+
584
+ /**
585
+ * File these moves' positions into the move history.
586
+ *
587
+ * Every move carries the position it was played from, and the position a move
588
+ * was played from is the position *after* the move before it — so ply k's frame
589
+ * is ply k+1's `state_before`, and the tail of the list is the live position.
590
+ * Nothing is recomputed and nothing is searched again.
591
+ */
592
+ function noteFrames(moves) {
593
+ moves.forEach((m) => {
594
+ if (m && m.state_before && typeof m.ply === "number") nav.note(m.ply - 1, m.state_before);
595
+ });
596
+ nav.note(S.ply, S.state);
597
+ }
598
+
599
+ function pick(source, color) {
600
+ if (busy || !S || !S.your_turn || nav.browsing() || proposal) return;
601
+ suggestion = null;
602
+ if (sel && sel.source === source && sel.color === color) {
603
+ sel = null; // putting tiles back is instant and unambiguous
604
+ render();
605
+ return;
606
+ }
607
+ // capture the outgoing dish before the held view replaces it
608
+ const takenRects = middle.sourceTiles(source, color).map((t) => t.getBoundingClientRect());
609
+ const chip =
610
+ source === CENTER && S.state.marker_in_center
611
+ ? middle.centerEl().querySelector(".marker")
612
+ : null;
613
+ const markerRect = chip ? chip.getBoundingClientRect() : null;
614
+ const restRects = {};
615
+ if (source !== CENTER) {
616
+ middle.remainderTiles(source, color).forEach((t) => {
617
+ (restRects[t.dataset.color] = restRects[t.dataset.color] || []).push(
618
+ t.getBoundingClientRect()
619
+ );
620
+ });
621
+ }
622
+ sel = { source, color };
623
+ flyInto(() => {
624
+ const plan = [];
625
+ const handTiles = [].slice.call(ui.handTiles.querySelectorAll(".tile"));
626
+ takenRects.forEach((r, i) => plan.push([r, handTiles[i], color]));
627
+ if (markerRect) plan.push([markerRect, ui.handTiles.querySelector(".marker"), "marker"]);
628
+ Object.keys(restRects).forEach((c) => {
629
+ const now = middle.centerEl().querySelectorAll('.tile[data-color="' + c + '"]');
630
+ const olds = restRects[c];
631
+ olds.forEach((r, i) => plan.push([r, now[now.length - olds.length + i], Number(c)]));
632
+ });
633
+ return plan;
634
+ });
635
+ }
636
+
637
+ /* ---------------------------------------------------------- derived positions */
638
+ /** What a pick moves, before it has a destination. */
639
+ function pickInfo(source, color) {
640
+ return {
641
+ source,
642
+ color,
643
+ count: poolCount(S.state, source, color),
644
+ took_marker: source === CENTER && S.state.marker_in_center,
645
+ };
646
+ }
647
+
648
+ /** The live position with the selection lifted into the hand: the source pool
649
+ * emptied, a factory's leftovers pushed to the middle, a taken marker in hand. */
650
+ function heldView(info) {
651
+ const st = structuredClone(S.state);
652
+ if (info.source === CENTER) {
653
+ st.center[info.color] = 0;
654
+ if (info.took_marker) st.marker_in_center = false;
655
+ } else {
656
+ const dish = st.factories[info.source];
657
+ dish[info.color] = 0;
658
+ for (let c = 0; c < COLORS.length; c++) {
659
+ st.center[c] += dish[c];
660
+ dish[c] = 0;
661
+ }
662
+ }
663
+ return st;
664
+ }
665
+
666
+ /** The position `move` would leave — what confirm mode draws while it waits. */
667
+ function placedView(move) {
668
+ const st = heldView(move);
669
+ const me = st.players[S.human_seat];
670
+ if (move.dest !== FLOOR && move.placed) {
671
+ const line = me.pattern_lines[move.dest];
672
+ line.color = move.color;
673
+ line.count += move.placed;
674
+ }
675
+ if (move.to_floor) me.floor[move.color] += move.to_floor;
676
+ if (move.to_lid) st.lid[move.color] += move.to_lid;
677
+ if (move.took_marker) me.floor_marker = true;
678
+ const occupied =
679
+ me.floor.reduce((a, b) => a + b, 0) + (me.floor_marker ? 1 : 0);
680
+ me.floor_penalty = CUM_PENALTY[Math.min(7, occupied)];
681
+ st.tiles_left -= move.count;
682
+ return st;
683
+ }
684
+
685
+ /** The hand tray: the selection as real tiles, in the page, scrolling with it. */
686
+ function renderHand(info) {
687
+ ui.handTiles.innerHTML = "";
688
+ if (!info || !info.count) {
689
+ ui.hand.hidden = true;
690
+ return;
691
+ }
692
+ for (let k = 0; k < info.count; k++) ui.handTiles.appendChild(tileEl(info.color));
693
+ if (info.took_marker) ui.handTiles.appendChild(markerChip(true));
694
+ ui.hand.hidden = false;
695
+ }
696
+
697
+ /** A soft light around the tiles a placed move just put down. */
698
+ function glowPlacement(move) {
699
+ placedTiles(move).forEach((t) => t.classList.add("proposed"));
700
+ }
701
+
702
+ /** The placed move's own tiles on the freshly drawn board, in take order. */
703
+ function placedTiles(move) {
704
+ const board = boards.human;
705
+ const out = [];
706
+ if (move.dest !== FLOOR && move.placed) {
707
+ out.push.apply(out, board.lineTiles(move.dest).slice(-move.placed));
708
+ }
709
+ if (move.to_floor) {
710
+ const mine = board.floorTiles().filter((t) => Number(t.dataset.color) === move.color);
711
+ out.push.apply(out, mine.slice(-move.to_floor));
712
+ }
713
+ if (move.took_marker) {
714
+ const floor = board.floorEl();
715
+ const chip = floor && floor.querySelector(".marker");
716
+ if (chip) out.push(chip);
717
+ }
718
+ return out;
719
+ }
720
+
721
+ /**
722
+ * Redraw, then fly clones from rectangles captured off the outgoing page to the
723
+ * elements `find()` names on the new one. The page is never *between* positions:
724
+ * the new position is drawn at once, and the pieces that moved are briefly
725
+ * covered while their clones travel. With animation off, the redraw is all.
726
+ */
727
+ async function flyInto(find) {
728
+ render();
729
+ if (!animated()) return;
730
+ const plan = find() || [];
731
+ const flights = [];
732
+ const covered = [];
733
+ plan.forEach(([rect, target, color]) => {
734
+ if (!rect || !target) return;
735
+ if (target.style) {
736
+ target.style.visibility = "hidden";
737
+ covered.push(target);
738
+ }
739
+ flights.push({ from: rect, to: target, color, hide: false });
740
+ });
741
+ await flyTiles(flights, { layer: ui.fly });
742
+ covered.forEach((elm) => {
743
+ elm.style.visibility = "";
744
+ });
745
+ }
746
+
747
+ /* ------------------------------------------------------------- confirm mode */
748
+ /**
749
+ * A row was clicked. With confirm mode off this *is* the move; with it on (the
750
+ * default) the page draws the position the move would leave — real tiles in
751
+ * their real places, the new ones glowing — and the banner under the middle
752
+ * asks for the word. Cancel goes all the way back to before the pick.
753
+ */
754
+ function route(id) {
755
+ if (busy || !session || !S || !S.your_turn || nav.browsing() || proposal) return;
756
+ if (!confirmOn()) {
757
+ play(id);
758
+ return;
759
+ }
760
+ propose(id);
761
+ }
762
+
763
+ /** The banner a placed move answers to. It swaps in for the action row's usual
764
+ * controls (see board.css), so showing it never moves the page. */
765
+ function syncBanner() {
766
+ const on = !!proposal && !busy;
767
+ ui.confirmBar.hidden = !on;
768
+ ui.confirmBar.parentElement.classList.toggle("placing", on);
769
+ if (on) {
770
+ ui.confirmDetail.textContent =
771
+ "You " + proposal.move.text + ". Nothing is final until you play it.";
772
+ }
773
+ }
774
+
775
+ /** Place the move: draw its position, glow its tiles, and wait for the word. */
776
+ function propose(id) {
777
+ const move = describeAction(session.state, id);
778
+ // take-off points: the hand tray the selection is sitting in
779
+ const rects = [].slice
780
+ .call(ui.handTiles.querySelectorAll(".tile"))
781
+ .map((t) => t.getBoundingClientRect());
782
+ const chip = ui.handTiles.querySelector(".marker");
783
+ const markerRect = chip ? chip.getBoundingClientRect() : null;
784
+ proposal = { id, move };
785
+ sel = null; // a placed move is validated or cancelled whole
786
+ flyInto(() => {
787
+ const landed = placedTiles(move);
788
+ const lidRect =
789
+ move.to_lid > 0 ? lidTarget().getBoundingClientRect() : null;
790
+ const plan = [];
791
+ rects.forEach((r, i) => {
792
+ // in take order: the row, then the floor; overflow melts into the lid
793
+ const target = landed[i] || lidRect;
794
+ plan.push([r, target, move.color]);
795
+ });
796
+ if (markerRect) {
797
+ const floor = boards.human.floorEl();
798
+ plan.push([markerRect, floor && floor.querySelector(".marker"), "marker"]);
799
+ }
800
+ return plan;
801
+ });
802
+ }
803
+
804
+ /** The banner's Cancel: all the way back, as if nothing had been picked. */
805
+ function cancelMove() {
806
+ proposal = null;
807
+ sel = null;
808
+ suggestion = null;
809
+ render();
810
+ }
811
+
812
+ /**
813
+ * One turn: your move — applied and animated at once — then the AI's reply.
814
+ *
815
+ * The tiles are animated from the *current* DOM, before the new state is drawn,
816
+ * so what you see leaving the dish is what actually left it. With coach mode on
817
+ * the position is snapshotted first and the rating is queued: it runs on the
818
+ * worker *after* the AI has replied, and fills in on the move's log entry —
819
+ * nothing about your move, the board or the animations ever waits for it.
820
+ */
821
+ async function play(id) {
822
+ if (busy || !session || !S || !S.your_turn || nav.browsing()) return;
823
+ const move = describeAction(session.state, id);
824
+ const committed = !!(proposal && proposal.id === id); // a validated placement
825
+ // the direct path takes off from the hand the selection is sitting in
826
+ const fromHand = !committed && !ui.hand.hidden;
827
+ const handRects = fromHand
828
+ ? [].slice.call(ui.handTiles.querySelectorAll(".tile")).map((t) => t.getBoundingClientRect())
829
+ : null;
830
+ const handChip = fromHand ? ui.handTiles.querySelector(".marker") : null;
831
+ const markerRect = handChip ? handChip.getBoundingClientRect() : null;
832
+ if (!committed) committing = move; // keeps the held view up while the tiles fly
833
+ sel = null;
834
+ suggestion = null;
835
+ setBusy(true);
836
+ clearScoring(ui.scoring);
837
+ try {
838
+ const setupBefore = coachOn ? session.state.toSetup() : null;
839
+ // the coach's head start covers exactly this position: stop it where it
840
+ // stands (the opponent needs the worker) and grade from what it read.
841
+ // Cancelled even if coach mode was switched off meanwhile — a stale
842
+ // analysis must never keep the opponent waiting.
843
+ const reading =
844
+ analysis && analysis.forSession === session && analysis.ply === S.ply
845
+ ? analysis
846
+ : null;
847
+ if (reading) worker.postMessage({ type: "cancel" });
848
+ const headStart = coachOn ? reading : null;
849
+ const { move: applied, reports } = session.playHuman(id);
850
+ const entry = session.log[applied.log_n];
851
+ if (setupBefore && entry) {
852
+ if (headStart) {
853
+ entry.coach = { pending: true };
854
+ finishFromAnalysis(headStart, id, entry, setupBefore);
855
+ } else {
856
+ queueRating(setupBefore, id, entry);
857
+ }
858
+ }
859
+ const takeoff = committed
860
+ ? { skip: true }
861
+ : handRects
862
+ ? { fromRects: handRects, markerRect }
863
+ : null;
864
+ await settle([applied], boards.human, reports, "human", takeoff);
865
+ if (session.aiTurn) await runAiTurn();
866
+ } catch (err) {
867
+ status.stopClock();
868
+ say(err.message);
869
+ adopt();
870
+ } finally {
871
+ committing = null;
872
+ setBusy(false);
873
+ resumeIfPending(); // a failed search leaves the AI owing a move
874
+ flushRatings(); // now the table is quiet, let the coach think
875
+ }
876
+ }
877
+
878
+ /** If the position says the AI still owes a move, let it move. */
879
+ async function resumeIfPending() {
880
+ if (busy || !session || !session.aiTurn) return;
881
+ setBusy(true);
882
+ try {
883
+ await runAiTurn();
884
+ } catch (err) {
885
+ status.stopClock();
886
+ say(err.message);
887
+ adopt();
888
+ } finally {
889
+ setBusy(false);
890
+ }
891
+ }
892
+
893
+ /** The AI's turn: the clock runs in the status band, then its move plays out. */
894
+ async function runAiTurn() {
895
+ const firstReport = session.roundReports.length;
896
+ const budget = session.thinkTimeS;
897
+ liveSims = 0;
898
+ const thinking = session.aiReplies();
899
+ status.set({ headline: "The AI is thinking", detail: turnDetail(), tone: "ai", keepClock: true });
900
+ status.startClock({
901
+ budget,
902
+ label: (spent, cap) => {
903
+ // it is your CPU doing the work, so the band says how much work that is
904
+ const counted = liveSims ? " · " + liveSims.toLocaleString() + " positions" : "";
905
+ return cap
906
+ ? "AI is thinking: " + spent.toFixed(1) + "s of " + cap + "s" + counted
907
+ : "AI is picking a move";
908
+ },
909
+ });
910
+ let moves;
911
+ try {
912
+ moves = await thinking;
913
+ } finally {
914
+ status.stopClock();
915
+ }
916
+ await settle(moves, boards.ai, session.reportsSince(firstReport), "ai");
917
+ }
918
+
919
+ /**
920
+ * Play a list of moves out on the table, one after another.
921
+ *
922
+ * The AI can move twice in a row — a round boundary is resolved inside the
923
+ * engine's `apply`, and the next round is opened by whoever holds the marker.
924
+ * Its second move is therefore played on a table that was scored and refilled
925
+ * inside the first one, and which this page had never drawn. Each move carries
926
+ * the position it was played from, so we draw that first and then animate: both
927
+ * moves are shown, in order, from the right board.
928
+ */
929
+ async function playMoves(moves, board, reports, mover, takeoff) {
930
+ let taken = 0;
931
+ for (let i = 0; i < moves.length; i++) {
932
+ const move = moves[i];
933
+ if (i > 0 && move.state_before) {
934
+ drawPosition(move.state_before);
935
+ await sleep(420); // a beat, so the refilled table registers as a new one
936
+ }
937
+ if (mover === "ai") {
938
+ status.set({
939
+ headline: "AI " + move.text,
940
+ detail: move.search_text || turnDetail(),
941
+ tone: "ai",
942
+ });
943
+ }
944
+ await animateTake(move, board, middle, i === 0 ? takeoff : null);
945
+ // an observability hook, so a test can prove that *every* move animates —
946
+ // including the second of the AI's double move across a round boundary
947
+ document.dispatchEvent(
948
+ new CustomEvent("azul:animated", { detail: { ply: move.ply, side: move.side } })
949
+ );
950
+ if (move.ended_round && reports[taken]) {
951
+ const report = reports[taken++];
952
+ status.set({
953
+ headline: "Round " + (report.round + 1) + " scoring",
954
+ detail: "full lines move to the wall, the floor line goes to the lid",
955
+ tone: "scoring",
956
+ });
957
+ await animateTiling(report);
958
+ }
959
+ }
960
+ }
961
+
962
+ /** Play out `moves`, then show the position they led to and the round's scoring. */
963
+ async function settle(moves, board, reports, mover, takeoff) {
964
+ await playMoves(moves, board, reports, mover, takeoff);
965
+ adopt({ moves });
966
+ if (reports.length) {
967
+ const last = reports[reports.length - 1];
968
+ flashWall(last);
969
+ if (!last.game_over) renderRoundPanel(ui.scoring, last, sides());
970
+ }
971
+ if (S.state.is_terminal && S.final) {
972
+ renderFinalPanel(ui.scoring, S.final, sides());
973
+ showRecord();
974
+ // the research bargain, honoured at the moment it matters: the finished
975
+ // game goes to the training pile (unless Settings says no), fire and forget
976
+ shareRecord(currentRecord());
977
+ // one coarse path per net and outcome (so the dashboard can show win
978
+ // rates per model), with the score line as the hit's detail
979
+ const model = meta ? meta.run + "-" + meta.checkpoint : "unknown";
980
+ const result =
981
+ S.final.winner_side === "human"
982
+ ? "human-wins"
983
+ : S.final.winner_side === "ai"
984
+ ? "net-wins"
985
+ : "draw";
986
+ track("game-end/" + model + "/" + result, {
987
+ title:
988
+ S.state.scores[S.human_seat] + "–" + S.state.scores[S.ai_seat] +
989
+ " in " + S.final.rounds_played + " rounds",
990
+ });
991
+ } else if (mover === "ai" && S.last_ai_move) {
992
+ // let the AI's own move stand as the headline for a beat before your turn
993
+ status.set({ headline: "AI " + S.last_ai_move.text, detail: turnDetail(), tone: "ai" });
994
+ await sleep(500);
995
+ }
996
+ render();
997
+ }
998
+
999
+ function sides() {
1000
+ return [[S.human_seat, "You"], [S.ai_seat, aiLabel()]];
1001
+ }
1002
+
1003
+ /* ---------------------------------------------------------------- coach mode */
1004
+ /* Same definition as the local GUI (ludometer/gui/coach.py): the search runs on
1005
+ * the position *before* your move was applied — snapshotted at the moment you
1006
+ * clicked — and the verdict is the gap between your move's Q at the root and
1007
+ * the best explored child's. The difference is *when*: the rating is queued and
1008
+ * runs on the worker after the opponent has replied, so your move, the flights
1009
+ * and the AI's answer never wait for it. Until it lands, the move's log entry
1010
+ * says "rating…". */
1011
+ const ratings = []; // {setup, actionId, entry, forSession} still to be rated
1012
+ let ratingNow = false;
1013
+
1014
+ function queueRating(setup, actionId, entry) {
1015
+ if (!entry) return;
1016
+ entry.coach = { pending: true };
1017
+ ratings.push({ setup, actionId, entry, forSession: session });
1018
+ }
1019
+
1020
+ /** Work the rating queue, one search at a time. Reentry-safe; never throws. */
1021
+ async function flushRatings() {
1022
+ if (ratingNow) return;
1023
+ ratingNow = true;
1024
+ try {
1025
+ while (ratings.length) {
1026
+ const job = ratings.shift();
1027
+ let verdict;
1028
+ try {
1029
+ const reply = await ask({
1030
+ type: "rate",
1031
+ setup: job.setup,
1032
+ actionId: job.actionId,
1033
+ budgetS: coachBudget(),
1034
+ });
1035
+ verdict = reply.coach;
1036
+ } catch (err) {
1037
+ verdict = { unrated: true, reason: err.message };
1038
+ }
1039
+ job.entry.coach = verdict;
1040
+ // the entry lives in the session's own log, so any later redraw shows the
1041
+ // verdict; redraw now only if the table is quiet and still this game's
1042
+ if (job.forSession === session && S && !busy) render();
1043
+ }
1044
+ } finally {
1045
+ ratingNow = false;
1046
+ }
1047
+ }
1048
+
1049
+ /* The coach's head start. The rating search runs on the position *before* your
1050
+ * move and only reads the played move out of the finished tree afterwards — so
1051
+ * it can run while you are still choosing. `startAnalysis` kicks it off the
1052
+ * moment it is your turn (idempotent per position); when you move, the search
1053
+ * is cancelled where it stands, the opponent gets the worker back, and the
1054
+ * verdict is read from whatever tree had grown. A tree too thin to be honest
1055
+ * (you moved within a fraction of a second) falls back to the old post-move
1056
+ * rating, so the verdict is never worse than it used to be. */
1057
+ const ANALYSIS_MIN_SIMS = 400;
1058
+
1059
+ function startAnalysis() {
1060
+ if (!coachOn || !engineReady || busy || !session || !S || !S.your_turn) return;
1061
+ if (analysis && analysis.forSession === session && analysis.ply === S.ply) return;
1062
+ const job = { forSession: session, ply: S.ply, budgetS: coachBudget() };
1063
+ job.promise = ask({ type: "analyze", setup: session.state.toSetup(), budgetS: job.budgetS })
1064
+ .then((reply) => reply.analysis)
1065
+ .catch(() => null);
1066
+ analysis = job;
1067
+ }
1068
+
1069
+ /** The verdict `rate` would have given, read out of an analyzed tree. */
1070
+ function verdictFrom(a, budgetS, actionId) {
1071
+ const base = { budgetS, legal: a.legal, sims: a.sims, elapsedS: a.elapsedS };
1072
+ if (a.forced) return { ...base, delta: 0, forced: true };
1073
+ const explored = a.children || [];
1074
+ if (!explored.length) {
1075
+ return { ...base, unrated: true, reason: "the search had no time to explore this position" };
1076
+ }
1077
+ const best = explored.reduce((x, y) => (y.q > x.q ? y : x));
1078
+ const mine = explored.find((c) => c.action === actionId);
1079
+ if (!mine) {
1080
+ return { ...base, unrated: true, reason: "the search never explored this move" };
1081
+ }
1082
+ return {
1083
+ ...base,
1084
+ delta: Math.min(0, mine.q - best.q),
1085
+ your_q: mine.q,
1086
+ best_q: best.q,
1087
+ visits: mine.visits,
1088
+ best_visits: best.visits,
1089
+ best_text: a.best_text,
1090
+ explored: explored.length,
1091
+ };
1092
+ }
1093
+
1094
+ /** Settle a move's verdict from the head-start analysis — or fall back. */
1095
+ async function finishFromAnalysis(job, actionId, entry, setup) {
1096
+ const a = await job.promise;
1097
+ if (!a || (!a.forced && a.sims < ANALYSIS_MIN_SIMS)) {
1098
+ // the move came faster than the coach could read: judge it the old way
1099
+ queueRating(setup, actionId, entry);
1100
+ flushRatings();
1101
+ return;
1102
+ }
1103
+ entry.coach = verdictFrom(a, job.budgetS, actionId);
1104
+ if (job.forSession === session && S && !busy) render();
1105
+ }
1106
+
1107
+ function syncCoach() {
1108
+ ui.coach.checked = coachOn;
1109
+ ui.coach.disabled = !engineReady;
1110
+ ui.coachField.classList.toggle("off", !coachOn);
1111
+ ui.coachLegend.textContent = COACH_LEGEND;
1112
+ ui.coachLegend.hidden = !coachOn;
1113
+ }
1114
+
1115
+ /* --------------------------------------------------------------------- hints */
1116
+ /** The policy head's own pick, shown as a suggestion (one forward pass). */
1117
+ async function askHint() {
1118
+ if (!session || !S || !S.your_turn || busy || nav.browsing() || proposal) return;
1119
+ setBusy(true);
1120
+ try {
1121
+ const reply = await ask({ type: "policy", setup: session.state.toSetup() });
1122
+ const move = describeAction(session.state, reply.action);
1123
+ suggestion = { source: move.source, color: move.color, dest: move.dest };
1124
+ sel = { source: move.source, color: move.color };
1125
+ render();
1126
+ say("the policy head would " + move.text);
1127
+ } catch (err) {
1128
+ say(err.message);
1129
+ } finally {
1130
+ setBusy(false);
1131
+ }
1132
+ }
1133
+
1134
+ /* ----------------------------------------------------------------- animation */
1135
+ function lidTarget() {
1136
+ return document.getElementById("lid-row") || ui.counts;
1137
+ }
1138
+
1139
+ /** Where a move's tiles are going, in landing order: line, then floor, then lid. */
1140
+ function travelTargets(board, move) {
1141
+ const targets = [];
1142
+ if (move.dest !== FLOOR && move.placed) {
1143
+ const slots = board.lineSlots(move.dest);
1144
+ targets.push.apply(targets, slots.slice(Math.max(0, slots.length - move.placed)));
1145
+ }
1146
+ const floorSlots = board.floorSlots();
1147
+ let taken = move.took_marker ? 1 : 0; // the marker claims the first free slot
1148
+ while (targets.length < move.count && taken < floorSlots.length) {
1149
+ targets.push(floorSlots[taken++]);
1150
+ }
1151
+ const lid = lidTarget();
1152
+ while (targets.length < move.count) targets.push(lid); // the rest overflow to the lid
1153
+ return targets;
1154
+ }
1155
+
1156
+ /**
1157
+ * Tiles leave a dish; everything else in that dish is pushed into the middle.
1158
+ *
1159
+ * Both halves matter: the tiles you took travel to your board, and the dish's
1160
+ * *remainder* travels to the centre. Watching the leftovers arrive in the
1161
+ * middle is how you learn what the next player can take.
1162
+ *
1163
+ * `takeoff` says where this story already got to. `{skip: true}` is a validated
1164
+ * placement — the board has shown the final position since the tiles were
1165
+ * placed, so nothing moves. `{fromRects, markerRect}` is a selection sitting in
1166
+ * the hand tray — the tiles continue from there, and the leftovers, already
1167
+ * drawn in the middle by the held view, stay put. `null` is the AI (or a bare
1168
+ * page): everything flies from the dish, as it always has.
1169
+ */
1170
+ async function animateTake(move, board, table, takeoff) {
1171
+ if (takeoff && takeoff.skip) {
1172
+ // a validated placement: the board has been showing exactly this position
1173
+ // since the tiles were placed, so there is nothing left to move
1174
+ popFloorCost(move, board);
1175
+ return;
1176
+ }
1177
+ const dish = table.sourceEl(move.source);
1178
+ const row = board.lineRow(move.dest);
1179
+ if (dish) dish.classList.add("picked");
1180
+ if (row) row.classList.add("incoming");
1181
+
1182
+ const targets = travelTargets(board, move);
1183
+ let flights;
1184
+ if (takeoff && takeoff.fromRects) {
1185
+ // the selection was sitting in the hand tray; it continues from there, and
1186
+ // the tray empties the moment the tiles push off
1187
+ ui.hand.hidden = true;
1188
+ ui.handTiles.innerHTML = "";
1189
+ flights = takeoff.fromRects.map((r, i) => ({ from: r, to: targets[i], color: move.color }));
1190
+ if (move.took_marker && takeoff.markerRect) {
1191
+ const slot = board.floorSlots()[0];
1192
+ if (slot) flights.push({ from: takeoff.markerRect, to: slot, color: "marker" });
1193
+ }
1194
+ } else {
1195
+ // the AI's move (or a hintless page state): straight from the dish
1196
+ const taken = table.sourceTiles(move.source, move.color, move.count);
1197
+ flights = taken.map((from, i) => ({ from, to: targets[i], color: move.color }));
1198
+ const centreEl = table.centerEl();
1199
+ table.remainderTiles(move.source, move.color).forEach((from) => {
1200
+ flights.push({ from, to: centreEl, color: Number(from.dataset.color) });
1201
+ });
1202
+ if (move.took_marker) {
1203
+ const chip = centreEl.querySelector(".marker");
1204
+ const slot = board.floorSlots()[0];
1205
+ if (chip && slot) flights.push({ from: chip, to: slot, color: "marker" });
1206
+ }
1207
+ }
1208
+
1209
+ await flyTiles(flights, { layer: ui.fly });
1210
+ popFloorCost(move, board);
1211
+ if (dish) dish.classList.remove("picked");
1212
+ if (row) row.classList.remove("incoming");
1213
+ }
1214
+
1215
+ /** What this move just added to the floor bill, popped off the floor line. */
1216
+ function popFloorCost(move, board) {
1217
+ const dropped = (move.to_floor || 0) + (move.took_marker ? 1 : 0);
1218
+ if (!dropped || !move.state_before) return;
1219
+ const me = move.state_before.players[move.player];
1220
+ if (!me) return;
1221
+ const occupied = me.floor.reduce((a, b) => a + b, 0) + (me.floor_marker ? 1 : 0);
1222
+ const cost =
1223
+ CUM_PENALTY[Math.min(7, occupied + dropped)] - CUM_PENALTY[Math.min(7, occupied)];
1224
+ if (!cost) return;
1225
+ popScore(ui.pops, board.floorEl(), String(cost).replace("-", "−"), "loss");
1226
+ }
1227
+
1228
+ /* ------------------------------------------------- animated history steps */
1229
+ /**
1230
+ * A step through the history plays its move — forwards, or in reverse.
1231
+ *
1232
+ * The plan is read from the *outgoing* frame's DOM before the target frame is
1233
+ * drawn; the target is then rendered immediately (browsing must never feel
1234
+ * slower than the keypress), and the moved tiles fly over the finished board
1235
+ * from where they were to where they now are. A step that crosses a round
1236
+ * boundary just redraws — half the board changes there, and a lone flight
1237
+ * would tell less of the story than the scoring panel already did.
1238
+ */
1239
+ function onNavChange(change) {
1240
+ const token = ++navToken;
1241
+ const plan = change ? planNavStep(change) : null;
1242
+ render();
1243
+ if (plan) flyNavStep(plan, token);
1244
+ }
1245
+
1246
+ function planNavStep(change) {
1247
+ if (!animated() || !S) return null;
1248
+ const delta = change.to - change.from;
1249
+ if (Math.abs(delta) !== 1) return null;
1250
+ const ply = Math.max(change.from, change.to); // the move between the two frames
1251
+ const entry = (S.log || []).find((e) => e.kind === "move" && e.ply === ply);
1252
+ if (!entry || typeof entry.color !== "number") return null;
1253
+ const a = nav.stateAt(change.from);
1254
+ const b = nav.stateAt(change.to) || (change.to === nav.latest() ? S.state : null);
1255
+ if (!a || !b || a.round !== b.round) return null;
1256
+ const board = entry.side === "human" ? boards.human : boards.ai;
1257
+ const placed = Math.max(0, entry.count - (entry.overflow || 0));
1258
+ const floorCount = entry.dest === FLOOR ? entry.count : entry.overflow || 0;
1259
+ let from;
1260
+ if (delta > 0) {
1261
+ // forwards: the tiles leave the dish they were taken from
1262
+ from = middle.sourceTiles(entry.source, entry.color, entry.count);
1263
+ } else {
1264
+ // backwards: they leave the row (and the floor) they had landed on
1265
+ const rowTiles = entry.dest !== FLOOR ? board.lineTiles(entry.dest).slice(-placed) : [];
1266
+ from = rowTiles.concat(floorCount ? board.floorTiles().slice(-floorCount) : []);
1267
+ }
1268
+ const rects = from.map((elm) => elm.getBoundingClientRect());
1269
+ return { dir: delta, entry, board, placed, floorCount, rects };
1270
+ }
1271
+
1272
+ /** Fly a planned step over the already-rendered target frame. */
1273
+ async function flyNavStep(plan, token) {
1274
+ if (token !== navToken) return;
1275
+ const { entry, board } = plan;
1276
+ let dests;
1277
+ if (plan.dir > 0) {
1278
+ const rowTiles =
1279
+ entry.dest !== FLOOR ? board.lineTiles(entry.dest).slice(-plan.placed) : [];
1280
+ dests = rowTiles.concat(
1281
+ plan.floorCount ? board.floorTiles().slice(-plan.floorCount) : []
1282
+ );
1283
+ } else {
1284
+ dests = middle.sourceTiles(entry.source, entry.color, entry.count);
1285
+ }
1286
+ const flights = [];
1287
+ const covered = [];
1288
+ dests.forEach((elm, i) => {
1289
+ const from = plan.rects[i];
1290
+ if (!from || !elm) return;
1291
+ elm.style.visibility = "hidden"; // the flight is this tile, mid-journey
1292
+ covered.push(elm);
1293
+ flights.push({ from, to: elm, color: entry.color, hide: false });
1294
+ });
1295
+ await flyTiles(flights, { layer: ui.fly, duration: 300, stagger: 35 });
1296
+ covered.forEach((elm) => {
1297
+ elm.style.visibility = "";
1298
+ });
1299
+ }
1300
+
1301
+ /** Round end: full lines travel to the wall, the floor line travels to the lid —
1302
+ * each wall tile popping its points as it lands, each floor line its bill.
1303
+ * One player is scored completely — wall, floor bill, lid — before the other
1304
+ * starts, the way a table is counted in real life, so you can follow along. */
1305
+ async function animateTiling(report) {
1306
+ if (!report) return;
1307
+ const lidEl = lidTarget();
1308
+ // with the tiles not animating, the pops still tell the arithmetic one step
1309
+ // at a time — this offset keeps the second player's count after the first's
1310
+ let beatOffset = 0;
1311
+ for (const [seat, board] of [[S.human_seat, boards.human], [S.ai_seat, boards.ai]]) {
1312
+ const player = report.players[seat];
1313
+ if (!player) continue;
1314
+ const wall = [];
1315
+ const pops = [];
1316
+ player.tiles.forEach((t) => {
1317
+ const tiles = board.lineTiles(t.row);
1318
+ const from = tiles[tiles.length - 1] || board.lineRow(t.row);
1319
+ const to = board.wallCell(t.row, t.col);
1320
+ if (from && to) {
1321
+ wall.push({ from, to, color: t.color, hide: false });
1322
+ // a rect, not the element: the pop may outlive this render of the cell
1323
+ pops.push({ at: to.getBoundingClientRect(), text: "+" + t.points });
1324
+ }
1325
+ });
1326
+ const lid = board.floorTiles().map((from) => ({
1327
+ from, to: lidEl, color: Number(from.dataset.color),
1328
+ }));
1329
+ // each "+N" appears as its tile touches down — or on a steady beat when
1330
+ // the tiles are not animating
1331
+ const land = scaled(520);
1332
+ const beat = scaled(70);
1333
+ pops.forEach((p, i) => {
1334
+ setTimeout(
1335
+ () => popScore(ui.pops, p.at, p.text),
1336
+ land ? land + i * beat : beatOffset + i * 240
1337
+ );
1338
+ });
1339
+ beatOffset += pops.length * 240;
1340
+ await flyTiles(wall, { layer: ui.fly, duration: 520, stagger: 70 });
1341
+ if (player.floor.penalty) {
1342
+ popScore(ui.pops, board.floorEl(), String(player.floor.penalty).replace("-", "−"), "loss");
1343
+ }
1344
+ if (lid.length) {
1345
+ lidEl.classList.add("receiving");
1346
+ await flyTiles(lid, { layer: ui.fly, duration: 420, stagger: 40 });
1347
+ setTimeout(() => lidEl.classList.remove("receiving"), 400);
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ /** The tiles that just reached the wall glaze over as they land. */
1353
+ function flashWall(report) {
1354
+ [[S.human_seat, boards.human], [S.ai_seat, boards.ai]].forEach(([seat, board]) => {
1355
+ const player = report.players[seat];
1356
+ if (!player) return;
1357
+ player.tiles.forEach((t, i) => {
1358
+ const cell = board.wallTile(t.row, t.col);
1359
+ if (!cell) return;
1360
+ cell.style.animationDelay = i * 70 + "ms";
1361
+ cell.classList.add("landing");
1362
+ });
1363
+ });
1364
+ }
1365
+
1366
+ /* ------------------------------------------------------------- game records */
1367
+ /* A finished game is data worth keeping, so the page offers it as a file. It is
1368
+ * offered, never sent: see js/record.js for why that distinction is load-bearing.
1369
+ * The record is built on demand, so it always describes the position as it
1370
+ * stands, and browsing the history does not change it (the navigator replays,
1371
+ * it never mutates the session). */
1372
+
1373
+ /** The current game as a record, or null before the first deal. */
1374
+ function currentRecord() {
1375
+ if (!session) return null;
1376
+ return buildRecord(session, { net: meta || {}, backend: backend ? backend.name : null });
1377
+ }
1378
+
1379
+ /** What the row under a finished game says depends on the sharing switch. */
1380
+ function recordInvite() {
1381
+ return sharingOn()
1382
+ ? "Finished, and shared: the moves, the tiles that were dealt, and which " +
1383
+ "net you played are on their way to the public training pile. Thank " +
1384
+ "you. You can keep a copy too, or turn sharing off in Settings."
1385
+ : "Finished. Sharing is off, so nothing was sent. If you would like this " +
1386
+ "game to help train the net, you can save it and send it to me, or " +
1387
+ "turn sharing on in Settings.";
1388
+ }
1389
+
1390
+ function showRecord() {
1391
+ ui.recordNote.textContent = recordInvite();
1392
+ ui.record.classList.remove("done");
1393
+ ui.record.hidden = false;
1394
+ }
1395
+
1396
+ function hideRecord() {
1397
+ ui.record.hidden = true;
1398
+ ui.record.classList.remove("done");
1399
+ }
1400
+
1401
+ /** Say what happened, in the row itself — this page has no toasts. */
1402
+ function recordSaid(message) {
1403
+ ui.recordNote.textContent = message;
1404
+ ui.record.classList.add("done");
1405
+ }
1406
+
1407
+ /* A tiny public handle. It exists so the browser tests can pull the record out
1408
+ * of a real game and replay it, and it costs a curious player nothing to find. */
1409
+ window.faience = { record: currentRecord, log: () => (session ? session.log : []) };
1410
+
1411
+ /* -------------------------------------------------------------------- wiring */
1412
+ ui.recordSave.addEventListener("click", () => {
1413
+ const record = currentRecord();
1414
+ if (!record) return;
1415
+ recordSaid(
1416
+ downloadRecord(record)
1417
+ ? "Saved. A copy of the game is yours to keep, or to send to me if sharing is off."
1418
+ : "This browser would not save the file. Try Copy as text instead."
1419
+ );
1420
+ });
1421
+ ui.recordCopy.addEventListener("click", async () => {
1422
+ const record = currentRecord();
1423
+ if (!record) return;
1424
+ const ok = await copyRecord(record);
1425
+ recordSaid(
1426
+ ok
1427
+ ? "Copied. Paste it anywhere; a GitHub issue on the repo still reaches me."
1428
+ : "This browser would not let the page copy. Use Save this game instead."
1429
+ );
1430
+ });
1431
+
1432
+ /* The way out: a tab closed or navigated away mid-game still counts. The game
1433
+ * has no outcome, so it goes flagged unfinished, by beacon (built for exactly
1434
+ * this moment), and never blocks the navigation. A finished game was already
1435
+ * shared when it ended, so terminal positions send nothing here. */
1436
+ window.addEventListener("pagehide", () => {
1437
+ if (session && !session.state.isTerminal) shareOnLeave(currentRecord());
1438
+ });
1439
+
1440
+ // wake the collector while the first game is still being dealt, and deliver
1441
+ // any game a previous visit could not (the Space may have been asleep)
1442
+ warmCollector();
1443
+
1444
+ ui.setup.addEventListener("submit", newGame);
1445
+ ui.think.addEventListener("change", () => {
1446
+ if (session) session.thinkTimeS = currentThinkTime();
1447
+ if (S) render();
1448
+ });
1449
+ ui.hint.addEventListener("click", askHint);
1450
+ ui.confirm.addEventListener("click", () => {
1451
+ if (!proposal || busy) return;
1452
+ play(proposal.id); // play() reads the proposal itself; it stays up until the
1453
+ // real position takes over, so the board never flashes back
1454
+ });
1455
+ ui.confirmCancel.addEventListener("click", cancelMove);
1456
+ ui.cancel.addEventListener("click", () => {
1457
+ sel = null;
1458
+ suggestion = null;
1459
+ render();
1460
+ });
1461
+ ui.coach.addEventListener("change", () => {
1462
+ coachOn = ui.coach.checked;
1463
+ syncCoach();
1464
+ startAnalysis(); // switched on mid-turn: start reading this position now
1465
+ say(
1466
+ coachOn
1467
+ ? "coach mode on: your moves are scored by the AI's own search"
1468
+ : "coach mode off"
1469
+ );
1470
+ });
1471
+ document.addEventListener("keydown", (event) => {
1472
+ if (event.key !== "Escape") return;
1473
+ if (nav.browsing()) {
1474
+ nav.toLatest();
1475
+ return;
1476
+ }
1477
+ if (proposal && !busy) {
1478
+ cancelMove(); // all the way back, same as the banner's Cancel
1479
+ return;
1480
+ }
1481
+ if (sel) {
1482
+ sel = null;
1483
+ suggestion = null;
1484
+ render();
1485
+ }
1486
+ });
1487
+ // ← / → / End walk the game, exactly as they do in a chess client
1488
+ bindHistoryKeys(nav, { enabled: () => !busy });
1489
+
1490
+ // The corner buttons: "?" walks you to the What-this-is panel; the bars open
1491
+ // the public tally. If the tally is ever switched off (COUNT_URL emptied), the
1492
+ // page stops pointing at it — it never claims a transparency it does not keep.
1493
+ el("corner-about").addEventListener("click", () => {
1494
+ const about = el("about");
1495
+ about.scrollIntoView({ behavior: "smooth", block: "start" });
1496
+ about.classList.add("lit");
1497
+ setTimeout(() => about.classList.remove("lit"), 1600);
1498
+ });
1499
+ if (analyticsOn()) {
1500
+ el("corner-stats").href = statsUrl();
1501
+ track("pageview");
1502
+ } else {
1503
+ el("corner-stats").remove();
1504
+ const note = el("tally-note");
1505
+ if (note) note.remove();
1506
+ }
1507
+
1508
+ syncCoach();
1509
+ bootEngine();
js/engine.js ADDED
@@ -0,0 +1,788 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Azul rules engine — a line-by-line port of ludometer/azul/engine.py.
2
+ *
3
+ * Official 2-player rules (docs/DESIGN.md). Same action encoding
4
+ * (`action_id = source * 30 + color * 6 + dest`) and, crucially, the same
5
+ * 182-float observation layout, because the ONNX net in ../model/ was trained on
6
+ * exactly those numbers: a single misplaced offset would leave the search
7
+ * evaluating garbage while still looking like it works.
8
+ *
9
+ * The port is proven, not asserted: scripts/dump_fixtures.py plays seeded games
10
+ * with the Python engine and records every state, legal-action list, encoding and
11
+ * outcome; test/engine.test.mjs replays them here and demands an exact match.
12
+ *
13
+ * Chance. The Python engine keeps all randomness in one `random.Random` that is
14
+ * only consumed at a round refill. Here the RNG is an injected object with
15
+ * `shuffle` / `clone`, which is what lets three callers coexist: the page uses a
16
+ * seeded PRNG, MCTS reshuffles a clone's bag to determinize a refill, and the
17
+ * fixture test replays Python's own shuffles so the two engines see identical
18
+ * deals (JS cannot reproduce the Mersenne Twister, and it does not need to —
19
+ * only the rules are under test).
20
+ *
21
+ * Colors are 0..4 = blue, yellow, red, black, teal.
22
+ */
23
+
24
+ export const NUM_COLORS = 5;
25
+ export const TILES_PER_COLOR = 20;
26
+ export const NUM_FACTORIES = 5; // 2-player count
27
+ export const FACTORY_SIZE = 4;
28
+ export const NUM_ROWS = 5;
29
+ export const CENTER = 5; // action `source` value for the center
30
+ export const FLOOR = 5; // action `dest` value for the floor line
31
+ export const ACTION_SPACE = 180; // source (6) * color (5) * dest (6)
32
+ export const ENCODED_SIZE = 182;
33
+
34
+ export const FLOOR_PENALTIES = [-1, -1, -2, -2, -2, -3, -3];
35
+ export const FLOOR_SLOTS = FLOOR_PENALTIES.length;
36
+ export const CUM_PENALTY = (() => {
37
+ const out = [0];
38
+ for (const p of FLOOR_PENALTIES) out.push(out[out.length - 1] + p);
39
+ return out;
40
+ })();
41
+
42
+ export const COLOR_NAMES = ["blue", "yellow", "red", "black", "teal"];
43
+
44
+ export const ROW_BONUS = 2;
45
+ export const COL_BONUS = 7;
46
+ export const COLOR_BONUS = 10;
47
+
48
+ /** Flat wall lookup: WALL_IDX[color * 5 + row] -> index into the 25-cell wall. */
49
+ export const WALL_IDX = (() => {
50
+ const out = new Int32Array(NUM_COLORS * NUM_ROWS);
51
+ for (let c = 0; c < NUM_COLORS; c++) {
52
+ for (let r = 0; r < NUM_ROWS; r++) out[c * 5 + r] = r * 5 + ((c + r) % 5);
53
+ }
54
+ return out;
55
+ })();
56
+
57
+ export function wallCol(color, row) {
58
+ return (color + row) % NUM_COLORS;
59
+ }
60
+
61
+ export function encodeAction(source, color, dest) {
62
+ return source * 30 + color * 6 + dest;
63
+ }
64
+
65
+ export function decodeAction(actionId) {
66
+ const source = Math.floor(actionId / 30);
67
+ const rest = actionId - source * 30;
68
+ const color = Math.floor(rest / 6);
69
+ return [source, color, rest - color * 6];
70
+ }
71
+
72
+ /* ------------------------------------------------------------ lookup tables */
73
+ /* _ACTION_TABLE[source][color][open_mask] -> the ready-made action ids (pattern
74
+ * rows in ascending order, floor last), mirroring the Python hot path so the
75
+ * legal-action *order* matches too, not just the set. */
76
+ const _ALL_ROWS = (1 << NUM_ROWS) - 1;
77
+ const _ACTION_TABLE = [];
78
+ for (let src = 0; src < 6; src++) {
79
+ const perSource = [];
80
+ for (let c = 0; c < NUM_COLORS; c++) {
81
+ const perColor = [];
82
+ for (let mask = 0; mask < 1 << NUM_ROWS; mask++) {
83
+ const ids = [];
84
+ for (let r = 0; r < NUM_ROWS; r++) if ((mask >> r) & 1) ids.push(src * 30 + c * 6 + r);
85
+ ids.push(src * 30 + c * 6 + FLOOR);
86
+ perColor.push(ids);
87
+ }
88
+ perSource.push(perColor);
89
+ }
90
+ _ACTION_TABLE.push(perSource);
91
+ }
92
+ /* _AND_KEEP[color][row]: AND-masks closing `row` for every color but `color`. */
93
+ const _AND_KEEP = [];
94
+ for (let c = 0; c < NUM_COLORS; c++) {
95
+ const perColor = [];
96
+ for (let r = 0; r < NUM_ROWS; r++) {
97
+ const masks = [];
98
+ for (let c2 = 0; c2 < NUM_COLORS; c2++) masks.push(c2 === c ? _ALL_ROWS : _ALL_ROWS ^ (1 << r));
99
+ perColor.push(masks);
100
+ }
101
+ _AND_KEEP.push(perColor);
102
+ }
103
+ /* _AND_CLOSE[row]: closes `row` for every color (the line is full / tiled). */
104
+ const _AND_CLOSE = [];
105
+ for (let r = 0; r < NUM_ROWS; r++) {
106
+ const masks = [];
107
+ for (let c = 0; c < NUM_COLORS; c++) masks.push(_ALL_ROWS ^ (1 << r));
108
+ _AND_CLOSE.push(masks);
109
+ }
110
+
111
+ /* --------------------------------------------------------------------- RNG */
112
+ /**
113
+ * Small seeded PRNG (mulberry32) with the bits the engine needs.
114
+ *
115
+ * It is *not* Python's Mersenne Twister — the two engines deal different tiles
116
+ * from the same seed, which is fine: reproducibility is per-engine, and the
117
+ * fixture test hands the JS side Python's recorded shuffles instead.
118
+ */
119
+ export class Rng {
120
+ constructor(seed = 0) {
121
+ this.seed(seed);
122
+ }
123
+
124
+ seed(n) {
125
+ this.state = (Number(n) >>> 0) || 0x9e3779b9;
126
+ return this;
127
+ }
128
+
129
+ /** uint32 */
130
+ next() {
131
+ this.state = (this.state + 0x6d2b79f5) >>> 0;
132
+ let t = this.state;
133
+ t = Math.imul(t ^ (t >>> 15), t | 1);
134
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
135
+ return (t ^ (t >>> 14)) >>> 0;
136
+ }
137
+
138
+ /** float in [0, 1) */
139
+ random() {
140
+ return this.next() / 4294967296;
141
+ }
142
+
143
+ /** int in [0, n) */
144
+ randrange(n) {
145
+ return this.next() % n;
146
+ }
147
+
148
+ /** Fisher-Yates, in place (same loop shape as random.shuffle). */
149
+ shuffle(arr) {
150
+ for (let i = arr.length - 1; i > 0; i--) {
151
+ const j = this.randrange(i + 1);
152
+ const tmp = arr[i];
153
+ arr[i] = arr[j];
154
+ arr[j] = tmp;
155
+ }
156
+ return arr;
157
+ }
158
+
159
+ clone() {
160
+ const other = new Rng(0);
161
+ other.state = this.state;
162
+ return other;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * An RNG that replays a recorded list of shuffle *results* (fixtures only).
168
+ *
169
+ * `shuffle(bag)` overwrites the bag with the next recorded ordering, so the JS
170
+ * engine deals exactly what Python dealt without owning Python's RNG.
171
+ */
172
+ export class ScriptedRng {
173
+ constructor(shuffles) {
174
+ this.shuffles = shuffles;
175
+ this.index = 0;
176
+ }
177
+
178
+ seed() {
179
+ return this;
180
+ }
181
+
182
+ shuffle(arr) {
183
+ if (this.index >= this.shuffles.length) {
184
+ throw new Error(`ScriptedRng ran out of recorded shuffles (${this.index})`);
185
+ }
186
+ const next = this.shuffles[this.index++];
187
+ if (next.length !== arr.length) {
188
+ throw new Error(`recorded shuffle #${this.index - 1} has ${next.length} tiles, bag has ${arr.length}`);
189
+ }
190
+ for (let i = 0; i < next.length; i++) arr[i] = next[i];
191
+ return arr;
192
+ }
193
+
194
+ clone() {
195
+ const other = new ScriptedRng(this.shuffles);
196
+ other.index = this.index;
197
+ return other;
198
+ }
199
+ }
200
+
201
+ /* ------------------------------------------------------------------- state */
202
+ export class AzulState {
203
+ /** Deal a new 2-player game. `rng` defaults to a fresh seeded `Rng`. */
204
+ static newGame(seed = 0, rng = null) {
205
+ const self = new AzulState();
206
+ self.numPlayers = 2;
207
+ self.rng = rng || new Rng(seed);
208
+ self.bag = [];
209
+ for (let c = 0; c < NUM_COLORS; c++) {
210
+ for (let i = 0; i < TILES_PER_COLOR; i++) self.bag.push(c);
211
+ }
212
+ self.rng.shuffle(self.bag);
213
+ self.lid = [0, 0, 0, 0, 0];
214
+ self.factories = [];
215
+ for (let i = 0; i < NUM_FACTORIES; i++) self.factories.push([0, 0, 0, 0, 0]);
216
+ self.center = [0, 0, 0, 0, 0];
217
+ self.markerInCenter = true;
218
+ self.walls = [new Array(25).fill(0), new Array(25).fill(0)];
219
+ self.plColor = [new Array(NUM_ROWS).fill(-1), new Array(NUM_ROWS).fill(-1)];
220
+ self.plCount = [new Array(NUM_ROWS).fill(0), new Array(NUM_ROWS).fill(0)];
221
+ self.floor = [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0]];
222
+ self.floorMarker = [false, false];
223
+ self.openMask = [new Array(NUM_COLORS).fill(_ALL_ROWS), new Array(NUM_COLORS).fill(_ALL_ROWS)];
224
+ self.scores = [0, 0];
225
+ self.currentPlayer = 0;
226
+ self.firstPlayer = 0;
227
+ self.roundIndex = 0;
228
+ self.isTerminal = false;
229
+ self.exhausted = false;
230
+ self.tilesLeft = 0;
231
+ self._refill();
232
+ return self;
233
+ }
234
+
235
+ /**
236
+ * Build a state from an explicit field dump (fixtures and tests only).
237
+ *
238
+ * The Python engine lets tests hand-edit `factories` / `center` / `pl_*` /
239
+ * `walls` and then call `recount()`; this is the same door, so a fixture can
240
+ * put the engine in a position random play would essentially never reach
241
+ * (an all-monochrome round end, a bag that runs dry, a wall one tile short).
242
+ */
243
+ static fromSetup(setup, rng = null) {
244
+ const self = new AzulState();
245
+ self.numPlayers = 2;
246
+ self.rng = rng || new Rng(0);
247
+ self.bag = setup.bag.slice();
248
+ self.lid = setup.lid.slice();
249
+ self.factories = setup.factories.map((f) => f.slice());
250
+ self.center = setup.center.slice();
251
+ self.markerInCenter = setup.marker_in_center;
252
+ self.walls = setup.walls.map((w) => w.slice());
253
+ self.plColor = setup.pl_color.map((x) => x.slice());
254
+ self.plCount = setup.pl_count.map((x) => x.slice());
255
+ self.floor = setup.floor.map((f) => f.slice());
256
+ self.floorMarker = setup.floor_marker.slice();
257
+ self.openMask = [new Array(NUM_COLORS).fill(_ALL_ROWS), new Array(NUM_COLORS).fill(_ALL_ROWS)];
258
+ self.scores = setup.scores.slice();
259
+ self.currentPlayer = setup.current_player;
260
+ self.firstPlayer = setup.first_player;
261
+ self.roundIndex = setup.round_index;
262
+ self.isTerminal = Boolean(setup.is_terminal);
263
+ self.exhausted = Boolean(setup.exhausted);
264
+ self.tilesLeft = 0;
265
+ self.recount();
266
+ return self;
267
+ }
268
+
269
+ /** The inverse of `fromSetup`: a structured-clone-safe field dump. */
270
+ toSetup() {
271
+ return {
272
+ bag: this.bag.slice(),
273
+ lid: this.lid.slice(),
274
+ factories: this.factories.map((f) => f.slice()),
275
+ center: this.center.slice(),
276
+ marker_in_center: this.markerInCenter,
277
+ walls: this.walls.map((w) => w.slice()),
278
+ pl_color: this.plColor.map((x) => x.slice()),
279
+ pl_count: this.plCount.map((x) => x.slice()),
280
+ floor: this.floor.map((f) => f.slice()),
281
+ floor_marker: this.floorMarker.slice(),
282
+ scores: this.scores.slice(),
283
+ current_player: this.currentPlayer,
284
+ first_player: this.firstPlayer,
285
+ round_index: this.roundIndex,
286
+ is_terminal: this.isTerminal,
287
+ exhausted: this.exhausted,
288
+ };
289
+ }
290
+
291
+ /** Rebuild the derived caches (`tilesLeft` and the placement masks). */
292
+ recount() {
293
+ let total = this.center[0] + this.center[1] + this.center[2] + this.center[3] + this.center[4];
294
+ for (const f of this.factories) total += f[0] + f[1] + f[2] + f[3] + f[4];
295
+ this.tilesLeft = total;
296
+ for (let p = 0; p < this.numPlayers; p++) this._rebuildMask(p);
297
+ }
298
+
299
+ /** Deep-enough copy: every mutable container is duplicated. */
300
+ clone() {
301
+ const other = new AzulState();
302
+ other.numPlayers = this.numPlayers;
303
+ other.rng = this.rng.clone();
304
+ other.bag = this.bag.slice();
305
+ other.lid = this.lid.slice();
306
+ other.factories = this.factories.map((f) => f.slice());
307
+ other.center = this.center.slice();
308
+ other.markerInCenter = this.markerInCenter;
309
+ other.walls = this.walls.map((w) => w.slice());
310
+ other.plColor = this.plColor.map((x) => x.slice());
311
+ other.plCount = this.plCount.map((x) => x.slice());
312
+ other.floor = this.floor.map((f) => f.slice());
313
+ other.floorMarker = this.floorMarker.slice();
314
+ other.openMask = this.openMask.map((m) => m.slice());
315
+ other.scores = this.scores.slice();
316
+ other.currentPlayer = this.currentPlayer;
317
+ other.firstPlayer = this.firstPlayer;
318
+ other.roundIndex = this.roundIndex;
319
+ other.isTerminal = this.isTerminal;
320
+ other.exhausted = this.exhausted;
321
+ other.tilesLeft = this.tilesLeft;
322
+ return other;
323
+ }
324
+
325
+ _rebuildMask(player) {
326
+ const wall = this.walls[player];
327
+ const plc = this.plColor[player];
328
+ const pln = this.plCount[player];
329
+ const masks = this.openMask[player];
330
+ for (let c = 0; c < NUM_COLORS; c++) {
331
+ const base = c * 5;
332
+ let m = 0;
333
+ for (let r = 0; r < NUM_ROWS; r++) {
334
+ const n = pln[r];
335
+ if (n <= r && (n === 0 || plc[r] === c) && !wall[WALL_IDX[base + r]]) m |= 1 << r;
336
+ }
337
+ masks[c] = m;
338
+ }
339
+ }
340
+
341
+ /* ---------------------------------------------------------- legal moves */
342
+ legalActions() {
343
+ if (this.isTerminal) return [];
344
+ const masks = this.openMask[this.currentPlayer];
345
+ const out = [];
346
+ for (let src = 0; src < NUM_FACTORIES; src++) {
347
+ const pool = this.factories[src];
348
+ const table = _ACTION_TABLE[src];
349
+ for (let c = 0; c < NUM_COLORS; c++) {
350
+ if (pool[c]) {
351
+ const ids = table[c][masks[c]];
352
+ for (let i = 0; i < ids.length; i++) out.push(ids[i]);
353
+ }
354
+ }
355
+ }
356
+ const pool = this.center;
357
+ const table = _ACTION_TABLE[CENTER];
358
+ for (let c = 0; c < NUM_COLORS; c++) {
359
+ if (pool[c]) {
360
+ const ids = table[c][masks[c]];
361
+ for (let i = 0; i < ids.length; i++) out.push(ids[i]);
362
+ }
363
+ }
364
+ return out;
365
+ }
366
+
367
+ isLegal(actionId) {
368
+ if (this.isTerminal || !(actionId >= 0 && actionId < ACTION_SPACE)) return false;
369
+ const [src, color, dest] = decodeAction(actionId);
370
+ const pool = src === CENTER ? this.center : this.factories[src];
371
+ if (pool[color] === 0) return false;
372
+ if (dest === FLOOR) return true;
373
+ const p = this.currentPlayer;
374
+ const n = this.plCount[p][dest];
375
+ if (n > dest) return false;
376
+ if (n && this.plColor[p][dest] !== color) return false;
377
+ return !this.walls[p][WALL_IDX[color * 5 + dest]];
378
+ }
379
+
380
+ /* ---------------------------------------------------------------- moves */
381
+ /** Play `actionId`, then resolve round end / refill / game end as needed. */
382
+ apply(actionId) {
383
+ if (this.isTerminal) throw new Error("game is over");
384
+ if (!(actionId >= 0 && actionId < ACTION_SPACE)) throw new Error(`action ${actionId} out of range`);
385
+ const src = Math.floor(actionId / 30);
386
+ const rest = actionId - src * 30;
387
+ const color = Math.floor(rest / 6);
388
+ const dest = rest - color * 6;
389
+
390
+ const p = this.currentPlayer;
391
+ const pool = src === CENTER ? this.center : this.factories[src];
392
+ const count = pool[color];
393
+ if (count === 0) throw new Error(`no color ${color} at source ${src}`);
394
+
395
+ if (dest !== FLOOR) {
396
+ const held = this.plCount[p][dest];
397
+ if (held > dest) throw new Error(`pattern line ${dest} is full`);
398
+ if (held && this.plColor[p][dest] !== color) throw new Error(`pattern line ${dest} holds another color`);
399
+ if (this.walls[p][WALL_IDX[color * 5 + dest]]) throw new Error(`color ${color} already on wall row ${dest}`);
400
+ }
401
+
402
+ // --- take the tiles
403
+ pool[color] = 0;
404
+ if (src === CENTER) {
405
+ if (this.markerInCenter) {
406
+ this.markerInCenter = false;
407
+ this.floorMarker[p] = true;
408
+ }
409
+ } else {
410
+ const cen = this.center;
411
+ for (let c = 0; c < NUM_COLORS; c++) {
412
+ const n = pool[c];
413
+ if (n) {
414
+ cen[c] += n;
415
+ pool[c] = 0;
416
+ }
417
+ }
418
+ }
419
+ this.tilesLeft -= count;
420
+
421
+ // --- place them
422
+ let overflow;
423
+ if (dest !== FLOOR) {
424
+ const pln = this.plCount[p];
425
+ const room = dest + 1 - pln[dest];
426
+ this.plColor[p][dest] = color;
427
+ let keep;
428
+ if (count < room) {
429
+ pln[dest] += count;
430
+ overflow = 0;
431
+ keep = _AND_KEEP[color][dest];
432
+ } else {
433
+ pln[dest] = dest + 1;
434
+ overflow = count - room;
435
+ keep = _AND_CLOSE[dest];
436
+ }
437
+ const masks = this.openMask[p];
438
+ masks[0] &= keep[0];
439
+ masks[1] &= keep[1];
440
+ masks[2] &= keep[2];
441
+ masks[3] &= keep[3];
442
+ masks[4] &= keep[4];
443
+ } else {
444
+ overflow = count;
445
+ }
446
+
447
+ if (overflow) {
448
+ const fl = this.floor[p];
449
+ let occupied = fl[0] + fl[1] + fl[2] + fl[3] + fl[4];
450
+ if (this.floorMarker[p]) occupied += 1;
451
+ const room = FLOOR_SLOTS - occupied;
452
+ if (overflow <= room) {
453
+ fl[color] += overflow;
454
+ } else if (room > 0) {
455
+ fl[color] += room;
456
+ this.lid[color] += overflow - room;
457
+ } else {
458
+ this.lid[color] += overflow;
459
+ }
460
+ }
461
+
462
+ // --- round / game transitions
463
+ if (this.tilesLeft) this.currentPlayer = 1 - p;
464
+ else this._endRound(p);
465
+ }
466
+
467
+ /* --------------------------------------------------------- round logic */
468
+ _endRound(lastMover) {
469
+ const lid = this.lid;
470
+ for (let q = 0; q < this.numPlayers; q++) {
471
+ const wall = this.walls[q];
472
+ const plc = this.plColor[q];
473
+ const pln = this.plCount[q];
474
+ let gain = 0;
475
+ for (let r = 0; r < NUM_ROWS; r++) {
476
+ if (pln[r] !== r + 1) continue;
477
+ const c = plc[r];
478
+ const idx = WALL_IDX[c * 5 + r];
479
+ wall[idx] = 1;
480
+ const rowBase = r * 5;
481
+ const col = idx - rowBase;
482
+ let h = 1;
483
+ for (let i = col - 1; i >= 0 && wall[rowBase + i]; i--) h += 1;
484
+ for (let i = col + 1; i < 5 && wall[rowBase + i]; i++) h += 1;
485
+ let v = 1;
486
+ for (let i = r - 1; i >= 0 && wall[i * 5 + col]; i--) v += 1;
487
+ for (let i = r + 1; i < 5 && wall[i * 5 + col]; i++) v += 1;
488
+ if (h > 1 || v > 1) gain += (h > 1 ? h : 0) + (v > 1 ? v : 0);
489
+ else gain += 1;
490
+ lid[c] += r; // the r leftover tiles of the line
491
+ plc[r] = -1;
492
+ pln[r] = 0;
493
+ }
494
+
495
+ const fl = this.floor[q];
496
+ let occupied = fl[0] + fl[1] + fl[2] + fl[3] + fl[4];
497
+ if (this.floorMarker[q]) occupied += 1;
498
+ gain += CUM_PENALTY[Math.min(FLOOR_SLOTS, occupied)];
499
+ for (let c = 0; c < NUM_COLORS; c++) {
500
+ const n = fl[c];
501
+ if (n) {
502
+ lid[c] += n;
503
+ fl[c] = 0;
504
+ }
505
+ }
506
+ const total = this.scores[q] + gain;
507
+ this.scores[q] = Math.max(0, total);
508
+ this._rebuildMask(q);
509
+ }
510
+
511
+ // who starts next round: the marker holder (marker goes back to the center)
512
+ let holder = null;
513
+ for (let q = 0; q < this.numPlayers; q++) {
514
+ if (this.floorMarker[q]) {
515
+ this.floorMarker[q] = false;
516
+ holder = q;
517
+ }
518
+ }
519
+ if (holder === null) holder = 1 - lastMover;
520
+ this.firstPlayer = holder;
521
+ this.markerInCenter = true;
522
+ this.currentPlayer = holder;
523
+
524
+ if (this._anyRowComplete()) {
525
+ this._finish();
526
+ return;
527
+ }
528
+
529
+ this.roundIndex += 1;
530
+ this._refill();
531
+ if (this.tilesLeft === 0) {
532
+ // No tiles anywhere: cannot deal another round, stop the game.
533
+ this.exhausted = true;
534
+ this._finish();
535
+ }
536
+ }
537
+
538
+ _anyRowComplete() {
539
+ for (const wall of this.walls) {
540
+ for (const base of [0, 5, 10, 15, 20]) {
541
+ if (wall[base] && wall[base + 1] && wall[base + 2] && wall[base + 3] && wall[base + 4]) return true;
542
+ }
543
+ }
544
+ return false;
545
+ }
546
+
547
+ _finish() {
548
+ for (let q = 0; q < this.numPlayers; q++) {
549
+ this.scores[q] +=
550
+ ROW_BONUS * this.completedRows(q) + COL_BONUS * this.completedCols(q) + COLOR_BONUS * this.completedColors(q);
551
+ }
552
+ this.isTerminal = true;
553
+ }
554
+
555
+ _refill() {
556
+ const bag = this.bag;
557
+ const lid = this.lid;
558
+ let total = 0;
559
+ for (const f of this.factories) {
560
+ for (let k = 0; k < FACTORY_SIZE; k++) {
561
+ if (!bag.length) {
562
+ for (let c = 0; c < NUM_COLORS; c++) {
563
+ const n = lid[c];
564
+ if (n) {
565
+ for (let i = 0; i < n; i++) bag.push(c);
566
+ lid[c] = 0;
567
+ }
568
+ }
569
+ if (!bag.length) {
570
+ this.tilesLeft = total;
571
+ return;
572
+ }
573
+ this.rng.shuffle(bag);
574
+ }
575
+ f[bag.pop()] += 1;
576
+ total += 1;
577
+ }
578
+ }
579
+ this.tilesLeft = total;
580
+ }
581
+
582
+ /* ---------------------------------------------------------- inspection */
583
+ floorOccupied(player) {
584
+ const fl = this.floor[player];
585
+ return fl[0] + fl[1] + fl[2] + fl[3] + fl[4] + (this.floorMarker[player] ? 1 : 0);
586
+ }
587
+
588
+ floorPenalty(player) {
589
+ return CUM_PENALTY[Math.min(FLOOR_SLOTS, this.floorOccupied(player))];
590
+ }
591
+
592
+ completedRows(player) {
593
+ const wall = this.walls[player];
594
+ let n = 0;
595
+ for (const base of [0, 5, 10, 15, 20]) {
596
+ if (wall[base] && wall[base + 1] && wall[base + 2] && wall[base + 3] && wall[base + 4]) n += 1;
597
+ }
598
+ return n;
599
+ }
600
+
601
+ completedCols(player) {
602
+ const wall = this.walls[player];
603
+ let n = 0;
604
+ for (let col = 0; col < 5; col++) {
605
+ if (wall[col] && wall[col + 5] && wall[col + 10] && wall[col + 15] && wall[col + 20]) n += 1;
606
+ }
607
+ return n;
608
+ }
609
+
610
+ completedColors(player) {
611
+ const wall = this.walls[player];
612
+ let done = 0;
613
+ for (let c = 0; c < NUM_COLORS; c++) {
614
+ const base = c * 5;
615
+ let all = true;
616
+ for (let r = 0; r < NUM_ROWS; r++) {
617
+ if (!wall[WALL_IDX[base + r]]) {
618
+ all = false;
619
+ break;
620
+ }
621
+ }
622
+ if (all) done += 1;
623
+ }
624
+ return done;
625
+ }
626
+
627
+ /** +1 if player 0 wins, -1 if player 1 wins, 0 for a draw, null if unfinished. */
628
+ outcome() {
629
+ if (!this.isTerminal) return null;
630
+ const s0 = this.scores[0];
631
+ const s1 = this.scores[1];
632
+ if (s0 !== s1) return s0 > s1 ? 1.0 : -1.0;
633
+ const r0 = this.completedRows(0);
634
+ const r1 = this.completedRows(1);
635
+ if (r0 !== r1) return r0 > r1 ? 1.0 : -1.0;
636
+ return 0.0;
637
+ }
638
+
639
+ bagCounts() {
640
+ const counts = [0, 0, 0, 0, 0];
641
+ for (const c of this.bag) counts[c] += 1;
642
+ return counts;
643
+ }
644
+
645
+ /* ------------------------------------------------------------ encoding */
646
+ /**
647
+ * Fixed-size float32 observation from the current player's perspective.
648
+ *
649
+ * The offsets below are the OFF_* constants of ludometer/azul/engine.py and
650
+ * must never drift from them — the exported net reads this vector verbatim.
651
+ *
652
+ * [ 0: 25) my wall, row-major 5x5 [126: 151) factories /4
653
+ * [ 25: 50) their wall [151: 156) factory non-empty
654
+ * [ 50: 80) my pattern lines (one-hot, fill) [156: 161) center counts /10
655
+ * [ 80: 110) their pattern lines [161: 162) center total /20
656
+ * [110: 117) my floor (counts /7, slots, mark) [162: 163) marker in center
657
+ * [117: 124) their floor [163: 168) bag counts /20
658
+ * [124: 126) scores /100 [168: 173) lid counts /20
659
+ * [173: 174) tiles left /20
660
+ * [174: 175) I start next round
661
+ * [175: 176) round /10
662
+ * [176: 179) my rows/cols/colors /5
663
+ * [179: 182) theirs
664
+ */
665
+ encode(out = null) {
666
+ const v = out || new Float32Array(ENCODED_SIZE);
667
+ if (out) v.fill(0);
668
+ const me = this.currentPlayer;
669
+ const op = 1 - me;
670
+
671
+ const myWall = this.walls[me];
672
+ const opWall = this.walls[op];
673
+ for (let i = 0; i < 25; i++) {
674
+ v[i] = myWall[i];
675
+ v[25 + i] = opWall[i];
676
+ }
677
+
678
+ for (const [off, p] of [[50, me], [80, op]]) {
679
+ const plc = this.plColor[p];
680
+ const pln = this.plCount[p];
681
+ for (let r = 0; r < NUM_ROWS; r++) {
682
+ const n = pln[r];
683
+ if (n) {
684
+ const base = off + r * 6;
685
+ v[base + plc[r]] = 1.0;
686
+ v[base + 5] = n / (r + 1);
687
+ }
688
+ }
689
+ }
690
+
691
+ for (const [off, p] of [[110, me], [117, op]]) {
692
+ const fl = this.floor[p];
693
+ for (let c = 0; c < NUM_COLORS; c++) {
694
+ if (fl[c]) v[off + c] = fl[c] / FLOOR_SLOTS;
695
+ }
696
+ v[off + 5] = Math.min(this.floorOccupied(p), FLOOR_SLOTS) / FLOOR_SLOTS;
697
+ v[off + 6] = this.floorMarker[p] ? 1.0 : 0.0;
698
+ }
699
+
700
+ v[124] = this.scores[me] / 100.0;
701
+ v[125] = this.scores[op] / 100.0;
702
+
703
+ for (let i = 0; i < NUM_FACTORIES; i++) {
704
+ const f = this.factories[i];
705
+ const base = 126 + i * 5;
706
+ let total = 0;
707
+ for (let c = 0; c < NUM_COLORS; c++) {
708
+ const n = f[c];
709
+ if (n) {
710
+ v[base + c] = n / FACTORY_SIZE;
711
+ total += n;
712
+ }
713
+ }
714
+ if (total) v[151 + i] = 1.0;
715
+ }
716
+
717
+ let cenTotal = 0;
718
+ for (let c = 0; c < NUM_COLORS; c++) {
719
+ const n = this.center[c];
720
+ if (n) {
721
+ v[156 + c] = n / 10.0;
722
+ cenTotal += n;
723
+ }
724
+ }
725
+ v[161] = cenTotal / 20.0;
726
+ v[162] = this.markerInCenter ? 1.0 : 0.0;
727
+
728
+ const bag = this.bagCounts();
729
+ for (let c = 0; c < NUM_COLORS; c++) {
730
+ v[163 + c] = bag[c] / TILES_PER_COLOR;
731
+ v[168 + c] = this.lid[c] / TILES_PER_COLOR;
732
+ }
733
+
734
+ v[173] = this.tilesLeft / 20.0;
735
+ v[174] = this.floorMarker[me] || this.firstPlayer === me ? 1.0 : 0.0;
736
+ v[175] = Math.min(this.roundIndex, 10) / 10.0;
737
+
738
+ for (const [off, p] of [[176, me], [179, op]]) {
739
+ v[off] = this.completedRows(p) / 5.0;
740
+ v[off + 1] = this.completedCols(p) / 5.0;
741
+ v[off + 2] = this.completedColors(p) / 5.0;
742
+ }
743
+ return v;
744
+ }
745
+
746
+ /* -------------------------------------------------------------- display */
747
+ /** Full state as plain data — the shape the old server's /api/state sent. */
748
+ toJSON() {
749
+ const players = [];
750
+ for (let p = 0; p < this.numPlayers; p++) {
751
+ const wall = [];
752
+ for (let r = 0; r < NUM_ROWS; r++) wall.push(this.walls[p].slice(r * 5, r * 5 + 5));
753
+ const patternLines = [];
754
+ for (let r = 0; r < NUM_ROWS; r++) {
755
+ patternLines.push({ capacity: r + 1, color: this.plColor[p][r], count: this.plCount[p][r] });
756
+ }
757
+ players.push({
758
+ score: this.scores[p],
759
+ wall,
760
+ pattern_lines: patternLines,
761
+ floor: this.floor[p].slice(),
762
+ floor_marker: this.floorMarker[p],
763
+ floor_penalty: this.floorPenalty(p),
764
+ completed_rows: this.completedRows(p),
765
+ completed_cols: this.completedCols(p),
766
+ completed_colors: this.completedColors(p),
767
+ });
768
+ }
769
+ return {
770
+ round: this.roundIndex,
771
+ current_player: this.currentPlayer,
772
+ first_player: this.firstPlayer,
773
+ factories: this.factories.map((f) => f.slice()),
774
+ center: this.center.slice(),
775
+ marker_in_center: this.markerInCenter,
776
+ bag: this.bagCounts(),
777
+ lid: this.lid.slice(),
778
+ tiles_left: this.tilesLeft,
779
+ scores: this.scores.slice(),
780
+ is_terminal: this.isTerminal,
781
+ exhausted: this.exhausted,
782
+ outcome: this.outcome(),
783
+ legal_actions: this.legalActions(),
784
+ color_names: COLOR_NAMES.slice(),
785
+ players,
786
+ };
787
+ }
788
+ }
js/game.js ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* One human-vs-AI game, held in the page — a port of ludometer/gui/session.py.
2
+ *
3
+ * The Python GUI kept this object on a Flask server and shipped snapshots over
4
+ * /api/state. Here it lives in the tab: same state, same log, same round-end
5
+ * reports, no network. The only real difference is that the AI's move is awaited
6
+ * from a Web Worker instead of an HTTP request, which is why `aiReply` takes
7
+ * callbacks rather than returning everything at once.
8
+ *
9
+ * Turn flow: the human plays one action, then the AI replies. Players alternate
10
+ * *within* a round, but a round boundary is resolved inside `apply`, and the next
11
+ * round is started by whoever holds the first-player marker — which can be the AI
12
+ * twice in a row. Hence the AI reply is a loop, not a single move.
13
+ */
14
+
15
+ import { AzulState, Rng, ACTION_SPACE } from "./engine.js";
16
+ import { describeAction, finalReport, roundReport } from "./report.js";
17
+
18
+ const MAX_AI_REPLIES = 12; // safety net; 2 is already unusual
19
+
20
+ export class IllegalMove extends Error {}
21
+
22
+ export class GameSession {
23
+ /**
24
+ * @param {object} opts
25
+ * @param {number} opts.seed
26
+ * @param {boolean} opts.humanPlaysFirst
27
+ * @param {string} opts.agentName
28
+ * @param {object} opts.opponentInfo {checkpoint, elo, run, ...} for the blurb
29
+ * @param {number} opts.thinkTimeS
30
+ * @param {(state) => Promise<number>} opts.think asks the AI for one action
31
+ */
32
+ constructor(opts) {
33
+ this.seed = opts.seed >>> 0;
34
+ this.agentName = opts.agentName || "the net";
35
+ this.opponentInfo = opts.opponentInfo || {};
36
+ this.thinkTimeS = Number(opts.thinkTimeS) || 0;
37
+ this.think = opts.think;
38
+ this.humanPlaysFirst = opts.humanPlaysFirst !== false;
39
+ this.humanSeat = this.humanPlaysFirst ? 0 : 1;
40
+ this.aiSeat = 1 - this.humanSeat;
41
+ this.state = AzulState.newGame(this.seed, new Rng(this.seed));
42
+ this.ply = 0;
43
+ this.log = [];
44
+ this.roundReports = [];
45
+ this.lastAiMoves = [];
46
+ /* Every round's deal, written down as it happens. This page shuffles with
47
+ * mulberry32 and Python shuffles with the Mersenne Twister, so a seed alone
48
+ * does not carry a game across; the deals do. See js/record.js. */
49
+ this.deals = [];
50
+ this._recordDeal();
51
+ this._logEntry(
52
+ "start",
53
+ `New game: you are player ${this.humanSeat + 1}, the AI (${this.agentName}) is player ${this.aiSeat + 1}. ` +
54
+ `${this.humanPlaysFirst ? "You" : "The AI"} start${this.humanPlaysFirst ? "" : "s"}.`
55
+ );
56
+ if (this.opponentBlurb) this._logEntry("start", this.opponentBlurb);
57
+ }
58
+
59
+ /* -------------------------------------------------------------- helpers */
60
+ _logEntry(kind, text, extra = {}) {
61
+ const entry = { n: this.log.length, kind, text, ...extra };
62
+ this.log.push(entry);
63
+ return entry;
64
+ }
65
+
66
+ /** One line naming the checkpoint behind the opponent. */
67
+ get opponentBlurb() {
68
+ const info = this.opponentInfo;
69
+ if (!info || !info.checkpoint) return "";
70
+ const elo = typeof info.elo === "number" ? `, rated ${info.elo >= 0 ? "+" : ""}${Math.round(info.elo)} on our internal ladder` : "";
71
+ const thinking = this.thinkTimeS ? ` It thinks for ${this.thinkTimeS}s per move, in this tab.` : " It replies from the policy head, with no search.";
72
+ return `You're facing ${info.checkpoint}${elo}.${thinking}`;
73
+ }
74
+
75
+ /** Snapshot the tiles this round was dealt, with the bag and lid behind it. */
76
+ _recordDeal() {
77
+ const state = this.state;
78
+ if (state.isTerminal) return;
79
+ if (this.deals.some((d) => d.round === state.roundIndex)) return;
80
+ this.deals.push({
81
+ round: state.roundIndex,
82
+ factories: state.factories.map((f) => f.slice()),
83
+ // per colour, not the shuffled tile list: a converter checks conservation
84
+ bag: state.bagCounts(),
85
+ lid: state.lid.slice(),
86
+ });
87
+ }
88
+
89
+ sideOf(player) {
90
+ return player === this.humanSeat ? "human" : "ai";
91
+ }
92
+
93
+ labelOf(player) {
94
+ return player === this.humanSeat ? "You" : "AI";
95
+ }
96
+
97
+ get humanTurn() {
98
+ return !this.state.isTerminal && this.state.currentPlayer === this.humanSeat;
99
+ }
100
+
101
+ get aiTurn() {
102
+ return !this.state.isTerminal && this.state.currentPlayer === this.aiSeat;
103
+ }
104
+
105
+ legalForHuman() {
106
+ return this.humanTurn ? this.state.legalActions() : [];
107
+ }
108
+
109
+ /* ---------------------------------------------------------------- moves */
110
+ _apply(actionId) {
111
+ const state = this.state;
112
+ const player = state.currentPlayer;
113
+ if (!state.isLegal(actionId)) throw new IllegalMove(`action ${actionId} is not legal right now`);
114
+ const move = describeAction(state, actionId);
115
+ move.side = this.sideOf(player);
116
+ move.label = this.labelOf(player);
117
+ const before = state.clone();
118
+ const roundBefore = state.roundIndex;
119
+ // The position this move was played from — the page animates from it. When
120
+ // the AI moves twice across a round boundary, its second move starts from a
121
+ // refilled table that `apply` produced inside the first and that nothing
122
+ // ever drew; this is how both moves get animated. It doubles as the move
123
+ // history the ← / → navigator replays.
124
+ move.state_before = before.toJSON();
125
+ state.apply(actionId);
126
+ this.ply += 1;
127
+ move.ply = this.ply;
128
+ const entry = this._logEntry("move", `${move.label} ${move.text}`, {
129
+ side: move.side,
130
+ player,
131
+ action_id: actionId,
132
+ ply: this.ply,
133
+ // what the log draws as little tiles instead of colour words
134
+ color: move.color,
135
+ count: move.count,
136
+ source: move.source,
137
+ dest: move.dest,
138
+ overflow: move.overflow,
139
+ took_marker: move.took_marker,
140
+ });
141
+ move.log_n = entry.n;
142
+
143
+ move.ended_round = state.roundIndex !== roundBefore || state.isTerminal;
144
+ if (move.ended_round) {
145
+ const report = roundReport(before, move);
146
+ report.game_over = state.isTerminal;
147
+ report.next_first_player = state.firstPlayer;
148
+ report.scores_after = state.scores.slice();
149
+ report.labels = [this.labelOf(0), this.labelOf(1)];
150
+ report.sides = [this.sideOf(0), this.sideOf(1)];
151
+ this.roundReports.push(report);
152
+ const you = report.players[this.humanSeat];
153
+ const ai = report.players[this.aiSeat];
154
+ const sign = (n) => (n >= 0 ? `+${n}` : String(n));
155
+ this._logEntry(
156
+ "round",
157
+ `End of round ${report.round + 1}: you ${sign(you.delta)} → ${you.score_after}, AI ${sign(ai.delta)} → ${ai.score_after}.`,
158
+ // every entry carries its ply, so the move navigator can show the log as
159
+ // it stood at any point in the game
160
+ { round: report.round, report_n: this.roundReports.length - 1, ply: this.ply }
161
+ );
162
+ // a fresh round was dealt inside `apply`; write it down before it is played
163
+ this._recordDeal();
164
+ if (state.isTerminal) {
165
+ const final = finalReport(state, this.humanSeat) || {};
166
+ this._logEntry(
167
+ "end",
168
+ `${final.headline || "Game over."} Final score ${state.scores[this.humanSeat]}–${state.scores[this.aiSeat]}.`,
169
+ { ply: this.ply }
170
+ );
171
+ }
172
+ }
173
+ return move;
174
+ }
175
+
176
+ /** One AI move, with what the search cost attached. */
177
+ async _aiMove(onThinking) {
178
+ const { action, search } = await this.think(this.state, onThinking);
179
+ const move = this._apply(action);
180
+ if (search && search.sims) {
181
+ move.search = search;
182
+ move.search_text = `searched ${search.sims.toLocaleString()} positions in ${(search.elapsedS || 0).toFixed(1)}s`;
183
+ // sims and the root value ride on the log entry, not just on `move`:
184
+ // `lastAiMoves` only holds the current batch, and the game record needs
185
+ // every move's numbers at the end of the game (see js/record.js)
186
+ this._logEntry("think", `AI ${move.search_text}.`, {
187
+ ply: move.ply,
188
+ sims: search.sims,
189
+ ...(Number.isFinite(search.value) ? { value: search.value } : {}),
190
+ });
191
+ }
192
+ return move;
193
+ }
194
+
195
+ /** Let the AI move until it is the human's turn again (or the game ends). */
196
+ async aiReplies(onThinking) {
197
+ const moves = [];
198
+ for (let i = 0; i < MAX_AI_REPLIES; i++) {
199
+ if (!this.aiTurn) break;
200
+ moves.push(await this._aiMove(onThinking));
201
+ }
202
+ this.lastAiMoves = moves;
203
+ return moves;
204
+ }
205
+
206
+ /** Apply the human's action. The caller then asks for `aiReplies`. */
207
+ playHuman(actionId) {
208
+ if (this.state.isTerminal) throw new IllegalMove("the game is over, start a new one");
209
+ if (this.state.currentPlayer !== this.humanSeat) throw new IllegalMove("it is not your turn");
210
+ if (!Number.isInteger(actionId) || actionId < 0 || actionId >= ACTION_SPACE) {
211
+ throw new IllegalMove(`action id must be an integer in 0..179, got ${actionId}`);
212
+ }
213
+ const first = this.roundReports.length;
214
+ const move = this._apply(actionId);
215
+ return { move, reports: this.roundReports.slice(first) };
216
+ }
217
+
218
+ /** Reports created since index `first` — the overlays still to be shown. */
219
+ reportsSince(first) {
220
+ return this.roundReports.slice(first);
221
+ }
222
+
223
+ /* ------------------------------------------------------------- snapshot */
224
+ snapshot() {
225
+ return {
226
+ state: this.state.toJSON(),
227
+ seed: this.seed,
228
+ agent_name: this.agentName,
229
+ opponent_info: this.opponentInfo,
230
+ opponent_blurb: this.opponentBlurb || null,
231
+ human_seat: this.humanSeat,
232
+ ai_seat: this.aiSeat,
233
+ your_turn: this.humanTurn,
234
+ ai_pending: this.aiTurn,
235
+ think_time_s: this.thinkTimeS,
236
+ human_legal_actions: this.legalForHuman(),
237
+ ply: this.ply,
238
+ last_ai_move: this.lastAiMoves.length ? this.lastAiMoves[this.lastAiMoves.length - 1] : null,
239
+ log: this.log,
240
+ final: finalReport(this.state, this.humanSeat),
241
+ };
242
+ }
243
+ }
js/mcts.js ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* PUCT MCTS over cloned Azul states — a port of ludometer/train/mcts.py.
2
+ *
3
+ * Same search as the trained agent plays with, down to the constants: edge stats
4
+ * `(N, W, P)` live in the parent, a simulation descends by maximising
5
+ * `Q + c_puct * P * sqrt(Nparent + 1) / (1 + N)`, expands one leaf, evaluates it
6
+ * with the net and backs the value up.
7
+ *
8
+ * Perspectives. Azul does not strictly alternate — the holder of the first-player
9
+ * marker starts the next round, so the same player can move twice in a row across
10
+ * a round boundary. Values are therefore propagated in *player 0's* frame (`v0`)
11
+ * and converted per node from `node.player`; nothing here assumes alternation.
12
+ *
13
+ * Chance nodes. Inside a round Azul is deterministic; the only chance event is the
14
+ * refill triggered by the move that takes the last tiles. Those edges are handled
15
+ * exactly as Python does — by re-sampled determinizations: clone the parent,
16
+ * reseed and **reshuffle the clone's bag**, apply the move, and file the result in
17
+ * a per-edge table keyed by the post-refill factory/center contents. Reshuffling
18
+ * is what stops the search peeking at the pre-shuffled deal (bag *counts* are
19
+ * public, the order is not). Past `chanceChildren` distinct outcomes a traversal
20
+ * reuses a stored one at random, so the subtree stays deep while the edge's Q
21
+ * remains an average over sampled refills.
22
+ *
23
+ * The one real difference from Python: evaluation is asynchronous, because
24
+ * onnxruntime-web's `run` is. Each simulation therefore awaits at most one
25
+ * inference (batch of 1), and the loop yields to the event loop periodically so a
26
+ * worker can still answer messages. Dirichlet noise is not ported — it only ever
27
+ * applies to self-play training, never to a game against a human.
28
+ *
29
+ * Batching (`config.batch > 1`). A profile of a 5 s think said 92 % of it is
30
+ * spent inside one `session.run` per simulation — the search itself, cloning and
31
+ * legal-move generation included, is 2 %. The only way to buy more positions is
32
+ * therefore to put more of them in each forward pass, which is what `_collect`
33
+ * does: descend `batch` times, each descent laying down a **virtual loss** on the
34
+ * edges it walks so the next one is pushed elsewhere, evaluate the whole leaf set
35
+ * in one dispatch, then back the real values up and take the virtual loss off
36
+ * again. Because the virtual loss is added and removed in the same units it is
37
+ * exact bookkeeping: a finished batch leaves the tree in the state the same
38
+ * sequence of leaf evaluations would have left it in sequentially. `batch = 1`
39
+ * takes the original code path, untouched.
40
+ *
41
+ * The margin head (`config.margin`). If the loaded net has a third output the
42
+ * search also averages it up the tree, in player 0's frame like the value, and
43
+ * the *root* move is then chosen lexicographically: among the moves whose win-Q
44
+ * is within `marginEpsilon` of the best, play the one with the largest expected
45
+ * score gap. A two-output net has no margin at all and keeps the old rule
46
+ * (most-visited child), bit for bit.
47
+ */
48
+
49
+ import { ACTION_SPACE, CENTER, Rng } from "./engine.js";
50
+
51
+ /** Matches configs/run2.json, which is what the exported checkpoint trained with. */
52
+ export const DEFAULT_CONFIG = {
53
+ // An upper bound only: with a time budget the clock is meant to bind first, so
54
+ // this sits well above what the fastest machine reaches in the longest budget
55
+ // (~1k sims/s per second of budget in the opening, several times that once the
56
+ // tree starts hitting terminal positions).
57
+ sims: 200000,
58
+ cPuct: 1.4,
59
+ chanceChildren: 4,
60
+ fpu: 0.0, // Q assumed for an unvisited edge, in the parent's frame
61
+ /* Leaves gathered per forward pass. 1 is the original one-at-a-time search;
62
+ * the worker raises it to what the active backend actually likes (measured:
63
+ * 16 for WASM, 64 for WebGPU). This is a *ceiling* — see `batchRamp`. */
64
+ batch: 1,
65
+ /* The batch may not exceed the tree divided by this.
66
+ *
67
+ * Measured the hard way. A flat batch of 64 from the first simulation loses
68
+ * 3–17 to the same search at batch 1 over the same number of simulations:
69
+ * with nothing in the tree yet, virtual loss pushes all 64 descents down 64
70
+ * different early branches, and a 800-simulation search never recovers from
71
+ * spending its first eighth that way. The damage is entirely a function of
72
+ * batch ÷ tree, so the batch starts small and grows with the tree, and by the
73
+ * time it is at its ceiling the tree is 16× bigger than it. */
74
+ batchRamp: 16,
75
+ /* …but never below this, because on a GPU a batch of one is not a smaller
76
+ * step, it is a wasted 4 ms dispatch. Raised with `batch` by the worker. */
77
+ minBatch: 1,
78
+ /* Discouragement applied to an edge already on a pending descent, in the same
79
+ * [-1, 1] units as the value. 1.0 = "assume it loses" is the usual choice and
80
+ * is what keeps a batch from collecting the same leaf `batch` times. */
81
+ virtualLoss: 1.0,
82
+ /* Root moves whose win-Q is within this of the best are considered equally
83
+ * good, and the margin head breaks the tie. Ignored without a margin head. */
84
+ marginEpsilon: 0.03,
85
+ /* A child must have at least this share of the most-visited child's visits to
86
+ * be a candidate for that tie-break — a one-visit edge can carry a Q of +1 by
87
+ * luck, and that must not be allowed to define "the best". */
88
+ marginMinVisitShare: 0.1,
89
+ };
90
+
91
+ /* Two arg-max players can loop forever in a game where no pattern line is ever
92
+ * completed (see mcts.py STALL_ROUNDS): past this many rounds the caller
93
+ * re-introduces sampling, and MAX_GAME_MOVES is the hard backstop. */
94
+ export const STALL_ROUNDS = 16;
95
+ export const MAX_GAME_MOVES = 400;
96
+
97
+ /** How often the search hands the event loop back (keeps a worker responsive). */
98
+ const YIELD_EVERY = 64;
99
+
100
+ const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
101
+ const yieldToLoop = () => new Promise((r) => setTimeout(r, 0));
102
+
103
+ class Node {
104
+ constructor(state) {
105
+ this.state = state;
106
+ this.player = state.currentPlayer;
107
+ this.legal = state.legalActions();
108
+ this.priors = null;
109
+ this.visits = null;
110
+ this.wins = null;
111
+ this.margins = null; // parallel to `wins`, only allocated with a margin head
112
+ this.children = null; // Node | Map<string, Node> | null, per edge
113
+ this.expanded = false;
114
+ this.pending = false; // already queued for evaluation in the current batch
115
+ this.nVisits = 0;
116
+ this.terminalV0 = state.isTerminal ? state.outcome() || 0 : 0;
117
+ // The margin of a finished game is the real score gap, in player 0's frame,
118
+ // on the same scale the head is trained on (points).
119
+ this.terminalMargin0 = state.isTerminal ? state.scores[0] - state.scores[1] : 0;
120
+ }
121
+
122
+ get isTerminal() {
123
+ return this.state.isTerminal;
124
+ }
125
+
126
+ initEdges(priors, withMargin = false) {
127
+ const n = this.legal.length;
128
+ this.priors = priors;
129
+ this.visits = new Int32Array(n);
130
+ this.wins = new Float64Array(n);
131
+ if (withMargin) this.margins = new Float64Array(n);
132
+ this.children = new Array(n).fill(null);
133
+ this.expanded = true;
134
+ }
135
+ }
136
+
137
+ export class MCTS {
138
+ /**
139
+ * @param {{evaluate(state, legal): Promise<{priors, value}>}} evaluator
140
+ * @param {object} config overrides for DEFAULT_CONFIG
141
+ * @param {import("./engine.js").Rng} rng the search's own RNG
142
+ */
143
+ constructor(evaluator, config = {}, rng = null) {
144
+ this.evaluator = evaluator;
145
+ this.config = { ...DEFAULT_CONFIG, ...config };
146
+ this.rng = rng || new Rng(1);
147
+ this.counter = 0;
148
+ this.nodesCreated = 0;
149
+ this.evals = 0;
150
+ this.batches = 0;
151
+ // A margin head is a property of the loaded net, so it is read off the
152
+ // evaluator rather than configured — `config.margin` can still force it off.
153
+ this.hasMargin =
154
+ config.margin === false ? false : !!(evaluator && evaluator.hasMargin);
155
+ }
156
+
157
+ /**
158
+ * Run simulations from `state` (never mutated) and return the visit policy.
159
+ *
160
+ * @param {AzulState} state
161
+ * @param {object} opts
162
+ * @param {number} opts.timeLimitS wall-clock budget; 0/undefined means "run `sims`"
163
+ * @param {function} opts.onProgress called with {sims, elapsedS} every so often
164
+ * @param {function} opts.shouldStop polled; return true to abandon the search
165
+ * @returns {Promise<{policy, value, visits, sims, elapsedS, best}>}
166
+ */
167
+ async search(state, opts = {}) {
168
+ const { timeLimitS = 0, onProgress = null, shouldStop = null } = opts;
169
+ const root = this._newNode(state.clone());
170
+ // kept so a caller can read the root's edge statistics back out afterwards —
171
+ // that table is all coach mode is (see rootChildren)
172
+ this._root = root;
173
+ if (root.isTerminal) throw new Error("cannot search a terminal state");
174
+ const started = nowMs();
175
+
176
+ const value = await this._expand(root);
177
+ if (root.legal.length === 1) {
178
+ const policy = new Float32Array(ACTION_SPACE);
179
+ policy[root.legal[0]] = 1;
180
+ return {
181
+ policy,
182
+ value,
183
+ visits: new Map([[root.legal[0], 1]]),
184
+ sims: 0,
185
+ elapsedS: 0,
186
+ best: root.legal[0],
187
+ forced: true,
188
+ };
189
+ }
190
+
191
+ const cap = this.config.sims;
192
+ const batch = Math.max(1, this.config.batch | 0);
193
+ const minBatch = Math.max(1, Math.min(batch, this.config.minBatch | 0));
194
+ const ramp = Math.max(1, this.config.batchRamp | 0);
195
+ const deadline = timeLimitS > 0 ? started + timeLimitS * 1000 : Infinity;
196
+ let done = 0;
197
+ let sinceYield = 0;
198
+ while (done < cap) {
199
+ // As big as the tree can afford, never bigger than the backend wants.
200
+ const want = Math.max(minBatch, Math.min(batch, Math.floor(root.nVisits / ramp)));
201
+ const step =
202
+ want === 1
203
+ ? ((await this._simulate(root)), 1)
204
+ : await this._simulateBatch(root, Math.min(want, cap - done));
205
+ // A batch that collected nothing (everything below the root is pending or
206
+ // terminal) would spin; stop rather than burn the clock.
207
+ if (step <= 0) break;
208
+ done += step;
209
+ sinceYield += step;
210
+ if (sinceYield >= YIELD_EVERY) {
211
+ sinceYield = 0;
212
+ if (onProgress) onProgress({ sims: done, elapsedS: (nowMs() - started) / 1000 });
213
+ await yieldToLoop();
214
+ if (shouldStop && shouldStop()) break;
215
+ }
216
+ if (nowMs() >= deadline) break;
217
+ }
218
+ const elapsedS = (nowMs() - started) / 1000;
219
+
220
+ const total = root.nVisits;
221
+ const policy = new Float32Array(ACTION_SPACE);
222
+ const visits = new Map();
223
+ let bestN = -1;
224
+ let winsSum = 0;
225
+ for (let i = 0; i < root.legal.length; i++) {
226
+ const action = root.legal[i];
227
+ const n = root.visits[i];
228
+ visits.set(action, n);
229
+ winsSum += root.wins[i];
230
+ if (n && total) policy[action] = n / total;
231
+ if (n > bestN) bestN = n;
232
+ }
233
+ return {
234
+ policy,
235
+ value: total ? winsSum / total : value,
236
+ visits,
237
+ sims: total,
238
+ elapsedS,
239
+ best: this._bestRootAction(root),
240
+ forced: false,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * The move to play from the finished tree.
246
+ *
247
+ * Without a margin head this is the rule the agent has always used and the one
248
+ * the Python trainer uses: the most-visited root child. With one, visits still
249
+ * decide which moves are *candidates* — a move the search barely looked at is
250
+ * not evidence of anything — but among the candidates whose win-Q is within
251
+ * `marginEpsilon` of the best, the largest expected score gap wins. That is
252
+ * what turns "wins by one point" into "wins by twenty" without ever trading a
253
+ * win away for points.
254
+ */
255
+ _bestRootAction(root) {
256
+ let bestN = -1;
257
+ let bestI = 0;
258
+ for (let i = 0; i < root.legal.length; i++) {
259
+ if (root.visits[i] > bestN) {
260
+ bestN = root.visits[i];
261
+ bestI = i;
262
+ }
263
+ }
264
+ if (!this.hasMargin || !root.margins || bestN <= 0) return root.legal[bestI];
265
+
266
+ const floor = Math.max(1, bestN * this.config.marginMinVisitShare);
267
+ let bestQ = -Infinity;
268
+ for (let i = 0; i < root.legal.length; i++) {
269
+ const n = root.visits[i];
270
+ if (n >= floor) bestQ = Math.max(bestQ, root.wins[i] / n);
271
+ }
272
+ if (!Number.isFinite(bestQ)) return root.legal[bestI];
273
+
274
+ let pickI = bestI;
275
+ let pickMargin = -Infinity;
276
+ let pickVisits = -1;
277
+ for (let i = 0; i < root.legal.length; i++) {
278
+ const n = root.visits[i];
279
+ if (n < floor) continue;
280
+ if (root.wins[i] / n < bestQ - this.config.marginEpsilon) continue;
281
+ const margin = root.margins[i] / n;
282
+ // ties on margin fall back to the visit count, so the rule stays a
283
+ // refinement of the old one rather than a different search
284
+ if (margin > pickMargin || (margin === pickMargin && n > pickVisits)) {
285
+ pickMargin = margin;
286
+ pickVisits = n;
287
+ pickI = i;
288
+ }
289
+ }
290
+ return root.legal[pickI];
291
+ }
292
+
293
+ /**
294
+ * The last search's root edges — `{action, visits, q, prior}` per legal move.
295
+ *
296
+ * A port of `RootStatsMCTS.root_children()` in ludometer/gui/coach.py, and read
297
+ * for exactly the same reason: `q` is the search's own value estimate for that
298
+ * move, in the root player's frame. An edge the search never visited has no `q`
299
+ * at all and reports `null` — callers must not invent one.
300
+ */
301
+ rootChildren() {
302
+ const root = this._root;
303
+ if (!root || !root.expanded) return [];
304
+ const out = [];
305
+ for (let i = 0; i < root.legal.length; i++) {
306
+ const visits = root.visits[i];
307
+ out.push({
308
+ action: root.legal[i],
309
+ visits,
310
+ q: visits ? root.wins[i] / visits : null,
311
+ // expected score gap, in the root player's frame; null without a margin head
312
+ margin: visits && root.margins ? root.margins[i] / visits : null,
313
+ prior: root.priors ? root.priors[i] : 0,
314
+ });
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /* --------------------------------------------------------------- internals */
320
+ _newNode(state) {
321
+ this.nodesCreated += 1;
322
+ return new Node(state);
323
+ }
324
+
325
+ async _expand(node) {
326
+ const { priors, value, margin } = await this.evaluator.evaluate(node.state, node.legal);
327
+ this.evals += 1;
328
+ node.initEdges(priors, this.hasMargin);
329
+ node.evalMargin = this.hasMargin && margin != null ? margin : 0;
330
+ return value;
331
+ }
332
+
333
+ _select(node) {
334
+ const { cPuct, fpu } = this.config;
335
+ const priors = node.priors;
336
+ const visits = node.visits;
337
+ const wins = node.wins;
338
+ const scale = cPuct * Math.sqrt(node.nVisits + 1);
339
+ let best = -1e30;
340
+ let bestI = 0;
341
+ for (let i = 0; i < visits.length; i++) {
342
+ const n = visits[i];
343
+ const q = n ? wins[i] / n : fpu;
344
+ const score = q + (scale * priors[i]) / (1 + n);
345
+ if (score > best) {
346
+ best = score;
347
+ bestI = i;
348
+ }
349
+ }
350
+ return bestI;
351
+ }
352
+
353
+ /** True iff `action` empties the board and therefore triggers a refill. */
354
+ _isStochastic(state, action) {
355
+ const source = Math.floor(action / 30);
356
+ const color = Math.floor((action - source * 30) / 6);
357
+ const pool = source === CENTER ? state.center : state.factories[source];
358
+ return pool[color] === state.tilesLeft;
359
+ }
360
+
361
+ /** Clone with a fresh bag order, then apply `action` — one refill draw. */
362
+ _determinize(state, action) {
363
+ const child = state.clone();
364
+ this.counter += 1;
365
+ child.rng.seed((Math.imul(this.counter, 2654435761) ^ 0x9e3779b9) >>> 0);
366
+ child.rng.shuffle(child.bag);
367
+ child.apply(action);
368
+ return child;
369
+ }
370
+
371
+ /** Identity of a post-refill position: factory + center contents. */
372
+ static _chanceKey(state) {
373
+ const parts = [];
374
+ for (const f of state.factories) parts.push(f.join(""));
375
+ parts.push(state.center.join(""));
376
+ return parts.join("|");
377
+ }
378
+
379
+ _child(node, index) {
380
+ const entry = node.children[index];
381
+ if (entry instanceof Node) return entry;
382
+ const action = node.legal[index];
383
+ if (entry === null && !this._isStochastic(node.state, action)) {
384
+ const next = node.state.clone();
385
+ next.apply(action);
386
+ const child = this._newNode(next);
387
+ node.children[index] = child;
388
+ return child;
389
+ }
390
+ const table = entry === null ? new Map() : entry;
391
+ node.children[index] = table;
392
+ if (table.size >= this.config.chanceChildren) {
393
+ const keys = Array.from(table.keys());
394
+ return table.get(keys[this.rng.randrange(keys.length)]);
395
+ }
396
+ const state = this._determinize(node.state, action);
397
+ const key = MCTS._chanceKey(state);
398
+ let child = table.get(key);
399
+ if (child === undefined) {
400
+ child = this._newNode(state);
401
+ table.set(key, child);
402
+ }
403
+ return child;
404
+ }
405
+
406
+ async _simulate(root) {
407
+ let node = root;
408
+ const path = [];
409
+ let v0;
410
+ let m0 = 0;
411
+ for (;;) {
412
+ if (node.isTerminal) {
413
+ v0 = node.terminalV0;
414
+ m0 = node.terminalMargin0;
415
+ break;
416
+ }
417
+ if (!node.expanded) {
418
+ const value = await this._expand(node);
419
+ v0 = node.player === 0 ? value : -value;
420
+ m0 = node.player === 0 ? node.evalMargin : -node.evalMargin;
421
+ break;
422
+ }
423
+ const index = this._select(node);
424
+ path.push([node, index]);
425
+ node = this._child(node, index);
426
+ }
427
+ for (const [parent, index] of path) {
428
+ parent.visits[index] += 1;
429
+ parent.nVisits += 1;
430
+ const sign = parent.player === 0 ? 1 : -1;
431
+ parent.wins[index] += sign * v0;
432
+ if (parent.margins) parent.margins[index] += sign * m0;
433
+ }
434
+ }
435
+
436
+ /* ------------------------------------------------------------- batched search */
437
+
438
+ /**
439
+ * One descent, laying virtual loss as it goes.
440
+ *
441
+ * Ends either at a terminal node — backed up immediately, since no net is
442
+ * needed — or at an unexpanded one, which is filed in `queue` for the coming
443
+ * forward pass. A leaf that two descents both reach keeps one queue entry and
444
+ * two paths: both are real simulations and both get the same value back.
445
+ */
446
+ _collect(root, queue, byNode) {
447
+ const vl = this.config.virtualLoss;
448
+ let node = root;
449
+ const path = [];
450
+ for (;;) {
451
+ if (node.isTerminal) {
452
+ this._backup(path, node.terminalV0, node.terminalMargin0);
453
+ return;
454
+ }
455
+ if (!node.expanded) {
456
+ let entry = byNode.get(node);
457
+ if (!entry) {
458
+ entry = { node, paths: [] };
459
+ byNode.set(node, entry);
460
+ queue.push(entry);
461
+ node.pending = true;
462
+ }
463
+ entry.paths.push(path);
464
+ return;
465
+ }
466
+ const index = this._select(node);
467
+ // The visit is taken now and the loss assumed now; `_backup` puts the
468
+ // assumed loss back and adds the real value, so the finished tree is the
469
+ // one a sequential search would have built.
470
+ node.visits[index] += 1;
471
+ node.nVisits += 1;
472
+ node.wins[index] -= vl;
473
+ path.push([node, index]);
474
+ node = this._child(node, index);
475
+ }
476
+ }
477
+
478
+ /** Undo the virtual loss along `path` and credit the real result. */
479
+ _backup(path, v0, m0) {
480
+ const vl = this.config.virtualLoss;
481
+ for (let k = 0; k < path.length; k++) {
482
+ const parent = path[k][0];
483
+ const index = path[k][1];
484
+ const sign = parent.player === 0 ? 1 : -1;
485
+ parent.wins[index] += vl + sign * v0;
486
+ if (parent.margins) parent.margins[index] += sign * m0;
487
+ }
488
+ }
489
+
490
+ /** Gather up to `want` leaves, evaluate them in one pass, back them all up. */
491
+ async _simulateBatch(root, want) {
492
+ const queue = [];
493
+ const byNode = new Map();
494
+ for (let i = 0; i < want; i++) this._collect(root, queue, byNode);
495
+ if (!queue.length) return want; // every descent hit a terminal node
496
+
497
+ const results = await this.evaluator.evaluateBatch(
498
+ queue.map((e) => e.node.state),
499
+ queue.map((e) => e.node.legal)
500
+ );
501
+ this.evals += queue.length;
502
+ this.batches += 1;
503
+ for (let i = 0; i < queue.length; i++) {
504
+ const { node, paths } = queue[i];
505
+ const { priors, value, margin } = results[i];
506
+ node.initEdges(priors, this.hasMargin);
507
+ node.evalMargin = this.hasMargin && margin != null ? margin : 0;
508
+ node.pending = false;
509
+ const sign = node.player === 0 ? 1 : -1;
510
+ const v0 = sign * value;
511
+ const m0 = sign * node.evalMargin;
512
+ for (let p = 0; p < paths.length; p++) this._backup(paths[p], v0, m0);
513
+ }
514
+ return want;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Pick an action from a visit distribution.
520
+ *
521
+ * `temperature <= 0` is arg-max; otherwise the distribution is raised to
522
+ * `1 / temperature`, renormalised and sampled — the stall-breaker the Python
523
+ * agent uses once a game has dragged past STALL_ROUNDS rounds.
524
+ */
525
+ export function selectAction(policy, temperature = 0, rng = null) {
526
+ if (temperature <= 0) {
527
+ let best = 0;
528
+ let bestP = -Infinity;
529
+ for (let i = 0; i < policy.length; i++) {
530
+ if (policy[i] > bestP) {
531
+ bestP = policy[i];
532
+ best = i;
533
+ }
534
+ }
535
+ return best;
536
+ }
537
+ const probs = new Float64Array(policy.length);
538
+ let total = 0;
539
+ for (let i = 0; i < policy.length; i++) {
540
+ const p = policy[i] > 0 ? Math.pow(policy[i], 1 / temperature) : 0;
541
+ probs[i] = p;
542
+ total += p;
543
+ }
544
+ if (!(total > 0) || !Number.isFinite(total)) return selectAction(policy, 0);
545
+ const draw = (rng ? rng.random() : Math.random()) * total;
546
+ let acc = 0;
547
+ for (let i = 0; i < probs.length; i++) {
548
+ acc += probs[i];
549
+ if (draw <= acc) return i;
550
+ }
551
+ return selectAction(policy, 0);
552
+ }
553
+
554
+ /** The playing agent: search, then play the arg-max visit count. */
555
+ export class MCTSAgent {
556
+ constructor(evaluator, config = {}, rng = null) {
557
+ this.mcts = new MCTS(evaluator, config, rng);
558
+ this.rng = this.mcts.rng;
559
+ this.lastSearch = {};
560
+ }
561
+
562
+ /** @returns {Promise<number>} the chosen action id */
563
+ async act(state, opts = {}) {
564
+ const legal = state.legalActions();
565
+ if (!legal.length) throw new Error("no legal actions (terminal state?)");
566
+ if (legal.length === 1) {
567
+ this.lastSearch = { sims: 0, elapsedS: 0, forced: true };
568
+ return legal[0];
569
+ }
570
+ const result = await this.mcts.search(state, opts);
571
+ this.lastSearch = {
572
+ sims: result.sims,
573
+ elapsedS: result.elapsedS,
574
+ value: result.value,
575
+ forced: result.forced,
576
+ nodes: this.mcts.nodesCreated,
577
+ };
578
+ // a pathologically long game gets randomised so that it terminates
579
+ const temperature = state.roundIndex >= STALL_ROUNDS ? 1 : 0;
580
+ if (temperature > 0) return selectAction(result.policy, temperature, this.rng);
581
+ return result.best;
582
+ }
583
+ }
js/net.js ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The policy+value net, as seen from JavaScript.
2
+ *
3
+ * A port of ludometer/train/net.py's `NetEvaluator`: encode the position, run the
4
+ * exported ONNX graph, softmax the logits **over the legal actions only** (the
5
+ * same thing as masking the rest to -inf, and cheaper), and hand back priors
6
+ * aligned with the caller's `legal` list plus a value in [-1, 1] for the player
7
+ * to move.
8
+ *
9
+ * The graph is ../model/model.onnx, exported by ludometer/export/onnx_export.py
10
+ * and checked against torch on 100 real positions before it is written, so the
11
+ * only thing that can go wrong on this side is the input vector — which is what
12
+ * the engine fixtures pin down.
13
+ *
14
+ * onnxruntime-web is passed in rather than imported, so this module stays usable
15
+ * from a worker, from node, and from a test with a stub evaluator.
16
+ *
17
+ * Two things beyond a single forward pass live here, both measured rather than
18
+ * assumed (see NOTES_FOR_REMI.md):
19
+ *
20
+ * - **Batching.** One position at a time is the worst case for every backend:
21
+ * on this laptop the WASM runtime does 2.1 k positions/s at batch 1 and
22
+ * 4.2 k at batch 32, and the WebGPU runtime does 250/s at batch 1 (a GPU
23
+ * dispatch costs ~4 ms whatever is in it) but 16 k/s at batch 64. So
24
+ * `evaluateBatch` is the primitive and `evaluate` is the batch-of-one
25
+ * special case; the search feeds it through virtual loss.
26
+ *
27
+ * - **The margin head.** Older exports have two outputs (`policy`, `value`);
28
+ * a later one may add `margin`, a prediction of the score gap. Which
29
+ * outputs exist is read off the session, never assumed, so the same player
30
+ * code runs both.
31
+ */
32
+
33
+ import { ENCODED_SIZE } from "./engine.js";
34
+
35
+ /** The name of the optional third output: a signed score-gap prediction. */
36
+ export const MARGIN_OUTPUT = "margin";
37
+
38
+ export class OnnxEvaluator {
39
+ constructor(session, ort, backend = "wasm") {
40
+ this.session = session;
41
+ this.ort = ort;
42
+ this.backend = backend;
43
+ this.buffer = new Float32Array(ENCODED_SIZE);
44
+ this.batchBuffer = null; // grown on demand by evaluateBatch
45
+ this.calls = 0;
46
+ this.positions = 0;
47
+ // Feature-detected, never assumed: a run3-era graph has policy+value, a
48
+ // later one may also have margin. `outputNames` is what the runtime loaded.
49
+ const names = session.outputNames || [];
50
+ this.hasMargin = names.indexOf(MARGIN_OUTPUT) !== -1;
51
+ this.outputNames = Array.from(names);
52
+ }
53
+
54
+ /**
55
+ * @param {object} ort the onnxruntime-web module namespace
56
+ * @param {string|ArrayBuffer|Uint8Array} model URL or raw bytes of model.onnx
57
+ * @param {object} opts {executionProviders, backend}
58
+ */
59
+ static async create(ort, model, opts = {}) {
60
+ const backend = opts.backend || "wasm";
61
+ const session = await ort.InferenceSession.create(model, {
62
+ executionProviders: opts.executionProviders || [backend],
63
+ graphOptimizationLevel: "all",
64
+ });
65
+ return new OnnxEvaluator(session, ort, backend);
66
+ }
67
+
68
+ /** `(state, legal) -> {priors, value, margin}`; priors are aligned with `legal`. */
69
+ async evaluate(state, legal) {
70
+ state.encode(this.buffer);
71
+ const input = new this.ort.Tensor("float32", this.buffer, [1, ENCODED_SIZE]);
72
+ const out = await this.session.run({ obs: input });
73
+ this.calls += 1;
74
+ this.positions += 1;
75
+ return this._read(out, 0, legal);
76
+ }
77
+
78
+ /**
79
+ * Evaluate `n` positions in one graph run.
80
+ *
81
+ * `states[i]` is scored against `legals[i]`; the result array is aligned with
82
+ * both. This is the primitive the batched search calls — one dispatch for the
83
+ * whole leaf set instead of one per leaf.
84
+ */
85
+ async evaluateBatch(states, legals) {
86
+ const n = states.length;
87
+ if (n === 0) return [];
88
+ if (n === 1) return [await this.evaluate(states[0], legals[0])];
89
+ if (!this.batchBuffer || this.batchBuffer.length < n * ENCODED_SIZE) {
90
+ this.batchBuffer = new Float32Array(n * ENCODED_SIZE);
91
+ }
92
+ // A fresh view per run: ort keeps a reference to the tensor's data, so the
93
+ // sub-arrays must cover exactly the rows this run submits.
94
+ const data = this.batchBuffer.subarray(0, n * ENCODED_SIZE);
95
+ for (let i = 0; i < n; i++) {
96
+ states[i].encode(data.subarray(i * ENCODED_SIZE, (i + 1) * ENCODED_SIZE));
97
+ }
98
+ const input = new this.ort.Tensor("float32", data, [n, ENCODED_SIZE]);
99
+ const out = await this.session.run({ obs: input });
100
+ this.calls += 1;
101
+ this.positions += n;
102
+ const results = new Array(n);
103
+ for (let i = 0; i < n; i++) results[i] = this._read(out, i, legals[i]);
104
+ return results;
105
+ }
106
+
107
+ /** Row `row` of a (possibly batched) output, softmaxed over `legal`. */
108
+ _read(out, row, legal) {
109
+ const value = out.value.data[row];
110
+ const margin = this.hasMargin ? out[MARGIN_OUTPUT].data[row] : null;
111
+ const n = legal.length;
112
+ if (n === 0) return { priors: new Float32Array(0), value, margin };
113
+
114
+ const logits = out.policy.data;
115
+ // policy is [batch, ACTION_SPACE]; the last dim is the row stride.
116
+ const dims = out.policy.dims;
117
+ const stride = dims && dims.length ? dims[dims.length - 1] : logits.length;
118
+ const base = row * stride;
119
+ const priors = new Float32Array(n);
120
+ let max = -Infinity;
121
+ for (let i = 0; i < n; i++) {
122
+ const v = logits[base + legal[i]];
123
+ priors[i] = v;
124
+ if (v > max) max = v;
125
+ }
126
+ let sum = 0;
127
+ for (let i = 0; i < n; i++) {
128
+ const e = Math.exp(priors[i] - max);
129
+ priors[i] = e;
130
+ sum += e;
131
+ }
132
+ for (let i = 0; i < n; i++) priors[i] /= sum;
133
+ return { priors, value, margin };
134
+ }
135
+ }
136
+
137
+ /** Uniform priors, zero value — lets the search run with no net at all (tests). */
138
+ export class UniformEvaluator {
139
+ constructor() {
140
+ this.calls = 0;
141
+ this.positions = 0;
142
+ this.hasMargin = false;
143
+ this.backend = "none";
144
+ }
145
+
146
+ async evaluate(_state, legal) {
147
+ this.calls += 1;
148
+ this.positions += 1;
149
+ const n = legal.length;
150
+ return { priors: new Float32Array(n).fill(n ? 1 / n : 0), value: 0, margin: null };
151
+ }
152
+
153
+ async evaluateBatch(states, legals) {
154
+ this.calls += 1;
155
+ this.positions += states.length;
156
+ return legals.map((legal) => ({
157
+ priors: new Float32Array(legal.length).fill(legal.length ? 1 / legal.length : 0),
158
+ value: 0,
159
+ margin: null,
160
+ }));
161
+ }
162
+ }
163
+
164
+ /* ------------------------------------------------------------------- backends */
165
+
166
+ /**
167
+ * What the two vendored onnxruntime-web builds are, and when each one is used.
168
+ *
169
+ * The default build is the pure-WASM one the player has always shipped. The
170
+ * `jspi` build adds onnxruntime's native **WebGPU** execution provider; it is
171
+ * only downloaded when the browser can actually use it, so a browser without
172
+ * WebGPU pays nothing for its existence.
173
+ *
174
+ * Two things have to be true for the WebGPU build to load at all:
175
+ * - `navigator.gpu` — the browser has WebGPU;
176
+ * - `WebAssembly.Suspending` — the browser has JSPI (stack switching), which
177
+ * is how onnxruntime's WebGPU EP suspends the WASM call while the GPU
178
+ * works. The alternative build (asyncify) needs no JSPI but is 2.5 MB
179
+ * larger over the wire, which is not worth it for a fallback path.
180
+ *
181
+ * Anything else — Safari today, Firefox today, an old Chrome, a machine with no
182
+ * GPU adapter — takes the WASM path, which is exactly what shipped before.
183
+ */
184
+ export const BACKENDS = {
185
+ wasm: {
186
+ module: "../vendor/onnxruntime-web/ort.wasm.bundle.min.mjs",
187
+ wasm: "../vendor/onnxruntime-web/ort-wasm-simd-threaded.wasm",
188
+ ep: "wasm",
189
+ },
190
+ webgpu: {
191
+ module: "../vendor/onnxruntime-web/ort.jspi.bundle.min.mjs",
192
+ wasm: "../vendor/onnxruntime-web/ort-wasm-simd-threaded.jspi.wasm",
193
+ ep: "webgpu",
194
+ },
195
+ };
196
+
197
+ /**
198
+ * Is the WebGPU path worth trying in this environment?
199
+ *
200
+ * Deliberately cheap and synchronous — `requestAdapter()` is the real test and
201
+ * it happens when the session is created, where a failure falls back anyway.
202
+ */
203
+ export function webgpuLikely(scope = globalThis) {
204
+ return !!(
205
+ scope.navigator &&
206
+ scope.navigator.gpu &&
207
+ typeof scope.WebAssembly !== "undefined" &&
208
+ typeof scope.WebAssembly.Suspending === "function"
209
+ );
210
+ }
js/record.js ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* A finished game, written down: the smallest thing that replays exactly.
2
+ *
3
+ * The point of this file is data collection, so the rule it follows is *record
4
+ * only what cannot be recomputed*. Search targets, position encodings, coach
5
+ * verdicts and value estimates can all be regenerated offline by replaying the
6
+ * game at any depth, so none of them are stored. Three things cannot:
7
+ *
8
+ * 1. **the chance events.** The bag is shuffled by this page's own RNG
9
+ * (mulberry32, see engine.js), and Python's Mersenne Twister cannot
10
+ * reproduce it from the same seed. So the deal of every round is written
11
+ * down explicitly: the five factories, plus the bag and lid counts, which
12
+ * is what a converter needs to check that no tile appeared or vanished.
13
+ * The seed is kept as well, because *this* engine does reproduce a game
14
+ * from it, which is how the tests verify a record end to end.
15
+ * 2. **the moves**, as action ids. The encoding is `source*30 + color*6 +
16
+ * dest` in this engine and in the Python one, character for character, and
17
+ * the engine parity test holds them to identical ordered legal actions and
18
+ * identical 182-float encodings. So an integer here means exactly the same
19
+ * move there.
20
+ * 3. **the circumstances**: which seat the human held, which net answered,
21
+ * how long it was allowed to think, and how many positions it actually
22
+ * visited per move (that last one depends on the visitor's machine, so it
23
+ * is not recoverable either).
24
+ *
25
+ * Nothing here sends anything anywhere: this module only builds the value.
26
+ * Sending is js/upload.js's job, governed by the sharing switch in Settings
27
+ * (on by default) and described honestly in the About panel; the Save and
28
+ * Copy buttons hand the very same record to the player, so what is shared
29
+ * and what a player can read are one and the same thing.
30
+ */
31
+
32
+ export const FORMAT = "faience-game/1";
33
+
34
+ /**
35
+ * Build the record of `session` as it stands.
36
+ *
37
+ * `extra` carries what the session does not know about itself:
38
+ * `{net, backend}` — the exported model's metadata and the runtime that ran it.
39
+ * Safe to call at any point in a game; `final.finished` says whether the
40
+ * position is terminal.
41
+ */
42
+ export function buildRecord(session, extra = {}) {
43
+ if (!session) return null;
44
+ const state = session.state;
45
+ const net = extra.net || {};
46
+ const info = session.opponentInfo || {};
47
+
48
+ const moves = session.log
49
+ .filter((e) => e.kind === "move")
50
+ .map((e) => {
51
+ const move = { ply: e.ply, player: e.player, action: e.action_id };
52
+ const think = session.log.find((t) => t.kind === "think" && t.ply === e.ply);
53
+ if (think) {
54
+ if (Number.isFinite(think.sims) && think.sims > 0) move.sims = think.sims;
55
+ // the net's own read of the position it moved from, on its [-1, 1]
56
+ // scale: the cheapest signal for "the AI did not see this coming"
57
+ if (Number.isFinite(think.value)) move.value = round4(think.value);
58
+ }
59
+ return move;
60
+ });
61
+
62
+ return {
63
+ format: FORMAT,
64
+ created_at: new Date().toISOString(),
65
+ seed: session.seed,
66
+ human_seat: session.humanSeat,
67
+ human_first: !!session.humanPlaysFirst,
68
+ net: {
69
+ run: net.run || info.run || null,
70
+ checkpoint: net.checkpoint || info.checkpoint || null,
71
+ elo: typeof net.elo === "number" ? net.elo : typeof info.elo === "number" ? info.elo : null,
72
+ params: net.num_params || null,
73
+ backend: extra.backend || null,
74
+ },
75
+ think_time_s: session.thinkTimeS,
76
+ moves,
77
+ deals: (session.deals || []).map((d) => ({
78
+ round: d.round,
79
+ factories: d.factories.map((f) => f.slice()),
80
+ bag: d.bag.slice(),
81
+ lid: d.lid.slice(),
82
+ })),
83
+ final: {
84
+ finished: !!state.isTerminal,
85
+ scores: state.scores.slice(),
86
+ outcome: outcomeOf(session),
87
+ rounds: state.roundIndex + 1,
88
+ exhausted: !!state.exhausted,
89
+ },
90
+ };
91
+ }
92
+
93
+ function outcomeOf(session) {
94
+ const state = session.state;
95
+ if (!state.isTerminal) return null;
96
+ const mine = state.scores[session.humanSeat];
97
+ const theirs = state.scores[session.aiSeat];
98
+ if (mine > theirs) return "human";
99
+ if (theirs > mine) return "ai";
100
+ return "draw";
101
+ }
102
+
103
+ function round4(v) {
104
+ return Math.round(v * 1e4) / 1e4;
105
+ }
106
+
107
+ /** `faience-run4-ckpt-037888-seed31337-ai.json` — sortable, self-describing. */
108
+ export function recordFilename(record) {
109
+ const bits = ["faience"];
110
+ if (record.net.run) bits.push(record.net.run);
111
+ if (record.net.checkpoint) bits.push(record.net.checkpoint);
112
+ bits.push("seed" + record.seed);
113
+ if (record.final.outcome) bits.push(record.final.outcome);
114
+ return bits.join("-").replace(/[^A-Za-z0-9._-]/g, "") + ".json";
115
+ }
116
+
117
+ /** Indented for a file, so a human can read what they are about to send. */
118
+ export const asFile = (record) => JSON.stringify(record, null, 1);
119
+
120
+ /** One line, for pasting into an issue or a chat. */
121
+ export const asLine = (record) => JSON.stringify(record);
122
+
123
+ /**
124
+ * Hand `record` to the player as a download. Returns true if the browser took
125
+ * it; the caller says so in the page (this module never touches the status).
126
+ */
127
+ export function downloadRecord(record) {
128
+ try {
129
+ const blob = new Blob([asFile(record)], { type: "application/json" });
130
+ const url = URL.createObjectURL(blob);
131
+ const a = document.createElement("a");
132
+ a.href = url;
133
+ a.download = recordFilename(record);
134
+ a.style.display = "none";
135
+ document.body.appendChild(a);
136
+ a.click();
137
+ a.remove();
138
+ // let the click start before the URL goes away
139
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
140
+ return true;
141
+ } catch (err) {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /** Copy `record` to the clipboard as one line. Resolves true on success. */
147
+ export async function copyRecord(record) {
148
+ const text = asLine(record);
149
+ try {
150
+ await navigator.clipboard.writeText(text);
151
+ return true;
152
+ } catch (err) {
153
+ // an older browser, or a page the clipboard API refuses: fall back to the
154
+ // oldest trick there is, a hidden field and the document's own copy command
155
+ try {
156
+ const box = document.createElement("textarea");
157
+ box.value = text;
158
+ box.setAttribute("readonly", "");
159
+ box.style.position = "fixed";
160
+ box.style.top = "-1000px";
161
+ document.body.appendChild(box);
162
+ box.select();
163
+ const ok = document.execCommand("copy");
164
+ box.remove();
165
+ return ok;
166
+ } catch (err2) {
167
+ return false;
168
+ }
169
+ }
170
+ }
js/report.js ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Plain-language descriptions of what the engine just did — a port of
2
+ * ludometer/gui/moves.py.
3
+ *
4
+ * `apply()` mutates in place and resolves the round boundary itself, so nothing
5
+ * in the engine reports *which* tiles moved to the wall or what each one earned.
6
+ * This rebuilds that story from a snapshot taken **before** the move, using
7
+ * exactly the engine's rules — the same reconstruction the Python GUI server did,
8
+ * moved into the page now that there is no server.
9
+ */
10
+
11
+ import {
12
+ CENTER,
13
+ COL_BONUS,
14
+ COLOR_BONUS,
15
+ COLOR_NAMES,
16
+ CUM_PENALTY,
17
+ FLOOR,
18
+ FLOOR_PENALTIES,
19
+ FLOOR_SLOTS,
20
+ NUM_COLORS,
21
+ NUM_ROWS,
22
+ ROW_BONUS,
23
+ WALL_IDX,
24
+ decodeAction,
25
+ } from "./engine.js";
26
+
27
+ export const sourceLabel = (source) => (source === CENTER ? "the center" : `factory ${source + 1}`);
28
+ export const destLabel = (dest) => (dest === FLOOR ? "the floor line" : `row ${dest + 1}`);
29
+
30
+ /** Describe `actionId` as it would play out in `state` (no mutation). */
31
+ export function describeAction(state, actionId) {
32
+ const [source, color, dest] = decodeAction(actionId);
33
+ const player = state.currentPlayer;
34
+ const pool = source === CENTER ? state.center : state.factories[source];
35
+ const count = pool[color];
36
+ const tookMarker = source === CENTER && state.markerInCenter;
37
+
38
+ let placed = 0;
39
+ let overflow = count;
40
+ if (dest !== FLOOR) {
41
+ const room = dest + 1 - state.plCount[player][dest];
42
+ placed = Math.min(count, room);
43
+ overflow = count - placed;
44
+ }
45
+ const occupied = state.floorOccupied(player) + (tookMarker ? 1 : 0);
46
+ const toFloor = Math.max(0, Math.min(overflow, FLOOR_SLOTS - occupied));
47
+
48
+ let text = `took ${count} ${COLOR_NAMES[color]} from ${sourceLabel(source)} → ${destLabel(dest)}`;
49
+ if (dest !== FLOOR && overflow) text += ` (${overflow} to the floor)`;
50
+ if (tookMarker) text += " + first-player marker";
51
+
52
+ return {
53
+ action_id: actionId,
54
+ player,
55
+ source,
56
+ color,
57
+ dest,
58
+ count,
59
+ placed,
60
+ overflow,
61
+ to_floor: toFloor,
62
+ to_lid: overflow - toFloor,
63
+ took_marker: tookMarker,
64
+ color_name: COLOR_NAMES[color],
65
+ source_label: sourceLabel(source),
66
+ dest_label: destLabel(dest),
67
+ text,
68
+ };
69
+ }
70
+
71
+ function runLengths(wall, row, col) {
72
+ const base = row * 5;
73
+ let h = 1;
74
+ for (let i = col - 1; i >= 0 && wall[base + i]; i--) h += 1;
75
+ for (let i = col + 1; i < 5 && wall[base + i]; i++) h += 1;
76
+ let v = 1;
77
+ for (let i = row - 1; i >= 0 && wall[i * 5 + col]; i--) v += 1;
78
+ for (let i = row + 1; i < 5 && wall[i * 5 + col]; i++) v += 1;
79
+ return [h, v];
80
+ }
81
+
82
+ /** Replay the engine's wall-tiling for one player on a snapshot. */
83
+ function tilePlayer(before, player) {
84
+ const wall = before.walls[player].slice();
85
+ const colors = before.plColor[player];
86
+ const counts = before.plCount[player];
87
+ const tiles = [];
88
+ let gain = 0;
89
+ for (let row = 0; row < NUM_ROWS; row++) {
90
+ if (counts[row] !== row + 1) continue;
91
+ const color = colors[row];
92
+ const idx = WALL_IDX[color * 5 + row];
93
+ wall[idx] = 1;
94
+ const col = idx - row * 5;
95
+ const [h, v] = runLengths(wall, row, col);
96
+ const points = h > 1 || v > 1 ? (h > 1 ? h : 0) + (v > 1 ? v : 0) : 1;
97
+ gain += points;
98
+ tiles.push({ row, col, color, color_name: COLOR_NAMES[color], points, h_run: h, v_run: v, discarded: row });
99
+ }
100
+
101
+ const floor = before.floor[player];
102
+ const floorTiles = [];
103
+ for (let c = 0; c < NUM_COLORS; c++) for (let k = 0; k < floor[c]; k++) floorTiles.push(c);
104
+ const occupied = before.floorOccupied(player);
105
+ const penalty = CUM_PENALTY[Math.min(FLOOR_SLOTS, occupied)];
106
+ const scoreBefore = before.scores[player];
107
+ const scoreAfter = Math.max(0, scoreBefore + gain + penalty);
108
+ const carried = [];
109
+ for (let r = 0; r < NUM_ROWS; r++) {
110
+ if (counts[r] && counts[r] !== r + 1) carried.push({ row: r, color: colors[r], count: counts[r] });
111
+ }
112
+ return {
113
+ seat: player,
114
+ tiles,
115
+ tiling_points: gain,
116
+ carried_rows: carried,
117
+ floor: {
118
+ tiles: floorTiles,
119
+ marker: before.floorMarker[player],
120
+ occupied,
121
+ penalty,
122
+ slot_penalties: FLOOR_PENALTIES.slice(0, Math.min(FLOOR_SLOTS, occupied)),
123
+ },
124
+ score_before: scoreBefore,
125
+ score_after: scoreAfter,
126
+ delta: scoreAfter - scoreBefore,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * `before` with `move`'s tiles placed — the state the engine tiles from.
132
+ *
133
+ * `apply` places the tiles *and* resolves the round in one step, so the position
134
+ * wall-tiling actually starts from is never observable. Only the mover's own
135
+ * pattern lines and floor change, and `describeAction` already worked out how the
136
+ * tiles split, so replaying that much is enough.
137
+ */
138
+ export function afterPlacement(before, move) {
139
+ const state = before.clone();
140
+ const player = move.player;
141
+ if (move.took_marker) state.floorMarker[player] = true;
142
+ const dest = move.dest;
143
+ if (dest !== FLOOR) {
144
+ state.plColor[player][dest] = move.color;
145
+ state.plCount[player][dest] += move.placed;
146
+ }
147
+ if (move.to_floor) state.floor[player][move.color] += move.to_floor;
148
+ return state;
149
+ }
150
+
151
+ /** Wall-tiling result of the round ended by `move` played in `before`. */
152
+ export function roundReport(before, move) {
153
+ const state = afterPlacement(before, move);
154
+ return {
155
+ round: state.roundIndex,
156
+ players: [tilePlayer(state, 0), tilePlayer(state, 1)],
157
+ };
158
+ }
159
+
160
+ function bonusBreakdown(state, player) {
161
+ const rows = state.completedRows(player);
162
+ const cols = state.completedCols(player);
163
+ const colors = state.completedColors(player);
164
+ const rowPts = ROW_BONUS * rows;
165
+ const colPts = COL_BONUS * cols;
166
+ const colorPts = COLOR_BONUS * colors;
167
+ const total = rowPts + colPts + colorPts;
168
+ return {
169
+ seat: player,
170
+ rows,
171
+ row_points: rowPts,
172
+ cols,
173
+ col_points: colPts,
174
+ colors,
175
+ color_points: colorPts,
176
+ total,
177
+ score_before_bonus: state.scores[player] - total,
178
+ final_score: state.scores[player],
179
+ };
180
+ }
181
+
182
+ /** End-of-game summary: bonuses per player, winner, why the game stopped. */
183
+ export function finalReport(state, humanSeat) {
184
+ if (!state.isTerminal) return null;
185
+ const outcome = state.outcome() || 0;
186
+ const winner = outcome === 0 ? null : outcome > 0 ? 0 : 1;
187
+ let headline;
188
+ if (winner === null) headline = "A draw: same score, same completed rows.";
189
+ else if (winner === humanSeat) headline = "You win!";
190
+ else headline = "The AI wins.";
191
+ return {
192
+ winner,
193
+ winner_side: winner === null ? null : winner === humanSeat ? "human" : "ai",
194
+ headline,
195
+ scores: state.scores.slice(),
196
+ bonuses: [bonusBreakdown(state, 0), bonusBreakdown(state, 1)],
197
+ exhausted: state.exhausted,
198
+ rounds_played: state.roundIndex + 1,
199
+ };
200
+ }
js/upload.js ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Sharing games: the page's side of the research bargain.
2
+ *
3
+ * Faïence is a research project, and the games people play are the research
4
+ * material. So when a game ends (and when a game is abandoned mid-way), this
5
+ * module sends the record that js/record.js builds to a small collector
6
+ * (web/ingest/, a separate Space) which replays it in the real engine and
7
+ * commits it to the public dataset. The About panel says all of this in the
8
+ * page, and Settings holds the switch: sharing is ON by default, visibly, and
9
+ * turning it off is respected everywhere in this file.
10
+ *
11
+ * Two promises this module keeps:
12
+ *
13
+ * 1. **A game never depends on the upload.** Everything here is fire and
14
+ * forget: sendBeacon on the way out of the page, a fetch whose failure
15
+ * is swallowed everywhere else, and no caller ever awaits either. If
16
+ * the collector is asleep, blocked or gone, the game plays on and the
17
+ * record waits in localStorage for the visitor's next visit.
18
+ * 2. **The record carries no identity.** It is the moves, the deals, the
19
+ * net and the score, exactly what the Save button offers as a file. The
20
+ * collector stores nothing else (it never logs IPs or user agents), and
21
+ * everything it stores is public, so a player can read the whole take.
22
+ *
23
+ * The dev guard mirrors analytics.js: localhost plays thousands of games in
24
+ * the test suites and none of them are players, so none of them may send.
25
+ */
26
+
27
+ export const INGEST_BASE = "https://remifabre-faience-ingest.hf.space";
28
+ export const DATASET_URL = "https://huggingface.co/datasets/RemiFabre/faience-games";
29
+
30
+ const SHARE_KEY = "faience.share";
31
+ const OUTBOX_KEY = "faience.outbox";
32
+ const OUTBOX_MAX = 8; // a courtesy buffer, not an archive: oldest games drop first
33
+
34
+ /** Whether sharing is on. Anything but a stored "off" means yes: the default. */
35
+ export function sharingOn() {
36
+ try {
37
+ return localStorage.getItem(SHARE_KEY) !== "off";
38
+ } catch (err) {
39
+ return true;
40
+ }
41
+ }
42
+
43
+ export function setSharing(on) {
44
+ try {
45
+ localStorage.setItem(SHARE_KEY, on ? "on" : "off");
46
+ } catch (err) {
47
+ /* private mode; the choice just will not survive a reload */
48
+ }
49
+ }
50
+
51
+ /** True while the page is somewhere no data should ever be sent from. */
52
+ function devHost() {
53
+ const host = location.hostname;
54
+ return host === "localhost" || host === "127.0.0.1" || host === "";
55
+ }
56
+
57
+ function readOutbox() {
58
+ try {
59
+ const kept = JSON.parse(localStorage.getItem(OUTBOX_KEY) || "[]");
60
+ return Array.isArray(kept) ? kept : [];
61
+ } catch (err) {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ function writeOutbox(entries) {
67
+ try {
68
+ localStorage.setItem(OUTBOX_KEY, JSON.stringify(entries.slice(-OUTBOX_MAX)));
69
+ } catch (err) {
70
+ /* full or forbidden storage loses the retry, never the game */
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Queue `record` and try to deliver the whole outbox. The normal path for a
76
+ * finished game: the collector's reply confirms delivery, so what fails
77
+ * (asleep, offline, blocked) stays queued for the next call on any visit.
78
+ */
79
+ export function shareRecord(record) {
80
+ if (!sharingOn() || devHost() || !record || !record.moves || !record.moves.length) return;
81
+ writeOutbox([...readOutbox(), JSON.stringify(record)]);
82
+ flushOutbox();
83
+ }
84
+
85
+ let flushing = false;
86
+ export async function flushOutbox() {
87
+ if (flushing || !sharingOn() || devHost()) return;
88
+ const entries = readOutbox();
89
+ if (!entries.length) return;
90
+ flushing = true;
91
+ try {
92
+ const kept = [];
93
+ for (const body of entries) {
94
+ try {
95
+ // no Content-Type header: the default text/plain keeps this a simple
96
+ // CORS request, same as the beacon, and the collector reads the body
97
+ // not the label. The collector answers 204 once it has the record
98
+ // (duplicates are deduplicated there, by content).
99
+ const reply = await fetch(INGEST_BASE + "/game", { method: "POST", body });
100
+ if (!reply.ok) kept.push(body);
101
+ } catch (err) {
102
+ kept.push(body);
103
+ }
104
+ }
105
+ writeOutbox(kept);
106
+ } finally {
107
+ flushing = false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The way out of a closing page: sendBeacon is built for exactly this moment
113
+ * and survives the navigation. Delivery cannot be confirmed, so the record is
114
+ * also queued; if the beacon did land, the collector deduplicates the retry.
115
+ */
116
+ export function shareOnLeave(record) {
117
+ if (!sharingOn() || devHost() || !record || !record.moves || !record.moves.length) return;
118
+ const body = JSON.stringify(record);
119
+ writeOutbox([...readOutbox(), body]);
120
+ try {
121
+ navigator.sendBeacon(INGEST_BASE + "/game", body);
122
+ } catch (err) {
123
+ /* the queue already has it */
124
+ }
125
+ }
126
+
127
+ /**
128
+ * A page load costs the collector one /health ping, so a Space that fell
129
+ * asleep is awake long before the first game here could finish. Fire-and-
130
+ * forget by construction; also the moment to deliver anything left over.
131
+ */
132
+ export function warmCollector() {
133
+ if (!sharingOn() || devHost()) return;
134
+ try {
135
+ fetch(INGEST_BASE + "/health", { mode: "no-cors" }).catch(() => {});
136
+ } catch (err) {
137
+ /* nothing to do: the retry path covers it */
138
+ }
139
+ flushOutbox();
140
+ }
js/worker.js ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The AI's thread.
2
+ *
3
+ * Everything expensive happens here: loading the 13 MB ONNX graph, and running
4
+ * PUCT search against it. The page keeps the game state and only ships the
5
+ * position over (`toSetup()` is structured-clone-safe), so the UI thread never
6
+ * blocks — the board stays interactive and the "thinking" clock keeps ticking
7
+ * while the search runs.
8
+ *
9
+ * Backends. Two onnxruntime-web builds are vendored and exactly one is
10
+ * downloaded: the WebGPU one when the browser can run it (`navigator.gpu` plus
11
+ * JSPI), the pure-WASM one otherwise. The choice is made here rather than on the
12
+ * page so that a WebGPU session that fails to create can quietly fall back to
13
+ * WASM before the page has been told anything. See js/net.js for the measured
14
+ * reason the two differ — and why the batch size differs with them.
15
+ *
16
+ * Protocol (all messages carry an `id` that is echoed back):
17
+ * -> {type:"init", backends, modelUrl}
18
+ * <- {type:"loading"} ... {type:"ready", backend, batch, margin} | {type:"error"}
19
+ * -> {type:"search", setup, budgetS} <- {type:"progress"} ... {type:"result"}
20
+ * -> {type:"policy", setup} <- {type:"result"} (hint: no search)
21
+ * -> {type:"rate", setup, actionId, budgetS}
22
+ * <- {type:"progress"} ... {type:"result"} (coach)
23
+ * -> {type:"analyze", setup, budgetS} <- {type:"result"} (coach, move not yet known)
24
+ * -> {type:"cancel"}
25
+ */
26
+
27
+ import { AzulState, Rng } from "./engine.js";
28
+ import { MCTS, STALL_ROUNDS, selectAction } from "./mcts.js";
29
+ import { OnnxEvaluator, webgpuLikely } from "./net.js";
30
+ import { describeAction } from "./report.js";
31
+
32
+ /**
33
+ * Leaves per forward pass, per backend.
34
+ *
35
+ * Measured on this laptop (Chrome, Apple M3 Pro), raw `session.run` throughput:
36
+ *
37
+ * backend batch 1 batch 16 batch 64 batch 256
38
+ * wasm 2.1 k/s 4.0 k/s 4.2 k/s 4.1 k/s
39
+ * webgpu 0.25 k/s 4.6 k/s 16.5 k/s 50 k/s
40
+ *
41
+ * WASM saturates by 16, so there is nothing to buy past it and a bigger batch
42
+ * only blurs the search. A GPU dispatch costs ~4 ms whatever it carries, so
43
+ * WebGPU is *worse* than WASM until about batch 8 and only then starts winning;
44
+ * 64 is where the tree still stays sharp enough to be worth the extra breadth.
45
+ *
46
+ * `minBatch` is the floor the ramp may not go under (see mcts.js `batchRamp`):
47
+ * on the CPU a batch of one is simply a small step, but on the GPU it is a 4 ms
48
+ * dispatch carrying one position, so the GPU never goes below 8.
49
+ */
50
+ export const BATCH_BY_BACKEND = {
51
+ wasm: { batch: 16, minBatch: 1 },
52
+ webgpu: { batch: 64, minBatch: 8 },
53
+ };
54
+
55
+ let ort = null;
56
+ let evaluator = null;
57
+ let searchConfig = {};
58
+ let cancelled = false;
59
+ const rng = new Rng((Date.now() ^ 0x5eed) >>> 0);
60
+
61
+ /**
62
+ * Download the model, reporting bytes as they arrive.
63
+ *
64
+ * It is a 13 MB file, which on a phone is several seconds of nothing happening;
65
+ * streaming the body lets the page show a real percentage instead of a spinner
66
+ * that might as well be broken.
67
+ */
68
+ async function fetchWithProgress(url, id) {
69
+ const response = await fetch(url);
70
+ if (!response.ok) throw new Error(`model fetch failed: ${response.status} ${response.statusText}`);
71
+ const total = Number(response.headers.get("content-length")) || 0;
72
+ if (!response.body) return new Uint8Array(await response.arrayBuffer());
73
+ const reader = response.body.getReader();
74
+ const chunks = [];
75
+ let received = 0;
76
+ for (;;) {
77
+ const { done, value } = await reader.read();
78
+ if (done) break;
79
+ chunks.push(value);
80
+ received += value.length;
81
+ self.postMessage({ type: "loading", id, received, total });
82
+ }
83
+ const bytes = new Uint8Array(received);
84
+ let offset = 0;
85
+ for (const chunk of chunks) {
86
+ bytes.set(chunk, offset);
87
+ offset += chunk.length;
88
+ }
89
+ return bytes;
90
+ }
91
+
92
+ /** Load one onnxruntime-web build and point it at its own .wasm. */
93
+ async function loadRuntime(spec) {
94
+ const runtime = await import(spec.module);
95
+ // SharedArrayBuffer needs cross-origin isolation, which GitHub Pages does not
96
+ // send; the CPU kernels therefore run on one thread either way.
97
+ runtime.env.wasm.numThreads = 1;
98
+ // Object form on purpose: a bare string prefix makes onnxruntime look for the
99
+ // Emscripten glue .mjs next to the .wasm, and the bundled build has it inlined.
100
+ runtime.env.wasm.wasmPaths = { wasm: spec.wasm };
101
+ runtime.env.logLevel = "error";
102
+ return runtime;
103
+ }
104
+
105
+ async function init(msg) {
106
+ const bytes = await fetchWithProgress(msg.modelUrl, msg.id);
107
+ // Ordered best-first by the page; each entry is {name, module, wasm, ep}.
108
+ // Anything that fails — no adapter, a runtime that will not instantiate, a
109
+ // graph the EP cannot take — falls through to the next, so the worst case is
110
+ // the WASM path that shipped before.
111
+ const tried = [];
112
+ // `navigator.gpu` inside the worker is the authority — a page can be served to
113
+ // a browser whose worker scope has no WebGPU at all — and skipping the entry
114
+ // here means the 15 MB WebGPU runtime is never even requested.
115
+ const wanted = msg.backends.filter((spec) => spec.ep !== "webgpu" || webgpuLikely());
116
+ for (const spec of wanted) {
117
+ try {
118
+ ort = await loadRuntime(spec);
119
+ evaluator = await OnnxEvaluator.create(ort, bytes, {
120
+ backend: spec.name,
121
+ executionProviders: [spec.ep],
122
+ });
123
+ // Warm-up is also the real test: a WebGPU session can be created and then
124
+ // fail on its first dispatch, and that must still fall back.
125
+ const warm = AzulState.newGame(1, new Rng(1));
126
+ await evaluator.evaluate(warm, warm.legalActions());
127
+ break;
128
+ } catch (err) {
129
+ tried.push(`${spec.name}: ${String((err && err.message) || err)}`);
130
+ ort = null;
131
+ evaluator = null;
132
+ }
133
+ }
134
+ if (!evaluator) throw new Error("no usable onnxruntime backend — " + tried.join("; "));
135
+
136
+ searchConfig = { ...(BATCH_BY_BACKEND[evaluator.backend] || { batch: 1, minBatch: 1 }) };
137
+ return {
138
+ bytes: bytes.length,
139
+ backend: evaluator.backend,
140
+ batch: searchConfig.batch,
141
+ margin: evaluator.hasMargin,
142
+ outputs: evaluator.outputNames,
143
+ fallbacks: tried,
144
+ };
145
+ }
146
+
147
+ /** The search's own timing, so the page can show a real positions/s. */
148
+ let lastRate = null;
149
+
150
+ function noteRate(result) {
151
+ if (result && result.sims > 32 && result.elapsedS > 0.2) {
152
+ lastRate = result.sims / result.elapsedS;
153
+ }
154
+ return result;
155
+ }
156
+
157
+ async function search(msg) {
158
+ const state = AzulState.fromSetup(msg.setup, new Rng(rng.next()));
159
+ const legal = state.legalActions();
160
+ if (!legal.length) throw new Error("no legal actions in the position sent to the worker");
161
+ if (legal.length === 1) {
162
+ return { action: legal[0], search: { sims: 0, elapsedS: 0, forced: true } };
163
+ }
164
+
165
+ const mcts = new MCTS(evaluator, searchConfig, new Rng(rng.next()));
166
+ cancelled = false;
167
+ const result = await mcts.search(state, {
168
+ timeLimitS: msg.budgetS,
169
+ shouldStop: () => cancelled,
170
+ onProgress: ({ sims, elapsedS }) => {
171
+ self.postMessage({ type: "progress", id: msg.id, sims, elapsedS });
172
+ },
173
+ });
174
+ // A game that drags past STALL_ROUNDS gets randomised so that it terminates
175
+ // (two arg-max players can otherwise loop forever — see mcts.js).
176
+ const action =
177
+ state.roundIndex >= STALL_ROUNDS ? selectAction(result.policy, 1, mcts.rng) : result.best;
178
+ const top = [...result.visits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
179
+ noteRate(result);
180
+ return {
181
+ action,
182
+ search: {
183
+ sims: result.sims,
184
+ elapsedS: result.elapsedS,
185
+ value: result.value,
186
+ nodes: mcts.nodesCreated,
187
+ forced: false,
188
+ top,
189
+ backend: evaluator.backend,
190
+ batch: searchConfig.batch,
191
+ rate: lastRate,
192
+ },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Coach mode: rate one of *your* moves with the AI's own search.
198
+ *
199
+ * A port of ludometer/gui/coach.py, definition for definition. The same PUCT
200
+ * search the opponent plays with is run on your position and the root's edge
201
+ * statistics are read back out:
202
+ *
203
+ * delta = Q(the move you played) − max over explored children Q
204
+ *
205
+ * `Q` is in the root player's frame — yours — on the net's [-1, 1] scale, so
206
+ * 0.00 means "the move the AI would have played" and −0.06 means the search
207
+ * values yours six hundredths of a win worse. A move the search never visited
208
+ * has no `Q` and is reported `unrated` rather than given an invented number.
209
+ */
210
+ async function rate(msg) {
211
+ const state = AzulState.fromSetup(msg.setup, new Rng(rng.next()));
212
+ const action = msg.actionId;
213
+ const legal = state.legalActions();
214
+ const base = { budgetS: msg.budgetS, legal: legal.length, sims: 0, elapsedS: 0 };
215
+ if (legal.indexOf(action) === -1) {
216
+ return { coach: { ...base, unrated: true, reason: "that move is not legal" } };
217
+ }
218
+ if (legal.length === 1) {
219
+ return { coach: { ...base, delta: 0, forced: true } };
220
+ }
221
+
222
+ const mcts = new MCTS(evaluator, searchConfig, new Rng(rng.next()));
223
+ const result = await mcts.search(state, {
224
+ timeLimitS: msg.budgetS,
225
+ onProgress: ({ sims, elapsedS }) => {
226
+ self.postMessage({ type: "progress", id: msg.id, sims, elapsedS });
227
+ },
228
+ });
229
+ base.sims = result.sims;
230
+ base.elapsedS = result.elapsedS;
231
+
232
+ const explored = mcts.rootChildren().filter((c) => c.visits && c.q !== null);
233
+ if (!explored.length) {
234
+ return {
235
+ coach: { ...base, unrated: true, reason: "the search had no time to explore this position" },
236
+ };
237
+ }
238
+ const best = explored.reduce((a, b) => (b.q > a.q ? b : a));
239
+ const mine = explored.find((c) => c.action === action);
240
+ if (!mine) {
241
+ return {
242
+ coach: { ...base, unrated: true, reason: "the search never explored this move" },
243
+ };
244
+ }
245
+ return {
246
+ coach: {
247
+ ...base,
248
+ // Q is already in your frame, so this can only be <= 0; clamp the float
249
+ // noise away rather than showing "+0.00"
250
+ delta: Math.min(0, mine.q - best.q),
251
+ your_q: mine.q,
252
+ best_q: best.q,
253
+ visits: mine.visits,
254
+ best_visits: best.visits,
255
+ best_text: describeAction(state, best.action).text,
256
+ explored: explored.length,
257
+ },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Coach mode's head start: the same search `rate` runs, but *before* the move
263
+ * is known — it runs while the human is still thinking. The whole tree is
264
+ * position-only, so the root's explored children can be shipped back whole and
265
+ * the page can grade whichever move is eventually played. Honors `cancel`, so
266
+ * the moment the human moves, the opponent's own search gets the worker back.
267
+ */
268
+ async function analyze(msg) {
269
+ const state = AzulState.fromSetup(msg.setup, new Rng(rng.next()));
270
+ const legal = state.legalActions();
271
+ const base = { budgetS: msg.budgetS, legal: legal.length, sims: 0, elapsedS: 0 };
272
+ if (legal.length <= 1) return { analysis: { ...base, forced: true, children: [] } };
273
+
274
+ const mcts = new MCTS(evaluator, searchConfig, new Rng(rng.next()));
275
+ cancelled = false;
276
+ const result = await mcts.search(state, {
277
+ timeLimitS: msg.budgetS,
278
+ shouldStop: () => cancelled,
279
+ onProgress: ({ sims, elapsedS }) => {
280
+ self.postMessage({ type: "progress", id: msg.id, sims, elapsedS });
281
+ },
282
+ });
283
+ const children = mcts
284
+ .rootChildren()
285
+ .filter((c) => c.visits && c.q !== null)
286
+ .map((c) => ({ action: c.action, q: c.q, visits: c.visits }));
287
+ let bestAction = null;
288
+ let bestText = null;
289
+ if (children.length) {
290
+ const best = children.reduce((a, b) => (b.q > a.q ? b : a));
291
+ bestAction = best.action;
292
+ bestText = describeAction(state, best.action).text;
293
+ }
294
+ return {
295
+ analysis: {
296
+ ...base,
297
+ sims: result.sims,
298
+ elapsedS: result.elapsedS,
299
+ children,
300
+ best_action: bestAction,
301
+ best_text: bestText,
302
+ },
303
+ };
304
+ }
305
+
306
+ /** The policy head's own pick, no search — what the "Suggest a move" button asks. */
307
+ async function policy(msg) {
308
+ const state = AzulState.fromSetup(msg.setup, new Rng(rng.next()));
309
+ const legal = state.legalActions();
310
+ if (!legal.length) throw new Error("no legal actions in the position sent to the worker");
311
+ const { priors, value } = await evaluator.evaluate(state, legal);
312
+ let best = 0;
313
+ for (let i = 1; i < priors.length; i++) if (priors[i] > priors[best]) best = i;
314
+ return { action: legal[best], search: { sims: 0, elapsedS: 0, value, prior: priors[best], forced: false } };
315
+ }
316
+
317
+ self.onmessage = async (event) => {
318
+ const msg = event.data;
319
+ if (msg.type === "cancel") {
320
+ cancelled = true;
321
+ return;
322
+ }
323
+ try {
324
+ let payload;
325
+ if (msg.type === "init") payload = await init(msg);
326
+ else if (msg.type === "search") payload = await search(msg);
327
+ else if (msg.type === "policy") payload = await policy(msg);
328
+ else if (msg.type === "rate") payload = await rate(msg);
329
+ else if (msg.type === "analyze") payload = await analyze(msg);
330
+ else throw new Error(`unknown message type ${msg.type}`);
331
+ self.postMessage({ type: msg.type === "init" ? "ready" : "result", id: msg.id, ...payload });
332
+ } catch (err) {
333
+ self.postMessage({ type: "error", id: msg.id, message: String((err && err.message) || err) });
334
+ }
335
+ };
model/model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:77316466e2de41f35d53d06232e781cc5bd5c592a97bc8967e36f8168510da44
3
+ size 7329526
model/model_meta.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run": "run4",
3
+ "checkpoint": "ckpt-037888",
4
+ "elo": 2361.0,
5
+ "games": 37888,
6
+ "exported_at": "2026-08-16T20:39:05Z",
7
+ "input_size": 182,
8
+ "action_space": 180,
9
+ "net_config": {
10
+ "arch": "structured",
11
+ "input_size": 182,
12
+ "action_space": 180,
13
+ "embed": 96,
14
+ "layers": 1,
15
+ "heads": 4,
16
+ "ffn_mult": 2,
17
+ "body": 1024,
18
+ "body_blocks": 1,
19
+ "value_hidden": 128,
20
+ "policy_rank": 32,
21
+ "policy_global": true,
22
+ "margin_head": true,
23
+ "version": 2
24
+ },
25
+ "num_params": 1810331,
26
+ "outputs": [
27
+ "policy",
28
+ "value",
29
+ "margin"
30
+ ],
31
+ "has_margin": true,
32
+ "opset": 17,
33
+ "onnx_bytes": 7329526,
34
+ "onnx_sha256": "77316466e2de41f35d53d06232e781cc5bd5c592a97bc8967e36f8168510da44",
35
+ "parity": {
36
+ "checked": true,
37
+ "n": 100,
38
+ "policy_max_abs_diff": 1.2874603271484375e-05,
39
+ "value_max_abs_diff": 1.4007091522216797e-06,
40
+ "tol": 0.0001,
41
+ "margin_max_abs_diff": 6.109476089477539e-07
42
+ },
43
+ "source_checkpoint": "runs/run4/checkpoints/ckpt-037888.pt",
44
+ "torch_reference": "/Users/remi/ludometer/web/player/test/fixtures/torch_reference.json.gz"
45
+ }
social.png ADDED

Git LFS Details

  • SHA256: 2f8d6bc28b6152b313e2857194721127f7f9535b24d779f771e0793cd4447ae9
  • Pointer size: 131 Bytes
  • Size of remote file: 207 kB
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ui/PORTING.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `web/play/ui/` — the portable half of the table
2
+
3
+ These files draw an Azul game. They know the engine's **state JSON** and the
4
+ engine's **action encoding** (`source*30 + colour*6 + destination`), and nothing
5
+ else: no framework, no build step, no `fetch`, no endpoint name, no globals. The
6
+ local GUI (`web/play/app.js`) and the GitHub Pages player (`web/player/`) can
7
+ therefore draw the same table from two completely different back ends — one a
8
+ Flask server, one a Web Worker running ONNX in the tab.
9
+
10
+ `web/player/ui/` is a **byte-for-byte copy** of this directory. Change these
11
+ files, then copy them across; `tests/test_gui.py` fails if the two ever drift.
12
+
13
+ | file | exports | what it owns |
14
+ |---|---|---|
15
+ | `theme.css` | — | **every colour in the game**, as custom properties, plus a skin (see THEMING.md) |
16
+ | `dom.js` | `node`, `tileEl`, `markerChip`, `actionId`, `preview`, `destsFor`, colour tables | the primitives, plus the two rules the view layer is allowed to know |
17
+ | `board.js` | `createBoard`, `createMiddle` | one player board (both players use the same call), and the factories + centre |
18
+ | `status.js` | `createStatus` | the status band, including the AI's clock |
19
+ | `log.js` | `renderLog`, `glyphRun`, `coachChip`, `formatDelta` | the move log — newest first, tiles drawn as tiles |
20
+ | `scoring.js` | `renderRoundPanel`, `renderFinalPanel`, `clearScoring` | inline round and end-game scoring |
21
+ | `animate.js` | `flyTiles`, `flightDuration`, `sleep`, `speed`, `setSpeed`, `initSpeed` | straight-line tile flights, and the one switch that governs motion |
22
+ | `settings.js` | `createSettings` | the gear and its inline panel; pass `{popups: true}` for the score pop-up switch |
23
+ | `history.js` | `createHistory`, `bindHistoryKeys` | ← / → move navigation over stored positions; `onChange` receives `{from, to}` plies so a page can animate the step |
24
+ | `popups.js` | `popScore`, `popupsOn`, `setPopups`, `initPopups` | score pop-ups — "+3" floating off the square that earned it |
25
+ | `board.css` | — | tiles, boards, middle, status band, settings, navigator, log, scoring, flights, the hand tray, score pop-ups |
26
+
27
+ ## The state the modules expect
28
+
29
+ Exactly what `AzulState.to_json()` returns (Python) — which is exactly what
30
+ `web/player/js/engine.js`'s `toJSON()` returns, field for field. That is the
31
+ whole compatibility contract:
32
+
33
+ ```js
34
+ { round, current_player, factories: [[5], ×5], center: [5], marker_in_center,
35
+ bag: [5], lid: [5], tiles_left, scores: [2], is_terminal,
36
+ players: [{ score, wall: [5][5], pattern_lines: [{capacity, color, count}],
37
+ floor: [5], floor_marker, floor_penalty, completed_rows, ... }] }
38
+ ```
39
+
40
+ The scoring panels additionally take the reports built by
41
+ `ludometer/gui/moves.py` (`round_report` / `final_report`). A port that has no
42
+ such reports can skip `scoring.js` and keep the rest.
43
+
44
+ ## Wiring it up (the whole integration)
45
+
46
+ ```js
47
+ import { createBoard, createMiddle } from "./ui/board.js";
48
+ import { createStatus } from "./ui/status.js";
49
+ import { createSettings } from "./ui/settings.js";
50
+ import { createHistory, bindHistoryKeys } from "./ui/history.js";
51
+ import { renderLog } from "./ui/log.js";
52
+ import { flyTiles, initSpeed } from "./ui/animate.js";
53
+
54
+ initSpeed(); // do this before anything animates
55
+ createSettings(document.getElementById("settings"));
56
+ const status = createStatus(document.getElementById("status"));
57
+ const middle = createMiddle(document.getElementById("middle"), { onPick: pick });
58
+ const you = createBoard(document.getElementById("board-human"),
59
+ { seat: 0, interactive: true, onPlay: play });
60
+ const them = createBoard(document.getElementById("board-ai"), { seat: 1 });
61
+ const nav = createHistory(document.getElementById("nav"),
62
+ { log: () => S.log, onChange: render });
63
+ bindHistoryKeys(nav, { enabled: () => !busy });
64
+
65
+ you.render({ state, legalActions, selection, title: "You", toMove: true });
66
+ them.render({ state, title: "AI" });
67
+ status.set({ headline: "Your turn — pick a colour", detail: "Round 1", tone: "you" });
68
+ ```
69
+
70
+ `onPlay(actionId)` is the only thing a host page must implement; `legalActions`
71
+ is an array of action ids from wherever the rules live. Boards are identical by
72
+ construction — `interactive` is the only difference — so a page cannot
73
+ accidentally make one player's board bigger than the other's.
74
+
75
+ Animations aim at elements the board hands back: `lineSlots(row)`,
76
+ `floorSlots()`, `wallCell(row, col)`, `lineTiles(row)`, plus `sourceTiles()` and
77
+ `remainderTiles()` on the middle. Build the flights **before** re-rendering, so
78
+ the tiles fly from where they actually were:
79
+
80
+ ```js
81
+ await flyTiles(taken.map((from, i) => ({ from, to: targets[i], color })),
82
+ { layer: flyLayer });
83
+ adopt(newState); // now redraw
84
+ ```
85
+
86
+ ## Notes for whoever ports this
87
+
88
+ * **Motion is governed by the settings panel, and by nothing else.** There used
89
+ to be a `prefers-reduced-motion` check in `animate.js` and a blanket
90
+ `transition-duration: .001ms !important` in `board.css`. macOS ships "Reduce
91
+ motion" on for a lot of people, so those players watched tiles teleport with
92
+ no way to ask for the animation back, while every headless test passed
93
+ (the harness set `no-preference` first). Do not reintroduce either gate: add
94
+ a setting instead. `prefersReducedMotion()` is still exported, purely so the
95
+ settings panel can explain itself.
96
+ * **Every move carries the position it was played from** (`state_before`). That
97
+ is what lets a page animate *both* of the AI's moves when it moves twice
98
+ across a round boundary, and it is what `history.js` replays. A back end that
99
+ does not report it loses those two features and nothing else.
100
+ * **Overlays: there are none, by design.** Round and game-end scoring is drawn
101
+ inline under the boards, the status band carries every "please wait" state,
102
+ and the settings panel opens in the flow rather than over the page. Keep it
103
+ that way — the board must stay readable at all times.
104
+ * **The log is flat and newest-first.** Same type, same weight, same rule for
105
+ every entry, including the top one. Highlighting the newest makes it read as a
106
+ heading, which is what this redesign removed.
107
+ * **Colours live in `theme.css`.** No hex literal belongs in `board.css`, in a
108
+ page shell, or in a JS module. See THEMING.md.
109
+ * **Coach verdicts are just log data.** `renderLog` draws `entry.coach` through
110
+ `coachChip`; a port that has its own search can produce the same shape
111
+ (`{delta, best_text, unrated, reason, forced}`) and get the same chip for free.
ui/THEMING.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Theming the table
2
+
3
+ Every colour this game draws with lives in **`theme.css`**, as a CSS custom
4
+ property on `:root`. Nothing else — not `board.css`, not either page's
5
+ `style.css`, not a JS module — is allowed a hex literal. If you want to restyle
6
+ the table, that is the only file you open.
7
+
8
+ ```
9
+ web/play/ui/theme.css ← edit here
10
+ web/player/ui/theme.css ← byte-for-byte copy (tests/test_gui.py enforces it)
11
+ ```
12
+
13
+ ## The one rule the palette is built on
14
+
15
+ **Hue means occupied.**
16
+
17
+ A player's board is mostly empty: 25 wall squares, 15 pattern-line slots, 7
18
+ floor slots. Those used to be painted in a pale tint of the colour that belongs
19
+ there, which turned each board into forty pastel squares with five real tiles
20
+ hidden among them. Now:
21
+
22
+ * a **tile** is saturated, carries a fired rim (`--cN-rim`) and casts a shadow —
23
+ it stands *on* the board;
24
+ * an **empty slot** is `--slot`: one neutral, everywhere, sunk into the board
25
+ with an inset shadow — it is a hole *in* the board;
26
+ * the wall keeps its pattern as a thick **outlined** diamond in `--cN-motif`
27
+ (the full glaze by default, so the colour reads from across the table), with
28
+ a `--cN-wash` inside it, at `--slot-motif` opacity. Outlined, never filled —
29
+ enough to plan with, never enough to look like a tile. A skin whose slot is
30
+ too close to a glaze re-points that colour's motif (dusk does this for
31
+ charcoal: `--c3-motif: var(--c3-ink)`).
32
+
33
+ Keep that contrast, whatever else you change. `web/play/test/gui.test.mjs`
34
+ checks it numerically: every empty square must be near-neutral (low chroma)
35
+ while the four chromatic glazes must be strongly saturated, and every empty
36
+ square must be visibly recessed while every tile is visibly raised.
37
+
38
+ ## The token groups
39
+
40
+ | group | what it is | examples |
41
+ |---|---|---|
42
+ | the table | cloth, plaster, mortar, ink | `--linen`, `--plaster`, `--grout`, `--ink-soft` |
43
+ | the five glazes | one set of five per tile colour | `--c0` … `--c4`, plus `-hi` `-lo` `-rim` `-ink` `-wash` |
44
+ | empty slots | the neutral well | `--slot`, `--slot-lo`, `--slot-rim`, `--slot-shade`, `--slot-motif`, `--cN-motif` |
45
+ | fixtures | factory dishes, the centre, the marker, the lid | `--dish`, `--marker-face`, `--lid-receiving` |
46
+ | meaning | what the status band and the coach are *about* | `--tone-you`, `--tone-ai`, `--grade-blunder`, `--gain` |
47
+ | chrome | panels, buttons, fields, focus | `--panel-top`, `--btn-primary-top`, `--focus-ring` |
48
+ | geometry | tile size, corner radius, the three type families | `--tile`, `--radius`, `--font-display` |
49
+
50
+ Colours in the *meaning* group are aliases: `--tone-ai: var(--c0)`. That is
51
+ deliberate — the interface never invents an accent that is not already a tile on
52
+ the table, so a new skin gets a coherent UI for free.
53
+
54
+ ## The five glazes
55
+
56
+ Each colour needs five values. `--cN` is the glaze; `--cN-hi` and `--cN-lo` are
57
+ the lit and shaded edges of the bevel; `--cN-rim` is the darker fired edge drawn
58
+ around a placed tile; `--cN-ink` is the same hue as a *line* — outlines, small
59
+ text, the wall's diamonds — and must stay legible against `--slot` and against
60
+ `--plaster`. `--cN-wash` is a transparent version for highlight backgrounds.
61
+
62
+ The shipped values are matched to the tiles in the physical base game — cobalt,
63
+ mustard ochre, brick terracotta, charcoal, and a pale cyan-turquoise ("ice")
64
+ that must read clearly lighter than the cobalt at a glance. That last constraint
65
+ is the one people get wrong: if ice and cobalt are close in lightness the board
66
+ becomes unreadable, whatever their hues.
67
+
68
+ ## Writing a skin
69
+
70
+ A skin is the same list of properties, redeclared under a `data-skin` attribute
71
+ on the root element. `theme.css` ships one, `dusk`, as a worked example — same
72
+ factory, lit at the end of the day.
73
+
74
+ ```css
75
+ :root[data-skin="seaside"] {
76
+ --linen: #dfe7e6;
77
+ --plaster: #f7fbfa;
78
+ --grout: #a9bcbb;
79
+ --ink: #1d2b2b;
80
+
81
+ --c0: #1d4f8f; --c0-hi: #5d8ec9; --c0-lo: #0d2f5c; --c0-rim: #0a2749; --c0-ink: #164079;
82
+ /* … c1 through c4 … */
83
+
84
+ --slot: #c2cbca;
85
+ --slot-lo: #adb8b7;
86
+ --slot-rim: #8d9a99;
87
+ }
88
+ ```
89
+
90
+ Turn it on:
91
+
92
+ ```js
93
+ document.documentElement.dataset.skin = "seaside"; // or delete it for the default
94
+ ```
95
+
96
+ Nothing else needs touching. There is no skin picker in the interface today —
97
+ the mechanism exists so a future change is one file and one line, not a sweep
98
+ through the stylesheets.
99
+
100
+ ### Checklist for a new skin
101
+
102
+ 1. Redeclare **every** token you are changing, including the `-hi`/`-lo`/`-rim`/
103
+ `-ink`/`-wash` members of each glaze. Half a glaze looks broken, not eclectic.
104
+ 2. Keep `--slot` near-neutral and darker than `--plaster`. That is the whole
105
+ readability argument above.
106
+ 3. Check `--cN-ink` against both `--slot` and `--plaster` — it is used for text.
107
+ 4. If your ground is dark, redeclare `--shadow-panel`, `--shadow-tile` and
108
+ `--shadow-tile-raised`: warm brown shadows disappear on a slate table.
109
+ 5. Run `node web/play/test/gui.test.mjs` — it renders the board and asserts the
110
+ filled-versus-empty contrast holds.
111
+
112
+ ## What is deliberately *not* themed
113
+
114
+ The tile's bevel — the pinwheel conic gradient and the glaze highlight in
115
+ `board.css` — is geometry, not colour, and it is drawn from `--base`, `--hi` and
116
+ `--lo`. A skin changes the clay; it does not need to redraw the kiln.
117
+
118
+ Do not reproduce the published Azul artwork or the layout of any commercial Azul
119
+ client here. The palette is matched to the physical tiles, which is a fact about
120
+ the game; the drawing is ours.
ui/animate.js ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Straight-line tile flights.
2
+ *
3
+ * Every movement on this table is the same gesture: a tile leaves one place and
4
+ * travels in a straight line to another. Five things move — tiles from a dish
5
+ * to a board, a dish's leftovers to the middle, the first-player marker, a full
6
+ * pattern line to the wall, and the floor line to the lid — and all of them are
7
+ * the same call.
8
+ *
9
+ * A flight is a clone in a fixed-position layer: the real tiles are hidden for
10
+ * the duration, and the page re-renders from the new state when the group
11
+ * lands. Nothing here knows what a move *means*; callers pass elements (or
12
+ * rectangles) and a colour.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────────────
15
+ * WHY THERE IS NO `prefers-reduced-motion` CHECK IN THIS FILE
16
+ *
17
+ * There used to be one, and it was a bug with a long tail. `flyTiles` returned
18
+ * immediately when the OS asked for reduced motion, `sleep` collapsed to zero,
19
+ * and board.css additionally forced `transition-duration: .001ms !important` on
20
+ * everything. macOS ships "Reduce motion" switched on for a lot of people, and
21
+ * Safari and Chrome both forward it, so those players saw tiles teleport with
22
+ * no way whatsoever to ask for the animation back — while every headless test
23
+ * passed, because the test harness set `no-preference` first.
24
+ *
25
+ * Motion is now governed by one thing only: the speed setting in the page
26
+ * (`speed()` / `setSpeed()` below, persisted in localStorage). The OS flag is
27
+ * still readable through `prefersReducedMotion()` so the settings panel can
28
+ * *mention* it, but it never silences anything on its own.
29
+ * ───────────────────────────────────────────────────────────────────────────── */
30
+
31
+ export const FLIGHT_MS = 460; // one group of tiles, door to door, at 1×
32
+ export const STAGGER_MS = 45; // ... plus this per tile after the first
33
+ export const LAND_MS = 90; // a beat to let the last tile settle
34
+
35
+ const STORE_KEY = "ludometer.anim.speed";
36
+ /** What the settings panel offers. 0 means "no animation at all". */
37
+ export const SPEEDS = [0, 0.5, 1, 2];
38
+ export const DEFAULT_SPEED = 1;
39
+
40
+ let current = DEFAULT_SPEED;
41
+ const listeners = [];
42
+
43
+ function readStored() {
44
+ try {
45
+ const raw = window.localStorage.getItem(STORE_KEY);
46
+ if (raw === null) return DEFAULT_SPEED;
47
+ const value = Number(raw);
48
+ return SPEEDS.indexOf(value) === -1 ? DEFAULT_SPEED : value;
49
+ } catch (err) {
50
+ return DEFAULT_SPEED; // private mode, a locked-down profile: animate anyway
51
+ }
52
+ }
53
+
54
+ /** The current animation speed: 0 (off), 0.5 (slower), 1 (default) or 2 (fast). */
55
+ export const speed = () => current;
56
+
57
+ /** Whether tiles move at all. */
58
+ export const animated = () => current > 0;
59
+
60
+ /**
61
+ * Choose a speed. Persists it, reflects it on `<body data-anim>` (so CSS can
62
+ * see it) and tells every listener.
63
+ */
64
+ export function setSpeed(value) {
65
+ const next = SPEEDS.indexOf(Number(value)) === -1 ? DEFAULT_SPEED : Number(value);
66
+ current = next;
67
+ try {
68
+ window.localStorage.setItem(STORE_KEY, String(next));
69
+ } catch (err) {
70
+ /* not being able to remember it is not a reason to refuse it */
71
+ }
72
+ if (document.body) document.body.dataset.anim = next === 0 ? "off" : String(next);
73
+ listeners.forEach((fn) => fn(next));
74
+ return next;
75
+ }
76
+
77
+ export function onSpeedChange(fn) {
78
+ listeners.push(fn);
79
+ return () => listeners.splice(listeners.indexOf(fn), 1);
80
+ }
81
+
82
+ /** Load the stored speed. Call once, early, before anything animates. */
83
+ export function initSpeed() {
84
+ return setSpeed(readStored());
85
+ }
86
+
87
+ /** How long `ms` at 1× lasts at the current speed. Zero when animation is off. */
88
+ export function scaled(ms) {
89
+ if (!current) return 0;
90
+ return ms / current;
91
+ }
92
+
93
+ /**
94
+ * Whether the *operating system* asks for reduced motion.
95
+ *
96
+ * Informational only — see the note at the top of this file. The settings panel
97
+ * uses it to explain itself; nothing else may branch on it.
98
+ */
99
+ export const prefersReducedMotion = () =>
100
+ !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
101
+
102
+ /** A pause in a turn sequence, in 1× milliseconds. */
103
+ export const sleep = (ms) =>
104
+ new Promise((done) => setTimeout(done, scaled(ms)));
105
+
106
+ /** A rectangle for an element, or the rectangle itself if one is passed. */
107
+ export function rectOf(target) {
108
+ if (!target) return null;
109
+ if (typeof target.getBoundingClientRect === "function") {
110
+ const r = target.getBoundingClientRect();
111
+ if (!r.width && !r.height) return null;
112
+ return r;
113
+ }
114
+ return target;
115
+ }
116
+
117
+ /**
118
+ * Fly `flights` and resolve once they have landed.
119
+ *
120
+ * Each flight is `{from, to, color, hide}`: `from`/`to` are elements or client
121
+ * rectangles, `color` is 0..4 (or "marker"), `hide` defaults to true and hides
122
+ * the source element while its clone is in the air.
123
+ *
124
+ * `options`: `{layer, duration, stagger, scale}` — `duration` and `stagger` are
125
+ * 1× milliseconds and are scaled by the speed setting here. Returns a promise
126
+ * that always resolves (never rejects) so a caller can `await` it in a turn
127
+ * sequence.
128
+ */
129
+ export function flyTiles(flights, options = {}) {
130
+ const layer = options.layer;
131
+ const baseDuration = options.duration === undefined ? FLIGHT_MS : options.duration;
132
+ const baseStagger = options.stagger === undefined ? STAGGER_MS : options.stagger;
133
+ if (!layer || !flights || !flights.length || !animated()) return Promise.resolve();
134
+ const duration = scaled(baseDuration);
135
+ const stagger = scaled(baseStagger);
136
+
137
+ const airborne = [];
138
+ flights.forEach((flight, i) => {
139
+ const a = rectOf(flight.from);
140
+ const b = rectOf(flight.to);
141
+ if (!a || !b) return;
142
+ const ghost = document.createElement("div");
143
+ ghost.className = "fly-tile " + (flight.color === "marker" ? "marker" : "tile");
144
+ if (flight.color !== "marker") ghost.dataset.color = flight.color;
145
+ else ghost.textContent = "1";
146
+ ghost.style.left = a.left + "px";
147
+ ghost.style.top = a.top + "px";
148
+ ghost.style.width = a.width + "px";
149
+ ghost.style.height = a.height + "px";
150
+ ghost.style.transitionDuration = duration + "ms";
151
+ // the delay is per *airborne* clone, not per requested flight, so a skipped
152
+ // flight (source already gone) never leaves a clone waiting past `total`
153
+ const delay = airborne.length * stagger;
154
+ ghost.style.transitionDelay = delay + "ms";
155
+ layer.appendChild(ghost);
156
+ if (flight.hide !== false && flight.from && flight.from.style) {
157
+ flight.from.style.visibility = "hidden";
158
+ }
159
+ // A tile stays a tile in the air. The destination is sometimes a whole
160
+ // panel (the lid, the centre dish), and matching its width used to blow the
161
+ // clone up to the width of the page mid-flight — so the scale is clamped to
162
+ // "slightly bigger or smaller", never "as big as whatever it lands in".
163
+ const raw = options.scale === false ? 1 : b.width / (a.width || 1);
164
+ const scale = Math.max(0.6, Math.min(1.25, raw));
165
+ const dx = b.left + (b.width - a.width * scale) / 2 - a.left;
166
+ const dy = b.top + (b.height - a.height * scale) / 2 - a.top;
167
+ airborne.push([ghost, dx, dy, scale, delay]);
168
+ });
169
+
170
+ if (!airborne.length) return Promise.resolve();
171
+ const total = duration + (airborne.length - 1) * stagger + scaled(LAND_MS);
172
+ return new Promise((done) => {
173
+ requestAnimationFrame(() => {
174
+ // Commit every clone's starting position before any of them moves. The
175
+ // clones were styled and appended in the same task that is about to set
176
+ // their transforms; without a forced style flush the browser's first
177
+ // recalc would see only the final state, and whichever clone nothing
178
+ // else happened to flush — always the last one — would skip its
179
+ // transition and simply appear at the destination.
180
+ void layer.offsetWidth;
181
+ airborne.forEach(([ghost, dx, dy, scale, delay]) => {
182
+ ghost.style.transform =
183
+ "translate(" + dx + "px, " + dy + "px) scale(" + scale.toFixed(3) + ")";
184
+ // each tile seats the moment *it* lands — the raised in-flight shadow
185
+ // drops then, not when the whole group has finished arriving
186
+ setTimeout(() => ghost.classList.add("landed"), duration + delay);
187
+ });
188
+ setTimeout(() => {
189
+ airborne.forEach(([ghost]) => ghost.remove());
190
+ done();
191
+ }, total);
192
+ });
193
+ });
194
+ }
195
+
196
+ /** Total wall time `flyTiles` will take for `count` tiles — for pacing a turn. */
197
+ export function flightDuration(count, duration = FLIGHT_MS, stagger = STAGGER_MS) {
198
+ if (!count || !animated()) return 0;
199
+ return scaled(duration + (count - 1) * stagger + LAND_MS);
200
+ }
ui/board.css ADDED
@@ -0,0 +1,848 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url("theme.css");
2
+
3
+ /* Azul board kit — the portable half of the page.
4
+ *
5
+ * Direction: glazed azulejo ceramic on a linen table. A tile that is on the
6
+ * board is saturated, rimmed and raised; a square that is empty is a neutral
7
+ * recessed well. That single contrast is what makes the position readable from
8
+ * across a room, and it is the thing this file exists to protect.
9
+ *
10
+ * **Every colour comes from theme.css.** There is not one hex literal below
11
+ * this comment — if you find yourself typing one, add a token instead, or the
12
+ * next skin will quietly break. See THEMING.md.
13
+ *
14
+ * Scope: tiles, the two player boards, the middle of the table, the status
15
+ * band, the settings panel, the move navigator, the move log, the inline
16
+ * scoring panels and the flight layer. Nothing in here knows about a server, a
17
+ * form or a page shell — this file plus ui/*.js is what another page needs to
18
+ * draw the same table.
19
+ *
20
+ * Motion: animations are governed by the in-game speed setting alone (see
21
+ * animate.js), never by an OS accessibility flag. `body[data-anim="off"]` is
22
+ * the only thing that turns them off, and the setting writes it.
23
+ */
24
+
25
+ .panel {
26
+ padding: 14px 16px 16px;
27
+ /* the solid colour matters as well as the gradient: it is what a contrast
28
+ * check (and a browser with gradients disabled) actually reads */
29
+ background-color: var(--panel-bottom);
30
+ background-image: linear-gradient(var(--panel-top), var(--panel-bottom));
31
+ border: 1px solid var(--panel-edge);
32
+ border-radius: 4px;
33
+ box-shadow: var(--shadow-panel);
34
+ }
35
+ .panel-title {
36
+ margin: 0 0 10px;
37
+ font-family: var(--font-label);
38
+ font-size: 11px;
39
+ font-weight: 400;
40
+ letter-spacing: .16em;
41
+ text-transform: uppercase;
42
+ color: var(--ink-faint);
43
+ }
44
+
45
+ /* ============================================================== status band ==
46
+ * The signature: one wide plaster cartouche that says what is happening, keeps
47
+ * the score, and turns into the AI's clock when it is thinking. Because it
48
+ * always holds the answer, nothing ever has to cover the board to ask you to
49
+ * wait — there is not a single overlay in this page. */
50
+ .status {
51
+ position: relative;
52
+ display: flex;
53
+ align-items: center;
54
+ gap: clamp(14px, 3vw, 40px);
55
+ padding: 14px clamp(16px, 2.4vw, 26px) 16px;
56
+ overflow: hidden;
57
+ background: linear-gradient(var(--plaster-hi), var(--plaster-sunk));
58
+ border: 1px solid var(--panel-edge);
59
+ border-left: 5px solid var(--tone-idle);
60
+ border-radius: 4px;
61
+ box-shadow: var(--shadow-panel);
62
+ }
63
+ .status[data-tone="you"] { border-left-color: var(--tone-you); }
64
+ .status[data-tone="ai"] { border-left-color: var(--tone-ai); }
65
+ .status[data-tone="scoring"] { border-left-color: var(--tone-scoring); }
66
+ .status[data-tone="end"] { border-left-color: var(--tone-end); }
67
+ /* browsing the history: the band is the only thing that changes colour, so you
68
+ * always know at a glance that what you are looking at is the past */
69
+ .status[data-tone="history"] { border-left-color: var(--ink-soft); }
70
+
71
+ .status-text { flex: 1; min-width: 0; }
72
+ .status-headline {
73
+ margin: 0;
74
+ font-family: var(--font-display);
75
+ font-size: clamp(21px, 2.5vw, 31px);
76
+ line-height: 1.15;
77
+ letter-spacing: .005em;
78
+ }
79
+ .status-detail {
80
+ margin: 4px 0 0;
81
+ font-family: var(--font-label);
82
+ font-size: 12.5px;
83
+ letter-spacing: .08em;
84
+ color: var(--ink-soft);
85
+ min-height: 15px;
86
+ }
87
+
88
+ .status-score { display: flex; align-items: center; gap: 10px; }
89
+ .status-dash { color: var(--ink-faint); font-family: var(--font-display); font-size: 22px; }
90
+ .status-side { display: flex; flex-direction: column; align-items: center; min-width: 62px; }
91
+ .status-side-score {
92
+ font-family: var(--font-display);
93
+ font-size: 34px;
94
+ line-height: 1;
95
+ color: var(--ink-faint);
96
+ }
97
+ .status-side.leading .status-side-score { color: var(--ink); }
98
+ .status-side-name {
99
+ font-family: var(--font-label);
100
+ font-size: 10px;
101
+ letter-spacing: .2em;
102
+ text-transform: uppercase;
103
+ color: var(--ink-faint);
104
+ }
105
+
106
+ /* the clock: a glaze bar drawn along the foot of the band */
107
+ .status-bar {
108
+ position: absolute;
109
+ inset: auto 0 0 0;
110
+ height: 3px;
111
+ background: var(--c3-wash);
112
+ opacity: 0;
113
+ transition: opacity .2s ease;
114
+ }
115
+ .status.ticking .status-bar { opacity: 1; }
116
+ .status-fill {
117
+ display: block;
118
+ width: 0;
119
+ height: 100%;
120
+ background: linear-gradient(90deg, var(--c0-hi), var(--c0));
121
+ transition: width .12s linear;
122
+ }
123
+ .status.indeterminate .status-fill {
124
+ width: 34% !important;
125
+ animation: glaze-sweep-bar 1.1s ease-in-out infinite alternate;
126
+ }
127
+ @keyframes glaze-sweep-bar {
128
+ from { transform: translateX(-100%); }
129
+ to { transform: translateX(300%); }
130
+ }
131
+
132
+ /* ==================================================================== tile ==
133
+ * A tile is *there*: saturated, rimmed with its own fired edge, and standing a
134
+ * little proud of the board. `background-color` carries the glaze on its own so
135
+ * that the colour survives even where gradients do not. */
136
+ .tile {
137
+ position: relative;
138
+ width: var(--tile);
139
+ height: var(--tile);
140
+ padding: 0;
141
+ border: 0;
142
+ border-radius: var(--radius);
143
+ background-color: var(--base);
144
+ background-image:
145
+ radial-gradient(circle at 31% 24%, rgba(255, 255, 255, .55), rgba(255, 255, 255, 0) 56%),
146
+ radial-gradient(circle at 50% 50%, rgba(255, 255, 255, .2) 0 17%, rgba(255, 255, 255, 0) 18%),
147
+ conic-gradient(from 45deg at 50% 50%,
148
+ rgba(255, 255, 255, .14) 0 25%, rgba(0, 0, 0, .08) 0 50%,
149
+ rgba(255, 255, 255, .14) 0 75%, rgba(0, 0, 0, .08) 0),
150
+ linear-gradient(158deg, var(--hi), var(--base) 52%, var(--lo));
151
+ box-shadow:
152
+ 0 0 0 1px var(--rim),
153
+ 0 1px 0 rgba(255, 255, 255, .45) inset,
154
+ 0 -2px 4px rgba(0, 0, 0, .28) inset,
155
+ var(--shadow-tile);
156
+ }
157
+ .tile[data-color="0"] { --base: var(--c0); --hi: var(--c0-hi); --lo: var(--c0-lo); --rim: var(--c0-rim); }
158
+ .tile[data-color="1"] { --base: var(--c1); --hi: var(--c1-hi); --lo: var(--c1-lo); --rim: var(--c1-rim); }
159
+ .tile[data-color="2"] { --base: var(--c2); --hi: var(--c2-hi); --lo: var(--c2-lo); --rim: var(--c2-rim); }
160
+ .tile[data-color="3"] { --base: var(--c3); --hi: var(--c3-hi); --lo: var(--c3-lo); --rim: var(--c3-rim); }
161
+ .tile[data-color="4"] { --base: var(--c4); --hi: var(--c4-hi); --lo: var(--c4-lo); --rim: var(--c4-rim); }
162
+ .tile.mini { width: 22px; height: 22px; }
163
+
164
+ /* a tile that has reached the wall is the most permanent thing on the board */
165
+ .tile.placed { box-shadow: 0 0 0 1.5px var(--rim), 0 1px 0 rgba(255, 255, 255, .5) inset, 0 -2px 5px rgba(0, 0, 0, .3) inset, var(--shadow-tile-raised); }
166
+
167
+ button.tile { cursor: pointer; transition: transform .13s ease, box-shadow .13s ease; }
168
+ button.tile:hover {
169
+ transform: translateY(-2px);
170
+ box-shadow: 0 0 0 1px var(--rim), 0 1px 0 rgba(255, 255, 255, .45) inset,
171
+ 0 -2px 4px rgba(0, 0, 0, .28) inset, var(--shadow-tile-raised);
172
+ }
173
+ button.tile.taken { transform: translateY(-4px) scale(1.04); }
174
+ button.tile.taken::after {
175
+ content: "";
176
+ position: absolute;
177
+ inset: -4px;
178
+ border: 2px solid var(--ink);
179
+ border-radius: 6px;
180
+ opacity: .85;
181
+ }
182
+ button.tile.dimmed { opacity: .34; }
183
+ button.tile:disabled { cursor: default; }
184
+
185
+ .marker {
186
+ width: var(--tile);
187
+ height: var(--tile);
188
+ display: grid;
189
+ place-items: center;
190
+ border-radius: var(--radius);
191
+ background: linear-gradient(var(--marker-face), var(--marker-lo));
192
+ box-shadow: 0 0 0 1px var(--grout-deep) inset, var(--shadow-tile);
193
+ font-family: var(--font-display);
194
+ font-size: 16px;
195
+ color: var(--ink-soft);
196
+ }
197
+ .marker.tiny { width: calc(var(--tile) - 4px); height: calc(var(--tile) - 4px); font-size: 12px; }
198
+
199
+ /* ============================================================= empty slot ==
200
+ * A slot is a *hole*: neutral (no glaze, ever), sunk into the board, ringed by
201
+ * its own shadow. One rule, one look, everywhere a square is empty. */
202
+ .slot, .cell.empty {
203
+ width: var(--tile);
204
+ height: var(--tile);
205
+ border-radius: var(--radius);
206
+ background-color: var(--slot);
207
+ background-image: linear-gradient(var(--slot-lo), var(--slot) 46%);
208
+ box-shadow:
209
+ 0 0 0 1px var(--slot-rim) inset,
210
+ 0 3px 5px -1px var(--slot-shade) inset,
211
+ 0 -1px 0 rgba(255, 255, 255, .3) inset;
212
+ }
213
+
214
+ /* ================================================= middle of the table == */
215
+ .factories {
216
+ display: flex;
217
+ flex-wrap: wrap;
218
+ gap: 14px 18px;
219
+ justify-content: center;
220
+ padding: 4px 0 2px;
221
+ }
222
+ .factory {
223
+ --dish-size: calc(var(--tile) * 2 + 32px);
224
+ position: relative;
225
+ width: var(--dish-size);
226
+ height: var(--dish-size);
227
+ display: grid;
228
+ grid-template-columns: repeat(2, var(--tile));
229
+ grid-auto-rows: var(--tile);
230
+ gap: 5px;
231
+ place-content: center;
232
+ border-radius: 50%;
233
+ background: radial-gradient(circle at 38% 30%, var(--dish-hi), var(--dish) 62%, var(--dish-lo));
234
+ box-shadow:
235
+ 0 0 0 1px var(--dish-rim) inset,
236
+ 0 3px 0 -1px rgba(255, 255, 255, .5) inset,
237
+ 0 -6px 10px -6px var(--slot-shade) inset,
238
+ 0 6px 14px -8px rgba(48, 34, 14, .5);
239
+ }
240
+ .factory:nth-child(odd) { transform: rotate(-1.1deg); }
241
+ .factory:nth-child(even) { transform: rotate(1.3deg); }
242
+ .factory .fac-id {
243
+ position: absolute;
244
+ bottom: 3px;
245
+ left: 50%;
246
+ transform: translateX(-50%);
247
+ font-family: var(--font-label);
248
+ font-size: 11px;
249
+ letter-spacing: .12em;
250
+ color: var(--ink-faint);
251
+ }
252
+ .factory.empty { opacity: .55; }
253
+ .factory .empty-note { grid-column: 1 / -1; text-align: center; }
254
+
255
+ .center-dish {
256
+ margin: 14px auto 0;
257
+ min-height: calc(var(--tile) + 24px);
258
+ max-width: 680px;
259
+ display: flex;
260
+ flex-wrap: wrap;
261
+ align-items: center;
262
+ justify-content: center;
263
+ gap: 6px 10px;
264
+ padding: 11px 20px;
265
+ border-radius: 999px;
266
+ background: radial-gradient(120% 160% at 50% 0%, var(--dish-hi), var(--dish) 60%, var(--dish-lo));
267
+ box-shadow:
268
+ 0 0 0 1px var(--dish-rim) inset,
269
+ 0 3px 0 -1px rgba(255, 255, 255, .5) inset,
270
+ 0 -8px 14px -8px var(--slot-shade) inset,
271
+ 0 8px 18px -12px rgba(48, 34, 14, .5);
272
+ }
273
+ .center-dish .empty-note, .factory .empty-note {
274
+ font-family: var(--font-label);
275
+ font-size: 11px;
276
+ letter-spacing: .14em;
277
+ text-transform: uppercase;
278
+ color: var(--ink-faint);
279
+ }
280
+ .group { display: flex; gap: 4px; }
281
+
282
+ /* the dish a move is drawing from, while it draws */
283
+ .factory.picked, .center-dish.picked {
284
+ box-shadow:
285
+ 0 0 0 2px var(--c0) inset,
286
+ 0 3px 0 -1px rgba(255, 255, 255, .5) inset,
287
+ 0 0 0 4px var(--c0-wash),
288
+ 0 6px 14px -8px rgba(48, 34, 14, .55);
289
+ }
290
+
291
+ /* ================================================================= board == */
292
+ /* Both boards are this component with `interactive` flipped, so the human's and
293
+ * the AI's are the same size, the same rows, the same everything. */
294
+ .board {
295
+ /* both boards share this: identical size is the point of the twin layout */
296
+ --tile: clamp(20px, 2.5vw, 42px);
297
+ display: flex;
298
+ flex-direction: column;
299
+ gap: 10px;
300
+ align-items: stretch;
301
+ padding: 12px 14px 14px;
302
+ background-color: var(--panel-bottom);
303
+ background-image: linear-gradient(var(--panel-top), var(--panel-bottom));
304
+ border: 1px solid var(--panel-edge);
305
+ border-radius: 4px;
306
+ box-shadow: var(--shadow-panel);
307
+ }
308
+ .board.to-move { border-color: var(--grout); box-shadow: var(--shadow-panel), 0 0 0 2px var(--c4-wash); }
309
+
310
+ .board-head {
311
+ display: grid;
312
+ grid-template-columns: 1fr auto;
313
+ align-items: baseline;
314
+ column-gap: 10px;
315
+ padding-bottom: 8px;
316
+ border-bottom: 1px solid var(--panel-edge);
317
+ }
318
+ .board-who {
319
+ font-family: var(--font-label);
320
+ font-size: 12px;
321
+ letter-spacing: .18em;
322
+ text-transform: uppercase;
323
+ color: var(--ink-soft);
324
+ }
325
+ .board-score { font-family: var(--font-display); font-size: 30px; line-height: 1; text-align: right; }
326
+ .board-notes {
327
+ grid-column: 1 / -1;
328
+ font-family: var(--font-label);
329
+ font-size: 11px;
330
+ letter-spacing: .06em;
331
+ color: var(--ink-faint);
332
+ min-height: 14px;
333
+ }
334
+
335
+ /* The board's spine: pattern line r and wall row r are ONE grid row, exactly as
336
+ * on the cardboard, so a full line visibly slides into the row beside it. */
337
+ .board-grid {
338
+ --row-h: calc(var(--tile) + 6px);
339
+ display: grid;
340
+ grid-template-columns: minmax(0, 1fr) max-content;
341
+ grid-auto-rows: var(--row-h);
342
+ column-gap: clamp(8px, 1vw, 18px);
343
+ row-gap: 0;
344
+ }
345
+ .line {
346
+ grid-column: 1;
347
+ display: flex;
348
+ align-items: center;
349
+ justify-content: flex-end;
350
+ gap: 4px;
351
+ height: 100%;
352
+ padding: 0 5px;
353
+ border: 1px solid transparent;
354
+ border-radius: 3px;
355
+ background: none;
356
+ text-align: right;
357
+ }
358
+ button.line { cursor: default; font: inherit; color: inherit; }
359
+ button.line.open {
360
+ cursor: pointer;
361
+ border-color: var(--c4-lo);
362
+ background: var(--c4-wash);
363
+ box-shadow: 0 0 0 3px var(--c4-wash);
364
+ }
365
+ button.line.open:hover { background: var(--c4-wash); filter: brightness(1.04); }
366
+ button.line.blocked { cursor: not-allowed; opacity: .5; }
367
+ .line .row-note {
368
+ flex: 1;
369
+ min-width: 0;
370
+ margin-right: auto;
371
+ overflow: hidden;
372
+ white-space: nowrap;
373
+ text-overflow: ellipsis;
374
+ text-align: left;
375
+ font-family: var(--font-label);
376
+ font-size: 11px;
377
+ letter-spacing: .04em;
378
+ color: var(--ink-faint);
379
+ }
380
+ .line.open .row-note { color: var(--c4-lo); }
381
+ .line.incoming { border-color: var(--c0); background: var(--c0-wash); }
382
+
383
+ /* One grouted strip per wall row; stacked with no row gap they read as one
384
+ * mortared panel, and each strip stays level with its pattern line. */
385
+ .wall-row {
386
+ grid-column: 2;
387
+ display: grid;
388
+ grid-template-columns: repeat(5, var(--tile));
389
+ align-items: center;
390
+ justify-content: center;
391
+ gap: 4px;
392
+ height: 100%;
393
+ padding: 0 5px;
394
+ /* the mortar has to be darker than the squares set into it, or the wall reads
395
+ * as one slab instead of a grid */
396
+ background: var(--grout-deep);
397
+ box-shadow: 0 2px 6px -3px var(--slot-shade) inset;
398
+ }
399
+ .wall-row.top { border-radius: 4px 4px 0 0; }
400
+ .wall-row.bottom { border-radius: 0 0 4px 4px; }
401
+
402
+ /* An empty wall square is a hole with the memory of its glaze in it. The well
403
+ * stays neutral (hue means occupied), but sunk into it is a small diamond of
404
+ * the colour that belongs there, drawn the way a setter would mark plaster: a
405
+ * matte, translucent wash of the glaze (`--cN-ghost`), edged with a fine line
406
+ * of the true colour (`--cN-motif`), over a breath of the same pigment settled
407
+ * into the recess. Flat, unlit and see-through — everything a glossy raised
408
+ * tile is not — so it reads as "this colour goes here", never as "a tile is
409
+ * here", and it reads from across the table. */
410
+ .cell { width: var(--tile); height: var(--tile); border-radius: var(--radius); }
411
+ .cell.empty { position: relative; }
412
+ .cell.empty::before {
413
+ content: "";
414
+ position: absolute;
415
+ inset: 0;
416
+ border-radius: var(--radius);
417
+ background: radial-gradient(circle at 50% 55%, var(--ghost-wash), transparent 72%);
418
+ }
419
+ .cell.empty::after {
420
+ content: "";
421
+ position: absolute;
422
+ inset: 27%;
423
+ transform: rotate(45deg);
424
+ border: 2px solid var(--ghost-glaze);
425
+ border-radius: 15%;
426
+ background: linear-gradient(155deg, var(--ghost-fill), var(--ghost-fill) 45%, var(--ghost-wash));
427
+ opacity: var(--slot-motif);
428
+ }
429
+ .cell.empty[data-color="0"] { --ghost-glaze: var(--c0-motif); --ghost-fill: var(--c0-ghost); --ghost-wash: var(--c0-wash); }
430
+ .cell.empty[data-color="1"] { --ghost-glaze: var(--c1-motif); --ghost-fill: var(--c1-ghost); --ghost-wash: var(--c1-wash); }
431
+ .cell.empty[data-color="2"] { --ghost-glaze: var(--c2-motif); --ghost-fill: var(--c2-ghost); --ghost-wash: var(--c2-wash); }
432
+ .cell.empty[data-color="3"] { --ghost-glaze: var(--c3-motif); --ghost-fill: var(--c3-ghost); --ghost-wash: var(--c3-wash); }
433
+ .cell.empty[data-color="4"] { --ghost-glaze: var(--c4-motif); --ghost-fill: var(--c4-ghost); --ghost-wash: var(--c4-wash); }
434
+
435
+ .floor {
436
+ display: flex;
437
+ align-items: flex-start;
438
+ gap: 4px;
439
+ padding: 4px 6px;
440
+ border: 1px solid transparent;
441
+ border-radius: 3px;
442
+ background: none;
443
+ }
444
+ .floor .cellwrap { display: flex; flex-direction: column; align-items: center; gap: 2px; }
445
+ .floor .pen { font-family: var(--font-label); font-size: 10px; letter-spacing: .06em; color: var(--ink-faint); }
446
+ button.floor.open {
447
+ cursor: pointer;
448
+ border-color: var(--c2);
449
+ background: var(--c2-wash);
450
+ box-shadow: 0 0 0 3px var(--c2-wash);
451
+ }
452
+ button.floor.open:hover { background: var(--c2-wash); filter: brightness(1.04); }
453
+ .floor-wrap { display: flex; flex-direction: column; gap: 4px; }
454
+ .floor-note { font-family: var(--font-label); font-size: 11px; color: var(--ink-faint); }
455
+ .floor-note.warn { color: var(--c2-ink); }
456
+
457
+ /* ========================================================= settings panel ==
458
+ * A gear and a row of presets, opened *inline*: it pushes the table down rather
459
+ * than covering it, because this page has no pop-ups and this is not the place
460
+ * to start. */
461
+ .settings { display: flex; flex-direction: column; }
462
+ .settings-head { display: flex; align-items: center; gap: 10px; }
463
+ .gear {
464
+ display: inline-flex;
465
+ align-items: center;
466
+ justify-content: center;
467
+ gap: 8px;
468
+ min-height: 34px;
469
+ padding: 4px 12px 4px 9px;
470
+ border: 1px solid var(--grout);
471
+ border-radius: 3px;
472
+ background: linear-gradient(var(--btn-top), var(--btn-bottom));
473
+ cursor: pointer;
474
+ font-family: var(--font-label);
475
+ font-size: 11.5px;
476
+ letter-spacing: .14em;
477
+ text-transform: uppercase;
478
+ color: var(--ink-soft);
479
+ }
480
+ .gear:hover { background: linear-gradient(var(--btn-top-hover), var(--btn-bottom-hover)); }
481
+ .gear .gear-icon { width: 15px; height: 15px; display: block; fill: var(--ink-soft); transition: transform .35s ease; }
482
+ .settings.open .gear .gear-icon { transform: rotate(60deg); }
483
+ .settings-note { font-family: var(--font-label); font-size: 11.5px; letter-spacing: .05em; color: var(--ink-faint); }
484
+
485
+ .settings-panel {
486
+ display: flex;
487
+ flex-wrap: wrap;
488
+ align-items: center;
489
+ gap: 10px 18px;
490
+ margin-top: 10px;
491
+ padding: 12px 14px;
492
+ background: var(--plaster-sunk);
493
+ border: 1px solid var(--panel-edge);
494
+ border-radius: 4px;
495
+ box-shadow: 0 2px 5px -3px var(--slot-shade) inset;
496
+ }
497
+ .settings-panel[hidden] { display: none; }
498
+ .settings-label {
499
+ font-family: var(--font-label);
500
+ font-size: 11px;
501
+ letter-spacing: .16em;
502
+ text-transform: uppercase;
503
+ color: var(--ink-faint);
504
+ }
505
+ .speeds { display: flex; gap: 0; }
506
+ /* `.flag` is the same segmented button in any other row of the panel (score
507
+ * pop-ups, future switches) — one look for "pick one of these". */
508
+ .speed, .flag {
509
+ min-width: 52px;
510
+ min-height: 34px;
511
+ padding: 5px 12px;
512
+ border: 1px solid var(--grout);
513
+ border-left-width: 0;
514
+ background: var(--field-bg);
515
+ cursor: pointer;
516
+ font-family: var(--font-label);
517
+ font-size: 13px;
518
+ letter-spacing: .06em;
519
+ color: var(--ink-soft);
520
+ }
521
+ .speed:first-child, .flag:first-child { border-left-width: 1px; border-radius: 3px 0 0 3px; }
522
+ .speed:last-child, .flag:last-child { border-radius: 0 3px 3px 0; }
523
+ .speed:hover, .flag:hover { background: var(--btn-bottom-hover); }
524
+ .speed[aria-pressed="true"], .flag[aria-pressed="true"] {
525
+ color: var(--btn-primary-ink);
526
+ border-color: var(--c0-lo);
527
+ background: linear-gradient(var(--btn-primary-top), var(--btn-primary-bottom));
528
+ }
529
+ .settings-gap { flex-basis: 100%; height: 0; margin: 0; }
530
+ .settings-hint {
531
+ flex-basis: 100%;
532
+ margin: 0;
533
+ font-family: var(--font-label);
534
+ font-size: 11.5px;
535
+ letter-spacing: .04em;
536
+ color: var(--ink-faint);
537
+ }
538
+
539
+ /* ========================================================= move navigator ==
540
+ * Previous / next, and the only place on the page that talks about the past. */
541
+ .nav { display: inline-flex; align-items: center; gap: 6px; }
542
+ .nav-btn {
543
+ min-width: 38px;
544
+ min-height: 34px;
545
+ padding: 4px 11px;
546
+ border: 1px solid var(--grout);
547
+ border-radius: 3px;
548
+ background: linear-gradient(var(--btn-top), var(--btn-bottom));
549
+ cursor: pointer;
550
+ font-family: var(--font-label);
551
+ font-size: 14px;
552
+ color: var(--ink-soft);
553
+ }
554
+ .nav-btn:hover:not(:disabled) { background: linear-gradient(var(--btn-top-hover), var(--btn-bottom-hover)); }
555
+ .nav-btn:disabled { opacity: .4; cursor: not-allowed; }
556
+ .nav-count {
557
+ min-width: 84px;
558
+ text-align: center;
559
+ font-family: var(--font-label);
560
+ font-size: 11.5px;
561
+ letter-spacing: .08em;
562
+ color: var(--ink-faint);
563
+ }
564
+ /* while you are browsing, the board is a record, not a game */
565
+ body.viewing .board-grid, body.viewing .factories, body.viewing .center-dish { cursor: default; }
566
+ body.viewing .middle-table { opacity: .93; }
567
+
568
+ /* ============================================================== move log ==
569
+ * Newest at the top, so the move you just made is the one you are looking at.
570
+ * Every entry, always, is drawn the same: a log is a record, not a headline —
571
+ * nothing here is highlighted, including the newest. */
572
+ .log {
573
+ margin: 0;
574
+ padding: 0;
575
+ list-style: none;
576
+ max-height: 300px;
577
+ overflow-y: auto;
578
+ }
579
+ .log-entry {
580
+ display: grid;
581
+ grid-template-columns: 44px minmax(0, 1fr);
582
+ gap: 10px;
583
+ padding: 5px 0;
584
+ border-top: 1px solid var(--panel-edge);
585
+ font-size: 13.5px;
586
+ line-height: 1.4;
587
+ color: var(--ink-soft);
588
+ }
589
+ .log-entry:first-child { border-top: 0; }
590
+ .log-tag {
591
+ font-family: var(--font-label);
592
+ font-size: 10px;
593
+ letter-spacing: .14em;
594
+ text-transform: uppercase;
595
+ color: var(--ink-faint);
596
+ padding-top: 3px;
597
+ }
598
+ .log-text { min-width: 0; }
599
+
600
+ /* the pictographic half: the tiles that moved, drawn as tiles */
601
+ .glyphs { display: inline-flex; align-items: center; gap: 2px; vertical-align: -3px; margin-right: 6px; }
602
+ .glyph {
603
+ width: 15px;
604
+ height: 15px;
605
+ border-radius: 2px;
606
+ background-color: var(--base);
607
+ background-image: linear-gradient(150deg, var(--hi), var(--base) 55%, var(--lo));
608
+ box-shadow: 0 0 0 1px var(--rim), 0 1px 0 rgba(255, 255, 255, .35) inset;
609
+ }
610
+ .glyph[data-color="0"] { --base: var(--c0); --hi: var(--c0-hi); --lo: var(--c0-lo); --rim: var(--c0-rim); }
611
+ .glyph[data-color="1"] { --base: var(--c1); --hi: var(--c1-hi); --lo: var(--c1-lo); --rim: var(--c1-rim); }
612
+ .glyph[data-color="2"] { --base: var(--c2); --hi: var(--c2-hi); --lo: var(--c2-lo); --rim: var(--c2-rim); }
613
+ .glyph[data-color="3"] { --base: var(--c3); --hi: var(--c3-hi); --lo: var(--c3-lo); --rim: var(--c3-rim); }
614
+ .glyph[data-color="4"] { --base: var(--c4); --hi: var(--c4-hi); --lo: var(--c4-lo); --rim: var(--c4-rim); }
615
+ .glyph.marker-glyph {
616
+ display: grid;
617
+ place-items: center;
618
+ background: linear-gradient(var(--marker-face), var(--marker-lo));
619
+ box-shadow: 0 0 0 1px var(--grout-deep);
620
+ font-family: var(--font-display);
621
+ font-size: 10px;
622
+ color: var(--ink-soft);
623
+ }
624
+ .glyph.spill { opacity: .5; }
625
+ .log-where { font-family: var(--font-label); font-size: 12.5px; letter-spacing: .04em; }
626
+ .log-arrow { color: var(--ink-faint); padding: 0 3px; }
627
+ .log-aside { color: var(--ink-faint); font-family: var(--font-label); font-size: 11.5px; }
628
+
629
+ .coach-chip { display: inline-flex; align-items: baseline; gap: 7px; margin-left: 8px; vertical-align: baseline; }
630
+ .coach-delta {
631
+ padding: 1px 7px 2px;
632
+ border-radius: 2px;
633
+ background: var(--plaster-sunk);
634
+ box-shadow: 0 0 0 1px var(--grout) inset;
635
+ font-family: var(--font-label);
636
+ font-size: 11.5px;
637
+ letter-spacing: .06em;
638
+ color: var(--ink-soft);
639
+ white-space: nowrap;
640
+ }
641
+ .coach-chip[data-grade="best"] .coach-delta { color: var(--grade-best); box-shadow: 0 0 0 1px var(--c0-ink) inset; background: var(--c0-wash); }
642
+ .coach-chip[data-grade="slip"] .coach-delta { color: var(--grade-slip); box-shadow: 0 0 0 1px var(--c1-ink) inset; background: var(--c1-wash); }
643
+ .coach-chip[data-grade="blunder"] .coach-delta { color: var(--grade-blunder); box-shadow: 0 0 0 1px var(--c2-ink) inset; background: var(--c2-wash); }
644
+ .coach-chip[data-grade="pending"] .coach-delta { font-style: italic; }
645
+ .coach-why { font-family: var(--font-label); font-size: 11.5px; letter-spacing: .03em; color: var(--ink-faint); }
646
+ .coach-legend {
647
+ margin: 0 0 8px;
648
+ font-family: var(--font-label);
649
+ font-size: 11.5px;
650
+ letter-spacing: .04em;
651
+ color: var(--ink-faint);
652
+ }
653
+
654
+ /* ========================================================= inline scoring == */
655
+ .scoring {
656
+ padding: 14px 16px 16px;
657
+ background: linear-gradient(var(--plaster-hi), var(--plaster-sunk));
658
+ border: 1px solid var(--panel-edge);
659
+ border-left: 5px solid var(--tone-scoring);
660
+ border-radius: 4px;
661
+ box-shadow: var(--shadow-panel);
662
+ }
663
+ .scoring.final { border-left-color: var(--tone-end); }
664
+ .scoring[hidden] { display: none; }
665
+ .scoring-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin-bottom: 12px; }
666
+ .scoring-title { margin: 0; font-family: var(--font-display); font-size: 22px; font-weight: 400; }
667
+ .scoring-note { font-family: var(--font-label); font-size: 11.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-faint); }
668
+ .scoring-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: clamp(12px, 1.6vw, 20px); }
669
+ .score-card { padding: 12px 14px; border-radius: 4px; background: var(--plaster-sunk); box-shadow: 0 0 0 1px var(--grout) inset; }
670
+ .score-card-who {
671
+ margin: 0 0 6px;
672
+ font-family: var(--font-label);
673
+ font-size: 11px;
674
+ font-weight: 400;
675
+ letter-spacing: .18em;
676
+ text-transform: uppercase;
677
+ color: var(--ink-faint);
678
+ }
679
+ .score-delta { margin: 0 0 8px; font-family: var(--font-display); font-size: 24px; }
680
+ .score-delta.big { font-size: 38px; line-height: 1; }
681
+ .score-delta.up { color: var(--gain); }
682
+ .score-delta.down { color: var(--loss); }
683
+ .score-delta.won { color: var(--c0); }
684
+ .score-tiles { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
685
+ .score-tile { display: flex; flex-direction: column; align-items: center; gap: 2px; }
686
+ .score-tile .pts { font-family: var(--font-display); font-size: 14px; }
687
+ .score-tile .where { font-family: var(--font-label); font-size: 9.5px; letter-spacing: .06em; color: var(--ink-faint); }
688
+ .score-line { margin: 2px 0 0; font-size: 13px; color: var(--ink-soft); }
689
+ .bonus-list { display: grid; grid-template-columns: 1fr auto; gap: 3px 12px; margin: 0; font-size: 13px; }
690
+ .bonus-list dt { color: var(--ink-soft); }
691
+ .bonus-list dd { margin: 0; text-align: right; font-family: var(--font-label); letter-spacing: .04em; }
692
+ .bonus-list dt.sum, .bonus-list dd.sum { padding-top: 5px; border-top: 1px solid var(--grout); color: var(--ink); }
693
+
694
+ /* ============================================================== hand tray ==
695
+ * Where a held selection goes while you choose a row: a small dish pinned to
696
+ * the corner of the middle panel. Purely a picture of the selection — the real
697
+ * tiles are hidden underneath it and come back the moment you change your mind. */
698
+ .middle-panel { position: relative; }
699
+ .hand {
700
+ position: absolute;
701
+ top: 8px;
702
+ right: 12px;
703
+ display: flex;
704
+ align-items: center;
705
+ gap: 9px;
706
+ padding: 6px 10px;
707
+ border-radius: 999px;
708
+ background: radial-gradient(120% 160% at 50% 0%, var(--dish-hi), var(--dish) 60%, var(--dish-lo));
709
+ box-shadow:
710
+ 0 0 0 1px var(--dish-rim) inset,
711
+ 0 2px 0 -1px rgba(255, 255, 255, .5) inset,
712
+ 0 6px 14px -10px rgba(48, 34, 14, .5);
713
+ }
714
+ .hand[hidden] { display: none; }
715
+ .hand-label {
716
+ font-family: var(--font-label);
717
+ font-size: 10px;
718
+ letter-spacing: .16em;
719
+ text-transform: uppercase;
720
+ color: var(--ink-faint);
721
+ }
722
+ .hand-tiles { display: flex; gap: 4px; }
723
+
724
+ /* ============================================================= confirm bar ==
725
+ * Confirm mode's one voice. It lives *inside* the middle panel's action row:
726
+ * while a move is placed, the row's usual controls step aside and the banner
727
+ * takes their exact place — centred, same height, so nothing on the page moves
728
+ * when it appears or goes. Never a pop-up, never over the board. */
729
+ .middle-actions.placing > :not(.confirm-bar) { display: none; }
730
+ .confirm-bar {
731
+ display: flex;
732
+ flex: 1;
733
+ flex-wrap: wrap;
734
+ align-items: center;
735
+ justify-content: center;
736
+ gap: 8px 14px;
737
+ min-height: 34px; /* the height of the buttons it replaces */
738
+ }
739
+ .confirm-bar[hidden] { display: none; }
740
+ .confirm-title {
741
+ display: inline-flex;
742
+ align-items: center;
743
+ gap: 9px;
744
+ font-family: var(--font-display);
745
+ font-size: 18px;
746
+ font-weight: 400;
747
+ color: var(--ink);
748
+ white-space: nowrap;
749
+ }
750
+ /* a small ochre diamond — the attention hue, in the table's own shape */
751
+ .confirm-title::before {
752
+ content: "";
753
+ width: 9px;
754
+ height: 9px;
755
+ transform: rotate(45deg);
756
+ border-radius: 1.5px;
757
+ background: var(--tone-scoring);
758
+ }
759
+ .confirm-detail {
760
+ font-family: var(--font-label);
761
+ font-size: 12.5px;
762
+ letter-spacing: .05em;
763
+ color: var(--ink-soft);
764
+ }
765
+
766
+ /* the placed move's own tiles: a breathing ring of the attention hue, the one
767
+ * moving thing on a page that is otherwise holding still for your word */
768
+ .proposed { position: relative; }
769
+ .proposed::after {
770
+ content: "";
771
+ position: absolute;
772
+ inset: -4px;
773
+ border: 2px solid var(--tone-scoring);
774
+ border-radius: 6px;
775
+ box-shadow: 0 0 0 3px var(--c1-wash), 0 0 12px var(--c1-wash);
776
+ pointer-events: none;
777
+ animation: proposed-pulse 1.1s ease-in-out infinite alternate;
778
+ }
779
+ @keyframes proposed-pulse {
780
+ from { opacity: .5; }
781
+ to { opacity: 1; }
782
+ }
783
+ body[data-anim="off"] .proposed::after { animation: none; opacity: 1; }
784
+
785
+ /* ============================================================== score pops ==
786
+ * Every point, announced where it is earned: "+3" floats off a wall tile as it
787
+ * lands, "−2" off a floor line as it bills you. Their layer sits above the
788
+ * flights and is separate from them — a number is not a tile. */
789
+ .pop-layer { position: fixed; inset: 0; z-index: 30; pointer-events: none; }
790
+ .score-pop {
791
+ position: fixed;
792
+ transform: translate(-50%, -70%);
793
+ font-family: var(--font-display);
794
+ font-size: 21px;
795
+ color: var(--gain);
796
+ text-shadow:
797
+ 0 1px 0 var(--plaster-hi),
798
+ 0 0 6px var(--plaster-hi);
799
+ opacity: 0;
800
+ animation: score-rise 1.1s cubic-bezier(.2, .5, .3, 1) both;
801
+ }
802
+ .score-pop[data-kind="loss"] { color: var(--loss); }
803
+ @keyframes score-rise {
804
+ 0% { opacity: 0; transform: translate(-50%, -40%) scale(.7); }
805
+ 18% { opacity: 1; transform: translate(-50%, -80%) scale(1.12); }
806
+ 70% { opacity: 1; }
807
+ 100% { opacity: 0; transform: translate(-50%, -190%) scale(1); }
808
+ }
809
+
810
+ /* =============================================================== flights == */
811
+ .fly-layer { position: fixed; inset: 0; z-index: 25; pointer-events: none; }
812
+ /* Straight line, constant speed: a tile is being moved by a hand, not thrown.
813
+ * The duration is written onto each clone by animate.js from the speed setting,
814
+ * so nothing here — and nothing in the OS — can shorten it behind your back. */
815
+ .fly-tile {
816
+ position: fixed;
817
+ transition-property: transform;
818
+ transition-timing-function: cubic-bezier(.36, .62, .38, 1);
819
+ will-change: transform;
820
+ box-shadow: 0 0 0 1px var(--rim), 0 1px 0 rgba(255, 255, 255, .45) inset, var(--shadow-tile-raised);
821
+ }
822
+ /* a clone that has arrived: the in-flight lift drops and it sits like a tile */
823
+ .fly-tile.landed {
824
+ box-shadow:
825
+ 0 0 0 1px var(--rim),
826
+ 0 1px 0 rgba(255, 255, 255, .45) inset,
827
+ 0 -2px 4px rgba(0, 0, 0, .28) inset,
828
+ var(--shadow-tile);
829
+ }
830
+ .fly-tile.marker { display: grid; place-items: center; }
831
+
832
+ /* a completed line's tile arriving on the wall */
833
+ @keyframes glaze-land {
834
+ 0% { transform: scale(1.3); opacity: 0; }
835
+ 60% { transform: scale(1); opacity: 1; }
836
+ 100% { transform: scale(1); opacity: 1; }
837
+ }
838
+ .tile.placed.landing { animation: glaze-land var(--land-ms, .42s) ease-out both; }
839
+
840
+ /* The only motion switch on this page is the one in the settings panel. An OS
841
+ * "reduce motion" flag is deliberately NOT honoured here: it used to silence
842
+ * every tile flight, which made the game look like it was teleporting tiles and
843
+ * left no way to turn the animation back on. */
844
+ body[data-anim="off"] .fly-tile,
845
+ body[data-anim="off"] .tile.placed.landing {
846
+ animation-duration: .001ms !important;
847
+ transition-duration: .001ms !important;
848
+ }
ui/board.js ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The Azul furniture: one player board, and the middle of the table.
2
+ *
3
+ * `createBoard(host, {...})` owns one container element and redraws it from a
4
+ * view object; `createMiddle(host, {...})` does the same for the factories and
5
+ * the centre dish. Both boards are built by the same function with the same
6
+ * markup, so the human's and the AI's are identical down to the pixel — only
7
+ * `interactive` differs.
8
+ *
9
+ * The modules never fetch anything and never name an endpoint: a caller hands
10
+ * them state and callbacks, and they hand back the elements an animation needs
11
+ * to aim at.
12
+ *
13
+ * const you = createBoard(el, { seat: 0, interactive: true, onPlay: play });
14
+ * you.render({ state, legalActions, selection, toMove: true, title: "You" });
15
+ * you.lineSlots(2); // where tiles going to row 3 should land
16
+ */
17
+
18
+ import {
19
+ CENTER,
20
+ COLORS,
21
+ FLOOR,
22
+ FLOOR_PENALTIES,
23
+ destsFor,
24
+ markerChip,
25
+ node,
26
+ poolCount,
27
+ preview,
28
+ tileEl,
29
+ actionId,
30
+ } from "./dom.js";
31
+
32
+ const EMPTY_VIEW = { state: null, legalActions: [], selection: null };
33
+
34
+ /* --------------------------------------------------------------- player board */
35
+ export function createBoard(host, options = {}) {
36
+ const opts = Object.assign(
37
+ { seat: 0, interactive: false, onPlay: null, onBlocked: null },
38
+ options
39
+ );
40
+ host.innerHTML = ""; // re-seating a board rebuilds it in place
41
+ host.classList.add("board");
42
+ host.dataset.seat = opts.seat;
43
+
44
+ const head = node("div", "board-head");
45
+ const who = node("span", "board-who", opts.title || "");
46
+ const score = node("span", "board-score", "0");
47
+ const notes = node("span", "board-notes", "");
48
+ head.append(who, score, notes);
49
+
50
+ const grid = node("div", "board-grid");
51
+ const floorWrap = node("div", "floor-wrap");
52
+ host.append(head, grid, floorWrap);
53
+
54
+ let view = EMPTY_VIEW;
55
+
56
+ function draw() {
57
+ const state = view.state;
58
+ grid.innerHTML = "";
59
+ floorWrap.innerHTML = "";
60
+ if (!state) return;
61
+
62
+ const seat = opts.seat;
63
+ const me = state.players[seat];
64
+ const legal = new Set(view.legalActions || []);
65
+ const sel = opts.interactive ? view.selection : null;
66
+ const open = sel ? destsFor(legal, sel.source, sel.color) : [];
67
+
68
+ who.textContent = view.title || who.textContent;
69
+ score.textContent = me.score;
70
+ const bits = [];
71
+ if (me.floor_penalty) bits.push(me.floor_penalty + " on the floor");
72
+ if (me.completed_rows) {
73
+ bits.push(me.completed_rows + " full row" + (me.completed_rows > 1 ? "s" : ""));
74
+ }
75
+ if (me.floor_marker) bits.push("holds the marker");
76
+ notes.textContent = bits.join(" · ");
77
+ host.classList.toggle("to-move", !!view.toMove);
78
+ host.classList.toggle("mine", !!opts.interactive);
79
+
80
+ for (let r = 0; r < 5; r++) {
81
+ const line = me.pattern_lines[r];
82
+ const isOpen = open.indexOf(r) !== -1;
83
+ const row = node(sel ? "button" : "div", "line");
84
+ if (row.tagName === "BUTTON") row.type = "button";
85
+ row.dataset.row = r;
86
+
87
+ const note = node("span", "row-note");
88
+ if (sel) {
89
+ if (isOpen) {
90
+ const p = preview(state, seat, sel.source, sel.color, r);
91
+ let text = p.placed + " of " + p.count + " here";
92
+ if (p.overflow) text += ", " + p.overflow + " spills (" + p.penalty + ")";
93
+ else if (p.completes) text += ", fills the row";
94
+ note.textContent = text;
95
+ row.classList.add("open");
96
+ row.addEventListener("click", () => {
97
+ if (opts.onPlay) opts.onPlay(actionId(sel.source, sel.color, r), r);
98
+ });
99
+ } else {
100
+ row.classList.add("blocked");
101
+ row.title = blockedReason(state, seat, sel.color, r);
102
+ note.textContent = row.title;
103
+ row.setAttribute("aria-disabled", "true");
104
+ }
105
+ } else {
106
+ note.textContent = line.count
107
+ ? line.count + "/" + line.capacity + " " + COLORS[line.color]
108
+ : line.capacity + (line.capacity === 1 ? " slot" : " slots");
109
+ }
110
+ row.appendChild(note);
111
+
112
+ // a pattern line fills towards the wall, so the empty slots sit on the left
113
+ for (let k = 0; k < line.capacity - line.count; k++) {
114
+ row.appendChild(node("div", "slot"));
115
+ }
116
+ for (let k = 0; k < line.count; k++) row.appendChild(tileEl(line.color));
117
+ if (view.highlightRow === r) row.classList.add("incoming");
118
+ grid.appendChild(row);
119
+
120
+ const wallRow = node("div", "wall-row");
121
+ wallRow.dataset.wallRow = r;
122
+ if (r === 0) wallRow.classList.add("top");
123
+ if (r === 4) wallRow.classList.add("bottom");
124
+ for (let col = 0; col < 5; col++) {
125
+ const colour = (col - r + 5) % 5;
126
+ if (me.wall[r][col]) {
127
+ const t = tileEl(colour, "placed");
128
+ t.dataset.row = r;
129
+ t.dataset.col = col;
130
+ t.title = COLORS[colour] + ", row " + (r + 1) + ", column " + (col + 1);
131
+ wallRow.appendChild(t);
132
+ } else {
133
+ // an empty square is a neutral well with the glaze only outlined in it
134
+ const cell = node("div", "cell empty");
135
+ cell.dataset.color = colour;
136
+ cell.dataset.row = r;
137
+ cell.dataset.col = col;
138
+ cell.title = COLORS[colour] + " goes here";
139
+ wallRow.appendChild(cell);
140
+ }
141
+ }
142
+ grid.appendChild(wallRow);
143
+ }
144
+
145
+ const floorOpen = sel && open.indexOf(FLOOR) !== -1;
146
+ const floor = node(floorOpen ? "button" : "div", "floor");
147
+ floor.dataset.row = FLOOR;
148
+ if (floorOpen) {
149
+ floor.type = "button";
150
+ floor.classList.add("open");
151
+ const p = preview(state, seat, sel.source, sel.color, FLOOR);
152
+ floor.title = "Drop all " + p.count + " tiles here (" + p.penalty + " this round)";
153
+ floor.addEventListener("click", () => {
154
+ if (opts.onPlay) opts.onPlay(actionId(sel.source, sel.color, FLOOR), FLOOR);
155
+ });
156
+ }
157
+ const occupants = [];
158
+ if (me.floor_marker) occupants.push("marker");
159
+ me.floor.forEach((n, c) => {
160
+ for (let k = 0; k < n; k++) occupants.push(c);
161
+ });
162
+ for (let i = 0; i < 7; i++) {
163
+ const cellwrap = node("div", "cellwrap");
164
+ const here = occupants[i];
165
+ if (here === "marker") cellwrap.appendChild(markerChip(true));
166
+ else if (here !== undefined) cellwrap.appendChild(tileEl(here));
167
+ else cellwrap.appendChild(node("div", "slot"));
168
+ cellwrap.appendChild(node("span", "pen", FLOOR_PENALTIES[i]));
169
+ floor.appendChild(cellwrap);
170
+ }
171
+ floorWrap.appendChild(floor);
172
+
173
+ const penalty = me.floor_penalty;
174
+ const floorNote = node(
175
+ "span",
176
+ "floor-note" + (penalty ? " warn" : ""),
177
+ penalty ? "Floor line: " + penalty + " at the end of this round" : "Floor line: clean"
178
+ );
179
+ if (floorOpen) {
180
+ const p = preview(state, seat, sel.source, sel.color, FLOOR);
181
+ floorNote.textContent =
182
+ "Floor line: " + penalty + " now, " + (penalty + p.penalty) + " if you drop here";
183
+ floorNote.classList.add("warn");
184
+ }
185
+ floorWrap.appendChild(floorNote);
186
+ }
187
+
188
+ return {
189
+ el: host,
190
+ seat: opts.seat,
191
+ render(next) {
192
+ view = Object.assign({}, EMPTY_VIEW, next);
193
+ draw();
194
+ },
195
+ setTitle(text) {
196
+ who.textContent = text;
197
+ },
198
+ lineRow: (r) => grid.querySelector('.line[data-row="' + r + '"]'),
199
+ lineSlots: (r) => {
200
+ const row = grid.querySelector('.line[data-row="' + r + '"]');
201
+ return row ? [].slice.call(row.querySelectorAll(".slot")) : [];
202
+ },
203
+ lineTiles: (r) => {
204
+ const row = grid.querySelector('.line[data-row="' + r + '"]');
205
+ return row ? [].slice.call(row.querySelectorAll(".tile")) : [];
206
+ },
207
+ floorEl: () => floorWrap.querySelector(".floor"),
208
+ floorSlots: () => [].slice.call(floorWrap.querySelectorAll(".floor .slot")),
209
+ floorTiles: () => [].slice.call(floorWrap.querySelectorAll(".floor .tile")),
210
+ wallCell: (r, c) =>
211
+ grid.querySelector('.wall-row[data-wall-row="' + r + '"] [data-col="' + c + '"]'),
212
+ wallTile: (r, c) =>
213
+ grid.querySelector('.tile.placed[data-row="' + r + '"][data-col="' + c + '"]'),
214
+ };
215
+ }
216
+
217
+ /** Why a row is closed for the colour in hand — the hover text on a dark row. */
218
+ export function blockedReason(state, seat, color, dest) {
219
+ const me = state.players[seat];
220
+ const line = me.pattern_lines[dest];
221
+ if (line.count >= line.capacity) return "This row is already full.";
222
+ if (line.count > 0 && line.color !== color) {
223
+ return "This row holds " + COLORS[line.color] + " tiles.";
224
+ }
225
+ if (me.wall[dest][(color + dest) % 5]) {
226
+ return COLORS[color] + " is already on your wall in this row.";
227
+ }
228
+ return "Not playable right now.";
229
+ }
230
+
231
+ /* ------------------------------------------------------- middle of the table */
232
+ export function createMiddle(host, options = {}) {
233
+ const opts = Object.assign({ onPick: null }, options);
234
+ host.innerHTML = "";
235
+ host.classList.add("middle-table");
236
+ const dishes = node("div", "factories");
237
+ const centre = node("div", "center-dish");
238
+ centre.dataset.source = CENTER;
239
+ host.append(dishes, centre);
240
+
241
+ function tileButton(state, legal, canPick, source, color) {
242
+ const b = node("button", "tile");
243
+ b.type = "button";
244
+ b.dataset.color = color;
245
+ b.dataset.source = source;
246
+ b.dataset.key = source + ":" + color;
247
+ const dests = canPick ? destsFor(legal, source, color) : [];
248
+ if (!dests.length) {
249
+ b.disabled = true;
250
+ if (canPick) b.title = "Nothing you can do with these yet.";
251
+ } else {
252
+ b.title =
253
+ poolCount(state, source, color) + " " + COLORS[color] + ", click to pick them up";
254
+ b.addEventListener("click", () => {
255
+ if (opts.onPick) opts.onPick(source, color);
256
+ });
257
+ }
258
+ return b;
259
+ }
260
+
261
+ return {
262
+ el: host,
263
+ render(view) {
264
+ const state = view.state;
265
+ dishes.innerHTML = "";
266
+ centre.innerHTML = "";
267
+ if (!state) return;
268
+ const legal = new Set(view.legalActions || []);
269
+ const canPick = !!view.canPick;
270
+ const sel = view.selection;
271
+
272
+ state.factories.forEach((counts, i) => {
273
+ const dish = node("div", "factory");
274
+ dish.dataset.source = i;
275
+ const total = counts.reduce((a, b) => a + b, 0);
276
+ if (!total) {
277
+ dish.classList.add("empty");
278
+ dish.appendChild(node("span", "empty-note", "empty"));
279
+ } else {
280
+ counts.forEach((n, c) => {
281
+ for (let k = 0; k < n; k++) {
282
+ dish.appendChild(tileButton(state, legal, canPick, i, c));
283
+ }
284
+ });
285
+ }
286
+ const id = node("span", "fac-id", String(i + 1));
287
+ id.title = "Factory " + (i + 1);
288
+ dish.appendChild(id);
289
+ dishes.appendChild(dish);
290
+ });
291
+
292
+ const centreTotal = state.center.reduce((a, b) => a + b, 0);
293
+ if (state.marker_in_center) centre.appendChild(markerChip(false));
294
+ if (!centreTotal) {
295
+ centre.appendChild(
296
+ node(
297
+ "span",
298
+ "empty-note",
299
+ state.marker_in_center ? "middle (marker only)" : "middle (empty)"
300
+ )
301
+ );
302
+ } else {
303
+ state.center.forEach((n, c) => {
304
+ if (!n) return;
305
+ const group = node("div", "group");
306
+ for (let k = 0; k < n; k++) {
307
+ group.appendChild(tileButton(state, legal, canPick, CENTER, c));
308
+ }
309
+ centre.appendChild(group);
310
+ });
311
+ }
312
+
313
+ if (sel) {
314
+ host.querySelectorAll("button.tile").forEach((b) => {
315
+ if (b.dataset.key === sel.source + ":" + sel.color) b.classList.add("taken");
316
+ else b.classList.add("dimmed");
317
+ });
318
+ }
319
+ },
320
+ sourceEl: (source) =>
321
+ source === CENTER ? centre : dishes.querySelector('.factory[data-source="' + source + '"]'),
322
+ centerEl: () => centre,
323
+ /** The drawn tiles of one colour in one dish — what a move takes away. */
324
+ sourceTiles(source, color, count) {
325
+ const dish = this.sourceEl(source);
326
+ if (!dish) return [];
327
+ const all = dish.querySelectorAll('.tile[data-color="' + color + '"]');
328
+ return [].slice.call(all, 0, count === undefined ? all.length : count);
329
+ },
330
+ /** Everything else in that dish — the tiles that get pushed to the middle. */
331
+ remainderTiles(source, color) {
332
+ if (source === CENTER) return [];
333
+ const dish = this.sourceEl(source);
334
+ if (!dish) return [];
335
+ return [].slice
336
+ .call(dish.querySelectorAll(".tile"))
337
+ .filter((t) => Number(t.dataset.color) !== color);
338
+ },
339
+ };
340
+ }
ui/confirm.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Confirm mode: a move is placed first, played only on your word.
2
+ *
3
+ * Clicking a row *places* the tiles — you see the board exactly as it would
4
+ * stand — and nothing is committed until you press "Play this move"; "Take it
5
+ * back" returns the tiles to your hand. The pace every board-game site has
6
+ * taught people, and the difference between exploring a position and being
7
+ * bound by a misclick — so it is ON by default, with a switch in the settings
8
+ * panel for players who would rather commit on the first click.
9
+ *
10
+ * This module is only the remembered choice; the placement itself lives in the
11
+ * page that owns the game (see the player's app.js).
12
+ */
13
+
14
+ const STORE_KEY = "ludometer.confirm";
15
+
16
+ let current = true;
17
+ const listeners = [];
18
+
19
+ function readStored() {
20
+ try {
21
+ const raw = window.localStorage.getItem(STORE_KEY);
22
+ return raw === null ? true : raw !== "off";
23
+ } catch (err) {
24
+ return true; // private mode: the careful default stands
25
+ }
26
+ }
27
+
28
+ /** Whether moves wait for confirmation. */
29
+ export const confirmOn = () => current;
30
+
31
+ /** Choose. Persists, and tells every listener. */
32
+ export function setConfirm(on) {
33
+ current = !!on;
34
+ try {
35
+ window.localStorage.setItem(STORE_KEY, current ? "on" : "off");
36
+ } catch (err) {
37
+ /* forgetting the choice is no reason to refuse it */
38
+ }
39
+ listeners.forEach((fn) => fn(current));
40
+ return current;
41
+ }
42
+
43
+ export function onConfirmChange(fn) {
44
+ listeners.push(fn);
45
+ return () => listeners.splice(listeners.indexOf(fn), 1);
46
+ }
47
+
48
+ /** Load the stored choice. Call once, early. */
49
+ export function initConfirm() {
50
+ current = readStored();
51
+ return current;
52
+ }
ui/dom.js ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Shared primitives for the Azul board modules.
2
+ *
3
+ * Framework-free, no network, no globals beyond the DOM. Everything here is
4
+ * pure presentation of the engine's own state JSON (`AzulState.to_json()`,
5
+ * which the browser port produces too), so these modules drop into any page
6
+ * that can hand them a state object.
7
+ */
8
+
9
+ export const COLORS = ["blue", "yellow", "red", "black", "teal"];
10
+ export const CENTER = 5;
11
+ export const FLOOR = 5;
12
+ export const FLOOR_PENALTIES = [-1, -1, -2, -2, -2, -3, -3];
13
+ export const CUM_PENALTY = [0, -1, -2, -4, -6, -8, -11, -14];
14
+
15
+ /** `document.createElement` with a class and text in one call. */
16
+ export function node(tag, cls, text) {
17
+ const n = document.createElement(tag);
18
+ if (cls) n.className = cls;
19
+ if (text !== undefined && text !== null) n.textContent = text;
20
+ return n;
21
+ }
22
+
23
+ /** A glazed tile of `color` (0..4). */
24
+ export function tileEl(color, cls) {
25
+ const t = node("div", "tile" + (cls ? " " + cls : ""));
26
+ t.dataset.color = color;
27
+ return t;
28
+ }
29
+
30
+ /** The first-player marker: an unglazed chip, not a tile. */
31
+ export function markerChip(tiny) {
32
+ const m = node("div", "marker" + (tiny ? " tiny" : ""), "1");
33
+ m.title =
34
+ "First-player marker: whoever takes it starts the next round and loses a floor slot.";
35
+ return m;
36
+ }
37
+
38
+ /** The engine's action encoding — the one rule this layer is allowed to know. */
39
+ export function actionId(source, color, dest) {
40
+ return source * 30 + color * 6 + dest;
41
+ }
42
+
43
+ export function decodeAction(id) {
44
+ return { source: Math.floor(id / 30), color: Math.floor((id % 30) / 6), dest: id % 6 };
45
+ }
46
+
47
+ export function sourceLabel(source) {
48
+ return source === CENTER ? "the middle" : "factory " + (source + 1);
49
+ }
50
+
51
+ export function destLabel(dest) {
52
+ return dest === FLOOR ? "the floor line" : "row " + (dest + 1);
53
+ }
54
+
55
+ /** How many tiles of `color` are in `source` right now. */
56
+ export function poolCount(state, source, color) {
57
+ return source === CENTER ? state.center[color] : state.factories[source][color];
58
+ }
59
+
60
+ /** Destinations that are legal for a held colour, from a legal-action set. */
61
+ export function destsFor(legal, source, color) {
62
+ const out = [];
63
+ for (let d = 0; d <= FLOOR; d++) {
64
+ if (legal.has(actionId(source, color, d))) out.push(d);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** What playing `source`/`color` into `dest` would cost this player. */
70
+ export function preview(state, seat, source, color, dest) {
71
+ const me = state.players[seat];
72
+ const count = poolCount(state, source, color);
73
+ const takesMarker = source === CENTER && state.marker_in_center;
74
+ let overflow = count;
75
+ let placed = 0;
76
+ if (dest !== FLOOR) {
77
+ const line = me.pattern_lines[dest];
78
+ placed = Math.min(count, line.capacity - line.count);
79
+ overflow = count - placed;
80
+ }
81
+ const occupied = me.floor.reduce((a, b) => a + b, 0) + (me.floor_marker ? 1 : 0);
82
+ const before = CUM_PENALTY[Math.min(7, occupied)];
83
+ const after = CUM_PENALTY[Math.min(7, occupied + overflow + (takesMarker ? 1 : 0))];
84
+ const line = dest !== FLOOR ? me.pattern_lines[dest] : null;
85
+ return {
86
+ count,
87
+ placed,
88
+ overflow,
89
+ takesMarker,
90
+ penalty: after - before,
91
+ completes: line ? placed + line.count === line.capacity : false,
92
+ };
93
+ }
ui/history.js ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Move navigation — the game as a list of positions you can walk back through.
2
+ *
3
+ * Chess clients have taught everyone the same two keys, so this is those two
4
+ * keys: ← steps back a move, → steps forward, and stepping past the last move
5
+ * puts you back in the live game. It is pure replay of positions the page has
6
+ * *already* been given — there is no request, no re-search and no engine call
7
+ * behind these buttons, and the live game is not touched while you browse.
8
+ *
9
+ * Frames are stored by ply, so `frames[k]` is the position after k moves:
10
+ * `frames[0]` is the deal. Every move the back end reports carries the position
11
+ * it was played from, which is exactly the position after the move before it,
12
+ * so the array fills itself in as the game goes on.
13
+ *
14
+ * const nav = createHistory(el, { log: () => S.log, onChange: render });
15
+ * nav.reset(state, 0); // a new deal
16
+ * nav.note(ply, state); // a position we have seen
17
+ * const frame = nav.frame(); // null while live; {state, log, ply} while browsing
18
+ */
19
+
20
+ import { node } from "./dom.js";
21
+
22
+ export function createHistory(host, options = {}) {
23
+ const opts = Object.assign({ log: () => [], onChange: null, enabled: null }, options);
24
+ host.classList.add("nav");
25
+ host.innerHTML = "";
26
+
27
+ const prev = node("button", "nav-btn", "◀");
28
+ prev.type = "button";
29
+ prev.title = "Previous move (←)";
30
+ prev.setAttribute("aria-label", "Previous move");
31
+ const count = node("span", "nav-count", "");
32
+ const next = node("button", "nav-btn", "▶");
33
+ next.type = "button";
34
+ next.title = "Next move (→)";
35
+ next.setAttribute("aria-label", "Next move");
36
+ // stepping forward one move at a time is no way back from move 3 of 40
37
+ const live = node("button", "nav-btn nav-live", "Latest");
38
+ live.type = "button";
39
+ live.title = "Back to the live game (End)";
40
+ live.hidden = true;
41
+ host.append(prev, count, next, live);
42
+
43
+ const frames = []; // frames[ply] = state JSON
44
+ let latest = 0; // the live game's ply
45
+ let viewing = null; // the ply being looked at, or null when live
46
+
47
+ const has = (ply) => ply >= 0 && ply <= latest && !!frames[ply];
48
+
49
+ /** The nearest recorded ply at or beyond `from` in direction `step`. */
50
+ function seek(from, step) {
51
+ for (let p = from; p >= 0 && p <= latest; p += step) {
52
+ if (has(p)) return p;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /** The log as it stood after `ply` moves — entries carry the ply they belong to. */
58
+ function logAt(ply) {
59
+ return (opts.log() || []).filter((e) => e.ply === undefined || e.ply <= ply);
60
+ }
61
+
62
+ /* `change` is {from, to}: the ply that was showing and the ply that now is
63
+ * (the live ply when not browsing). Pages that animate the step read it;
64
+ * pages that just redraw ignore the argument, as they always have. */
65
+ function announce(change) {
66
+ draw();
67
+ if (opts.onChange) opts.onChange(change);
68
+ }
69
+
70
+ const usable = () => !opts.enabled || opts.enabled();
71
+
72
+ function draw() {
73
+ const browsing = viewing !== null;
74
+ const on = usable();
75
+ prev.disabled = !on || seek(browsing ? viewing - 1 : latest - 1, -1) === null;
76
+ next.disabled = !on || !browsing;
77
+ live.hidden = !browsing;
78
+ live.disabled = !on;
79
+ count.textContent = browsing ? viewing + " / " + latest : latest ? "move " + latest : "";
80
+ host.classList.toggle("browsing", browsing);
81
+ document.body.classList.toggle("viewing", browsing);
82
+ }
83
+
84
+ /** Step by `delta` moves. Stepping past the last move resumes the live game. */
85
+ function step(delta) {
86
+ if (!usable()) return false;
87
+ const from = viewing === null ? latest : viewing;
88
+ const target = from + delta;
89
+ if (target >= latest) {
90
+ if (viewing === null) return false;
91
+ viewing = null;
92
+ announce({ from, to: latest });
93
+ return true;
94
+ }
95
+ const found = seek(target, delta < 0 ? -1 : 1);
96
+ if (found === null || found === viewing) return false;
97
+ viewing = found;
98
+ announce({ from, to: found });
99
+ return true;
100
+ }
101
+
102
+ /** Jump back to the live game. */
103
+ function toLatest() {
104
+ if (viewing === null || !usable()) return false;
105
+ const from = viewing;
106
+ viewing = null;
107
+ announce({ from, to: latest });
108
+ return true;
109
+ }
110
+
111
+ prev.addEventListener("click", () => step(-1));
112
+ next.addEventListener("click", () => step(1));
113
+ live.addEventListener("click", toLatest);
114
+ draw();
115
+
116
+ return {
117
+ el: host,
118
+
119
+ /** Start a new game: `state` is the position after `ply` moves. */
120
+ reset(state, ply = 0) {
121
+ frames.length = 0;
122
+ latest = ply;
123
+ viewing = null;
124
+ if (state) frames[ply] = state;
125
+ draw();
126
+ },
127
+
128
+ /** Record the position after `ply` moves. Fills holes; extends the game's end. */
129
+ note(ply, state) {
130
+ if (!state || typeof ply !== "number" || ply < 0) return;
131
+ frames[ply] = state;
132
+ if (ply > latest) latest = ply;
133
+ draw();
134
+ },
135
+
136
+ /** The frame to draw, or null when the live game is what should be drawn. */
137
+ frame() {
138
+ if (viewing === null) return null;
139
+ return { ply: viewing, state: frames[viewing], log: logAt(viewing), of: latest };
140
+ },
141
+
142
+ browsing: () => viewing !== null,
143
+ latest: () => latest,
144
+ /** The recorded position after `ply` moves, or null. */
145
+ stateAt: (ply) => (has(ply) ? frames[ply] : null),
146
+ step,
147
+ toLatest,
148
+ draw,
149
+ prevButton: prev,
150
+ nextButton: next,
151
+ liveButton: live,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * ← / → anywhere on the page, except while typing in a field.
157
+ *
158
+ * Returns the listener so a host page could remove it; every page we ship keeps
159
+ * it for the life of the document.
160
+ */
161
+ export function bindHistoryKeys(nav, options = {}) {
162
+ const listener = (event) => {
163
+ const back = event.key === "ArrowLeft";
164
+ const forward = event.key === "ArrowRight";
165
+ const home = event.key === "End";
166
+ if (!back && !forward && !home) return;
167
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
168
+ const target = event.target;
169
+ if (target && /^(INPUT|SELECT|TEXTAREA)$/.test(target.tagName)) return;
170
+ if (options.enabled && !options.enabled()) return;
171
+ const moved = home ? nav.toLatest() : nav.step(back ? -1 : 1);
172
+ if (moved) event.preventDefault();
173
+ };
174
+ document.addEventListener("keydown", listener);
175
+ return listener;
176
+ }
ui/layout.js ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Board layout: how much room the two boards take.
2
+ *
3
+ * "side" keeps the whole game on one screen: the factories on the left, both
4
+ * boards — smaller, but both visible at once — beside them. "stack" is the
5
+ * classic table: big boards below the factories. The default follows the
6
+ * device — a phone starts on "side" (Rémi's phone spec), a desktop starts on
7
+ * "stack" (Rémi tried "side" there and reached for the + at once).
8
+ *
9
+ * The choice is written onto <body data-boards="…">; the page's stylesheet
10
+ * does the rest. Persisted like the animation speed, switched from the
11
+ * settings panel (the − / + row).
12
+ */
13
+
14
+ const STORE_KEY = "ludometer.boards";
15
+ const MODES = ["side", "stack"];
16
+
17
+ const defaultMode = () =>
18
+ window.matchMedia && window.matchMedia("(max-width: 720px)").matches ? "side" : "stack";
19
+
20
+ let current = "stack";
21
+ const listeners = [];
22
+
23
+ function readStored() {
24
+ try {
25
+ const raw = window.localStorage.getItem(STORE_KEY);
26
+ return MODES.includes(raw) ? raw : defaultMode();
27
+ } catch (err) {
28
+ return defaultMode(); // private mode: still the right default for the device
29
+ }
30
+ }
31
+
32
+ /** The current mode: "side" or "stack". */
33
+ export const boardsMode = () => current;
34
+
35
+ /** Choose. Persists, writes the body attribute, tells every listener. */
36
+ export function setBoardsMode(mode) {
37
+ current = MODES.includes(mode) ? mode : "side";
38
+ try {
39
+ window.localStorage.setItem(STORE_KEY, current);
40
+ } catch (err) {
41
+ /* forgetting the choice is no reason to refuse it */
42
+ }
43
+ document.body.dataset.boards = current;
44
+ listeners.forEach((fn) => fn(current));
45
+ return current;
46
+ }
47
+
48
+ export function onBoardsChange(fn) {
49
+ listeners.push(fn);
50
+ return () => listeners.splice(listeners.indexOf(fn), 1);
51
+ }
52
+
53
+ /** Load the stored choice and stamp it on the body. Call once, early. */
54
+ export function initBoards() {
55
+ current = readStored();
56
+ document.body.dataset.boards = current;
57
+ return current;
58
+ }
ui/log.js ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The move log: every move of the game, in one flat list.
2
+ *
3
+ * Newest at the **top**. You look at a log to check what just happened far more
4
+ * often than to read the game from the beginning, and putting the newest line
5
+ * where your eye already is saves a scroll on every single turn.
6
+ *
7
+ * Deliberately uniform — same type, same weight, same rule for every line,
8
+ * including the newest one. A log entry is a record, not a headline; the status
9
+ * band is where "what is happening now" lives, and highlighting the top line
10
+ * would make this list compete with it.
11
+ *
12
+ * Moves are drawn **pictographically**: the tiles that moved are little tiles,
13
+ * in the same glazes they have on the board, so "took 3 ochre" is three ochre
14
+ * squares rather than a colour word you have to translate back into a colour.
15
+ * Only the places stay as words, because "factory 3" has no picture.
16
+ *
17
+ * An entry is `{n, kind, text, ply?, side?, coach?}`, and a move entry also
18
+ * carries `{count, color, source, dest, took_marker?, overflow?}` — with those
19
+ * missing, the entry falls back to its sentence, so any port that has not
20
+ * caught up still reads correctly.
21
+ */
22
+
23
+ import { CENTER, COLORS, FLOOR, node } from "./dom.js";
24
+
25
+ const TAGS = { move: "", round: "round", end: "final", think: "search", start: "setup" };
26
+
27
+ /** Short enough to sit next to the tiles: "factory 3", "middle", "row 5". */
28
+ const shortSource = (source) => (source === CENTER ? "middle" : "factory " + (source + 1));
29
+ const shortDest = (dest) => (dest === FLOOR ? "floor" : "row " + (dest + 1));
30
+
31
+ /** One tile, at log size. */
32
+ export function glyph(color) {
33
+ const g = node("i", "glyph");
34
+ g.dataset.color = color;
35
+ g.title = COLORS[color];
36
+ return g;
37
+ }
38
+
39
+ /** `count` tiles of `color`, as a row of little tiles. */
40
+ export function glyphRun(color, count, cls) {
41
+ const wrap = node("span", "glyphs" + (cls ? " " + cls : ""));
42
+ for (let i = 0; i < count; i++) wrap.appendChild(glyph(color));
43
+ return wrap;
44
+ }
45
+
46
+ /** How the delta reads: at the AI's choice, a slip, or a real mistake. */
47
+ export function grade(delta) {
48
+ if (delta === null || delta === undefined) return "unrated";
49
+ if (delta >= -0.005) return "best";
50
+ if (delta >= -0.05) return "slip";
51
+ return "blunder";
52
+ }
53
+
54
+ export function formatDelta(delta) {
55
+ if (delta === null || delta === undefined) return "unrated";
56
+ const rounded = Math.abs(delta) < 0.005 ? 0 : delta;
57
+ return (rounded === 0 ? "0.00" : rounded.toFixed(2)).replace("-", "−");
58
+ }
59
+
60
+ /** The coach's verdict on one move, as a small inline chip. */
61
+ export function coachChip(coach) {
62
+ const chip = node("span", "coach-chip");
63
+ if (!coach) return chip;
64
+ if (coach.pending) {
65
+ chip.dataset.grade = "pending";
66
+ chip.appendChild(node("span", "coach-delta", "rating…"));
67
+ return chip;
68
+ }
69
+ if (coach.unrated) {
70
+ chip.dataset.grade = "unrated";
71
+ chip.appendChild(node("span", "coach-delta", "unrated"));
72
+ chip.appendChild(
73
+ node("span", "coach-why", coach.reason || "the search never explored this move")
74
+ );
75
+ return chip;
76
+ }
77
+ chip.dataset.grade = grade(coach.delta);
78
+ chip.appendChild(node("span", "coach-delta", formatDelta(coach.delta)));
79
+ if (coach.best_text && coach.delta <= -0.02) {
80
+ chip.appendChild(node("span", "coach-why", "AI preferred: " + coach.best_text));
81
+ } else if (coach.forced) {
82
+ chip.appendChild(node("span", "coach-why", "only move"));
83
+ }
84
+ return chip;
85
+ }
86
+
87
+ /** Whether an entry carries enough to be drawn as pictures. */
88
+ function isPictorial(entry) {
89
+ return (
90
+ entry.kind === "move" &&
91
+ typeof entry.color === "number" &&
92
+ typeof entry.count === "number" &&
93
+ typeof entry.source === "number" &&
94
+ typeof entry.dest === "number"
95
+ );
96
+ }
97
+
98
+ /** The body of one move entry: tiles, then where from and where to. */
99
+ function moveBody(entry) {
100
+ const body = node("span", "log-text");
101
+ body.appendChild(glyphRun(entry.color, entry.count));
102
+ if (entry.took_marker) {
103
+ const wrap = node("span", "glyphs");
104
+ const chip = node("i", "glyph marker-glyph", "1");
105
+ chip.title = "and the first-player marker";
106
+ wrap.appendChild(chip);
107
+ body.appendChild(wrap);
108
+ }
109
+ body.appendChild(node("span", "log-where", shortSource(entry.source)));
110
+ body.appendChild(node("span", "log-arrow", "→"));
111
+ body.appendChild(node("span", "log-where", shortDest(entry.dest)));
112
+ if (entry.overflow) {
113
+ body.appendChild(
114
+ node("span", "log-aside", " · " + entry.overflow + " to the floor")
115
+ );
116
+ }
117
+ body.title = entry.text || "";
118
+ return body;
119
+ }
120
+
121
+ /**
122
+ * Draw the whole log into `host` (an `<ol>`), **newest first**.
123
+ *
124
+ * `entries` stays in play order — the reversing happens here, so callers never
125
+ * have to think about it and the ply numbers keep meaning what they say.
126
+ */
127
+ export function renderLog(host, entries) {
128
+ host.innerHTML = "";
129
+ const list = (entries || []).slice().reverse();
130
+ list.forEach((entry) => {
131
+ const li = node("li", "log-entry");
132
+ li.dataset.kind = entry.kind;
133
+ if (entry.side) li.dataset.side = entry.side;
134
+ if (entry.ply !== undefined) li.dataset.ply = entry.ply;
135
+ const tag = entry.kind === "move" ? entry.side || "" : TAGS[entry.kind] || entry.kind;
136
+ li.appendChild(node("span", "log-tag", tag === "human" ? "you" : tag));
137
+ const body = isPictorial(entry) ? moveBody(entry) : node("span", "log-text", entry.text);
138
+ if (entry.coach) body.appendChild(coachChip(entry.coach));
139
+ li.appendChild(body);
140
+ host.appendChild(li);
141
+ });
142
+ host.scrollTop = 0; // the newest line is at the top, so that is where we look
143
+ }
ui/popups.js ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Score pop-ups: every point announced where it is earned.
2
+ *
3
+ * A pop is a small number that floats off the square that produced it — "+3"
4
+ * off a wall tile as it lands, "−2" off a floor line as it bills you. They
5
+ * exist to teach: watching the numbers appear one by one is how a new player
6
+ * learns what a position is worth, so they are ON by default and switchable in
7
+ * the settings panel (persisted, like the animation speed).
8
+ *
9
+ * Pops live in their own fixed layer, deliberately not the flight layer — a
10
+ * number is not a tile, and the tests that count tile flights must not count
11
+ * these.
12
+ *
13
+ * popScore(layer, cellEl, "+3"); // a gain, in the gain colour
14
+ * popScore(layer, floorEl, "−2", "loss"); // a cost, in the loss colour
15
+ */
16
+
17
+ const STORE_KEY = "ludometer.pops";
18
+
19
+ let current = true;
20
+ const listeners = [];
21
+
22
+ function readStored() {
23
+ try {
24
+ const raw = window.localStorage.getItem(STORE_KEY);
25
+ return raw === null ? true : raw !== "off";
26
+ } catch (err) {
27
+ return true; // private mode: teach anyway
28
+ }
29
+ }
30
+
31
+ /** Whether score pop-ups are on. */
32
+ export const popupsOn = () => current;
33
+
34
+ /** Choose. Persists, and tells every listener. */
35
+ export function setPopups(on) {
36
+ current = !!on;
37
+ try {
38
+ window.localStorage.setItem(STORE_KEY, current ? "on" : "off");
39
+ } catch (err) {
40
+ /* forgetting the choice is no reason to refuse it */
41
+ }
42
+ listeners.forEach((fn) => fn(current));
43
+ return current;
44
+ }
45
+
46
+ export function onPopupsChange(fn) {
47
+ listeners.push(fn);
48
+ return () => listeners.splice(listeners.indexOf(fn), 1);
49
+ }
50
+
51
+ /** Load the stored choice. Call once, early. */
52
+ export function initPopups() {
53
+ current = readStored();
54
+ return current;
55
+ }
56
+
57
+ /**
58
+ * Float `text` off `target` (an element or a client rectangle) into `layer`.
59
+ *
60
+ * `kind` is "gain" (default) or "loss" and only picks the colour. The pop
61
+ * removes itself; nothing waits on it. A no-op while pop-ups are off, or when
62
+ * the target has no on-screen rectangle.
63
+ */
64
+ export function popScore(layer, target, text, kind) {
65
+ if (!current || !layer || !target) return;
66
+ const r = typeof target.getBoundingClientRect === "function"
67
+ ? target.getBoundingClientRect()
68
+ : target;
69
+ if (!r || (!r.width && !r.height)) return;
70
+ const pop = document.createElement("span");
71
+ pop.className = "score-pop";
72
+ pop.dataset.kind = kind || "gain";
73
+ pop.textContent = text;
74
+ pop.style.left = r.left + r.width / 2 + "px";
75
+ pop.style.top = r.top + "px";
76
+ layer.appendChild(pop);
77
+ setTimeout(() => pop.remove(), 1400);
78
+ }
ui/scoring.js ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Scoring, shown on the table instead of over it.
2
+ *
3
+ * Two panels, both inline: the round tally (what each wall tile earned, what the
4
+ * floor cost) and the final reckoning (the end-game bonuses, broken down). They
5
+ * sit in a two-column grid under the boards, left column = left board, so each
6
+ * player's numbers stay under their own wall and the position behind them is
7
+ * still fully readable.
8
+ *
9
+ * Both take the server's own report objects — no number here is re-derived.
10
+ */
11
+
12
+ import { COLORS, node, tileEl } from "./dom.js";
13
+
14
+ function miniTile(color) {
15
+ const t = tileEl(color, "mini");
16
+ return t;
17
+ }
18
+
19
+ function playerCard(label, kids) {
20
+ const card = node("div", "score-card");
21
+ card.appendChild(node("h3", "score-card-who", label));
22
+ kids.forEach((k) => card.appendChild(k));
23
+ return card;
24
+ }
25
+
26
+ function line(text) {
27
+ return node("p", "score-line", text);
28
+ }
29
+
30
+ /** One player's round: the tiles that reached the wall, and the floor's bill. */
31
+ function roundCard(report, seat, label) {
32
+ const p = report.players[seat];
33
+ const kids = [];
34
+ const delta = node(
35
+ "p",
36
+ "score-delta" + (p.delta < 0 ? " down" : p.delta > 0 ? " up" : ""),
37
+ (p.delta >= 0 ? "+" + p.delta : String(p.delta)) + " → " + p.score_after
38
+ );
39
+ kids.push(delta);
40
+
41
+ if (p.tiles.length) {
42
+ const placed = node("div", "score-tiles");
43
+ p.tiles.forEach((t) => {
44
+ const box = node("div", "score-tile");
45
+ box.appendChild(miniTile(t.color));
46
+ box.appendChild(node("span", "pts", "+" + t.points));
47
+ box.appendChild(node("span", "where", "r" + (t.row + 1) + "·c" + (t.col + 1)));
48
+ box.title =
49
+ t.color_name +
50
+ " → row " +
51
+ (t.row + 1) +
52
+ ", column " +
53
+ (t.col + 1) +
54
+ " (run of " +
55
+ t.h_run +
56
+ " across, " +
57
+ t.v_run +
58
+ " down)";
59
+ placed.appendChild(box);
60
+ });
61
+ kids.push(placed);
62
+ kids.push(
63
+ line(
64
+ "Wall: +" +
65
+ p.tiling_points +
66
+ " from " +
67
+ p.tiles.length +
68
+ " tile" +
69
+ (p.tiles.length > 1 ? "s" : "")
70
+ )
71
+ );
72
+ } else {
73
+ kids.push(line("No pattern line was full. Nothing reached the wall."));
74
+ }
75
+
76
+ const floorBits = [];
77
+ if (p.floor.tiles.length) {
78
+ floorBits.push(p.floor.tiles.length + " tile" + (p.floor.tiles.length > 1 ? "s" : ""));
79
+ }
80
+ if (p.floor.marker) floorBits.push("the marker");
81
+ kids.push(
82
+ line(
83
+ floorBits.length
84
+ ? "Floor: " + floorBits.join(" and ") + " → " + p.floor.penalty
85
+ : "Floor: clean."
86
+ )
87
+ );
88
+ if (p.carried_rows.length) {
89
+ kids.push(
90
+ line(
91
+ "Carried over: " +
92
+ p.carried_rows
93
+ .map((r) => r.count + " " + COLORS[r.color] + " in row " + (r.row + 1))
94
+ .join(", ")
95
+ )
96
+ );
97
+ }
98
+ return playerCard(label, kids);
99
+ }
100
+
101
+ /**
102
+ * Draw the round tally into `host`.
103
+ * `sides` is `[[seat, label], [seat, label]]`, left column first.
104
+ */
105
+ export function renderRoundPanel(host, report, sides) {
106
+ host.innerHTML = "";
107
+ host.classList.add("scoring");
108
+ host.hidden = false;
109
+ const head = node("div", "scoring-head");
110
+ head.appendChild(node("h2", "scoring-title", "Round " + (report.round + 1) + " scored"));
111
+ if (!report.game_over) {
112
+ const yours = sides[0][0] === report.next_first_player;
113
+ const who = yours ? sides[0][1] + " start" : sides[1][1] + " starts";
114
+ head.appendChild(node("span", "scoring-note", who + " the next round"));
115
+ }
116
+ host.appendChild(head);
117
+ const grid = node("div", "scoring-grid");
118
+ sides.forEach(([seat, label]) => grid.appendChild(roundCard(report, seat, label)));
119
+ host.appendChild(grid);
120
+ }
121
+
122
+ /** One player's end-game bonuses, itemised. */
123
+ function bonusCard(final, seat, label) {
124
+ const b = final.bonuses[seat];
125
+ const kids = [];
126
+ const total = node("p", "score-delta big", String(b.final_score));
127
+ if (final.winner === seat) total.classList.add("won");
128
+ kids.push(total);
129
+
130
+ const rows = [
131
+ ["On the board", b.score_before_bonus, ""],
132
+ ["Full rows ×2", "+" + b.row_points, b.rows + " row" + (b.rows === 1 ? "" : "s")],
133
+ ["Full columns ×7", "+" + b.col_points, b.cols + " column" + (b.cols === 1 ? "" : "s")],
134
+ [
135
+ "All five of a colour ×10",
136
+ "+" + b.color_points,
137
+ b.colors + " colour" + (b.colors === 1 ? "" : "s"),
138
+ ],
139
+ ["Bonus", "+" + b.total, ""],
140
+ ];
141
+ const table = node("dl", "bonus-list");
142
+ rows.forEach(([name, value, note], i) => {
143
+ const dt = node("dt", null, name);
144
+ const dd = node("dd", null, String(value));
145
+ if (i === rows.length - 1) {
146
+ dt.classList.add("sum");
147
+ dd.classList.add("sum");
148
+ }
149
+ if (note) dd.title = note;
150
+ table.append(dt, dd);
151
+ });
152
+ kids.push(table);
153
+ return playerCard(label, kids);
154
+ }
155
+
156
+ /** Draw the final reckoning into `host` — the board above stays readable. */
157
+ export function renderFinalPanel(host, final, sides) {
158
+ host.innerHTML = "";
159
+ host.classList.add("scoring", "final");
160
+ host.hidden = false;
161
+ const head = node("div", "scoring-head");
162
+ head.appendChild(node("h2", "scoring-title", final.headline));
163
+ head.appendChild(
164
+ node(
165
+ "span",
166
+ "scoring-note",
167
+ "Final score " +
168
+ final.scores[sides[0][0]] +
169
+ "–" +
170
+ final.scores[sides[1][0]] +
171
+ " after " +
172
+ final.rounds_played +
173
+ " rounds"
174
+ )
175
+ );
176
+ host.appendChild(head);
177
+ const grid = node("div", "scoring-grid");
178
+ sides.forEach(([seat, label]) => grid.appendChild(bonusCard(final, seat, label)));
179
+ host.appendChild(grid);
180
+ if (final.winner === null) {
181
+ host.appendChild(line("Level on score and on completed rows. The rulebook calls that a draw."));
182
+ }
183
+ if (final.exhausted) {
184
+ host.appendChild(line("The bag and the lid ran dry, so the game stopped early."));
185
+ }
186
+ }
187
+
188
+ /** Hide whichever panel is showing (a new deal, or the next round starting). */
189
+ export function clearScoring(host) {
190
+ host.innerHTML = "";
191
+ host.hidden = true;
192
+ host.classList.remove("final");
193
+ }
ui/settings.js ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The settings panel: a gear, and what is behind it.
2
+ *
3
+ * Inline, never a pop-up — it opens by pushing the table down, the same way
4
+ * everything else on this page reports itself. Animation speed is always here
5
+ * (it exists because the alternative was letting the operating system decide —
6
+ * see animate.js for that story); a page that shows score pop-ups asks for
7
+ * their switch with `{popups: true}`, a page that places moves before
8
+ * playing them asks for the confirm switch with `{confirm: true}`, and a
9
+ * page with the side-by-side table asks for the board-size − / + row with
10
+ * `{boards: true}` (see layout.js).
11
+ *
12
+ * const settings = createSettings(host); // speed only
13
+ * const settings = createSettings(host, {popups: true, confirm: true});
14
+ *
15
+ * Every control reads its stored value through its own module and writes it
16
+ * back on every click, so a reload keeps whatever you chose.
17
+ */
18
+
19
+ import { SPEEDS, initSpeed, prefersReducedMotion, setSpeed, speed } from "./animate.js";
20
+ import { confirmOn, initConfirm, setConfirm } from "./confirm.js";
21
+ import { boardsMode, initBoards, setBoardsMode } from "./layout.js";
22
+ import { initPopups, popupsOn, setPopups } from "./popups.js";
23
+ import { node } from "./dom.js";
24
+
25
+ const LABELS = { 0: "Off", 0.5: "0.5×", 1: "1×", 2: "2×" };
26
+ const TITLES = {
27
+ 0: "No tile animation: positions change at once",
28
+ 0.5: "Half speed: every tile easy to follow",
29
+ 1: "The default pace",
30
+ 2: "Twice as fast",
31
+ };
32
+
33
+ /* A gear, drawn rather than fetched — this page never asks the network for a
34
+ * byte, not even an icon. Parsed as HTML so the SVG namespace comes for free
35
+ * and this file need not name a URL of any kind. */
36
+ const GEAR_SVG =
37
+ '<svg viewBox="0 0 24 24" class="gear-icon" aria-hidden="true"><path d="' +
38
+ "M12 8.4a3.6 3.6 0 1 0 0 7.2 3.6 3.6 0 0 0 0-7.2zm9 4.9v-2.6l-2.4-.4a6.9 6.9 0 0 0-.8-1.9" +
39
+ "l1.4-2-1.8-1.8-2 1.4a6.9 6.9 0 0 0-1.9-.8L13.1 3h-2.6l-.4 2.4a6.9 6.9 0 0 0-1.9.8l-2-1.4" +
40
+ "-1.8 1.8 1.4 2a6.9 6.9 0 0 0-.8 1.9L3 10.7v2.6l2.4.4c.2.7.4 1.3.8 1.9l-1.4 2 1.8 1.8 2-1.4" +
41
+ "c.6.4 1.2.6 1.9.8l.4 2.4h2.6l.4-2.4c.7-.2 1.3-.4 1.9-.8l2 1.4 1.8-1.8-1.4-2c.4-.6.6-1.2.8-1.9l2.4-.4z" +
42
+ '"/></svg>';
43
+
44
+ function gearIcon() {
45
+ const holder = document.createElement("span");
46
+ holder.innerHTML = GEAR_SVG;
47
+ return holder.firstChild;
48
+ }
49
+
50
+ export function createSettings(host, options = {}) {
51
+ host.classList.add("settings");
52
+ host.innerHTML = "";
53
+
54
+ const head = node("div", "settings-head");
55
+ const gear = node("button", "gear");
56
+ gear.type = "button";
57
+ gear.setAttribute("aria-expanded", "false");
58
+ gear.appendChild(gearIcon());
59
+ gear.appendChild(node("span", null, "Settings"));
60
+ const summary = node("span", "settings-note", "");
61
+ head.append(gear, summary);
62
+
63
+ const panel = node("div", "settings-panel");
64
+ panel.hidden = true;
65
+ const label = node("span", "settings-label", "Tile animation");
66
+ const speeds = node("div", "speeds");
67
+ speeds.setAttribute("role", "group");
68
+ speeds.setAttribute("aria-label", "Animation speed");
69
+ const hint = node("p", "settings-hint", "");
70
+ panel.append(label, speeds, hint);
71
+
72
+ const buttons = SPEEDS.map((value) => {
73
+ const b = node("button", "speed", LABELS[value]);
74
+ b.type = "button";
75
+ b.dataset.speed = String(value);
76
+ b.title = TITLES[value];
77
+ b.addEventListener("click", () => {
78
+ setSpeed(value);
79
+ sync();
80
+ if (options.onChange) options.onChange(value);
81
+ });
82
+ speeds.appendChild(b);
83
+ return b;
84
+ });
85
+
86
+ /** One On/Off pair in the panel, wired to a stored preference. */
87
+ function flagRow(label, dataKey, titles, get, set) {
88
+ panel.append(node("i", "settings-gap"), node("span", "settings-label", label));
89
+ const flags = node("div", "speeds");
90
+ flags.setAttribute("role", "group");
91
+ flags.setAttribute("aria-label", label);
92
+ const pair = [true, false].map((value) => {
93
+ const b = node("button", "flag", value ? "On" : "Off");
94
+ b.type = "button";
95
+ b.dataset[dataKey] = String(value);
96
+ b.title = titles[value ? 0 : 1];
97
+ b.addEventListener("click", () => {
98
+ set(value);
99
+ sync();
100
+ if (options.onChange) options.onChange(speed());
101
+ });
102
+ flags.appendChild(b);
103
+ return b;
104
+ });
105
+ panel.append(flags);
106
+ return { pair, get, dataKey };
107
+ }
108
+
109
+ const flagRows = [];
110
+ if (options.popups) {
111
+ initPopups();
112
+ flagRows.push(
113
+ flagRow(
114
+ "Score pop-ups",
115
+ "pops",
116
+ [
117
+ "Every point floats off the square that earned it as the round is scored",
118
+ "Scoring stays in the panel below the boards",
119
+ ],
120
+ popupsOn,
121
+ setPopups
122
+ )
123
+ );
124
+ }
125
+ if (options.confirm) {
126
+ initConfirm();
127
+ flagRows.push(
128
+ flagRow(
129
+ "Confirm each move",
130
+ "confirm",
131
+ [
132
+ "Clicking a row places the tiles; the move plays only when you confirm it",
133
+ "Clicking a row plays the move at once",
134
+ ],
135
+ confirmOn,
136
+ setConfirm
137
+ )
138
+ );
139
+ }
140
+
141
+ /* Board size is a − / + pair, not On/Off: − keeps the whole game on one
142
+ * screen (factories left, both boards beside them), + gives the big boards
143
+ * back, one under the other. The page's stylesheet reads the choice off
144
+ * <body data-boards>. */
145
+ let boardButtons = [];
146
+ if (options.boards) {
147
+ initBoards();
148
+ panel.append(node("i", "settings-gap"), node("span", "settings-label", "Board size"));
149
+ const sizes = node("div", "speeds");
150
+ sizes.setAttribute("role", "group");
151
+ sizes.setAttribute("aria-label", "Board size");
152
+ boardButtons = [
153
+ ["side", "−", "Everything on one screen: the factories left, both boards beside them"],
154
+ ["stack", "+", "The classic table: big boards below the factories"],
155
+ ].map(([mode, label, title]) => {
156
+ const b = node("button", "flag", label);
157
+ b.type = "button";
158
+ b.dataset.boards = mode;
159
+ b.title = title;
160
+ b.addEventListener("click", () => {
161
+ setBoardsMode(mode);
162
+ sync();
163
+ if (options.onChange) options.onChange(speed());
164
+ });
165
+ sizes.appendChild(b);
166
+ return b;
167
+ });
168
+ panel.append(sizes);
169
+ }
170
+
171
+ host.append(head, panel);
172
+
173
+ function sync() {
174
+ const now = speed();
175
+ buttons.forEach((b) => {
176
+ b.setAttribute("aria-pressed", String(Number(b.dataset.speed) === now));
177
+ });
178
+ flagRows.forEach(({ pair, get, dataKey }) => {
179
+ pair.forEach((b) => {
180
+ b.setAttribute("aria-pressed", String((b.dataset[dataKey] === "true") === get()));
181
+ });
182
+ });
183
+ boardButtons.forEach((b) => {
184
+ b.setAttribute("aria-pressed", String(b.dataset.boards === boardsMode()));
185
+ });
186
+ const bits = [now ? "animation " + LABELS[now] : "animation off"];
187
+ if (options.popups) bits.push(popupsOn() ? "pop-ups on" : "pop-ups off");
188
+ if (options.confirm) bits.push(confirmOn() ? "confirm on" : "confirm off");
189
+ if (options.boards) bits.push(boardsMode() === "stack" ? "boards +" : "boards −");
190
+ summary.textContent = bits.join(" · ");
191
+ hint.textContent = prefersReducedMotion()
192
+ ? "Your system asks apps to reduce motion. The tiles still move here, because " +
193
+ "this switch (not that one) decides. Pick Off if you would rather they did not."
194
+ : "Tiles always travel to where they land. Slow it down to follow a long turn, " +
195
+ "or turn it off for instant play.";
196
+ }
197
+
198
+ gear.addEventListener("click", () => {
199
+ const opening = panel.hidden;
200
+ panel.hidden = !opening;
201
+ host.classList.toggle("open", opening);
202
+ gear.setAttribute("aria-expanded", String(opening));
203
+ });
204
+
205
+ initSpeed();
206
+ sync();
207
+
208
+ // `panel` is handed back so a page can seat its own controls among these —
209
+ // the player page moves its "AI thinks for" clock in here
210
+ return { el: host, sync, open: () => gear.click(), panel };
211
+ }
ui/status.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The status band: one glance, whole game state.
2
+ *
3
+ * A single line of display type says what is happening right now ("Your turn —
4
+ * pick a colour", "AI is thinking — 2.1s of 5s", "You won 74–68"), a second line
5
+ * carries the detail, and the running score sits on the right. When the AI is
6
+ * searching, the glaze bar under the text fills for its budget: the band is the
7
+ * clock, so nothing has to cover the board to tell you to wait.
8
+ *
9
+ * const status = createStatus(el);
10
+ * status.set({ headline: "Your turn", detail: "pick a colour", tone: "you" });
11
+ * status.startClock({ budget: 5, label: (s) => `AI is thinking — ${s}s` });
12
+ */
13
+
14
+ import { node } from "./dom.js";
15
+
16
+ export function createStatus(host) {
17
+ host.classList.add("status");
18
+ host.setAttribute("role", "status");
19
+ host.setAttribute("aria-live", "polite");
20
+
21
+ const text = node("div", "status-text");
22
+ const headline = node("p", "status-headline", "");
23
+ const detail = node("p", "status-detail", "");
24
+ text.append(headline, detail);
25
+
26
+ const tally = node("div", "status-score");
27
+ const you = scoreCell("You");
28
+ const them = scoreCell("AI");
29
+ tally.append(you.el, node("span", "status-dash", "–"), them.el);
30
+
31
+ const bar = node("div", "status-bar");
32
+ const fill = node("i", "status-fill");
33
+ bar.appendChild(fill);
34
+
35
+ host.append(text, tally, bar);
36
+
37
+ let timer = null;
38
+
39
+ function scoreCell(label) {
40
+ const el = node("div", "status-side");
41
+ const name = node("span", "status-side-name", label);
42
+ const value = node("span", "status-side-score", "0");
43
+ el.append(value, name);
44
+ return { el, name, value };
45
+ }
46
+
47
+ function stopClock() {
48
+ if (timer) {
49
+ clearInterval(timer);
50
+ timer = null;
51
+ }
52
+ host.classList.remove("ticking");
53
+ fill.style.width = "0%";
54
+ }
55
+
56
+ return {
57
+ el: host,
58
+ /** Set the whole band. `tone` styles it: you / ai / scoring / end / idle. */
59
+ set(next) {
60
+ const info = next || {};
61
+ headline.textContent = info.headline || "";
62
+ detail.textContent = info.detail || "";
63
+ host.dataset.tone = info.tone || "idle";
64
+ if (!info.keepClock) stopClock();
65
+ },
66
+ /** Update the two-sided score readout. */
67
+ setScore(left, right, leftLabel, rightLabel) {
68
+ you.value.textContent = left;
69
+ them.value.textContent = right;
70
+ if (leftLabel) you.name.textContent = leftLabel;
71
+ if (rightLabel) them.name.textContent = rightLabel;
72
+ you.el.classList.toggle("leading", left > right);
73
+ them.el.classList.toggle("leading", right > left);
74
+ },
75
+ /**
76
+ * Run the clock in the band. `label(spent, budget)` writes the headline;
77
+ * with a budget the glaze bar fills, without one it sweeps.
78
+ */
79
+ startClock(options) {
80
+ const opts = options || {};
81
+ const budget = Number(opts.budget) || 0;
82
+ const started = Date.now();
83
+ stopClock();
84
+ host.classList.add("ticking");
85
+ host.classList.toggle("indeterminate", !budget);
86
+ const tick = () => {
87
+ const spent = (Date.now() - started) / 1000;
88
+ if (opts.label) headline.textContent = opts.label(spent, budget);
89
+ if (budget) fill.style.width = Math.min(100, (spent / budget) * 100) + "%";
90
+ };
91
+ tick();
92
+ timer = setInterval(tick, 100);
93
+ },
94
+ stopClock,
95
+ };
96
+ }
ui/theme.css ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* theme.css — every colour this game is made of, in one place.
2
+ *
3
+ * If you want to restyle the table, this is the only file you need to open.
4
+ * Nothing below `:root` invents a colour: board.css, style.css and the JS
5
+ * modules all draw from these custom properties. See THEMING.md for how to add
6
+ * a skin.
7
+ *
8
+ * ─────────────────────────────────────────────────────────────────────────────
9
+ * The one rule this palette is built on: **hue means occupied.**
10
+ *
11
+ * The board is mostly empty space — 25 wall squares, 15 pattern-line slots and
12
+ * 7 floor slots per player — and for a long time all of them were painted in a
13
+ * pale tint of the colour that belongs there. The result was a mosaic of forty
14
+ * pastel squares in which the five real tiles had to be hunted for. So: a tile
15
+ * that is *there* is saturated, raised and rimmed; a slot that is *empty* is a
16
+ * neutral, recessed well with no glaze in it at all. The wall keeps its pattern
17
+ * as a thin outlined diamond in the tile's own ink — enough to plan with, far
18
+ * too faint to mistake for a tile. From a metre away the board now reads as
19
+ * holes with a few jewels in it, which is what the cardboard one looks like.
20
+ * ───────────────────────────────────────────────────────────────────────────── */
21
+
22
+ :root {
23
+ color-scheme: light;
24
+
25
+ /* ============================================================ the table ==
26
+ * Linen cloth, plaster boards, mortar between the wall squares. */
27
+ --linen: #e6dbc6;
28
+ --linen-dark: #d8c9ae;
29
+ --linen-warm: #efe6d3;
30
+ --linen-thread: rgba(255, 255, 255, .35);
31
+
32
+ --plaster: #faf6ec;
33
+ --plaster-hi: #fdfbf5;
34
+ --plaster-edge: #e2d7bd;
35
+ --plaster-sunk: #f2ebda;
36
+
37
+ --grout: #bcab8c;
38
+ --grout-deep: #a3906f;
39
+
40
+ --ink: #2c2722;
41
+ --ink-soft: #6a5d4e;
42
+ --ink-faint: #92836f;
43
+
44
+ /* ======================================================== the five glazes ==
45
+ * Matched to the tiles you hold in your hand: deep cobalt, mustard ochre,
46
+ * brick terracotta, near-black charcoal, and the pale one — "ice", a bright
47
+ * cyan-turquoise that must read lighter than the cobalt at a glance.
48
+ *
49
+ * --cN the glaze itself (what a tile *is*)
50
+ * --cN-hi the lit edge, top-left
51
+ * --cN-lo the shaded edge, bottom-right
52
+ * --cN-rim the fired rim drawn around a placed tile
53
+ * --cN-ink the same hue as a line: outlines, text, the wall's diamonds
54
+ * --cN-wash a transparent wash of the hue, for highlight backgrounds */
55
+ --c0: #16509f; --c0-hi: #5b8dd2; --c0-lo: #0a2f61; --c0-rim: #082851; --c0-ink: #123f7d;
56
+ --c1: #e2a41f; --c1-hi: #f7ca55; --c1-lo: #96680a; --c1-rim: #7c5507; --c1-ink: #8a6009;
57
+ --c2: #b8402c; --c2-hi: #dc7761; --c2-lo: #77220f; --c2-rim: #611a0b; --c2-ink: #8c2c18;
58
+ --c3: #262b31; --c3-hi: #5d6673; --c3-lo: #0b0e12; --c3-rim: #000; --c3-ink: #2b3038;
59
+ --c4: #4cc3d8; --c4-hi: #93e2ef; --c4-lo: #1b8399; --c4-rim: #146b7e; --c4-ink: #12707f;
60
+
61
+ --c0-wash: rgba(22, 80, 159, .12);
62
+ --c1-wash: rgba(226, 164, 31, .14);
63
+ --c2-wash: rgba(184, 64, 44, .12);
64
+ --c3-wash: rgba(38, 43, 49, .12);
65
+ --c4-wash: rgba(76, 195, 216, .16);
66
+
67
+ /* ========================================================== empty slots ==
68
+ * One neutral, for every empty square on the board. Deliberately duller and
69
+ * darker than any glaze and than the plaster around it, so a hole looks like
70
+ * a hole. `--slot-motif` is how strongly the wall's colour hint shows, and
71
+ * `--cN-motif` is the ink it is drawn in — the full glaze by default, so you
72
+ * can read which colour belongs in a square from across the table. The square
73
+ * stays a neutral well; only the outlined diamond in it carries the hue. */
74
+ --slot: #c6bda9;
75
+ --slot-lo: #b0a58e;
76
+ --slot-rim: #978b73;
77
+ --slot-shade: rgba(72, 56, 30, .34);
78
+ --slot-motif: .9;
79
+ --c0-motif: var(--c0);
80
+ --c1-motif: var(--c1);
81
+ --c2-motif: var(--c2);
82
+ --c3-motif: var(--c3);
83
+ --c4-motif: var(--c4);
84
+ /* the motif's body: the same glaze thinned to a matte wash, as if a drop of
85
+ * it had sunk into the plaster. Flat and translucent on purpose — a tile is
86
+ * glossy, raised and opaque, and this must never be mistaken for one. */
87
+ --c0-ghost: rgba(22, 80, 159, .34);
88
+ --c1-ghost: rgba(226, 164, 31, .38);
89
+ --c2-ghost: rgba(184, 64, 44, .34);
90
+ --c3-ghost: rgba(38, 43, 49, .30);
91
+ --c4-ghost: rgba(76, 195, 216, .38);
92
+
93
+ /* ============================================================== fixtures ==
94
+ * The bowls the tiles sit in, the marker chip, the lid. */
95
+ --dish: #f4ead4;
96
+ --dish-hi: #fffdf6;
97
+ --dish-lo: #dccdb0;
98
+ --dish-rim: var(--grout);
99
+ --marker-face: #fffdf4;
100
+ --marker-lo: #e9dcc0;
101
+ --lid-receiving: rgba(226, 164, 31, .2);
102
+
103
+ /* ============================================================== meaning ==
104
+ * The four things the status band can be about, and the coach's three
105
+ * verdicts. Every one of them is a glaze, so the page never invents an
106
+ * accent colour that is not on the table. */
107
+ --tone-you: var(--c4);
108
+ --tone-ai: var(--c0);
109
+ --tone-scoring: var(--c1);
110
+ --tone-end: var(--c2);
111
+ --tone-idle: var(--grout);
112
+
113
+ --grade-best: var(--c0-ink);
114
+ --grade-slip: var(--c1-ink);
115
+ --grade-blunder: var(--c2-ink);
116
+
117
+ --gain: var(--c0-ink);
118
+ --loss: var(--c2-ink);
119
+
120
+ /* ================================================================ chrome ==
121
+ * Panels, buttons, fields — the furniture around the table. */
122
+ --panel-top: #fdfbf5;
123
+ --panel-bottom: var(--plaster);
124
+ --panel-edge: var(--plaster-edge);
125
+ --field-bg: #fffdf7;
126
+ --btn-top: #fffdf8;
127
+ --btn-bottom: #f0e7d5;
128
+ --btn-top-hover: #fffefb;
129
+ --btn-bottom-hover: #f6efe1;
130
+ --btn-primary-top: #3d76bd;
131
+ --btn-primary-bottom: var(--c0-lo);
132
+ --btn-primary-top-hover: #4d84c8;
133
+ --btn-primary-bottom-hover: #1c5296;
134
+ --btn-primary-ink: #fdfaf2;
135
+ --focus-ring: var(--c4-lo);
136
+
137
+ --topbar-top: #fdfaf3;
138
+ --topbar-bottom: #f4ecdc;
139
+ --engine-top: #f6efe0;
140
+ --engine-bottom: #f1e8d5;
141
+ --badge-bg: var(--c0-wash);
142
+ --badge-edge: rgba(22, 80, 159, .28);
143
+ --toast-bg: #fffdf6;
144
+
145
+ --shadow-panel: 0 1px 0 #fffdf7 inset, 0 10px 26px -18px rgba(60, 44, 20, .55);
146
+ --shadow-tile: 0 2px 3px -1px rgba(48, 34, 14, .5);
147
+ --shadow-tile-raised: 0 6px 10px -4px rgba(48, 34, 14, .6);
148
+
149
+ /* ============================================================== geometry ==
150
+ * Not colour, but the same argument: one place to change it. */
151
+ --tile: 32px;
152
+ --radius: 3px;
153
+
154
+ --font-display: Palatino, "Palatino Linotype", "Book Antiqua", "Iowan Old Style", Georgia, serif;
155
+ --font-body: "Avenir Next", "Segoe UI", system-ui, -apple-system, Helvetica, Arial, sans-serif;
156
+ --font-label: "Avenir Next Condensed", "Avenir Next", "Helvetica Neue", system-ui, sans-serif;
157
+ }
158
+
159
+ /* ================================================================== skins ==
160
+ * A skin is this same list of properties, redeclared. Set it with
161
+ * `document.documentElement.dataset.skin = "dusk"`. THEMING.md walks through
162
+ * writing one; this is the worked example that proves the mechanism.
163
+ *
164
+ * "Dusk": the same factory, lit at the end of the day — a slate table, cooler
165
+ * plaster, and glazes fired one stop deeper. */
166
+ :root[data-skin="dusk"] {
167
+ --linen: #3a3730;
168
+ --linen-dark: #2c2a24;
169
+ --linen-warm: #45413a;
170
+ --linen-thread: rgba(255, 255, 255, .05);
171
+
172
+ --plaster: #4a4640;
173
+ --plaster-hi: #565149;
174
+ --plaster-edge: #625c52;
175
+ --plaster-sunk: #423e38;
176
+
177
+ --grout: #6d6659;
178
+ --grout-deep: #575146;
179
+
180
+ --ink: #f2ece0;
181
+ --ink-soft: #c3b9a7;
182
+ --ink-faint: #978d7c;
183
+
184
+ --c0: #3d79cf; --c0-hi: #86aee6; --c0-lo: #1c4478; --c0-rim: #16375f; --c0-ink: #93b6e8;
185
+ --c1: #f0b53c; --c1-hi: #ffd67a; --c1-lo: #a2740f; --c1-rim: #7d590a; --c1-ink: #f3c463;
186
+ --c2: #d4573f; --c2-hi: #ec8b74; --c2-lo: #872c17; --c2-rim: #6a200f; --c2-ink: #e88b73;
187
+ --c3: #1b1f24; --c3-hi: #4c545f; --c3-lo: #06080a; --c3-rim: #000; --c3-ink: #9aa3b0;
188
+ --c4: #5fd3e8; --c4-hi: #a6ecf6; --c4-lo: #1d8ca3; --c4-rim: #146b7e; --c4-ink: #7fdcec;
189
+
190
+ --c0-wash: rgba(61, 121, 207, .2);
191
+ --c1-wash: rgba(240, 181, 60, .18);
192
+ --c2-wash: rgba(212, 87, 63, .18);
193
+ --c3-wash: rgba(27, 31, 36, .3);
194
+ --c4-wash: rgba(95, 211, 232, .18);
195
+
196
+ --slot: #37342e;
197
+ --slot-lo: #2b2823;
198
+ --slot-rim: #514b41;
199
+ --slot-shade: rgba(0, 0, 0, .5);
200
+ --slot-motif: .9;
201
+ /* charcoal's glaze is darker than the dusk slot it sits in, so its motif is
202
+ * drawn in the charcoal *ink* instead — same hue, readable at night */
203
+ --c3-motif: var(--c3-ink);
204
+ --c0-ghost: rgba(61, 121, 207, .34);
205
+ --c1-ghost: rgba(240, 181, 60, .34);
206
+ --c2-ghost: rgba(212, 87, 63, .34);
207
+ --c3-ghost: rgba(154, 163, 176, .26);
208
+ --c4-ghost: rgba(95, 211, 232, .32);
209
+
210
+ --dish: #514c44;
211
+ --dish-hi: #605a50;
212
+ --dish-lo: #423e37;
213
+ --marker-face: #6a6459;
214
+ --marker-lo: #524d44;
215
+
216
+ --panel-top: #514c44;
217
+ --panel-bottom: var(--plaster);
218
+ --field-bg: #423e38;
219
+ --btn-top: #5a544a;
220
+ --btn-bottom: #48433c;
221
+ --btn-top-hover: #665f54;
222
+ --btn-bottom-hover: #524c44;
223
+ --btn-primary-top: #2f63b0;
224
+ --btn-primary-bottom: #16375f;
225
+ --btn-primary-top-hover: #3f76c6;
226
+ --btn-primary-bottom-hover: #102a4a;
227
+ --btn-primary-ink: #f4f8ff;
228
+
229
+ --topbar-top: #4f4a42;
230
+ --topbar-bottom: #423e38;
231
+ --engine-top: #494540;
232
+ --engine-bottom: #423e38;
233
+ --badge-bg: var(--c0-wash);
234
+ --badge-edge: rgba(61, 121, 207, .4);
235
+ --toast-bg: #514c44;
236
+
237
+ --grade-best: var(--c0-ink);
238
+ --grade-slip: var(--c1-ink);
239
+ --grade-blunder: var(--c2-ink);
240
+ --gain: var(--c4-ink);
241
+ --loss: var(--c2-ink);
242
+
243
+ --shadow-panel: 0 1px 0 rgba(255, 255, 255, .06) inset, 0 10px 26px -18px rgba(0, 0, 0, .8);
244
+ --shadow-tile: 0 2px 3px -1px rgba(0, 0, 0, .6);
245
+ --shadow-tile-raised: 0 6px 10px -4px rgba(0, 0, 0, .7);
246
+ }
vendor/onnxruntime-web/ort-wasm-simd-threaded.jspi.wasm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7c28cdb40958a998f5aa0981d5cb8e57ac1e7e9b4d2f18a7d74e00dd9629d7a3
3
+ size 15046878
vendor/onnxruntime-web/ort-wasm-simd-threaded.wasm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d1ab1b94b16a65b29d710d0b587b29e7bed336827577623913479b8afe8113e6
3
+ size 13479978
vendor/onnxruntime-web/ort.jspi.bundle.min.mjs ADDED
The diff for this file is too large to render. See raw diff
 
vendor/onnxruntime-web/ort.wasm.bundle.min.mjs ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /*!
2
+ * ONNX Runtime Web v1.27.0
3
+ * Copyright (c) Microsoft Corporation. All rights reserved.
4
+ * Licensed under the MIT License.
5
+ */
6
+ var Jt=Object.defineProperty;var Xa=Object.getOwnPropertyDescriptor;var Qa=Object.getOwnPropertyNames;var Za=Object.prototype.hasOwnProperty;var qt=(n=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(n,{get:(t,a)=>(typeof require<"u"?require:t)[a]}):n)(function(n){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+n+'" is not supported')});var F=(n,t)=>()=>(n&&(t=n(n=0)),t);var ut=(n,t)=>{for(var a in t)Jt(n,a,{get:t[a],enumerable:!0})},Ka=(n,t,a,u)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of Qa(t))!Za.call(n,o)&&o!==a&&Jt(n,o,{get:()=>t[o],enumerable:!(u=Xa(t,o))||u.enumerable});return n};var Xt=n=>Ka(Jt({},"__esModule",{value:!0}),n);var ft,Be,qe,es,dr,Qt=F(()=>{"use strict";ft=new Map,Be=[],qe=(n,t,a)=>{if(t&&typeof t.init=="function"&&typeof t.createInferenceSessionHandler=="function"){let u=ft.get(n);if(u===void 0)ft.set(n,{backend:t,priority:a});else{if(u.priority>a)return;if(u.priority===a&&u.backend!==t)throw new Error(`cannot register backend "${n}" using priority ${a}`)}if(a>=0){let o=Be.indexOf(n);o!==-1&&Be.splice(o,1);for(let d=0;d<Be.length;d++)if(ft.get(Be[d]).priority<=a){Be.splice(d,0,n);return}Be.push(n)}return}throw new TypeError("not a valid backend")},es=async n=>{let t=ft.get(n);if(!t)return"backend not found.";if(t.initialized)return t.backend;if(t.aborted)return t.error;{let a=!!t.initPromise;try{return a||(t.initPromise=t.backend.init(n)),await t.initPromise,t.initialized=!0,t.backend}catch(u){return a||(t.error=`${u}`,t.aborted=!0),t.error}finally{delete t.initPromise}}},dr=async n=>{let t=n.executionProviders||[],a=t.map(m=>typeof m=="string"?m:m.name),u=a.length===0?Be:a,o,d=[],c=new Set;for(let m of u){let h=await es(m);typeof h=="string"?d.push({name:m,err:h}):(o||(o=h),o===h&&c.add(m))}if(!o)throw new Error(`no available backend found. ERR: ${d.map(m=>`[${m.name}] ${m.err}`).join(", ")}`);for(let{name:m,err:h}of d)a.includes(m)&&console.warn(`removing requested execution provider "${m}" from session options because it is not available: ${h}`);let l=t.filter(m=>c.has(typeof m=="string"?m:m.name));return[o,new Proxy(n,{get:(m,h)=>h==="executionProviders"?l:Reflect.get(m,h)})]}});var pr=F(()=>{"use strict";Qt()});var mr,hr=F(()=>{"use strict";mr="1.27.0"});var wr,J,Zt=F(()=>{"use strict";hr();wr="warning",J={wasm:{},webgl:{},webgpu:{},versions:{common:mr},set logLevel(n){if(n!==void 0){if(typeof n!="string"||["verbose","info","warning","error","fatal"].indexOf(n)===-1)throw new Error(`Unsupported logging level: ${n}`);wr=n}},get logLevel(){return wr}};Object.defineProperty(J,"logLevel",{enumerable:!0})});var Y,br=F(()=>{"use strict";Zt();Y=J});var yr,gr,Er=F(()=>{"use strict";yr=(n,t)=>{let a=typeof document<"u"?document.createElement("canvas"):new OffscreenCanvas(1,1);a.width=n.dims[3],a.height=n.dims[2];let u=a.getContext("2d");if(u!=null){let o,d;t?.tensorLayout!==void 0&&t.tensorLayout==="NHWC"?(o=n.dims[2],d=n.dims[3]):(o=n.dims[3],d=n.dims[2]);let c=t?.format!==void 0?t.format:"RGB",l=t?.norm,m,h;l===void 0||l.mean===void 0?m=[255,255,255,255]:typeof l.mean=="number"?m=[l.mean,l.mean,l.mean,l.mean]:(m=[l.mean[0],l.mean[1],l.mean[2],0],l.mean[3]!==void 0&&(m[3]=l.mean[3])),l===void 0||l.bias===void 0?h=[0,0,0,0]:typeof l.bias=="number"?h=[l.bias,l.bias,l.bias,l.bias]:(h=[l.bias[0],l.bias[1],l.bias[2],0],l.bias[3]!==void 0&&(h[3]=l.bias[3]));let g=d*o,b=0,y=g,T=g*2,I=-1;c==="RGBA"?(b=0,y=g,T=g*2,I=g*3):c==="RGB"?(b=0,y=g,T=g*2):c==="RBG"&&(b=0,T=g,y=g*2);for(let D=0;D<d;D++)for(let z=0;z<o;z++){let v=(n.data[b++]-h[0])*m[0],O=(n.data[y++]-h[1])*m[1],N=(n.data[T++]-h[2])*m[2],_=I===-1?255:(n.data[I++]-h[3])*m[3];u.fillStyle="rgba("+v+","+O+","+N+","+_+")",u.fillRect(z,D,1,1)}if("toDataURL"in a)return a.toDataURL();throw new Error("toDataURL is not supported")}else throw new Error("Can not access image data")},gr=(n,t)=>{let a=typeof document<"u"?document.createElement("canvas").getContext("2d"):new OffscreenCanvas(1,1).getContext("2d"),u;if(a!=null){let o,d,c;t?.tensorLayout!==void 0&&t.tensorLayout==="NHWC"?(o=n.dims[2],d=n.dims[1],c=n.dims[3]):(o=n.dims[3],d=n.dims[2],c=n.dims[1]);let l=t!==void 0&&t.format!==void 0?t.format:"RGB",m=t?.norm,h,g;m===void 0||m.mean===void 0?h=[255,255,255,255]:typeof m.mean=="number"?h=[m.mean,m.mean,m.mean,m.mean]:(h=[m.mean[0],m.mean[1],m.mean[2],255],m.mean[3]!==void 0&&(h[3]=m.mean[3])),m===void 0||m.bias===void 0?g=[0,0,0,0]:typeof m.bias=="number"?g=[m.bias,m.bias,m.bias,m.bias]:(g=[m.bias[0],m.bias[1],m.bias[2],0],m.bias[3]!==void 0&&(g[3]=m.bias[3]));let b=d*o;if(t!==void 0&&(t.format!==void 0&&c===4&&t.format!=="RGBA"||c===3&&t.format!=="RGB"&&t.format!=="BGR"))throw new Error("Tensor format doesn't match input tensor dims");let y=4,T=0,I=1,D=2,z=3,v=0,O=b,N=b*2,_=-1;l==="RGBA"?(v=0,O=b,N=b*2,_=b*3):l==="RGB"?(v=0,O=b,N=b*2):l==="RBG"&&(v=0,N=b,O=b*2),u=a.createImageData(o,d);for(let k=0;k<d*o;T+=y,I+=y,D+=y,z+=y,k++)u.data[T]=(n.data[v++]-g[0])*h[0],u.data[I]=(n.data[O++]-g[1])*h[1],u.data[D]=(n.data[N++]-g[2])*h[2],u.data[z]=_===-1?255:(n.data[_++]-g[3])*h[3]}else throw new Error("Can not access image data");return u}});var Kt,Tr,Sr,vr,Or,Ar,Ir=F(()=>{"use strict";ct();Kt=(n,t)=>{if(n===void 0)throw new Error("Image buffer must be defined");if(t.height===void 0||t.width===void 0)throw new Error("Image height and width must be defined");if(t.tensorLayout==="NHWC")throw new Error("NHWC Tensor layout is not supported yet");let{height:a,width:u}=t,o=t.norm??{mean:255,bias:0},d,c;typeof o.mean=="number"?d=[o.mean,o.mean,o.mean,o.mean]:d=[o.mean[0],o.mean[1],o.mean[2],o.mean[3]??255],typeof o.bias=="number"?c=[o.bias,o.bias,o.bias,o.bias]:c=[o.bias[0],o.bias[1],o.bias[2],o.bias[3]??0];let l=t.format!==void 0?t.format:"RGBA",m=t.tensorFormat!==void 0&&t.tensorFormat!==void 0?t.tensorFormat:"RGB",h=a*u,g=m==="RGBA"?new Float32Array(h*4):new Float32Array(h*3),b=4,y=0,T=1,I=2,D=3,z=0,v=h,O=h*2,N=-1;l==="RGB"&&(b=3,y=0,T=1,I=2,D=-1),m==="RGBA"?N=h*3:m==="RBG"?(z=0,O=h,v=h*2):m==="BGR"&&(O=0,v=h,z=h*2);for(let k=0;k<h;k++,y+=b,I+=b,T+=b,D+=b)g[z++]=(n[y]+c[0])/d[0],g[v++]=(n[T]+c[1])/d[1],g[O++]=(n[I]+c[2])/d[2],N!==-1&&D!==-1&&(g[N++]=(n[D]+c[3])/d[3]);return m==="RGBA"?new Q("float32",g,[1,4,a,u]):new Q("float32",g,[1,3,a,u])},Tr=async(n,t)=>{let a=typeof HTMLImageElement<"u"&&n instanceof HTMLImageElement,u=typeof ImageData<"u"&&n instanceof ImageData,o=typeof ImageBitmap<"u"&&n instanceof ImageBitmap,d=typeof n=="string",c,l=t??{},m=()=>{if(typeof document<"u")return document.createElement("canvas");if(typeof OffscreenCanvas<"u")return new OffscreenCanvas(1,1);throw new Error("Canvas is not supported")},h=g=>typeof HTMLCanvasElement<"u"&&g instanceof HTMLCanvasElement||g instanceof OffscreenCanvas?g.getContext("2d"):null;if(a){let g=m();g.width=n.width,g.height=n.height;let b=h(g);if(b!=null){let y=n.height,T=n.width;if(t!==void 0&&t.resizedHeight!==void 0&&t.resizedWidth!==void 0&&(y=t.resizedHeight,T=t.resizedWidth),t!==void 0){if(l=t,t.tensorFormat!==void 0)throw new Error("Image input config format must be RGBA for HTMLImageElement");l.tensorFormat="RGBA",l.height=y,l.width=T}else l.tensorFormat="RGBA",l.height=y,l.width=T;b.drawImage(n,0,0),c=b.getImageData(0,0,T,y).data}else throw new Error("Can not access image data")}else if(u){let g,b;if(t!==void 0&&t.resizedWidth!==void 0&&t.resizedHeight!==void 0?(g=t.resizedHeight,b=t.resizedWidth):(g=n.height,b=n.width),t!==void 0&&(l=t),l.format="RGBA",l.height=g,l.width=b,t!==void 0){let y=m();y.width=b,y.height=g;let T=h(y);if(T!=null)T.putImageData(n,0,0),c=T.getImageData(0,0,b,g).data;else throw new Error("Can not access image data")}else c=n.data}else if(o){if(t===void 0)throw new Error("Please provide image config with format for Imagebitmap");let g=m();g.width=n.width,g.height=n.height;let b=h(g);if(b!=null){let y=n.height,T=n.width;return b.drawImage(n,0,0,T,y),c=b.getImageData(0,0,T,y).data,l.height=y,l.width=T,Kt(c,l)}else throw new Error("Can not access image data")}else{if(d)return new Promise((g,b)=>{let y=m(),T=h(y);if(!n||!T)return b();let I=new Image;I.crossOrigin="Anonymous",I.src=n,I.onload=()=>{y.width=I.width,y.height=I.height,T.drawImage(I,0,0,y.width,y.height);let D=T.getImageData(0,0,y.width,y.height);l.height=y.height,l.width=y.width,g(Kt(D.data,l))}});throw new Error("Input data provided is not supported - aborted tensor creation")}if(c!==void 0)return Kt(c,l);throw new Error("Input data provided is not supported - aborted tensor creation")},Sr=(n,t)=>{let{width:a,height:u,download:o,dispose:d}=t,c=[1,u,a,4];return new Q({location:"texture",type:"float32",texture:n,dims:c,download:o,dispose:d})},vr=(n,t)=>{let{dataType:a,dims:u,download:o,dispose:d}=t;return new Q({location:"gpu-buffer",type:a??"float32",gpuBuffer:n,dims:u,download:o,dispose:d})},Or=(n,t)=>{let{dataType:a,dims:u,download:o,dispose:d}=t;return new Q({location:"ml-tensor",type:a??"float32",mlTensor:n,dims:u,download:o,dispose:d})},Ar=(n,t,a)=>new Q({location:"cpu-pinned",type:n,data:t,dims:a??[t.length]})});var Le,Xe,Br,Lr,_r=F(()=>{"use strict";Le=new Map([["float32",Float32Array],["uint8",Uint8Array],["int8",Int8Array],["uint16",Uint16Array],["int16",Int16Array],["int32",Int32Array],["bool",Uint8Array],["float64",Float64Array],["uint32",Uint32Array],["int4",Uint8Array],["uint4",Uint8Array]]),Xe=new Map([[Float32Array,"float32"],[Uint8Array,"uint8"],[Int8Array,"int8"],[Uint16Array,"uint16"],[Int16Array,"int16"],[Int32Array,"int32"],[Float64Array,"float64"],[Uint32Array,"uint32"]]),Br=!1,Lr=()=>{if(!Br){Br=!0;let n=typeof BigInt64Array<"u"&&BigInt64Array.from,t=typeof BigUint64Array<"u"&&BigUint64Array.from,a=globalThis.Float16Array,u=typeof a<"u"&&a.from;n&&(Le.set("int64",BigInt64Array),Xe.set(BigInt64Array,"int64")),t&&(Le.set("uint64",BigUint64Array),Xe.set(BigUint64Array,"uint64")),u?(Le.set("float16",a),Xe.set(a,"float16")):Le.set("float16",Uint16Array)}}});var Pr,Dr,Ur=F(()=>{"use strict";ct();Pr=n=>{let t=1;for(let a=0;a<n.length;a++){let u=n[a];if(typeof u!="number"||!Number.isSafeInteger(u))throw new TypeError(`dims[${a}] must be an integer, got: ${u}`);if(u<0)throw new RangeError(`dims[${a}] must be a non-negative integer, got: ${u}`);t*=u}return t},Dr=(n,t)=>{switch(n.location){case"cpu":return new Q(n.type,n.data,t);case"cpu-pinned":return new Q({location:"cpu-pinned",data:n.data,type:n.type,dims:t});case"texture":return new Q({location:"texture",texture:n.texture,type:n.type,dims:t});case"gpu-buffer":return new Q({location:"gpu-buffer",gpuBuffer:n.gpuBuffer,type:n.type,dims:t});case"ml-tensor":return new Q({location:"ml-tensor",mlTensor:n.mlTensor,type:n.type,dims:t});default:throw new Error(`tensorReshape: tensor location ${n.location} is not supported`)}}});var Q,ct=F(()=>{"use strict";Er();Ir();_r();Ur();Q=class{constructor(t,a,u){Lr();let o,d;if(typeof t=="object"&&"location"in t)switch(this.dataLocation=t.location,o=t.type,d=t.dims,t.location){case"cpu-pinned":{let l=Le.get(o);if(!l)throw new TypeError(`unsupported type "${o}" to create tensor from pinned buffer`);if(!(t.data instanceof l))throw new TypeError(`buffer should be of type ${l.name}`);this.cpuData=t.data;break}case"texture":{if(o!=="float32")throw new TypeError(`unsupported type "${o}" to create tensor from texture`);this.gpuTextureData=t.texture,this.downloader=t.download,this.disposer=t.dispose;break}case"gpu-buffer":{if(o!=="float32"&&o!=="float16"&&o!=="int32"&&o!=="int64"&&o!=="uint32"&&o!=="uint8"&&o!=="bool"&&o!=="uint4"&&o!=="int4")throw new TypeError(`unsupported type "${o}" to create tensor from gpu buffer`);this.gpuBufferData=t.gpuBuffer,this.downloader=t.download,this.disposer=t.dispose;break}case"ml-tensor":{if(o!=="float32"&&o!=="float16"&&o!=="int32"&&o!=="int64"&&o!=="uint32"&&o!=="uint64"&&o!=="int8"&&o!=="uint8"&&o!=="bool"&&o!=="uint4"&&o!=="int4")throw new TypeError(`unsupported type "${o}" to create tensor from MLTensor`);this.mlTensorData=t.mlTensor,this.downloader=t.download,this.disposer=t.dispose;break}default:throw new Error(`Tensor constructor: unsupported location '${this.dataLocation}'`)}else{let l,m;if(typeof t=="string")if(o=t,m=u,t==="string"){if(!Array.isArray(a))throw new TypeError("A string tensor's data must be a string array.");l=a}else{let h=Le.get(t);if(h===void 0)throw new TypeError(`Unsupported tensor type: ${t}.`);if(Array.isArray(a)){if(t==="float16"&&h===Uint16Array||t==="uint4"||t==="int4")throw new TypeError(`Creating a ${t} tensor from number array is not supported. Please use ${h.name} as data.`);t==="uint64"||t==="int64"?l=h.from(a,BigInt):l=h.from(a)}else if(a instanceof h)l=a;else if(a instanceof Uint8ClampedArray)if(t==="uint8")l=Uint8Array.from(a);else throw new TypeError("A Uint8ClampedArray tensor's data must be type of uint8");else if(t==="float16"&&a instanceof Uint16Array&&h!==Uint16Array)l=new globalThis.Float16Array(a.buffer,a.byteOffset,a.length);else throw new TypeError(`A ${o} tensor's data must be type of ${h}`)}else if(m=a,Array.isArray(t)){if(t.length===0)throw new TypeError("Tensor type cannot be inferred from an empty array.");let h=typeof t[0];if(h==="string")o="string",l=t;else if(h==="boolean")o="bool",l=Uint8Array.from(t);else throw new TypeError(`Invalid element type of data array: ${h}.`)}else if(t instanceof Uint8ClampedArray)o="uint8",l=Uint8Array.from(t);else{let h=Xe.get(t.constructor);if(h===void 0)throw new TypeError(`Unsupported type for tensor data: ${t.constructor}.`);o=h,l=t}if(m===void 0)m=[l.length];else if(!Array.isArray(m))throw new TypeError("A tensor's dims must be a number array");d=m,this.cpuData=l,this.dataLocation="cpu"}let c=Pr(d);if(this.cpuData&&c!==this.cpuData.length&&!((o==="uint4"||o==="int4")&&Math.ceil(c/2)===this.cpuData.length))throw new Error(`Tensor's size(${c}) does not match data length(${this.cpuData.length}).`);this.type=o,this.dims=d,this.size=c}static async fromImage(t,a){return Tr(t,a)}static fromTexture(t,a){return Sr(t,a)}static fromGpuBuffer(t,a){return vr(t,a)}static fromMLTensor(t,a){return Or(t,a)}static fromPinnedBuffer(t,a,u){return Ar(t,a,u)}toDataURL(t){return yr(this,t)}toImageData(t){return gr(this,t)}get data(){if(this.ensureValid(),!this.cpuData)throw new Error("The data is not on CPU. Use `getData()` to download GPU data to CPU, or use `texture` or `gpuBuffer` property to access the GPU data directly.");return this.cpuData}get location(){return this.dataLocation}get texture(){if(this.ensureValid(),!this.gpuTextureData)throw new Error("The data is not stored as a WebGL texture.");return this.gpuTextureData}get gpuBuffer(){if(this.ensureValid(),!this.gpuBufferData)throw new Error("The data is not stored as a WebGPU buffer.");return this.gpuBufferData}get mlTensor(){if(this.ensureValid(),!this.mlTensorData)throw new Error("The data is not stored as a WebNN MLTensor.");return this.mlTensorData}async getData(t){switch(this.ensureValid(),this.dataLocation){case"cpu":case"cpu-pinned":return this.data;case"texture":case"gpu-buffer":case"ml-tensor":{if(!this.downloader)throw new Error("The current tensor is not created with a specified data downloader.");if(this.isDownloading)throw new Error("The current tensor is being downloaded.");try{this.isDownloading=!0;let a=await this.downloader();return this.downloader=void 0,this.dataLocation="cpu",this.cpuData=a,t&&this.disposer&&(this.disposer(),this.disposer=void 0),a}finally{this.isDownloading=!1}}default:throw new Error(`cannot get data from location: ${this.dataLocation}`)}}dispose(){if(this.isDownloading)throw new Error("The current tensor is being downloaded.");this.disposer&&(this.disposer(),this.disposer=void 0),this.cpuData=void 0,this.gpuTextureData=void 0,this.gpuBufferData=void 0,this.mlTensorData=void 0,this.downloader=void 0,this.isDownloading=void 0,this.dataLocation="none"}ensureValid(){if(this.dataLocation==="none")throw new Error("The tensor is disposed.")}reshape(t){if(this.ensureValid(),this.downloader||this.disposer)throw new Error("Cannot reshape a tensor that owns GPU resource.");return Dr(this,t)}}});var de,en=F(()=>{"use strict";ct();de=Q});var xr,Cr,_e,Pe,De,Ue,tn=F(()=>{"use strict";Zt();xr=(n,t)=>{(typeof J.trace>"u"?!J.wasm.trace:!J.trace)||console.timeStamp(`${n}::ORT::${t}`)},Cr=(n,t)=>{let a=new Error().stack?.split(/\r\n|\r|\n/g)||[],u=!1;for(let o=0;o<a.length;o++){if(u&&!a[o].includes("TRACE_FUNC")){let d=`FUNC_${n}::${a[o].trim().split(" ")[1]}`;t&&(d+=`::${t}`),xr("CPU",d);return}a[o].includes("TRACE_FUNC")&&(u=!0)}},_e=n=>{(typeof J.trace>"u"?!J.wasm.trace:!J.trace)||Cr("BEGIN",n)},Pe=n=>{(typeof J.trace>"u"?!J.wasm.trace:!J.trace)||Cr("END",n)},De=n=>{(typeof J.trace>"u"?!J.wasm.trace:!J.trace)||console.time(`ORT::${n}`)},Ue=n=>{(typeof J.trace>"u"?!J.wasm.trace:!J.trace)||console.timeEnd(`ORT::${n}`)}});var lt,Mr=F(()=>{"use strict";Qt();en();tn();lt=class n{constructor(t){this.handler=t}async run(t,a,u){_e(),De("InferenceSession.run");let o={},d={};if(typeof t!="object"||t===null||t instanceof de||Array.isArray(t))throw new TypeError("'feeds' must be an object that use input names as keys and OnnxValue as corresponding values.");let c=!0;if(typeof a=="object"){if(a===null)throw new TypeError("Unexpected argument[1]: cannot be null.");if(a instanceof de)throw new TypeError("'fetches' cannot be a Tensor");if(Array.isArray(a)){if(a.length===0)throw new TypeError("'fetches' cannot be an empty array.");c=!1;for(let h of a){if(typeof h!="string")throw new TypeError("'fetches' must be a string array or an object.");if(this.outputNames.indexOf(h)===-1)throw new RangeError(`'fetches' contains invalid output name: ${h}.`);o[h]=null}if(typeof u=="object"&&u!==null)d=u;else if(typeof u<"u")throw new TypeError("'options' must be an object.")}else{let h=!1,g=Object.getOwnPropertyNames(a);for(let b of this.outputNames)if(g.indexOf(b)!==-1){let y=a[b];(y===null||y instanceof de)&&(h=!0,c=!1,o[b]=y)}if(h){if(typeof u=="object"&&u!==null)d=u;else if(typeof u<"u")throw new TypeError("'options' must be an object.")}else d=a}}else if(typeof a<"u")throw new TypeError("Unexpected argument[1]: must be 'fetches' or 'options'.");for(let h of this.inputNames)if(typeof t[h]>"u")throw new Error(`input '${h}' is missing in 'feeds'.`);if(c)for(let h of this.outputNames)o[h]=null;let l=await this.handler.run(t,o,d),m={};for(let h in l)if(Object.hasOwnProperty.call(l,h)){let g=l[h];g instanceof de?m[h]=g:m[h]=new de(g.type,g.data,g.dims)}return Ue("InferenceSession.run"),Pe(),m}async release(){return this.handler.dispose()}static async create(t,a,u,o){_e(),De("InferenceSession.create");let d,c={};if(typeof t=="string"){if(d=t,typeof a=="object"&&a!==null)c=a;else if(typeof a<"u")throw new TypeError("'options' must be an object.")}else if(t instanceof Uint8Array){if(d=t,typeof a=="object"&&a!==null)c=a;else if(typeof a<"u")throw new TypeError("'options' must be an object.")}else if(t instanceof ArrayBuffer||typeof SharedArrayBuffer<"u"&&t instanceof SharedArrayBuffer){let g=t,b=0,y=t.byteLength;if(typeof a=="object"&&a!==null)c=a;else if(typeof a=="number"){if(b=a,!Number.isSafeInteger(b))throw new RangeError("'byteOffset' must be an integer.");if(b<0||b>=g.byteLength)throw new RangeError(`'byteOffset' is out of range [0, ${g.byteLength}).`);if(y=t.byteLength-b,typeof u=="number"){if(y=u,!Number.isSafeInteger(y))throw new RangeError("'byteLength' must be an integer.");if(y<=0||b+y>g.byteLength)throw new RangeError(`'byteLength' is out of range (0, ${g.byteLength-b}].`);if(typeof o=="object"&&o!==null)c=o;else if(typeof o<"u")throw new TypeError("'options' must be an object.")}else if(typeof u<"u")throw new TypeError("'byteLength' must be a number.")}else if(typeof a<"u")throw new TypeError("'options' must be an object.");d=new Uint8Array(g,b,y)}else throw new TypeError("Unexpected argument[0]: must be 'path' or 'buffer'.");let[l,m]=await dr(c),h=await l.createInferenceSessionHandler(d,m);return Ue("InferenceSession.create"),Pe(),new n(h)}startProfiling(){this.handler.startProfiling()}endProfiling(){this.handler.endProfiling()}get inputNames(){return this.handler.inputNames}get outputNames(){return this.handler.outputNames}get inputMetadata(){return this.handler.inputMetadata}get outputMetadata(){return this.handler.outputMetadata}}});var ts,Rr=F(()=>{"use strict";Mr();ts=lt});var Nr=F(()=>{"use strict"});var Fr=F(()=>{"use strict"});var kr=F(()=>{"use strict"});var Wr=F(()=>{"use strict"});var nn={};ut(nn,{InferenceSession:()=>ts,TRACE:()=>xr,TRACE_EVENT_BEGIN:()=>De,TRACE_EVENT_END:()=>Ue,TRACE_FUNC_BEGIN:()=>_e,TRACE_FUNC_END:()=>Pe,Tensor:()=>de,env:()=>Y,registerBackend:()=>qe});var Te=F(()=>{"use strict";pr();br();Rr();en();Nr();Fr();tn();kr();Wr()});var dt=F(()=>{"use strict"});var Hr={};ut(Hr,{default:()=>ns});var $r,zr,ns,jr=F(()=>{"use strict";rn();xe();pt();$r="ort-wasm-proxy-worker",zr=globalThis.self?.name===$r;zr&&(self.onmessage=n=>{let{type:t,in:a}=n.data;try{switch(t){case"init-wasm":mt(a.wasm).then(()=>{ht(a).then(()=>{postMessage({type:t})},u=>{postMessage({type:t,err:u})})},u=>{postMessage({type:t,err:u})});break;case"init-ep":{let{epName:u,env:o}=a;wt(o,u).then(()=>{postMessage({type:t})},d=>{postMessage({type:t,err:d})});break}case"copy-from":{let{buffer:u}=a,o=Qe(u);postMessage({type:t,out:o});break}case"create":{let{model:u,options:o}=a;bt(u,o).then(d=>{postMessage({type:t,out:d})},d=>{postMessage({type:t,err:d})});break}case"release":yt(a),postMessage({type:t});break;case"run":{let{sessionId:u,inputIndices:o,inputs:d,outputIndices:c,options:l}=a;gt(u,o,d,c,new Array(c.length).fill(null),l).then(m=>{m.some(h=>h[3]!=="cpu")?postMessage({type:t,err:"Proxy does not support non-cpu tensor location."}):postMessage({type:t,out:m},Tt([...d,...m]))},m=>{postMessage({type:t,err:m})});break}case"end-profiling":Et(a),postMessage({type:t});break;default:}}catch(u){postMessage({type:t,err:u})}});ns=zr?null:n=>new Worker(n??oe,{type:"module",name:$r})});var Yr={};ut(Yr,{default:()=>rs});async function Vr(n={}){var t=n,a=!!globalThis.window,u=!!globalThis.WorkerGlobalScope,o=u&&self.name?.startsWith("em-pthread");t.mountExternalData=(e,r)=>{e.startsWith("./")&&(e=e.substring(2)),(t.Rb||(t.Rb=new Map)).set(e,r)},t.unmountExternalData=()=>{delete t.Rb},globalThis.SharedArrayBuffer??new WebAssembly.Memory({initial:0,maximum:0,shared:!0}).buffer.constructor;var d,c,l=(e,r)=>{throw r},m=import.meta.url,h="";if(a||u){try{h=new URL(".",m).href}catch{}u&&(c=e=>{var r=new XMLHttpRequest;return r.open("GET",e,!1),r.responseType="arraybuffer",r.send(null),new Uint8Array(r.response)}),d=async e=>{if(k(e))return new Promise((i,s)=>{var f=new XMLHttpRequest;f.open("GET",e,!0),f.responseType="arraybuffer",f.onload=()=>{f.status==200||f.status==0&&f.response?i(f.response):s(f.status)},f.onerror=s,f.send(null)});var r=await fetch(e,{credentials:"same-origin"});if(r.ok)return r.arrayBuffer();throw Error(r.status+" : "+r.url)}}var g,b,y,T,I,D,z=console.log.bind(console),v=console.error.bind(console),O=z,N=v,_=!1,k=e=>e.startsWith("file://");function w(){ge.buffer!=j.buffer&&ee()}if(o){let e=function(r){try{var i=r.data,s=i.Pb;if(s==="load"){let f=[];self.onmessage=p=>f.push(p),D=()=>{postMessage({Pb:"loaded"});for(let p of f)e(p);self.onmessage=e};for(let p of i.Zb)t[p]&&!t[p].proxy||(t[p]=(...E)=>{postMessage({Pb:"callHandler",Yb:p,args:E})},p=="print"&&(O=t[p]),p=="printErr"&&(N=t[p]));ge=i.dc,ee(),b=i.ec,Ut(),it()}else if(s==="run"){(function(f){var p=(w(),W)[f+52>>>2>>>0];f=(w(),W)[f+56>>>2>>>0],sr(p,p-f),P(p)})(i.Ob),zt(i.Ob,0,0,1,0,0),wn(),Nt(i.Ob),Z||=!0;try{Ao(i.bc,i.Tb)}catch(f){if(f!="unwind")throw f}}else i.target!=="setimmediate"&&(s==="checkMailbox"?Z&&rt():s&&(N(`worker: received unknown command ${s}`),N(i)))}catch(f){throw tr(),f}};var Ss=e,Z=!1;self.onunhandledrejection=r=>{throw r.reason||r},self.onmessage=e}var j,ne,pe,B,W,re,me,A,K,je=!1;function ee(){var e=ge.buffer;t.HEAP8=j=new Int8Array(e),pe=new Int16Array(e),t.HEAPU8=ne=new Uint8Array(e),new Uint16Array(e),t.HEAP32=B=new Int32Array(e),t.HEAPU32=W=new Uint32Array(e),re=new Float32Array(e),me=new Float64Array(e),A=new BigInt64Array(e),new BigUint64Array(e)}function he(){je=!0,o?D():Ee.Ta()}function H(e){throw N(e="Aborted("+e+")"),_=!0,e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info."),I?.(e),e}function q(){return{a:{R:fa,f:Io,w:Bo,e:Lo,k:_o,g:Po,S:Do,b:Uo,G:xo,ta:vn,j:Co,L:In,Ja:Bn,pa:Ln,ra:_n,Ka:Pn,Ha:Dn,Aa:Un,Ga:xn,Y:Cn,qa:Mn,na:Rn,Ia:Nn,oa:Fn,Pa:Mo,Da:Ro,la:Fo,ua:ko,ia:Wo,T:Go,Ca:Nt,Ma:$o,xa:zo,ya:Ho,za:jo,va:$n,wa:zn,ja:Hn,Ra:Yo,Oa:Xo,V:Qo,U:Zo,Na:Jo,F:Ko,La:ea,ma:ta,u:Vo,H:na,Q:at,ka:oa,aa:ra,Sa:aa,Ea:Yn,Fa:Jn,sa:ye,I:qn,X:Xn,Ba:Qn,W:Zn,_:ja,M:Wa,$:Ha,N:ka,v:Pa,d:pa,m:la,n:ca,q:va,ba:Ra,x:Ba,o:wa,O:Na,D:Ga,J:Ma,ca:Ca,da:xa,A:Oa,P:Ua,ea:Da,z:La,E:Fa,c:da,r:ha,i:ma,Z:Va,l:ya,p:ga,s:ba,t:Ea,y:Aa,fa:_a,B:$a,K:Ia,C:za,ga:Sa,ha:Ta,h:ia,a:ge,Qa:V}}}async function Ut(){function e(s,f){return Ee=s.exports,Ee=(function(){var p=Ee,E=x=>()=>x()>>>0,S=x=>R=>x(R)>>>0;return(p=Object.assign({},p)).sb=E(p.sb),p.ub=S(p.ub),p.Ib=S(p.Ib),p.Jb=E(p.Jb),p.Nb=S(p.Nb),p})(),mn.push(Ee.vb),s=Ee,t._OrtInit=s.Ua,t._OrtGetLastError=s.Va,t._OrtCreateSessionOptions=s.Wa,t._OrtAppendExecutionProvider=s.Xa,t._OrtAddFreeDimensionOverride=s.Ya,t._OrtAddSessionConfigEntry=s.Za,t._OrtReleaseSessionOptions=s._a,t._OrtCreateSession=s.$a,t._OrtReleaseSession=s.ab,t._OrtGetInputOutputCount=s.bb,t._OrtGetInputOutputMetadata=s.cb,t._OrtFree=s.db,t._OrtCreateTensor=s.eb,t._OrtGetTensorData=s.fb,t._OrtReleaseTensor=s.gb,t._OrtCreateRunOptions=s.hb,t._OrtAddRunConfigEntry=s.ib,t._OrtReleaseRunOptions=s.jb,t._OrtCreateBinding=s.kb,t._OrtBindInput=s.lb,t._OrtBindOutput=s.mb,t._OrtClearBoundOutputs=s.nb,t._OrtReleaseBinding=s.ob,t._OrtRunWithBinding=s.pb,t._OrtRun=s.qb,t._OrtEndProfiling=s.rb,st=s.sb,Kn=t._free=s.tb,er=t._malloc=s.ub,zt=s.xb,tr=s.yb,nr=s.zb,rr=s.Ab,Ht=s.Bb,or=s.Cb,ar=s.Db,C=s.Eb,Je=s.Fb,sr=s.Gb,P=s.Hb,jt=s.Ib,U=s.Jb,ir=s.Kb,Vt=s.Lb,ur=s.Mb,fr=s.Nb,cr=s.wb,b=f,Ee}var r,i=q();return t.instantiateWasm?new Promise(s=>{t.instantiateWasm(i,(f,p)=>{s(e(f,p))})}):o?e(new WebAssembly.Instance(b,q()),b):(K??=t.locateFile?t.locateFile?t.locateFile("ort-wasm-simd-threaded.wasm",h):h+"ort-wasm-simd-threaded.wasm":new URL("ort-wasm-simd-threaded.wasm",import.meta.url).href,r=await(async function(s){var f=K;if(!g&&!k(f))try{var p=fetch(f,{credentials:"same-origin"});return await WebAssembly.instantiateStreaming(p,s)}catch(E){N(`wasm streaming compile failed: ${E}`),N("falling back to ArrayBuffer instantiation")}return(async function(E,S){try{var x=await(async function(R){if(!g)try{var X=await d(R);return new Uint8Array(X)}catch{}if(R==K&&g)R=new Uint8Array(g);else{if(!c)throw"both async and sync fetching of the wasm failed";R=c(R)}return R})(E);return await WebAssembly.instantiate(x,S)}catch(R){N(`failed to asynchronously prepare wasm: ${R}`),H(R)}})(f,s)})(i),e(r.instance,r.module))}class Se{name="ExitStatus";constructor(r){this.message=`Program terminated with exit(${r})`,this.status=r}}var ve=e=>{e.terminate(),e.onmessage=()=>{}},Ve=[],Re=0,te=null,ue=e=>{ce.length==0&&(yn(),bn(ce[0]));var r=ce.pop();if(!r)return 6;Ne.push(r),Oe[e.Ob]=r,r.Ob=e.Ob;var i={Pb:"run",bc:e.ac,Tb:e.Tb,Ob:e.Ob};return r.postMessage(i,e.Xb),0},se=0,L=(e,r,...i)=>{var s,f=16*i.length,p=U(),E=jt(f),S=E>>>3;for(s of i)typeof s=="bigint"?((w(),A)[S++>>>0]=1n,(w(),A)[S++>>>0]=s):((w(),A)[S++>>>0]=0n,(w(),me)[S++>>>0]=s);return e=nr(e,0,f,E,r),P(p),e};function V(e){if(o)return L(0,1,e);if(y=e,!(0<se)){for(var r of Ne)ve(r);for(r of ce)ve(r);ce=[],Ne=[],Oe={},_=!0}l(0,new Se(e))}function fe(e){if(o)return L(1,0,e);ye(e)}var ye=e=>{if(y=e,o)throw fe(e),"unwind";V(e)},ce=[],Ne=[],mn=[],Oe={},hn=e=>{var r=e.Ob;delete Oe[r],ce.push(e),Ne.splice(Ne.indexOf(e),1),e.Ob=0,rr(r)};function wn(){mn.forEach(e=>e())}var bn=e=>new Promise(r=>{e.onmessage=f=>{var p=f.data;if(f=p.Pb,p.Sb&&p.Sb!=st()){var E=Oe[p.Sb];E?E.postMessage(p,p.Xb):N(`Internal error! Worker sent a message "${f}" to target pthread ${p.Sb}, but that thread no longer exists!`)}else f==="checkMailbox"?rt():f==="spawnThread"?ue(p):f==="cleanupThread"?Rt(()=>{hn(Oe[p.cc])}):f==="loaded"?(e.loaded=!0,r(e)):p.target==="setimmediate"?e.postMessage(p):f==="uncaughtException"?e.onerror(p.error):f==="callHandler"?t[p.Yb](...p.args):f&&N(`worker sent an unknown command ${f}`)},e.onerror=f=>{throw N(`worker sent an error! ${f.filename}:${f.lineno}: ${f.message}`),f};var i,s=[];for(i of[])t.propertyIsEnumerable(i)&&s.push(i);e.postMessage({Pb:"load",Zb:s,dc:ge,ec:b})});function yn(){var e=new Worker((()=>{let r=URL;return import.meta.url>"file:"&&import.meta.url<"file;"?new r("ort.wasm.bundle.min.mjs",import.meta.url):new URL(import.meta.url)})(),{type:"module",workerData:"em-pthread",name:"em-pthread"});ce.push(e)}var ge,gn=[],M=e=>{var r=gn[e];return r||(gn[e]=r=cr.get(e)),r},Ao=(e,r)=>{se=0,e=M(e)(r),0<se?y=e:Ht(e)},tt=[],nt=0;function Io(e){var r=new xt(e>>>=0);return(w(),j)[r.Qb+12>>>0]==0&&(En(r,!0),nt--),Tn(r,!1),tt.push(r),fr(e)}var Fe=0,Bo=()=>{C(0,0);var e=tt.pop();ir(e.Ub),Fe=0};function En(e,r){r=r?1:0,(w(),j)[e.Qb+12>>>0]=r}function Tn(e,r){r=r?1:0,(w(),j)[e.Qb+13>>>0]=r}class xt{constructor(r){this.Ub=r,this.Qb=r-24}}var Ct=e=>{var r=Fe;if(!r)return Je(0),0;var i=new xt(r);(w(),W)[i.Qb+16>>>2>>>0]=r;var s=(w(),W)[i.Qb+4>>>2>>>0];if(!s)return Je(0),r;for(var f of e){if(f===0||f===s)break;if(ur(f,s,i.Qb+16))return Je(f),r}return Je(s),r};function Lo(){return Ct([])}function _o(e){return Ct([e>>>0])}function Po(e,r,i,s){return Ct([e>>>0,r>>>0,i>>>0,s>>>0])}var Do=()=>{var e=tt.pop();e||H("no exception to throw");var r=e.Ub;throw(w(),j)[e.Qb+13>>>0]==0&&(tt.push(e),Tn(e,!0),En(e,!1),nt++),Vt(r),Fe=r};function Uo(e,r,i){var s=new xt(e>>>=0);throw r>>>=0,i>>>=0,(w(),W)[s.Qb+16>>>2>>>0]=0,(w(),W)[s.Qb+4>>>2>>>0]=r,(w(),W)[s.Qb+8>>>2>>>0]=i,Vt(e),nt++,Fe=e}var xo=()=>nt;function Sn(e,r,i,s){return o?L(2,1,e,r,i,s):vn(e,r,i,s)}function vn(e,r,i,s){if(e>>>=0,r>>>=0,i>>>=0,s>>>=0,!globalThis.SharedArrayBuffer)return 6;var f=[];return o&&f.length===0?Sn(e,r,i,s):(e={ac:i,Ob:e,Tb:s,Xb:f},o?(e.Pb="spawnThread",postMessage(e,f),0):ue(e))}function Co(e){throw Fe||=e>>>0,Fe}var On=globalThis.TextDecoder&&new TextDecoder,An=(e,r=0,i,s)=>{var f=r>>>=0;if(i=f+i,s)s=i;else{for(;e[f]&&!(f>=i);)++f;s=f}if(16<s-r&&e.buffer&&On)return On.decode(e.buffer instanceof ArrayBuffer?e.subarray(r,s):e.slice(r,s));for(f="";r<s;)if(128&(i=e[r++])){var p=63&e[r++];if((224&i)==192)f+=String.fromCharCode((31&i)<<6|p);else{var E=63&e[r++];65536>(i=(240&i)==224?(15&i)<<12|p<<6|E:(7&i)<<18|p<<12|E<<6|63&e[r++])?f+=String.fromCharCode(i):(i-=65536,f+=String.fromCharCode(55296|i>>10,56320|1023&i))}}else f+=String.fromCharCode(i);return f},Mt=(e,r,i)=>(e>>>=0)?An((w(),ne),e,r,i):"";function In(e,r,i){return o?L(3,1,e,r,i):0}function Bn(e,r){if(o)return L(4,1,e,r)}function Ln(e,r){if(o)return L(5,1,e,r)}function _n(e,r,i){if(o)return L(6,1,e,r,i)}function Pn(e,r,i){return o?L(7,1,e,r,i):0}function Dn(e,r){if(o)return L(8,1,e,r)}function Un(e,r,i){if(o)return L(9,1,e,r,i)}function xn(e,r,i,s){if(o)return L(10,1,e,r,i,s)}function Cn(e,r,i,s){if(o)return L(11,1,e,r,i,s)}function Mn(e,r,i,s){if(o)return L(12,1,e,r,i,s)}function Rn(e){if(o)return L(13,1,e)}function Nn(e,r){if(o)return L(14,1,e,r)}function Fn(e,r,i){if(o)return L(15,1,e,r,i)}var Mo=()=>H("");function Ro(e){zt(e>>>0,!u,1,!a,131072,!1),wn()}var Rt=e=>{if(!_)try{if(e(),!(0<se))try{o?st()&&Ht(y):ye(y)}catch(r){r instanceof Se||r=="unwind"||l(0,r)}}catch(r){r instanceof Se||r=="unwind"||l(0,r)}},No=!Atomics.waitAsync||globalThis.navigator?.userAgent&&91>Number((navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)||[])[2]);function Nt(e){e>>>=0,No||(Atomics.waitAsync((w(),B),e>>>2,e).value.then(rt),e+=128,Atomics.store((w(),B),e>>>2,1))}var rt=()=>Rt(()=>{var e=st();e&&(Nt(e),ar())});function Fo(e,r){(e>>>=0)==r>>>0?setTimeout(rt):o?postMessage({Sb:e,Pb:"checkMailbox"}):(e=Oe[e])&&e.postMessage({Pb:"checkMailbox"})}var Ft=[];function ko(e,r,i,s,f){for(r>>>=0,f>>>=0,Ft.length=0,i=f>>>3,s=f+s>>>3;i<s;){var p;p=(w(),A)[i++>>>0]?(w(),A)[i++>>>0]:(w(),me)[i++>>>0],Ft.push(p)}return(r?lr[r]:ua[e])(...Ft)}var Wo=()=>{se=0};function Go(e){e>>>=0,o?postMessage({Pb:"cleanupThread",cc:e}):hn(Oe[e])}function $o(e){}function zo(e,r){e=-9007199254740992>e||9007199254740992<e?NaN:Number(e),r>>>=0,e=new Date(1e3*e),(w(),B)[r>>>2>>>0]=e.getUTCSeconds(),(w(),B)[r+4>>>2>>>0]=e.getUTCMinutes(),(w(),B)[r+8>>>2>>>0]=e.getUTCHours(),(w(),B)[r+12>>>2>>>0]=e.getUTCDate(),(w(),B)[r+16>>>2>>>0]=e.getUTCMonth(),(w(),B)[r+20>>>2>>>0]=e.getUTCFullYear()-1900,(w(),B)[r+24>>>2>>>0]=e.getUTCDay(),e=(e.getTime()-Date.UTC(e.getUTCFullYear(),0,1,0,0,0,0))/864e5|0,(w(),B)[r+28>>>2>>>0]=e}var kn=e=>e%4==0&&(e%100!=0||e%400==0),Wn=[0,31,60,91,121,152,182,213,244,274,305,335],Gn=[0,31,59,90,120,151,181,212,243,273,304,334];function Ho(e,r){e=-9007199254740992>e||9007199254740992<e?NaN:Number(e),r>>>=0,e=new Date(1e3*e),(w(),B)[r>>>2>>>0]=e.getSeconds(),(w(),B)[r+4>>>2>>>0]=e.getMinutes(),(w(),B)[r+8>>>2>>>0]=e.getHours(),(w(),B)[r+12>>>2>>>0]=e.getDate(),(w(),B)[r+16>>>2>>>0]=e.getMonth(),(w(),B)[r+20>>>2>>>0]=e.getFullYear()-1900,(w(),B)[r+24>>>2>>>0]=e.getDay();var i=(kn(e.getFullYear())?Wn:Gn)[e.getMonth()]+e.getDate()-1|0;(w(),B)[r+28>>>2>>>0]=i,(w(),B)[r+36>>>2>>>0]=-60*e.getTimezoneOffset(),i=new Date(e.getFullYear(),6,1).getTimezoneOffset();var s=new Date(e.getFullYear(),0,1).getTimezoneOffset();e=0|(i!=s&&e.getTimezoneOffset()==Math.min(s,i)),(w(),B)[r+32>>>2>>>0]=e}function jo(e){e>>>=0;var r=new Date((w(),B)[e+20>>>2>>>0]+1900,(w(),B)[e+16>>>2>>>0],(w(),B)[e+12>>>2>>>0],(w(),B)[e+8>>>2>>>0],(w(),B)[e+4>>>2>>>0],(w(),B)[e>>>2>>>0],0),i=(w(),B)[e+32>>>2>>>0],s=r.getTimezoneOffset(),f=new Date(r.getFullYear(),6,1).getTimezoneOffset(),p=new Date(r.getFullYear(),0,1).getTimezoneOffset(),E=Math.min(p,f);return 0>i?(w(),B)[e+32>>>2>>>0]=+(f!=p&&E==s):0<i!=(E==s)&&(f=Math.max(p,f),r.setTime(r.getTime()+6e4*((0<i?E:f)-s))),(w(),B)[e+24>>>2>>>0]=r.getDay(),i=(kn(r.getFullYear())?Wn:Gn)[r.getMonth()]+r.getDate()-1|0,(w(),B)[e+28>>>2>>>0]=i,(w(),B)[e>>>2>>>0]=r.getSeconds(),(w(),B)[e+4>>>2>>>0]=r.getMinutes(),(w(),B)[e+8>>>2>>>0]=r.getHours(),(w(),B)[e+12>>>2>>>0]=r.getDate(),(w(),B)[e+16>>>2>>>0]=r.getMonth(),(w(),B)[e+20>>>2>>>0]=r.getYear(),e=r.getTime(),BigInt(isNaN(e)?-1:e/1e3)}function $n(e,r,i,s,f,p,E){return o?L(16,1,e,r,i,s,f,p,E):-52}function zn(e,r,i,s,f,p){if(o)return L(17,1,e,r,i,s,f,p)}var Ye={},Vo=()=>performance.timeOrigin+performance.now();function Hn(e,r){if(o)return L(18,1,e,r);if(Ye[e]&&(clearTimeout(Ye[e].id),delete Ye[e]),!r)return 0;var i=setTimeout(()=>{delete Ye[e],Rt(()=>or(e,performance.timeOrigin+performance.now()))},r);return Ye[e]={id:i,jc:r},0}var Ae=(e,r,i)=>{var s=(w(),ne);if(r>>>=0,0<i){var f=r;i=r+i-1;for(var p=0;p<e.length;++p){var E=e.codePointAt(p);if(127>=E){if(r>=i)break;s[r++>>>0]=E}else if(2047>=E){if(r+1>=i)break;s[r++>>>0]=192|E>>6,s[r++>>>0]=128|63&E}else if(65535>=E){if(r+2>=i)break;s[r++>>>0]=224|E>>12,s[r++>>>0]=128|E>>6&63,s[r++>>>0]=128|63&E}else{if(r+3>=i)break;s[r++>>>0]=240|E>>18,s[r++>>>0]=128|E>>12&63,s[r++>>>0]=128|E>>6&63,s[r++>>>0]=128|63&E,p++}}s[r>>>0]=0,e=r-f}else e=0;return e};function Yo(e,r,i,s){e>>>=0,r>>>=0,i>>>=0,s>>>=0;var f=new Date().getFullYear(),p=new Date(f,0,1).getTimezoneOffset();f=new Date(f,6,1).getTimezoneOffset();var E=Math.max(p,f);(w(),W)[e>>>2>>>0]=60*E,(w(),B)[r>>>2>>>0]=+(p!=f),e=(r=S=>{var x=Math.abs(S);return`UTC${0<=S?"-":"+"}${String(Math.floor(x/60)).padStart(2,"0")}${String(x%60).padStart(2,"0")}`})(p),r=r(f),f<p?(Ae(e,i,17),Ae(r,s,17)):(Ae(e,s,17),Ae(r,i,17))}var Jo=()=>Date.now(),qo=1;function Xo(e,r,i){if(i>>>=0,!(0<=e&&3>=e))return 28;if(e===0)e=Date.now();else{if(!qo)return 52;e=performance.timeOrigin+performance.now()}return e=Math.round(1e6*e),(w(),A)[i>>>3>>>0]=BigInt(e),0}var kt=[];function Qo(e,r,i){e>>>=0,r>>>=0,i>>>=0,kt.length=0;for(var s;s=(w(),ne)[r++>>>0];){var f=s!=105;i+=(f&=s!=112)&&i%8?4:0,kt.push(s==112?(w(),W)[i>>>2>>>0]:s==106?(w(),A)[i>>>3>>>0]:s==105?(w(),B)[i>>>2>>>0]:(w(),me)[i>>>3>>>0]),i+=f?8:4}return lr[e](...kt)}var Zo=()=>{};function Ko(e,r){return N(Mt(e>>>0,r>>>0))}var ea=()=>{throw se+=1,"unwind"};function ta(){return 4294901760}var na=()=>navigator.hardwareConcurrency,Ie={},Wt=e=>{for(var r=0,i=0;i<e.length;++i){var s=e.charCodeAt(i);127>=s?r++:2047>=s?r+=2:55296<=s&&57343>=s?(r+=4,++i):r+=3}return r},ot=e=>{var r;return(r=/\bwasm-function\[\d+\]:(0x[0-9a-f]+)/.exec(e))?+r[1]:(r=/:(\d+):\d+(?:\)|$)/.exec(e))?2147483648|+r[1]:0},jn=e=>{for(var r of e)(e=ot(r))&&(Ie[e]=r)};function ra(){var e=Error().stack.toString().split(`
7
+ `);return e[0]=="Error"&&e.shift(),jn(e),Ie.Vb=ot(e[3]),Ie.$b=e,Ie.Vb}function at(e){if(!(e=Ie[e>>>0]))return 0;var r;if(r=/^\s+at .*\.wasm\.(.*) \(.*\)$/.exec(e))e=r[1];else if(r=/^\s+at (.*) \(.*\)$/.exec(e))e=r[1];else{if(!(r=/^(.+?)@/.exec(e)))return 0;e=r[1]}Kn(at.Wb??0),r=Wt(e)+1;var i=er(r);return i&&Ae(e,i,r),at.Wb=i,at.Wb}function oa(e){e>>>=0;var r=(w(),ne).length;if(e<=r||4294901760<e)return!1;for(var i=1;4>=i;i*=2){var s=r*(1+.2/i);s=Math.min(s,e+100663296);e:{s=(Math.min(4294901760,65536*Math.ceil(Math.max(e,s)/65536))-ge.buffer.byteLength+65535)/65536|0;try{ge.grow(s),ee();var f=1;break e}catch{}f=void 0}if(f)return!0}return!1}function aa(e,r,i){if(e>>>=0,r>>>=0,Ie.Vb==e)var s=Ie.$b;else(s=Error().stack.toString().split(`
8
+ `))[0]=="Error"&&s.shift(),jn(s);for(var f=3;s[f]&&ot(s[f])!=e;)++f;for(e=0;e<i&&s[e+f];++e)(w(),B)[r+4*e>>>2>>>0]=ot(s[e+f]);return e}var Gt,$t={},Vn=()=>{if(!Gt){var e,r={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:(globalThis.navigator?.language??"C").replace("-","_")+".UTF-8",_:"./this.program"};for(e in $t)$t[e]===void 0?delete r[e]:r[e]=$t[e];var i=[];for(e in r)i.push(`${e}=${r[e]}`);Gt=i}return Gt};function Yn(e,r){if(o)return L(19,1,e,r);e>>>=0,r>>>=0;var i,s=0,f=0;for(i of Vn()){var p=r+s;(w(),W)[e+f>>>2>>>0]=p,s+=Ae(i,p,1/0)+1,f+=4}return 0}function Jn(e,r){if(o)return L(20,1,e,r);e>>>=0,r>>>=0;var i=Vn();for(var s of((w(),W)[e>>>2>>>0]=i.length,e=0,i))e+=Wt(s)+1;return(w(),W)[r>>>2>>>0]=e,0}function qn(e){return o?L(21,1,e):52}function Xn(e,r,i,s){return o?L(22,1,e,r,i,s):52}function Qn(e,r,i,s){return o?L(23,1,e,r,i,s):70}var sa=[null,[],[]];function Zn(e,r,i,s){if(o)return L(24,1,e,r,i,s);r>>>=0,i>>>=0,s>>>=0;for(var f=0,p=0;p<i;p++){var E=(w(),W)[r>>>2>>>0],S=(w(),W)[r+4>>>2>>>0];r+=8;for(var x=0;x<S;x++){var R=e,X=(w(),ne)[E+x>>>0],le=sa[R];X===0||X===10?((R===1?O:N)(An(le)),le.length=0):le.push(X)}f+=S}return(w(),W)[s>>>2>>>0]=f,0}function ia(e){return e>>>0}o||(function(){for(var e=t.numThreads-1;e--;)yn();Ve.push(async()=>{var r=(async function(){if(!o)return Promise.all(ce.map(bn))})();Re++,await r,--Re==0&&te&&(r=te,te=null,r())})})(),o||(ge=new WebAssembly.Memory({initial:256,maximum:65536,shared:!0}),ee()),t.wasmBinary&&(g=t.wasmBinary),t.stackSave=()=>U(),t.stackRestore=e=>P(e),t.stackAlloc=e=>jt(e),t.setValue=function(e,r,i="i8"){switch(i.endsWith("*")&&(i="*"),i){case"i1":case"i8":(w(),j)[e>>>0]=r;break;case"i16":(w(),pe)[e>>>1>>>0]=r;break;case"i32":(w(),B)[e>>>2>>>0]=r;break;case"i64":(w(),A)[e>>>3>>>0]=BigInt(r);break;case"float":(w(),re)[e>>>2>>>0]=r;break;case"double":(w(),me)[e>>>3>>>0]=r;break;case"*":(w(),W)[e>>>2>>>0]=r;break;default:H(`invalid type for setValue: ${i}`)}},t.getValue=function(e,r="i8"){switch(r.endsWith("*")&&(r="*"),r){case"i1":case"i8":return(w(),j)[e>>>0];case"i16":return(w(),pe)[e>>>1>>>0];case"i32":return(w(),B)[e>>>2>>>0];case"i64":return(w(),A)[e>>>3>>>0];case"float":return(w(),re)[e>>>2>>>0];case"double":return(w(),me)[e>>>3>>>0];case"*":return(w(),W)[e>>>2>>>0];default:H(`invalid type for getValue: ${r}`)}},t.UTF8ToString=Mt,t.stringToUTF8=Ae,t.lengthBytesUTF8=Wt;var st,Kn,er,zt,tr,nr,rr,Ht,or,ar,C,Je,sr,P,jt,U,ir,Vt,ur,fr,cr,Ee,ua=[V,fe,Sn,In,Bn,Ln,_n,Pn,Dn,Un,xn,Cn,Mn,Rn,Nn,Fn,$n,zn,Hn,Yn,Jn,qn,Xn,Qn,Zn],lr={960468:(e,r,i,s,f)=>{if(t===void 0||!t.Rb)return 1;if((e=Mt(Number(e>>>0))).startsWith("./")&&(e=e.substring(2)),!(e=t.Rb.get(e)))return 2;if(r=Number(r>>>0),i=Number(i>>>0),s=Number(s>>>0),r+i>e.byteLength)return 3;try{let p=e.subarray(r,r+i);switch(f){case 0:(w(),ne).set(p,s>>>0);break;case 1:t.fc?t.fc(s,p):t.ic(s,p);break;default:return 4}return 0}catch{return 4}},961292:()=>typeof wasmOffsetConverter<"u"};function fa(){return typeof wasmOffsetConverter<"u"}function ca(e,r,i,s){var f=U();try{return M(e)(r,i,s)}catch(p){if(P(f),p!==p+0)throw p;C(1,0)}}function la(e,r,i){var s=U();try{return M(e)(r,i)}catch(f){if(P(s),f!==f+0)throw f;C(1,0)}}function da(e){var r=U();try{M(e)()}catch(i){if(P(r),i!==i+0)throw i;C(1,0)}}function pa(e,r){var i=U();try{return M(e)(r)}catch(s){if(P(i),s!==s+0)throw s;C(1,0)}}function ma(e,r,i){var s=U();try{M(e)(r,i)}catch(f){if(P(s),f!==f+0)throw f;C(1,0)}}function ha(e,r){var i=U();try{M(e)(r)}catch(s){if(P(i),s!==s+0)throw s;C(1,0)}}function wa(e,r,i,s,f,p,E){var S=U();try{return M(e)(r,i,s,f,p,E)}catch(x){if(P(S),x!==x+0)throw x;C(1,0)}}function ba(e,r,i,s,f,p){var E=U();try{M(e)(r,i,s,f,p)}catch(S){if(P(E),S!==S+0)throw S;C(1,0)}}function ya(e,r,i,s){var f=U();try{M(e)(r,i,s)}catch(p){if(P(f),p!==p+0)throw p;C(1,0)}}function ga(e,r,i,s,f){var p=U();try{M(e)(r,i,s,f)}catch(E){if(P(p),E!==E+0)throw E;C(1,0)}}function Ea(e,r,i,s,f,p,E){var S=U();try{M(e)(r,i,s,f,p,E)}catch(x){if(P(S),x!==x+0)throw x;C(1,0)}}function Ta(e,r,i,s,f,p,E){var S=U();try{M(e)(r,i,s,f,p,E)}catch(x){if(P(S),x!==x+0)throw x;C(1,0)}}function Sa(e,r,i,s,f,p,E,S){var x=U();try{M(e)(r,i,s,f,p,E,S)}catch(R){if(P(x),R!==R+0)throw R;C(1,0)}}function va(e,r,i,s,f){var p=U();try{return M(e)(r,i,s,f)}catch(E){if(P(p),E!==E+0)throw E;C(1,0)}}function Oa(e,r,i){var s=U();try{return M(e)(r,i)}catch(f){if(P(s),f!==f+0)throw f;C(1,0)}}function Aa(e,r,i,s,f,p,E,S){var x=U();try{M(e)(r,i,s,f,p,E,S)}catch(R){if(P(x),R!==R+0)throw R;C(1,0)}}function Ia(e,r,i,s,f,p,E,S,x,R,X,le){var we=U();try{M(e)(r,i,s,f,p,E,S,x,R,X,le)}catch(be){if(P(we),be!==be+0)throw be;C(1,0)}}function Ba(e,r,i,s,f,p){var E=U();try{return M(e)(r,i,s,f,p)}catch(S){if(P(E),S!==S+0)throw S;C(1,0)}}function La(e,r,i){var s=U();try{return M(e)(r,i)}catch(f){if(P(s),f!==f+0)throw f;return C(1,0),0n}}function _a(e,r,i,s,f,p,E,S,x){var R=U();try{M(e)(r,i,s,f,p,E,S,x)}catch(X){if(P(R),X!==X+0)throw X;C(1,0)}}function Pa(e){var r=U();try{return M(e)()}catch(i){if(P(r),i!==i+0)throw i;C(1,0)}}function Da(e,r){var i=U();try{return M(e)(r)}catch(s){if(P(i),s!==s+0)throw s;return C(1,0),0n}}function Ua(e){var r=U();try{return M(e)()}catch(i){if(P(r),i!==i+0)throw i;return C(1,0),0n}}function xa(e,r,i,s){var f=U();try{return M(e)(r,i,s)}catch(p){if(P(f),p!==p+0)throw p;C(1,0)}}function Ca(e,r,i,s,f){var p=U();try{return M(e)(r,i,s,f)}catch(E){if(P(p),E!==E+0)throw E;C(1,0)}}function Ma(e,r,i,s,f,p){var E=U();try{return M(e)(r,i,s,f,p)}catch(S){if(P(E),S!==S+0)throw S;C(1,0)}}function Ra(e,r,i,s,f,p){var E=U();try{return M(e)(r,i,s,f,p)}catch(S){if(P(E),S!==S+0)throw S;C(1,0)}}function Na(e,r,i,s,f,p,E,S){var x=U();try{return M(e)(r,i,s,f,p,E,S)}catch(R){if(P(x),R!==R+0)throw R;C(1,0)}}function Fa(e,r,i,s,f){var p=U();try{return M(e)(r,i,s,f)}catch(E){if(P(p),E!==E+0)throw E;return C(1,0),0n}}function ka(e,r,i,s){var f=U();try{return M(e)(r,i,s)}catch(p){if(P(f),p!==p+0)throw p;C(1,0)}}function Wa(e,r,i,s){var f=U();try{return M(e)(r,i,s)}catch(p){if(P(f),p!==p+0)throw p;C(1,0)}}function Ga(e,r,i,s,f,p,E,S,x,R,X,le){var we=U();try{return M(e)(r,i,s,f,p,E,S,x,R,X,le)}catch(be){if(P(we),be!==be+0)throw be;C(1,0)}}function $a(e,r,i,s,f,p,E,S,x,R,X){var le=U();try{M(e)(r,i,s,f,p,E,S,x,R,X)}catch(we){if(P(le),we!==we+0)throw we;C(1,0)}}function za(e,r,i,s,f,p,E,S,x,R,X,le,we,be,Ya,Ja){var qa=U();try{M(e)(r,i,s,f,p,E,S,x,R,X,le,we,be,Ya,Ja)}catch(Yt){if(P(qa),Yt!==Yt+0)throw Yt;C(1,0)}}function Ha(e,r,i){var s=U();try{return M(e)(r,i)}catch(f){if(P(s),f!==f+0)throw f;C(1,0)}}function ja(e,r,i){var s=U();try{return M(e)(r,i)}catch(f){if(P(s),f!==f+0)throw f;C(1,0)}}function Va(e,r,i,s){var f=U();try{M(e)(r,i,s)}catch(p){if(P(f),p!==p+0)throw p;C(1,0)}}function it(){if(0<Re)te=it;else if(o)T?.(t),he();else{for(var e=Ve;0<e.length;)e.shift()(t);0<Re?te=it:(t.calledRun=!0,_||(he(),T?.(t)))}}return o||(Ee=await Ut(),it()),t.PTR_SIZE=4,je?t:new Promise((e,r)=>{T=e,I=r})}var rs,os,Jr=F(()=>{"use strict";rs=Vr,os=globalThis.self?.name?.startsWith("em-pthread");os&&Vr()});var Qr,an,as,oe,Zr,on,ss,is,Kr,us,qr,eo,Xr,to,pt=F(()=>{"use strict";dt();Qr=typeof location>"u"?void 0:location.origin,an=import.meta.url>"file:"&&import.meta.url<"file;",as=()=>{if(!!1){if(an){let n=URL;return new URL(new n("ort.wasm.bundle.min.mjs",import.meta.url).href,Qr).href}return import.meta.url}},oe=as(),Zr=()=>{if(oe&&!oe.startsWith("blob:"))return oe.substring(0,oe.lastIndexOf("/")+1)},on=(n,t)=>{try{let a=t??oe;return(a?new URL(n,a):new URL(n)).origin===Qr}catch{return!1}},ss=(n,t)=>{let a=t??oe;try{return(a?new URL(n,a):new URL(n)).href}catch{return}},is=(n,t)=>`${t??"./"}${n}`,Kr=async n=>{let a=await(await fetch(n,{credentials:"same-origin"})).blob();return URL.createObjectURL(a)},us=async n=>(await import(/*webpackIgnore:true*/ /*@vite-ignore*/n)).default,qr=(jr(),Xt(Hr)).default,eo=async()=>{if(!oe)throw new Error("Failed to load proxy worker: cannot determine the script source URL.");if(on(oe))return[void 0,qr()];let n=await Kr(oe);return[n,qr(n)]},Xr=(Jr(),Xt(Yr)).default,to=async(n,t,a,u)=>{let o=Xr&&!(n||t);if(o)if(oe)o=on(oe)||u&&!a;else if(u&&!a)o=!0;else throw new Error("cannot determine the script source URL.");if(o)return[void 0,Xr];{let d="ort-wasm-simd-threaded.mjs",c=n??ss(d,t),l=!!1&&a&&c&&!on(c,t),m=l?await Kr(c):c??is(d,t);return[l?m:void 0,await us(m)]}}});var sn,un,St,no,fs,cs,ls,mt,$,xe=F(()=>{"use strict";pt();un=!1,St=!1,no=!1,fs=()=>{if(typeof SharedArrayBuffer>"u")return!1;try{return typeof MessageChannel<"u"&&new MessageChannel().port1.postMessage(new SharedArrayBuffer(1)),WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,5,4,1,3,1,1,10,11,1,9,0,65,0,254,16,2,0,26,11]))}catch{return!1}},cs=()=>{try{return WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,4,1,96,0,0,3,2,1,0,10,30,1,28,0,65,0,253,15,253,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,253,186,1,26,11]))}catch{return!1}},ls=()=>{try{return WebAssembly.validate(new Uint8Array([0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,19,1,17,0,65,1,253,15,65,2,253,15,65,3,253,15,253,147,2,11]))}catch{return!1}},mt=async n=>{if(un)return Promise.resolve();if(St)throw new Error("multiple calls to 'initializeWebAssembly()' detected.");if(no)throw new Error("previous call to 'initializeWebAssembly()' failed.");St=!0;let t=n.initTimeout,a=n.numThreads;if(n.simd!==!1){if(n.simd==="relaxed"){if(!ls())throw new Error("Relaxed WebAssembly SIMD is not supported in the current environment.")}else if(!cs())throw new Error("WebAssembly SIMD is not supported in the current environment.")}let u=fs();a>1&&!u&&(typeof self<"u"&&!self.crossOriginIsolated&&console.warn("env.wasm.numThreads is set to "+a+", but this will not work unless you enable crossOriginIsolated mode. See https://web.dev/cross-origin-isolation-guide/ for more info."),console.warn("WebAssembly multi-threading is not supported in the current environment. Falling back to single-threading."),n.numThreads=a=1);let o=n.wasmPaths,d=typeof o=="string"?o:void 0,c=o?.mjs,l=c?.href??c,m=o?.wasm,h=m?.href??m,g=n.wasmBinary,[b,y]=await to(l,d,a>1,!!g||!!h),T=!1,I=[];if(t>0&&I.push(new Promise(D=>{setTimeout(()=>{T=!0,D()},t)})),I.push(new Promise((D,z)=>{let v={numThreads:a};if(g)v.wasmBinary=g,v.locateFile=O=>O;else if(h||d)v.locateFile=O=>h??d+O;else if(l&&l.indexOf("blob:")!==0)v.locateFile=O=>new URL(O,l).href;else if(b){let O=Zr();O&&(v.locateFile=N=>O+N)}y(v).then(O=>{St=!1,un=!0,sn=O,D(),b&&URL.revokeObjectURL(b)},O=>{St=!1,no=!0,z(O)})})),await Promise.race(I),T)throw new Error(`WebAssembly backend initializing failed due to timeout: ${t}ms`)},$=()=>{if(un&&sn)return sn;throw new Error("WebAssembly is not initialized yet.")}});var ae,Ze,G,vt=F(()=>{"use strict";xe();ae=(n,t)=>{let a=$(),u=a.lengthBytesUTF8(n)+1,o=a._malloc(u);return a.stringToUTF8(n,o,u),t.push(o),o},Ze=(n,t,a,u)=>{if(typeof n=="object"&&n!==null){if(a.has(n))throw new Error("Circular reference in options");a.add(n)}Object.entries(n).forEach(([o,d])=>{let c=t?t+o:o;if(typeof d=="object")Ze(d,c+".",a,u);else if(typeof d=="string"||typeof d=="number")u(c,d.toString());else if(typeof d=="boolean")u(c,d?"1":"0");else throw new Error(`Can't handle extra config type: ${typeof d}`)})},G=n=>{let t=$(),a=t.stackSave();try{let u=t.PTR_SIZE,o=t.stackAlloc(2*u);t._OrtGetLastError(o,o+u);let d=Number(t.getValue(o,u===4?"i32":"i64")),c=t.getValue(o+u,"*"),l=c?t.UTF8ToString(c):"";throw new Error(`${n} ERROR_CODE: ${d}, ERROR_MESSAGE: ${l}`)}finally{t.stackRestore(a)}}});var ro,oo=F(()=>{"use strict";xe();vt();ro=n=>{let t=$(),a=0,u=[],o=n||{};try{if(n?.logSeverityLevel===void 0)o.logSeverityLevel=2;else if(typeof n.logSeverityLevel!="number"||!Number.isInteger(n.logSeverityLevel)||n.logSeverityLevel<0||n.logSeverityLevel>4)throw new Error(`log severity level is not valid: ${n.logSeverityLevel}`);if(n?.logVerbosityLevel===void 0)o.logVerbosityLevel=0;else if(typeof n.logVerbosityLevel!="number"||!Number.isInteger(n.logVerbosityLevel))throw new Error(`log verbosity level is not valid: ${n.logVerbosityLevel}`);n?.terminate===void 0&&(o.terminate=!1);let d=0;return n?.tag!==void 0&&(d=ae(n.tag,u)),a=t._OrtCreateRunOptions(o.logSeverityLevel,o.logVerbosityLevel,!!o.terminate,d),a===0&&G("Can't create run options."),n?.extra!==void 0&&Ze(n.extra,"",new WeakSet,(c,l)=>{let m=ae(c,u),h=ae(l,u);t._OrtAddRunConfigEntry(a,m,h)!==0&&G(`Can't set a run config entry: ${c} - ${l}.`)}),[a,u]}catch(d){throw a!==0&&t._OrtReleaseRunOptions(a),u.forEach(c=>t._free(c)),d}}});var ds,ps,ms,ke,hs,ao,so=F(()=>{"use strict";xe();vt();ds=n=>{switch(n){case"disabled":return 0;case"basic":return 1;case"extended":return 2;case"layout":return 3;case"all":return 99;default:throw new Error(`unsupported graph optimization level: ${n}`)}},ps=n=>{switch(n){case"sequential":return 0;case"parallel":return 1;default:throw new Error(`unsupported execution mode: ${n}`)}},ms=n=>{n.extra||(n.extra={}),n.extra.session||(n.extra.session={});let t=n.extra.session;t.use_ort_model_bytes_directly||(t.use_ort_model_bytes_directly="1"),n.executionProviders&&n.executionProviders.some(a=>(typeof a=="string"?a:a.name)==="webgpu")&&(n.enableMemPattern=!1)},ke=(n,t,a,u)=>{let o=ae(t,u),d=ae(a,u);$()._OrtAddSessionConfigEntry(n,o,d)!==0&&G(`Can't set a session config entry: ${t} - ${a}.`)},hs=async(n,t,a)=>{let u=t.executionProviders;for(let o of u){let d=typeof o=="string"?o:o.name,c=[];switch(d){case"webnn":if(d="WEBNN",ke(n,"session.disable_quant_qdq","1",a),ke(n,"session.disable_qdq_constant_folding","1",a),typeof o!="string"){let y=o?.deviceType;y&&ke(n,"deviceType",y,a)}break;case"webgpu":if(d="JS",typeof o!="string"){let b=o;if(b?.preferredLayout){if(b.preferredLayout!=="NCHW"&&b.preferredLayout!=="NHWC")throw new Error(`preferredLayout must be either 'NCHW' or 'NHWC': ${b.preferredLayout}`);ke(n,"preferredLayout",b.preferredLayout,a)}}break;case"wasm":case"cpu":continue;default:throw new Error(`not supported execution provider: ${d}`)}let l=ae(d,a),m=c.length,h=0,g=0;if(m>0){h=$()._malloc(m*$().PTR_SIZE),a.push(h),g=$()._malloc(m*$().PTR_SIZE),a.push(g);for(let b=0;b<m;b++)$().setValue(h+b*$().PTR_SIZE,c[b][0],"*"),$().setValue(g+b*$().PTR_SIZE,c[b][1],"*")}await $()._OrtAppendExecutionProvider(n,l,h,g,m)!==0&&G(`Can't append execution provider: ${d}.`)}},ao=async n=>{let t=$(),a=0,u=[],o=n||{};ms(o);try{let d=ds(o.graphOptimizationLevel??"all"),c=ps(o.executionMode??"sequential"),l=typeof o.logId=="string"?ae(o.logId,u):0,m=o.logSeverityLevel??2;if(!Number.isInteger(m)||m<0||m>4)throw new Error(`log severity level is not valid: ${m}`);let h=o.logVerbosityLevel??0;if(!Number.isInteger(h)||h<0||h>4)throw new Error(`log verbosity level is not valid: ${h}`);let g=typeof o.optimizedModelFilePath=="string"?ae(o.optimizedModelFilePath,u):0;if(a=t._OrtCreateSessionOptions(d,!!o.enableCpuMemArena,!!o.enableMemPattern,c,!!o.enableProfiling,0,l,m,h,g),a===0&&G("Can't create session options."),o.executionProviders&&await hs(a,o,u),o.enableGraphCapture!==void 0){if(typeof o.enableGraphCapture!="boolean")throw new Error(`enableGraphCapture must be a boolean value: ${o.enableGraphCapture}`);ke(a,"enableGraphCapture",o.enableGraphCapture.toString(),u)}if(o.freeDimensionOverrides)for(let[b,y]of Object.entries(o.freeDimensionOverrides)){if(typeof b!="string")throw new Error(`free dimension override name must be a string: ${b}`);if(typeof y!="number"||!Number.isInteger(y)||y<0)throw new Error(`free dimension override value must be a non-negative integer: ${y}`);let T=ae(b,u);t._OrtAddFreeDimensionOverride(a,T,y)!==0&&G(`Can't set a free dimension override: ${b} - ${y}.`)}return o.extra!==void 0&&Ze(o.extra,"",new WeakSet,(b,y)=>{ke(a,b,y,u)}),[a,u]}catch(d){throw a!==0&&t._OrtReleaseSessionOptions(a)!==0&&G("Can't release session options."),u.forEach(c=>t._free(c)),d}}});var We,Ot,Ge,io,uo,At,It,fo,fn=F(()=>{"use strict";We=n=>{switch(n){case"int8":return 3;case"uint8":return 2;case"bool":return 9;case"int16":return 5;case"uint16":return 4;case"int32":return 6;case"uint32":return 12;case"float16":return 10;case"float32":return 1;case"float64":return 11;case"string":return 8;case"int64":return 7;case"uint64":return 13;case"int4":return 22;case"uint4":return 21;default:throw new Error(`unsupported data type: ${n}`)}},Ot=n=>{switch(n){case 3:return"int8";case 2:return"uint8";case 9:return"bool";case 5:return"int16";case 4:return"uint16";case 6:return"int32";case 12:return"uint32";case 10:return"float16";case 1:return"float32";case 11:return"float64";case 8:return"string";case 7:return"int64";case 13:return"uint64";case 22:return"int4";case 21:return"uint4";default:throw new Error(`unsupported data type: ${n}`)}},Ge=(n,t)=>{let a=[-1,4,1,1,2,2,4,8,-1,1,2,8,4,8,-1,-1,-1,-1,-1,-1,-1,.5,.5][n],u=typeof t=="number"?t:t.reduce((o,d)=>o*d,1);return a>0?Math.ceil(u*a):void 0},io=n=>{switch(n){case"float16":return typeof Float16Array<"u"?Float16Array:Uint16Array;case"float32":return Float32Array;case"uint8":return Uint8Array;case"int8":return Int8Array;case"uint16":return Uint16Array;case"int16":return Int16Array;case"int32":return Int32Array;case"bool":return Uint8Array;case"float64":return Float64Array;case"uint32":return Uint32Array;case"int64":return BigInt64Array;case"uint64":return BigUint64Array;default:throw new Error(`unsupported type: ${n}`)}},uo=n=>{switch(n){case"verbose":return 0;case"info":return 1;case"warning":return 2;case"error":return 3;case"fatal":return 4;default:throw new Error(`unsupported logging level: ${n}`)}},At=n=>n==="float32"||n==="float16"||n==="int32"||n==="int64"||n==="uint32"||n==="uint8"||n==="bool"||n==="uint4"||n==="int4",It=n=>n==="float32"||n==="float16"||n==="int32"||n==="int64"||n==="uint32"||n==="uint64"||n==="int8"||n==="uint8"||n==="bool"||n==="uint4"||n==="int4",fo=n=>{switch(n){case"none":return 0;case"cpu":return 1;case"cpu-pinned":return 2;case"texture":return 3;case"gpu-buffer":return 4;case"ml-tensor":return 5;default:throw new Error(`unsupported data location: ${n}`)}}});var Ke,cn=F(()=>{"use strict";dt();Ke=async n=>{if(typeof n=="string")if(!1)try{let{readFile:t}=qt("node:fs/promises");return new Uint8Array(await t(n))}catch(t){if(t.code==="ERR_FS_FILE_TOO_LARGE"){let{createReadStream:a}=qt("node:fs"),u=a(n),o=[];for await(let d of u)o.push(d);return new Uint8Array(Buffer.concat(o))}throw t}else{let t=await fetch(n);if(!t.ok)throw new Error(`failed to load external data file: ${n}`);let a=t.headers.get("Content-Length"),u=a?parseInt(a,10):0;if(u<1073741824)return new Uint8Array(await t.arrayBuffer());{if(!t.body)throw new Error(`failed to load external data file: ${n}, no response body.`);let o=t.body.getReader(),d;try{d=new ArrayBuffer(u)}catch(l){if(l instanceof RangeError){let m=Math.ceil(u/65536);d=new WebAssembly.Memory({initial:m,maximum:m}).buffer}else throw l}let c=0;for(;;){let{done:l,value:m}=await o.read();if(l)break;let h=m.byteLength;new Uint8Array(d,c,h).set(m),c+=h}return new Uint8Array(d,0,u)}}else return n instanceof Blob?new Uint8Array(await n.arrayBuffer()):n instanceof Uint8Array?n:new Uint8Array(n)}});var ws,ht,wt,$e,bs,co,Qe,bt,yt,lo,gt,Et,Tt,rn=F(()=>{"use strict";Te();oo();so();fn();xe();vt();cn();ws=(n,t)=>{$()._OrtInit(n,t)!==0&&G("Can't initialize onnxruntime.")},ht=async n=>{ws(n.wasm.numThreads,uo(n.logLevel))},wt=async(n,t)=>{$().asyncInit?.();let a=n.webgpu.adapter;if(t==="webgpu"){if(typeof navigator>"u"||!navigator.gpu)throw new Error("WebGPU is not supported in current environment");if(a){if(typeof a.limits!="object"||typeof a.features!="object"||typeof a.requestDevice!="function")throw new Error("Invalid GPU adapter set in `env.webgpu.adapter`. It must be a GPUAdapter object.")}else{let u=n.webgpu.powerPreference;if(u!==void 0&&u!=="low-power"&&u!=="high-performance")throw new Error(`Invalid powerPreference setting: "${u}"`);let o=n.webgpu.forceFallbackAdapter;if(o!==void 0&&typeof o!="boolean")throw new Error(`Invalid forceFallbackAdapter setting: "${o}"`);if(a=await navigator.gpu.requestAdapter({powerPreference:u,forceFallbackAdapter:o}),!a)throw new Error('Failed to get GPU adapter. You may need to enable flag "--enable-unsafe-webgpu" if you are using Chrome.')}}if(t==="webnn"&&(typeof navigator>"u"||!navigator.ml))throw new Error("WebNN is not supported in current environment")},$e=new Map,bs=n=>{let t=$(),a=t.stackSave();try{let u=t.PTR_SIZE,o=t.stackAlloc(2*u);t._OrtGetInputOutputCount(n,o,o+u)!==0&&G("Can't get session input/output count.");let c=u===4?"i32":"i64";return[Number(t.getValue(o,c)),Number(t.getValue(o+u,c))]}finally{t.stackRestore(a)}},co=(n,t)=>{let a=$(),u=a.stackSave(),o=0;try{let d=a.PTR_SIZE,c=a.stackAlloc(2*d);a._OrtGetInputOutputMetadata(n,t,c,c+d)!==0&&G("Can't get session input/output metadata.");let m=Number(a.getValue(c,"*"));o=Number(a.getValue(c+d,"*"));let h=a.HEAP32[o/4];if(h===0)return[m,0];let g=a.HEAPU32[o/4+1],b=[];for(let y=0;y<g;y++){let T=Number(a.getValue(o+8+y*d,"*"));b.push(T!==0?a.UTF8ToString(T):Number(a.getValue(o+8+(y+g)*d,"*")))}return[m,h,b]}finally{a.stackRestore(u),o!==0&&a._OrtFree(o)}},Qe=n=>{let t=$(),a=t._malloc(n.byteLength);if(a===0)throw new Error(`Can't create a session. failed to allocate a buffer of size ${n.byteLength}.`);return t.HEAPU8.set(n,a),[a,n.byteLength]},bt=async(n,t)=>{let a,u,o=$();Array.isArray(n)?[a,u]=n:n.buffer===o.HEAPU8.buffer?[a,u]=[n.byteOffset,n.byteLength]:[a,u]=Qe(n);let d=0,c=0,l=0,m=[],h=[],g=[];try{if([c,m]=await ao(t),t?.externalData&&o.mountExternalData){let _=[];for(let k of t.externalData){let w=typeof k=="string"?k:k.path;_.push(Ke(typeof k=="string"?k:k.data).then(Z=>{o.mountExternalData(w,Z)}))}await Promise.all(_)}for(let _ of t?.executionProviders??[])if((typeof _=="string"?_:_.name)==="webnn"){if(o.shouldTransferToMLTensor=!1,typeof _!="string"){let w=_,Z=w?.context,j=w?.gpuDevice,ne=w?.deviceType,pe=w?.powerPreference;Z?o.currentContext=Z:j?o.currentContext=await o.webnnCreateMLContext(j):o.currentContext=await o.webnnCreateMLContext({deviceType:ne,powerPreference:pe})}else o.currentContext=await o.webnnCreateMLContext();break}d=await o._OrtCreateSession(a,u,c),o.webgpuOnCreateSession?.(d),d===0&&G("Can't create a session."),o.jsepOnCreateSession?.(),o.currentContext&&(o.webnnRegisterMLContext(d,o.currentContext),o.currentContext=void 0,o.shouldTransferToMLTensor=!0);let[b,y]=bs(d),T=!!t?.enableGraphCapture,I=[],D=[],z=[],v=[],O=[];for(let _=0;_<b;_++){let[k,w,Z]=co(d,_);k===0&&G("Can't get an input name."),h.push(k);let j=o.UTF8ToString(k);I.push(j),z.push(w===0?{name:j,isTensor:!1}:{name:j,isTensor:!0,type:Ot(w),shape:Z})}for(let _=0;_<y;_++){let[k,w,Z]=co(d,_+b);k===0&&G("Can't get an output name."),g.push(k);let j=o.UTF8ToString(k);D.push(j),v.push(w===0?{name:j,isTensor:!1}:{name:j,isTensor:!0,type:Ot(w),shape:Z})}return $e.set(d,[d,h,g,null,T,!1]),[d,I,D,z,v]}catch(b){throw h.forEach(y=>o._OrtFree(y)),g.forEach(y=>o._OrtFree(y)),l!==0&&o._OrtReleaseBinding(l)!==0&&G("Can't release IO binding."),d!==0&&o._OrtReleaseSession(d)!==0&&G("Can't release session."),b}finally{o._free(a),c!==0&&o._OrtReleaseSessionOptions(c)!==0&&G("Can't release session options."),m.forEach(b=>o._free(b)),o.unmountExternalData?.()}},yt=n=>{let t=$(),a=$e.get(n);if(!a)throw new Error(`cannot release session. invalid session id: ${n}`);let[u,o,d,c,l]=a;c&&(l&&t._OrtClearBoundOutputs(c.handle)!==0&&G("Can't clear bound outputs."),t._OrtReleaseBinding(c.handle)!==0&&G("Can't release IO binding.")),t.jsepOnReleaseSession?.(n),t.webnnOnReleaseSession?.(n),t.webgpuOnReleaseSession?.(n),o.forEach(m=>t._OrtFree(m)),d.forEach(m=>t._OrtFree(m)),t._OrtReleaseSession(u)!==0&&G("Can't release session."),$e.delete(n)},lo=async(n,t,a,u,o,d,c=!1)=>{if(!n){t.push(0);return}let l=$(),m=l.PTR_SIZE,h=n[0],g=n[1],b=n[3],y=b,T,I;if(h==="string"&&(b==="gpu-buffer"||b==="ml-tensor"))throw new Error("String tensor is not supported on GPU.");if(c&&b!=="gpu-buffer")throw new Error(`External buffer must be provided for input/output index ${d} when enableGraphCapture is true.`);if(b==="gpu-buffer"){let v=n[2].gpuBuffer;I=Ge(We(h),g);{let O=l.jsepRegisterBuffer;if(!O)throw new Error('Tensor location "gpu-buffer" is not supported without using WebGPU.');T=O(u,d,v,I)}}else if(b==="ml-tensor"){let v=n[2].mlTensor;I=Ge(We(h),g);let O=l.webnnRegisterMLTensor;if(!O)throw new Error('Tensor location "ml-tensor" is not supported without using WebNN.');T=O(u,v,We(h),g)}else{let v=n[2];if(Array.isArray(v)){I=m*v.length,T=l._malloc(I),a.push(T);for(let O=0;O<v.length;O++){if(typeof v[O]!="string")throw new TypeError(`tensor data at index ${O} is not a string`);l.setValue(T+O*m,ae(v[O],a),"*")}}else{let O=l.webnnIsGraphInput,N=l.webnnIsGraphOutput;if(h!=="string"&&O&&N){let _=l.UTF8ToString(o);if(O(u,_)||N(u,_)){let k=We(h);I=Ge(k,g),y="ml-tensor";let w=l.webnnCreateTemporaryTensor,Z=l.webnnUploadTensor;if(!w||!Z)throw new Error('Tensor location "ml-tensor" is not supported without using WebNN.');let j=await w(u,k,g);Z(j,new Uint8Array(v.buffer,v.byteOffset,v.byteLength)),T=j}else I=v.byteLength,T=l._malloc(I),a.push(T),l.HEAPU8.set(new Uint8Array(v.buffer,v.byteOffset,I),T)}else I=v.byteLength,T=l._malloc(I),a.push(T),l.HEAPU8.set(new Uint8Array(v.buffer,v.byteOffset,I),T)}}let D=l.stackSave(),z=l.stackAlloc(4*g.length);try{g.forEach((O,N)=>l.setValue(z+N*m,O,m===4?"i32":"i64"));let v=l._OrtCreateTensor(We(h),T,I,z,g.length,fo(y));v===0&&G(`Can't create tensor for input/output. session=${u}, index=${d}.`),t.push(v)}finally{l.stackRestore(D)}},gt=async(n,t,a,u,o,d)=>{let c=$(),l=c.PTR_SIZE,m=$e.get(n);if(!m)throw new Error(`cannot run inference. invalid session id: ${n}`);let h=m[0],g=m[1],b=m[2],y=m[3],T=m[4],I=m[5],D=t.length,z=u.length,v=0,O=[],N=[],_=[],k=[],w=[],Z=c.stackSave(),j=c.stackAlloc(D*l),ne=c.stackAlloc(D*l),pe=c.stackAlloc(z*l),B=c.stackAlloc(z*l);try{[v,O]=ro(d),De("wasm prepareInputOutputTensor");for(let A=0;A<D;A++)await lo(a[A],N,k,n,g[t[A]],t[A],T);for(let A=0;A<z;A++)await lo(o[A],_,k,n,b[u[A]],D+u[A],T);Ue("wasm prepareInputOutputTensor");for(let A=0;A<D;A++)c.setValue(j+A*l,N[A],"*"),c.setValue(ne+A*l,g[t[A]],"*");for(let A=0;A<z;A++)c.setValue(pe+A*l,_[A],"*"),c.setValue(B+A*l,b[u[A]],"*");c.jsepOnRunStart?.(h),c.webnnOnRunStart?.(h);let W;W=await c._OrtRun(h,ne,j,D,B,z,pe,v),W!==0&&G("failed to call OrtRun().");let re=[],me=[];De("wasm ProcessOutputTensor");for(let A=0;A<z;A++){let K=Number(c.getValue(pe+A*l,"*"));if(K===_[A]||w.includes(_[A])){re.push(o[A]),K!==_[A]&&c._OrtReleaseTensor(K)!==0&&G("Can't release tensor.");continue}let je=c.stackSave(),ee=c.stackAlloc(4*l),he=!1,H,q=0;try{c._OrtGetTensorData(K,ee,ee+l,ee+2*l,ee+3*l)!==0&&G(`Can't access output tensor data on index ${A}.`);let Se=l===4?"i32":"i64",ve=Number(c.getValue(ee,Se));q=c.getValue(ee+l,"*");let Ve=c.getValue(ee+l*2,"*"),Re=Number(c.getValue(ee+l*3,Se)),te=[];for(let L=0;L<Re;L++)te.push(Number(c.getValue(Ve+L*l,Se)));c._OrtFree(Ve)!==0&&G("Can't free memory for tensor dims.");let ue=te.reduce((L,V)=>L*V,1);H=Ot(ve);let se=y?.outputPreferredLocations[u[A]];if(H==="string"){if(se==="gpu-buffer"||se==="ml-tensor")throw new Error("String tensor is not supported on GPU.");let L=[];for(let V=0;V<ue;V++){let fe=c.getValue(q+V*l,"*"),ye=c.getValue(q+(V+1)*l,"*"),ce=V===ue-1?void 0:ye-fe;L.push(c.UTF8ToString(fe,ce))}re.push([H,te,L,"cpu"])}else if(se==="gpu-buffer"&&ue>0){let L=c.jsepGetBuffer;if(!L)throw new Error('preferredLocation "gpu-buffer" is not supported without using WebGPU.');let V=L(q),fe=Ge(ve,ue);if(fe===void 0||!At(H))throw new Error(`Unsupported data type: ${H}`);he=!0,re.push([H,te,{gpuBuffer:V,download:c.jsepCreateDownloader(V,fe,H),dispose:()=>{c._OrtReleaseTensor(K)!==0&&G("Can't release tensor.")}},"gpu-buffer"])}else if(se==="ml-tensor"&&ue>0){let L=c.webnnEnsureTensor,V=c.webnnIsGraphInputOutputTypeSupported;if(!L||!V)throw new Error('preferredLocation "ml-tensor" is not supported without using WebNN.');if(Ge(ve,ue)===void 0||!It(H))throw new Error(`Unsupported data type: ${H}`);if(!V(n,H,!1))throw new Error(`preferredLocation "ml-tensor" for ${H} output is not supported by current WebNN Context.`);let ye=await L(n,q,ve,te,!1);he=!0,re.push([H,te,{mlTensor:ye,download:c.webnnCreateMLTensorDownloader(q,H),dispose:()=>{c.webnnReleaseTensorId(q),c._OrtReleaseTensor(K)}},"ml-tensor"])}else if(se==="ml-tensor-cpu-output"&&ue>0){let L=c.webnnCreateMLTensorDownloader(q,H)(),V=re.length;he=!0,me.push((async()=>{let fe=[V,await L];return c.webnnReleaseTensorId(q),c._OrtReleaseTensor(K),fe})()),re.push([H,te,[],"cpu"])}else{let L=io(H),V=new L(ue);new Uint8Array(V.buffer,V.byteOffset,V.byteLength).set(c.HEAPU8.subarray(q,q+V.byteLength)),re.push([H,te,V,"cpu"])}}finally{c.stackRestore(je),H==="string"&&q&&c._free(q),he||c._OrtReleaseTensor(K)}}y&&!T&&(c._OrtClearBoundOutputs(y.handle)!==0&&G("Can't clear bound outputs."),$e.set(n,[h,g,b,y,T,!1]));for(let[A,K]of await Promise.all(me))re[A][2]=K;return Ue("wasm ProcessOutputTensor"),re}finally{c.webnnOnRunEnd?.(h),c.stackRestore(Z),N.forEach(W=>c._OrtReleaseTensor(W)),_.forEach(W=>c._OrtReleaseTensor(W)),k.forEach(W=>c._free(W)),v!==0&&c._OrtReleaseRunOptions(v),O.forEach(W=>c._free(W))}},Et=n=>{let t=$(),a=$e.get(n);if(!a)throw new Error("invalid session id");let u=a[0],o=t._OrtEndProfiling(u);o===0&&G("Can't get an profile file name."),t._OrtFree(o)},Tt=n=>{let t=[];for(let a of n){let u=a[2];!Array.isArray(u)&&"buffer"in u&&t.push(u.buffer)}return t}});var Me,ie,et,Lt,_t,Bt,ln,dn,ze,He,gs,po,mo,ho,wo,bo,yo,go,pn=F(()=>{"use strict";Te();rn();xe();pt();Me=()=>!!Y.wasm.proxy&&typeof document<"u",et=!1,Lt=!1,_t=!1,dn=new Map,ze=(n,t)=>{let a=dn.get(n);a?a.push(t):dn.set(n,[t])},He=()=>{if(et||!Lt||_t||!ie)throw new Error("worker not ready")},gs=n=>{switch(n.data.type){case"init-wasm":et=!1,n.data.err?(_t=!0,ln[1](n.data.err)):(Lt=!0,ln[0]()),Bt&&(URL.revokeObjectURL(Bt),Bt=void 0);break;case"init-ep":case"copy-from":case"create":case"release":case"run":case"end-profiling":{let t=dn.get(n.data.type);n.data.err?t.shift()[1](n.data.err):t.shift()[0](n.data.out);break}default:}},po=async()=>{if(!Lt){if(et)throw new Error("multiple calls to 'initWasm()' detected.");if(_t)throw new Error("previous call to 'initWasm()' failed.");if(et=!0,Me())return new Promise((n,t)=>{ie?.terminate(),eo().then(([a,u])=>{try{ie=u,ie.onerror=d=>t(d),ie.onmessage=gs,ln=[n,t];let o={type:"init-wasm",in:Y};!o.in.wasm.wasmPaths&&(a||an)&&(o.in.wasm.wasmPaths={wasm:new URL("ort-wasm-simd-threaded.wasm",import.meta.url).href}),ie.postMessage(o),Bt=a}catch(o){t(o)}},t)});try{await mt(Y.wasm),await ht(Y),Lt=!0}catch(n){throw _t=!0,n}finally{et=!1}}},mo=async n=>{if(Me())return He(),new Promise((t,a)=>{ze("init-ep",[t,a]);let u={type:"init-ep",in:{epName:n,env:Y}};ie.postMessage(u)});await wt(Y,n)},ho=async n=>Me()?(He(),new Promise((t,a)=>{ze("copy-from",[t,a]);let u={type:"copy-from",in:{buffer:n}};ie.postMessage(u,[n.buffer])})):Qe(n),wo=async(n,t)=>{if(Me()){if(t?.preferredOutputLocation)throw new Error('session option "preferredOutputLocation" is not supported for proxy.');return He(),new Promise((a,u)=>{ze("create",[a,u]);let o={type:"create",in:{model:n,options:{...t}}},d=[];n instanceof Uint8Array&&d.push(n.buffer),ie.postMessage(o,d)})}else return bt(n,t)},bo=async n=>{if(Me())return He(),new Promise((t,a)=>{ze("release",[t,a]);let u={type:"release",in:n};ie.postMessage(u)});yt(n)},yo=async(n,t,a,u,o,d)=>{if(Me()){if(a.some(c=>c[3]!=="cpu"))throw new Error("input tensor on GPU is not supported for proxy.");if(o.some(c=>c))throw new Error("pre-allocated output tensor is not supported for proxy.");return He(),new Promise((c,l)=>{ze("run",[c,l]);let m=a,h={type:"run",in:{sessionId:n,inputIndices:t,inputs:m,outputIndices:u,options:d}};ie.postMessage(h,Tt(m))})}else return gt(n,t,a,u,o,d)},go=async n=>{if(Me())return He(),new Promise((t,a)=>{ze("end-profiling",[t,a]);let u={type:"end-profiling",in:n};ie.postMessage(u)});Et(n)}});var Eo,Es,Pt,To=F(()=>{"use strict";Te();pn();fn();dt();cn();Eo=(n,t)=>{switch(n.location){case"cpu":return[n.type,n.dims,n.data,"cpu"];case"gpu-buffer":return[n.type,n.dims,{gpuBuffer:n.gpuBuffer},"gpu-buffer"];case"ml-tensor":return[n.type,n.dims,{mlTensor:n.mlTensor},"ml-tensor"];default:throw new Error(`invalid data location: ${n.location} for ${t()}`)}},Es=n=>{switch(n[3]){case"cpu":return new de(n[0],n[2],n[1]);case"gpu-buffer":{let t=n[0];if(!At(t))throw new Error(`not supported data type: ${t} for deserializing GPU tensor`);let{gpuBuffer:a,download:u,dispose:o}=n[2];return de.fromGpuBuffer(a,{dataType:t,dims:n[1],download:u,dispose:o})}case"ml-tensor":{let t=n[0];if(!It(t))throw new Error(`not supported data type: ${t} for deserializing MLTensor tensor`);let{mlTensor:a,download:u,dispose:o}=n[2];return de.fromMLTensor(a,{dataType:t,dims:n[1],download:u,dispose:o})}default:throw new Error(`invalid data location: ${n[3]}`)}},Pt=class{async fetchModelAndCopyToWasmMemory(t){return ho(await Ke(t))}async loadModel(t,a){_e();let u;typeof t=="string"?u=await this.fetchModelAndCopyToWasmMemory(t):u=t,[this.sessionId,this.inputNames,this.outputNames,this.inputMetadata,this.outputMetadata]=await wo(u,a),Pe()}async dispose(){return bo(this.sessionId)}async run(t,a,u){_e();let o=[],d=[];Object.entries(t).forEach(y=>{let T=y[0],I=y[1],D=this.inputNames.indexOf(T);if(D===-1)throw new Error(`invalid input '${T}'`);o.push(I),d.push(D)});let c=[],l=[];Object.entries(a).forEach(y=>{let T=y[0],I=y[1],D=this.outputNames.indexOf(T);if(D===-1)throw new Error(`invalid output '${T}'`);c.push(I),l.push(D)});let m=o.map((y,T)=>Eo(y,()=>`input "${this.inputNames[d[T]]}"`)),h=c.map((y,T)=>y?Eo(y,()=>`output "${this.outputNames[l[T]]}"`):null),g=await yo(this.sessionId,d,m,l,h,u),b={};for(let y=0;y<g.length;y++)b[this.outputNames[l[y]]]=c[y]??Es(g[y]);return Pe(),b}startProfiling(){}endProfiling(){go(this.sessionId)}}});var vo={};ut(vo,{OnnxruntimeWebAssemblyBackend:()=>Dt,initializeFlags:()=>So,wasmBackend:()=>Ts});var So,Dt,Ts,Oo=F(()=>{"use strict";Te();pn();To();So=()=>{(typeof Y.wasm.initTimeout!="number"||Y.wasm.initTimeout<0)&&(Y.wasm.initTimeout=0);let n=Y.wasm.simd;if(typeof n!="boolean"&&n!==void 0&&n!=="fixed"&&n!=="relaxed"&&(console.warn(`Property "env.wasm.simd" is set to unknown value "${n}". Reset it to \`false\` and ignore SIMD feature checking.`),Y.wasm.simd=!1),typeof Y.wasm.proxy!="boolean"&&(Y.wasm.proxy=!1),typeof Y.wasm.trace!="boolean"&&(Y.wasm.trace=!1),typeof Y.wasm.numThreads!="number"||!Number.isInteger(Y.wasm.numThreads)||Y.wasm.numThreads<=0)if(typeof self<"u"&&!self.crossOriginIsolated)Y.wasm.numThreads=1;else{let t=typeof navigator>"u"?qt("node:os").cpus().length:navigator.hardwareConcurrency;Y.wasm.numThreads=Math.min(4,Math.ceil((t||1)/2))}},Dt=class{async init(t){So(),await po(),await mo(t)}async createInferenceSessionHandler(t,a){let u=new Pt;return await u.loadModel(t,a),u}},Ts=new Dt});Te();Te();Te();var Gr="1.27.0";var au=nn;{let n=(Oo(),Xt(vo)).wasmBackend;qe("cpu",n,10),qe("wasm",n,10)}Object.defineProperty(Y.versions,"web",{value:Gr,enumerable:!0});export{ts as InferenceSession,xr as TRACE,De as TRACE_EVENT_BEGIN,Ue as TRACE_EVENT_END,_e as TRACE_FUNC_BEGIN,Pe as TRACE_FUNC_END,de as Tensor,au as default,Y as env,qe as registerBackend};
9
+ //# sourceMappingURL=ort.wasm.bundle.min.mjs.map