Deploy browser player — run4/ckpt-037888 elo 2361.0
Browse files- .gitattributes +1 -0
- README.md +31 -6
- css/style.css +417 -0
- index.html +201 -18
- js/analytics.js +65 -0
- js/app.js +1509 -0
- js/engine.js +788 -0
- js/game.js +243 -0
- js/mcts.js +583 -0
- js/net.js +210 -0
- js/record.js +170 -0
- js/report.js +200 -0
- js/upload.js +140 -0
- js/worker.js +335 -0
- model/model.onnx +3 -0
- model/model_meta.json +45 -0
- social.png +3 -0
- style.css +0 -28
- ui/PORTING.md +111 -0
- ui/THEMING.md +120 -0
- ui/animate.js +200 -0
- ui/board.css +848 -0
- ui/board.js +340 -0
- ui/confirm.js +52 -0
- ui/dom.js +93 -0
- ui/history.js +176 -0
- ui/layout.js +58 -0
- ui/log.js +143 -0
- ui/popups.js +78 -0
- ui/scoring.js +193 -0
- ui/settings.js +211 -0
- ui/status.js +96 -0
- ui/theme.css +246 -0
- vendor/onnxruntime-web/ort-wasm-simd-threaded.jspi.wasm +3 -0
- vendor/onnxruntime-web/ort-wasm-simd-threaded.wasm +3 -0
- vendor/onnxruntime-web/ort.jspi.bundle.min.mjs +0 -0
- vendor/onnxruntime-web/ort.wasm.bundle.min.mjs +9 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
-
pinned:
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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
|