noobpwnftw commited on
Commit
e16348e
·
0 Parent(s):

Deploy ChessTB Explorer on Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +15 -0
  2. .gitattributes +53 -0
  3. .gitignore +21 -0
  4. Dockerfile +41 -0
  5. LICENSE +674 -0
  6. README.md +937 -0
  7. THIRD_PARTY_LICENSES.md +133 -0
  8. app.py +2048 -0
  9. config.py +313 -0
  10. dtz_anchor.py +72 -0
  11. frontend/.gitignore +1 -0
  12. frontend/admin.html +7 -0
  13. frontend/index.html +7 -0
  14. frontend/package-lock.json +1698 -0
  15. frontend/package.json +19 -0
  16. frontend/src/App.jsx +43 -0
  17. frontend/src/assets/fonts/Inter-Medium.woff2 +0 -0
  18. frontend/src/assets/fonts/Inter-Regular.woff2 +0 -0
  19. frontend/src/assets/fonts/Inter-SemiBold.woff2 +0 -0
  20. frontend/src/assets/fonts/JetBrainsMono-Medium.woff2 +0 -0
  21. frontend/src/assets/fonts/JetBrainsMono-Regular.woff2 +0 -0
  22. frontend/src/components/BoardArea.jsx +41 -0
  23. frontend/src/components/FenBar.jsx +60 -0
  24. frontend/src/components/Header.jsx +51 -0
  25. frontend/src/components/PgnDialog.jsx +46 -0
  26. frontend/src/components/PgnPanel.jsx +92 -0
  27. frontend/src/components/PromotionDialog.jsx +23 -0
  28. frontend/src/components/ResultsPanel.jsx +183 -0
  29. frontend/src/components/ScreenNav.jsx +33 -0
  30. frontend/src/components/SettingsPanel.jsx +91 -0
  31. frontend/src/components/Toasts.jsx +9 -0
  32. frontend/src/components/ui/Button.jsx +18 -0
  33. frontend/src/components/ui/Icon.jsx +43 -0
  34. frontend/src/components/ui/Panel.jsx +14 -0
  35. frontend/src/components/ui/Segmented.jsx +38 -0
  36. frontend/src/engine/board.js +1282 -0
  37. frontend/src/engine/gameState.js +76 -0
  38. frontend/src/engine/moveLine.js +103 -0
  39. frontend/src/engine/pgnFormat.js +198 -0
  40. frontend/src/engine/scroll.js +49 -0
  41. frontend/src/engine/storage.js +36 -0
  42. frontend/src/engine/tablebase.js +735 -0
  43. frontend/src/engine/theme.js +158 -0
  44. frontend/src/engine/utils.js +22 -0
  45. frontend/src/hooks/useExplorer.js +491 -0
  46. frontend/src/main.jsx +8 -0
  47. frontend/src/styles/admin-page.css +158 -0
  48. frontend/src/styles/admin.css +8 -0
  49. frontend/src/styles/base.css +44 -0
  50. frontend/src/styles/chess/board.css +245 -0
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .github
4
+ .venv
5
+ venv
6
+ __pycache__
7
+ *.pyc
8
+ .pytest_cache
9
+ .coverage
10
+ htmlcov
11
+ .vscode
12
+ .idea
13
+ .DS_Store
14
+ Thumbs.db
15
+ frontend/node_modules
.gitattributes ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ChessTB Explorer: line-ending / diff attributes
2
+ #
3
+ # Every text file in this repo is normalized to LF, regardless of the
4
+ # committer's or cloner's platform or local `core.autocrlf` setting - the
5
+ # app is meant to run identically on Windows/Linux/macOS, and mixed line
6
+ # endings across files (or within one file after a Windows edit) cause
7
+ # noisy whole-file diffs and spurious merge conflicts for no functional
8
+ # reason. Binary assets are marked explicitly so Git never attempts text
9
+ # normalization or diffing on them.
10
+
11
+ # Fallback: let Git auto-detect text vs binary for anything not matched
12
+ # below, and normalize any text file's line endings to LF in the
13
+ # repository (working-tree endings still follow core.autocrlf locally).
14
+ * text=auto eol=lf
15
+
16
+ # --- Source / config / docs - force LF explicitly -------------------------
17
+ *.py text eol=lf
18
+ *.js text eol=lf
19
+ *.mjs text eol=lf
20
+ *.css text eol=lf
21
+ *.html text eol=lf
22
+ *.md text eol=lf
23
+ *.yml text eol=lf
24
+ *.yaml text eol=lf
25
+ *.txt text eol=lf
26
+ *.json text eol=lf
27
+ *.svg text eol=lf
28
+ .gitignore text eol=lf
29
+ .gitattributes text eol=lf
30
+ LICENSE text eol=lf
31
+
32
+ # --- Binary assets - never touch, never diff as text -----------------------
33
+ *.png binary
34
+ *.jpg binary
35
+ *.jpeg binary
36
+ *.woff2 binary
37
+ *.woff binary
38
+ *.ttf binary
39
+ *.ico binary
40
+
41
+ # --- Vendored third-party code - exclude from GitHub's language stats and
42
+ # from generated-diff/review noise; these files are never hand-edited here,
43
+ # treated as vendored files and excluded from generated-diff noise.
44
+ # See THIRD_PARTY_LICENSES.md for what's vendored and why.
45
+ frontend/src/vendor/** linguist-vendored
46
+ frontend/src/assets/fonts/** linguist-vendored
47
+
48
+ # --- Generated build output - not hand-edited; excluded from language
49
+ # stats and review diffs. static/dist/ is rebuilt from frontend/src/
50
+ # by `vite build` (see README.md's "Frontend build"); package-lock.json is
51
+ # written by npm.
52
+ static/dist/** linguist-generated
53
+ frontend/package-lock.json linguist-generated
.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local environment files: this project doesn't use one (config.py reads
2
+ # its two secrets, ADMIN_TOKEN and FLASK_SECRET_KEY, directly from the
3
+ # process environment rather than a .env file - see config.py's "Admin"
4
+ # section), but this stays as a no-cost safety net in case one is ever
5
+ # added locally.
6
+ .env
7
+
8
+ # Python
9
+ __pycache__/
10
+ *.pyc
11
+ .venv/
12
+ venv/
13
+ .pytest_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # Editors / OS
18
+ .vscode/
19
+ .idea/
20
+ .DS_Store
21
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Git is required because requirements.txt installs
4
+ # noobpwnftw/python-chess directly from GitHub.
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends git \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # Run as a non-root user rather than the image's default root. UID 1000 has
10
+ # no special meaning to Render (it just needs to own /app below); it
11
+ # matches Hugging Face Docker Spaces' convention, so the same image also
12
+ # runs there.
13
+ RUN useradd -m -u 1000 user
14
+
15
+ WORKDIR /app
16
+
17
+ # Install Python dependencies first so Docker can cache this layer.
18
+ COPY --chown=user requirements.txt /app/requirements.txt
19
+
20
+ RUN pip install --no-cache-dir --upgrade pip \
21
+ && pip install --no-cache-dir -r /app/requirements.txt
22
+
23
+ # Copy the actual application.
24
+ COPY --chown=user . /app
25
+
26
+ USER user
27
+
28
+ ENV HOME=/home/user
29
+ ENV PATH=/home/user/.local/bin:$PATH
30
+ ENV PYTHONUNBUFFERED=1
31
+
32
+ # Purely informational -- config.py's PORT reads the platform's own PORT
33
+ # environment variable at runtime (falling back to 7860 only when it's
34
+ # unset) and that's the port actually bound, regardless of this EXPOSE
35
+ # value. Render assigns and injects PORT itself (see render.yaml /
36
+ # README.md "Deploying to Render"), so nothing needs to change here to
37
+ # match it; 7860 is the default for a local `docker run` with no PORT set,
38
+ # and for Hugging Face Docker Spaces, which expects 7860 specifically.
39
+ EXPOSE 7860
40
+
41
+ CMD ["python", "app.py"]
LICENSE ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU General Public License is a free, copyleft license for
11
+ software and other kinds of works.
12
+
13
+ The licenses for most software and other practical works are designed
14
+ to take away your freedom to share and change the works. By contrast,
15
+ the GNU General Public License is intended to guarantee your freedom to
16
+ share and change all versions of a program--to make sure it remains free
17
+ software for all its users. We, the Free Software Foundation, use the
18
+ GNU General Public License for most of our software; it applies also to
19
+ any other work released this way by its authors. You can apply it to
20
+ your programs, too.
21
+
22
+ When we speak of free software, we are referring to freedom, not
23
+ price. Our General Public Licenses are designed to make sure that you
24
+ have the freedom to distribute copies of free software (and charge for
25
+ them if you wish), that you receive source code or can get it if you
26
+ want it, that you can change the software or use pieces of it in new
27
+ free programs, and that you know you can do these things.
28
+
29
+ To protect your rights, we need to prevent others from denying you
30
+ these rights or asking you to surrender the rights. Therefore, you have
31
+ certain responsibilities if you distribute copies of the software, or if
32
+ you modify it: responsibilities to respect the freedom of others.
33
+
34
+ For example, if you distribute copies of such a program, whether
35
+ gratis or for a fee, you must pass on to the recipients the same
36
+ freedoms that you received. You must make sure that they, too, receive
37
+ or can get the source code. And you must show them these terms so they
38
+ know their rights.
39
+
40
+ Developers that use the GNU GPL protect your rights with two steps:
41
+ (1) assert copyright on the software, and (2) offer you this License
42
+ giving you legal permission to copy, distribute and/or modify it.
43
+
44
+ For the developers' and authors' protection, the GPL clearly explains
45
+ that there is no warranty for this free software. For both users' and
46
+ authors' sake, the GPL requires that modified versions be marked as
47
+ changed, so that their problems will not be attributed erroneously to
48
+ authors of previous versions.
49
+
50
+ Some devices are designed to deny users access to install or run
51
+ modified versions of the software inside them, although the manufacturer
52
+ can do so. This is fundamentally incompatible with the aim of
53
+ protecting users' freedom to change the software. The systematic
54
+ pattern of such abuse occurs in the area of products for individuals to
55
+ use, which is precisely where it is most unacceptable. Therefore, we
56
+ have designed this version of the GPL to prohibit the practice for those
57
+ products. If such problems arise substantially in other domains, we
58
+ stand ready to extend this provision to those domains in future versions
59
+ of the GPL, as needed to protect the freedom of users.
60
+
61
+ Finally, every program is threatened constantly by software patents.
62
+ States should not allow patents to restrict development and use of
63
+ software on general-purpose computers, but in those that do, we wish to
64
+ avoid the special danger that patents applied to a free program could
65
+ make it effectively proprietary. To prevent this, the GPL assures that
66
+ patents cannot be used to render the program non-free.
67
+
68
+ The precise terms and conditions for copying, distribution and
69
+ modification follow.
70
+
71
+ TERMS AND CONDITIONS
72
+
73
+ 0. Definitions.
74
+
75
+ "This License" refers to version 3 of the GNU General Public License.
76
+
77
+ "Copyright" also means copyright-like laws that apply to other kinds of
78
+ works, such as semiconductor masks.
79
+
80
+ "The Program" refers to any copyrightable work licensed under this
81
+ License. Each licensee is addressed as "you". "Licensees" and
82
+ "recipients" may be individuals or organizations.
83
+
84
+ To "modify" a work means to copy from or adapt all or part of the work
85
+ in a fashion requiring copyright permission, other than the making of an
86
+ exact copy. The resulting work is called a "modified version" of the
87
+ earlier work or a work "based on" the earlier work.
88
+
89
+ A "covered work" means either the unmodified Program or a work based
90
+ on the Program.
91
+
92
+ To "propagate" a work means to do anything with it that, without
93
+ permission, would make you directly or secondarily liable for
94
+ infringement under applicable copyright law, except executing it on a
95
+ computer or modifying a private copy. Propagation includes copying,
96
+ distribution (with or without modification), making available to the
97
+ public, and in some countries other activities as well.
98
+
99
+ To "convey" a work means any kind of propagation that enables other
100
+ parties to make or receive copies. Mere interaction with a user through
101
+ a computer network, with no transfer of a copy, is not conveying.
102
+
103
+ An interactive user interface displays "Appropriate Legal Notices"
104
+ to the extent that it includes a convenient and prominently visible
105
+ feature that (1) displays an appropriate copyright notice, and (2)
106
+ tells the user that there is no warranty for the work (except to the
107
+ extent that warranties are provided), that licensees may convey the
108
+ work under this License, and how to view a copy of this License. If
109
+ the interface presents a list of user commands or options, such as a
110
+ menu, a prominent item in the list meets this criterion.
111
+
112
+ 1. Source Code.
113
+
114
+ The "source code" for a work means the preferred form of the work
115
+ for making modifications to it. "Object code" means any non-source
116
+ form of a work.
117
+
118
+ A "Standard Interface" means an interface that either is an official
119
+ standard defined by a recognized standards body, or, in the case of
120
+ interfaces specified for a particular programming language, one that
121
+ is widely used among developers working in that language.
122
+
123
+ The "System Libraries" of an executable work include anything, other
124
+ than the work as a whole, that (a) is included in the normal form of
125
+ packaging a Major Component, but which is not part of that Major
126
+ Component, and (b) serves only to enable use of the work with that
127
+ Major Component, or to implement a Standard Interface for which an
128
+ implementation is available to the public in source code form. A
129
+ "Major Component", in this context, means a major essential component
130
+ (kernel, window system, and so on) of the specific operating system
131
+ (if any) on which the executable work runs, or a compiler used to
132
+ produce the work, or an object code interpreter used to run it.
133
+
134
+ The "Corresponding Source" for a work in object code form means all
135
+ the source code needed to generate, install, and (for an executable
136
+ work) run the object code and to modify the work, including scripts to
137
+ control those activities. However, it does not include the work's
138
+ System Libraries, or general-purpose tools or generally available free
139
+ programs which are used unmodified in performing those activities but
140
+ which are not part of the work. For example, Corresponding Source
141
+ includes interface definition files associated with source files for
142
+ the work, and the source code for shared libraries and dynamically
143
+ linked subprograms that the work is specifically designed to require,
144
+ such as by intimate data communication or control flow between those
145
+ subprograms and other parts of the work.
146
+
147
+ The Corresponding Source need not include anything that users
148
+ can regenerate automatically from other parts of the Corresponding
149
+ Source.
150
+
151
+ The Corresponding Source for a work in source code form is that
152
+ same work.
153
+
154
+ 2. Basic Permissions.
155
+
156
+ All rights granted under this License are granted for the term of
157
+ copyright on the Program, and are irrevocable provided the stated
158
+ conditions are met. This License explicitly affirms your unlimited
159
+ permission to run the unmodified Program. The output from running a
160
+ covered work is covered by this License only if the output, given its
161
+ content, constitutes a covered work. This License acknowledges your
162
+ rights of fair use or other equivalent, as provided by copyright law.
163
+
164
+ You may make, run and propagate covered works that you do not
165
+ convey, without conditions so long as your license otherwise remains
166
+ in force. You may convey covered works to others for the sole purpose
167
+ of having them make modifications exclusively for you, or provide you
168
+ with facilities for running those works, provided that you comply with
169
+ the terms of this License in conveying all material for which you do
170
+ not control copyright. Those thus making or running the covered works
171
+ for you must do so exclusively on your behalf, under your direction
172
+ and control, on terms that prohibit them from making any copies of
173
+ your copyrighted material outside their relationship with you.
174
+
175
+ Conveying under any other circumstances is permitted solely under
176
+ the conditions stated below. Sublicensing is not allowed; section 10
177
+ makes it unnecessary.
178
+
179
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
+
181
+ No covered work shall be deemed part of an effective technological
182
+ measure under any applicable law fulfilling obligations under article
183
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184
+ similar laws prohibiting or restricting circumvention of such
185
+ measures.
186
+
187
+ When you convey a covered work, you waive any legal power to forbid
188
+ circumvention of technological measures to the extent such circumvention
189
+ is effected by exercising rights under this License with respect to
190
+ the covered work, and you disclaim any intention to limit operation or
191
+ modification of the work as a means of enforcing, against the work's
192
+ users, your or third parties' legal rights to forbid circumvention of
193
+ technological measures.
194
+
195
+ 4. Conveying Verbatim Copies.
196
+
197
+ You may convey verbatim copies of the Program's source code as you
198
+ receive it, in any medium, provided that you conspicuously and
199
+ appropriately publish on each copy an appropriate copyright notice;
200
+ keep intact all notices stating that this License and any
201
+ non-permissive terms added in accord with section 7 apply to the code;
202
+ keep intact all notices of the absence of any warranty; and give all
203
+ recipients a copy of this License along with the Program.
204
+
205
+ You may charge any price or no price for each copy that you convey,
206
+ and you may offer support or warranty protection for a fee.
207
+
208
+ 5. Conveying Modified Source Versions.
209
+
210
+ You may convey a work based on the Program, or the modifications to
211
+ produce it from the Program, in the form of source code under the
212
+ terms of section 4, provided that you also meet all of these conditions:
213
+
214
+ a) The work must carry prominent notices stating that you modified
215
+ it, and giving a relevant date.
216
+
217
+ b) The work must carry prominent notices stating that it is
218
+ released under this License and any conditions added under section
219
+ 7. This requirement modifies the requirement in section 4 to
220
+ "keep intact all notices".
221
+
222
+ c) You must license the entire work, as a whole, under this
223
+ License to anyone who comes into possession of a copy. This
224
+ License will therefore apply, along with any applicable section 7
225
+ additional terms, to the whole of the work, and all its parts,
226
+ regardless of how they are packaged. This License gives no
227
+ permission to license the work in any other way, but it does not
228
+ invalidate such permission if you have separately received it.
229
+
230
+ d) If the work has interactive user interfaces, each must display
231
+ Appropriate Legal Notices; however, if the Program has interactive
232
+ interfaces that do not display Appropriate Legal Notices, your
233
+ work need not make them do so.
234
+
235
+ A compilation of a covered work with other separate and independent
236
+ works, which are not by their nature extensions of the covered work,
237
+ and which are not combined with it such as to form a larger program,
238
+ in or on a volume of a storage or distribution medium, is called an
239
+ "aggregate" if the compilation and its resulting copyright are not
240
+ used to limit the access or legal rights of the compilation's users
241
+ beyond what the individual works permit. Inclusion of a covered work
242
+ in an aggregate does not cause this License to apply to the other
243
+ parts of the aggregate.
244
+
245
+ 6. Conveying Non-Source Forms.
246
+
247
+ You may convey a covered work in object code form under the terms
248
+ of sections 4 and 5, provided that you also convey the
249
+ machine-readable Corresponding Source under the terms of this License,
250
+ in one of these ways:
251
+
252
+ a) Convey the object code in, or embodied in, a physical product
253
+ (including a physical distribution medium), accompanied by the
254
+ Corresponding Source fixed on a durable physical medium
255
+ customarily used for software interchange.
256
+
257
+ b) Convey the object code in, or embodied in, a physical product
258
+ (including a physical distribution medium), accompanied by a
259
+ written offer, valid for at least three years and valid for as
260
+ long as you offer spare parts or customer support for that product
261
+ model, to give anyone who possesses the object code either (1) a
262
+ copy of the Corresponding Source for all the software in the
263
+ product that is covered by this License, on a durable physical
264
+ medium customarily used for software interchange, for a price no
265
+ more than your reasonable cost of physically performing this
266
+ conveying of source, or (2) access to copy the
267
+ Corresponding Source from a network server at no charge.
268
+
269
+ c) Convey individual copies of the object code with a copy of the
270
+ written offer to provide the Corresponding Source. This
271
+ alternative is allowed only occasionally and noncommercially, and
272
+ only if you received the object code with such an offer, in accord
273
+ with subsection 6b.
274
+
275
+ d) Convey the object code by offering access from a designated
276
+ place (gratis or for a charge), and offer equivalent access to the
277
+ Corresponding Source in the same way through the same place at no
278
+ further charge. You need not require recipients to copy the
279
+ Corresponding Source along with the object code. If the place to
280
+ copy the object code is a network server, the Corresponding Source
281
+ may be on a different server (operated by you or a third party)
282
+ that supports equivalent copying facilities, provided you maintain
283
+ clear directions next to the object code saying where to find the
284
+ Corresponding Source. Regardless of what server hosts the
285
+ Corresponding Source, you remain obligated to ensure that it is
286
+ available for as long as needed to satisfy these requirements.
287
+
288
+ e) Convey the object code using peer-to-peer transmission, provided
289
+ you inform other peers where the object code and Corresponding
290
+ Source of the work are being offered to the general public at no
291
+ charge under subsection 6d.
292
+
293
+ A separable portion of the object code, whose source code is excluded
294
+ from the Corresponding Source as a System Library, need not be
295
+ included in conveying the object code work.
296
+
297
+ A "User Product" is either (1) a "consumer product", which means any
298
+ tangible personal property which is normally used for personal, family,
299
+ or household purposes, or (2) anything designed or sold for incorporation
300
+ into a dwelling. In determining whether a product is a consumer product,
301
+ doubtful cases shall be resolved in favor of coverage. For a particular
302
+ product received by a particular user, "normally used" refers to a
303
+ typical or common use of that class of product, regardless of the status
304
+ of the particular user or of the way in which the particular user
305
+ actually uses, or expects or is expected to use, the product. A product
306
+ is a consumer product regardless of whether the product has substantial
307
+ commercial, industrial or non-consumer uses, unless such uses represent
308
+ the only significant mode of use of the product.
309
+
310
+ "Installation Information" for a User Product means any methods,
311
+ procedures, authorization keys, or other information required to install
312
+ and execute modified versions of a covered work in that User Product from
313
+ a modified version of its Corresponding Source. The information must
314
+ suffice to ensure that the continued functioning of the modified object
315
+ code is in no case prevented or interfered with solely because
316
+ modification has been made.
317
+
318
+ If you convey an object code work under this section in, or with, or
319
+ specifically for use in, a User Product, and the conveying occurs as
320
+ part of a transaction in which the right of possession and use of the
321
+ User Product is transferred to the recipient in perpetuity or for a
322
+ fixed term (regardless of how the transaction is characterized), the
323
+ Corresponding Source conveyed under this section must be accompanied
324
+ by the Installation Information. But this requirement does not apply
325
+ if neither you nor any third party retains the ability to install
326
+ modified object code on the User Product (for example, the work has
327
+ been installed in ROM).
328
+
329
+ The requirement to provide Installation Information does not include a
330
+ requirement to continue to provide support service, warranty, or updates
331
+ for a work that has been modified or installed by the recipient, or for
332
+ the User Product in which it has been modified or installed. Access to a
333
+ network may be denied when the modification itself materially and
334
+ adversely affects the operation of the network or violates the rules and
335
+ protocols for communication across the network.
336
+
337
+ Corresponding Source conveyed, and Installation Information provided,
338
+ in accord with this section must be in a format that is publicly
339
+ documented (and with an implementation available to the public in
340
+ source code form), and must require no special password or key for
341
+ unpacking, reading or copying.
342
+
343
+ 7. Additional Terms.
344
+
345
+ "Additional permissions" are terms that supplement the terms of this
346
+ License by making exceptions from one or more of its conditions.
347
+ Additional permissions that are applicable to the entire Program shall
348
+ be treated as though they were included in this License, to the extent
349
+ that they are valid under applicable law. If additional permissions
350
+ apply only to part of the Program, that part may be used separately
351
+ under those permissions, but the entire Program remains governed by
352
+ this License without regard to the additional permissions.
353
+
354
+ When you convey a copy of a covered work, you may at your option
355
+ remove any additional permissions from that copy, or from any part of
356
+ it. (Additional permissions may be written to require their own
357
+ removal in certain cases when you modify the work.) You may place
358
+ additional permissions on material, added by you to a covered work,
359
+ for which you have or can give appropriate copyright permission.
360
+
361
+ Notwithstanding any other provision of this License, for material you
362
+ add to a covered work, you may (if authorized by the copyright holders of
363
+ that material) supplement the terms of this License with terms:
364
+
365
+ a) Disclaiming warranty or limiting liability differently from the
366
+ terms of sections 15 and 16 of this License; or
367
+
368
+ b) Requiring preservation of specified reasonable legal notices or
369
+ author attributions in that material or in the Appropriate Legal
370
+ Notices displayed by works containing it; or
371
+
372
+ c) Prohibiting misrepresentation of the origin of that material, or
373
+ requiring that modified versions of such material be marked in
374
+ reasonable ways as different from the original version; or
375
+
376
+ d) Limiting the use for publicity purposes of names of licensors or
377
+ authors of the material; or
378
+
379
+ e) Declining to grant rights under trademark law for use of some
380
+ trade names, trademarks, or service marks; or
381
+
382
+ f) Requiring indemnification of licensors and authors of that
383
+ material by anyone who conveys the material (or modified versions of
384
+ it) with contractual assumptions of liability to the recipient, for
385
+ any liability that these contractual assumptions directly impose on
386
+ those licensors and authors.
387
+
388
+ All other non-permissive additional terms are considered "further
389
+ restrictions" within the meaning of section 10. If the Program as you
390
+ received it, or any part of it, contains a notice stating that it is
391
+ governed by this License along with a term that is a further
392
+ restriction, you may remove that term. If a license document contains
393
+ a further restriction but permits relicensing or conveying under this
394
+ License, you may add to a covered work material governed by the terms
395
+ of that license document, provided that the further restriction does
396
+ not survive such relicensing or conveying.
397
+
398
+ If you add terms to a covered work in accord with this section, you
399
+ must place, in the relevant source files, a statement of the
400
+ additional terms that apply to those files, or a notice indicating
401
+ where to find the applicable terms.
402
+
403
+ Additional terms, permissive or non-permissive, may be stated in the
404
+ form of a separately written license, or stated as exceptions;
405
+ the above requirements apply either way.
406
+
407
+ 8. Termination.
408
+
409
+ You may not propagate or modify a covered work except as expressly
410
+ provided under this License. Any attempt otherwise to propagate or
411
+ modify it is void, and will automatically terminate your rights under
412
+ this License (including any patent licenses granted under the third
413
+ paragraph of section 11).
414
+
415
+ However, if you cease all violation of this License, then your
416
+ license from a particular copyright holder is reinstated (a)
417
+ provisionally, unless and until the copyright holder explicitly and
418
+ finally terminates your license, and (b) permanently, if the copyright
419
+ holder fails to notify you of the violation by some reasonable means
420
+ prior to 60 days after the cessation.
421
+
422
+ Moreover, your license from a particular copyright holder is
423
+ reinstated permanently if the copyright holder notifies you of the
424
+ violation by some reasonable means, this is the first time you have
425
+ received notice of violation of this License (for any work) from that
426
+ copyright holder, and you cure the violation prior to 30 days after
427
+ your receipt of the notice.
428
+
429
+ Termination of your rights under this section does not terminate the
430
+ licenses of parties who have received copies or rights from you under
431
+ this License. If your rights have been terminated and not permanently
432
+ reinstated, you do not qualify to receive new licenses for the same
433
+ material under section 10.
434
+
435
+ 9. Acceptance Not Required for Having Copies.
436
+
437
+ You are not required to accept this License in order to receive or
438
+ run a copy of the Program. Ancillary propagation of a covered work
439
+ occurring solely as a consequence of using peer-to-peer transmission
440
+ to receive a copy likewise does not require acceptance. However,
441
+ nothing other than this License grants you permission to propagate or
442
+ modify any covered work. These actions infringe copyright if you do
443
+ not accept this License. Therefore, by modifying or propagating a
444
+ covered work, you indicate your acceptance of this License to do so.
445
+
446
+ 10. Automatic Licensing of Downstream Recipients.
447
+
448
+ Each time you convey a covered work, the recipient automatically
449
+ receives a license from the original licensors, to run, modify and
450
+ propagate that work, subject to this License. You are not responsible
451
+ for enforcing compliance by third parties with this License.
452
+
453
+ An "entity transaction" is a transaction transferring control of an
454
+ organization, or substantially all assets of one, or subdividing an
455
+ organization, or merging organizations. If propagation of a covered
456
+ work results from an entity transaction, each party to that
457
+ transaction who receives a copy of the work also receives whatever
458
+ licenses to the work the party's predecessor in interest had or could
459
+ give under the previous paragraph, plus a right to possession of the
460
+ Corresponding Source of the work from the predecessor in interest, if
461
+ the predecessor has it or can get it with reasonable efforts.
462
+
463
+ You may not impose any further restrictions on the exercise of the
464
+ rights granted or affirmed under this License. For example, you may
465
+ not impose a license fee, royalty, or other charge for exercise of
466
+ rights granted under this License, and you may not initiate litigation
467
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
468
+ any patent claim is infringed by making, using, selling, offering for
469
+ sale, or importing the Program or any portion of it.
470
+
471
+ 11. Patents.
472
+
473
+ A "contributor" is a copyright holder who authorizes use under this
474
+ License of the Program or a work on which the Program is based. The
475
+ work thus licensed is called the contributor's "contributor version".
476
+
477
+ A contributor's "essential patent claims" are all patent claims
478
+ owned or controlled by the contributor, whether already acquired or
479
+ hereafter acquired, that would be infringed by some manner, permitted
480
+ by this License, of making, using, or selling its contributor version,
481
+ but do not include claims that would be infringed only as a
482
+ consequence of further modification of the contributor version. For
483
+ purposes of this definition, "control" includes the right to grant
484
+ patent sublicenses in a manner consistent with the requirements of
485
+ this License.
486
+
487
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
488
+ patent license under the contributor's essential patent claims, to
489
+ make, use, sell, offer for sale, import and otherwise run, modify and
490
+ propagate the contents of its contributor version.
491
+
492
+ In the following three paragraphs, a "patent license" is any express
493
+ agreement or commitment, however denominated, not to enforce a patent
494
+ (such as an express permission to practice a patent or covenant not to
495
+ sue for patent infringement). To "grant" such a patent license to a
496
+ party means to make such an agreement or commitment not to enforce a
497
+ patent against the party.
498
+
499
+ If you convey a covered work, knowingly relying on a patent license,
500
+ and the Corresponding Source of the work is not available for anyone
501
+ to copy, free of charge and under the terms of this License, through a
502
+ publicly available network server or other readily accessible means,
503
+ then you must either (1) cause the Corresponding Source to be so
504
+ available, or (2) arrange to deprive yourself of the benefit of the
505
+ patent license for this particular work, or (3) arrange, in a manner
506
+ consistent with the requirements of this License, to extend the patent
507
+ license to downstream recipients. "Knowingly relying" means you have
508
+ actual knowledge that, but for the patent license, your conveying the
509
+ covered work in a country, or your recipient's use of the covered work
510
+ in a country, would infringe one or more identifiable patents in that
511
+ country that you have reason to believe are valid.
512
+
513
+ If, pursuant to or in connection with a single transaction or
514
+ arrangement, you convey, or propagate by procuring conveyance of, a
515
+ covered work, and grant a patent license to some of the parties
516
+ receiving the covered work authorizing them to use, propagate, modify
517
+ or convey a specific copy of the covered work, then the patent license
518
+ you grant is automatically extended to all recipients of the covered
519
+ work and works based on it.
520
+
521
+ A patent license is "discriminatory" if it does not include within
522
+ the scope of its coverage, prohibits the exercise of, or is
523
+ conditioned on the non-exercise of one or more of the rights that are
524
+ specifically granted under this License. You may not convey a covered
525
+ work if you are a party to an arrangement with a third party that is
526
+ in the business of distributing software, under which you make payment
527
+ to the third party based on the extent of your activity of conveying
528
+ the work, and under which the third party grants, to any of the
529
+ parties who would receive the covered work from you, a discriminatory
530
+ patent license (a) in connection with copies of the covered work
531
+ conveyed by you (or copies made from those copies), or (b) primarily
532
+ for and in connection with specific products or compilations that
533
+ contain the covered work, unless you entered into that arrangement,
534
+ or that patent license was granted, prior to 28 March 2007.
535
+
536
+ Nothing in this License shall be construed as excluding or limiting
537
+ any implied license or other defenses to infringement that may
538
+ otherwise be available to you under applicable patent law.
539
+
540
+ 12. No Surrender of Others' Freedom.
541
+
542
+ If conditions are imposed on you (whether by court order, agreement or
543
+ otherwise) that contradict the conditions of this License, they do not
544
+ excuse you from the conditions of this License. If you cannot convey a
545
+ covered work so as to satisfy simultaneously your obligations under this
546
+ License and any other pertinent obligations, then as a consequence you may
547
+ not convey it at all. For example, if you agree to terms that obligate you
548
+ to collect a royalty for further conveying from those to whom you convey
549
+ the Program, the only way you could satisfy both those terms and this
550
+ License would be to refrain entirely from conveying the Program.
551
+
552
+ 13. Use with the GNU Affero General Public License.
553
+
554
+ Notwithstanding any other provision of this License, you have
555
+ permission to link or combine any covered work with a work licensed
556
+ under version 3 of the GNU Affero General Public License into a single
557
+ combined work, and to convey the resulting work. The terms of this
558
+ License will continue to apply to the part which is the covered work,
559
+ but the special requirements of the GNU Affero General Public License,
560
+ section 13, concerning interaction through a network will apply to the
561
+ combination as such.
562
+
563
+ 14. Revised Versions of this License.
564
+
565
+ The Free Software Foundation may publish revised and/or new versions of
566
+ the GNU General Public License from time to time. Such new versions will
567
+ be similar in spirit to the present version, but may differ in detail to
568
+ address new problems or concerns.
569
+
570
+ Each version is given a distinguishing version number. If the
571
+ Program specifies that a certain numbered version of the GNU General
572
+ Public License "or any later version" applies to it, you have the
573
+ option of following the terms and conditions either of that numbered
574
+ version or of any later version published by the Free Software
575
+ Foundation. If the Program does not specify a version number of the
576
+ GNU General Public License, you may choose any version ever published
577
+ by the Free Software Foundation.
578
+
579
+ If the Program specifies that a proxy can decide which future
580
+ versions of the GNU General Public License can be used, that proxy's
581
+ public statement of acceptance of a version permanently authorizes you
582
+ to choose that version for the Program.
583
+
584
+ Later license versions may give you additional or different
585
+ permissions. However, no additional obligations are imposed on any
586
+ author or copyright holder as a result of your choosing to follow a
587
+ later version.
588
+
589
+ 15. Disclaimer of Warranty.
590
+
591
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
+
600
+ 16. Limitation of Liability.
601
+
602
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610
+ SUCH DAMAGES.
611
+
612
+ 17. Interpretation of Sections 15 and 16.
613
+
614
+ If the disclaimer of warranty and limitation of liability provided
615
+ above cannot be given local legal effect according to their terms,
616
+ reviewing courts shall apply local law that most closely approximates
617
+ an absolute waiver of all civil liability in connection with the
618
+ Program, unless a warranty or assumption of liability accompanies a
619
+ copy of the Program in return for a fee.
620
+
621
+ END OF TERMS AND CONDITIONS
622
+
623
+ How to Apply These Terms to Your New Programs
624
+
625
+ If you develop a new program, and you want it to be of the greatest
626
+ possible use to the public, the best way to achieve this is to make it
627
+ free software which everyone can redistribute and change under these terms.
628
+
629
+ To do so, attach the following notices to the program. It is safest
630
+ to attach them to the start of each source file to most effectively
631
+ state the exclusion of warranty; and each file should have at least
632
+ the "copyright" line and a pointer to where the full notice is found.
633
+
634
+ <one line to give the program's name and a brief idea of what it does.>
635
+ Copyright (C) <year> <name of author>
636
+
637
+ This program is free software: you can redistribute it and/or modify
638
+ it under the terms of the GNU General Public License as published by
639
+ the Free Software Foundation, either version 3 of the License, or
640
+ (at your option) any later version.
641
+
642
+ This program is distributed in the hope that it will be useful,
643
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
644
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645
+ GNU General Public License for more details.
646
+
647
+ You should have received a copy of the GNU General Public License
648
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
649
+
650
+ Also add information on how to contact you by electronic and paper mail.
651
+
652
+ If the program does terminal interaction, make it output a short
653
+ notice like this when it starts in an interactive mode:
654
+
655
+ <program> Copyright (C) <year> <name of author>
656
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657
+ This is free software, and you are welcome to redistribute it
658
+ under certain conditions; type `show c' for details.
659
+
660
+ The hypothetical commands `show w' and `show c' should show the appropriate
661
+ parts of the General Public License. Of course, your program's commands
662
+ might be different; for a GUI interface, you would use an "about box".
663
+
664
+ You should also get your employer (if you work as a programmer) or school,
665
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
666
+ For more information on this, and how to apply and follow the GNU GPL, see
667
+ <https://www.gnu.org/licenses/>.
668
+
669
+ The GNU General Public License does not permit incorporating your program
670
+ into proprietary programs. If your program is a subroutine library, you
671
+ may consider it more useful to permit linking proprietary applications with
672
+ the library. If this is what you want to do, use the GNU Lesser General
673
+ Public License instead of this License. But first, please read
674
+ <https://www.gnu.org/licenses/why-not-lgpl.html>.
README.md ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ChessTB Explorer
3
+ emoji: ♟️
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: true
8
+ app_port: 7860
9
+ license: gpl-3.0
10
+ short_description: "Interactive tablebase probing: WDL/DTZ/DTC/DTM/DTM50"
11
+ ---
12
+
13
+ # ChessTB Explorer ♟️
14
+
15
+ Interactive tablebase probing interface with WDL/DTZ/DTC/DTM/DTM50 metrics.
16
+
17
+ This Hugging Face Space is deployed from
18
+ [PunisherChess/ChessTB-Explorer-Render](https://github.com/PunisherChess/ChessTB-Explorer-Render)
19
+ at commit `4969e401fa41ef1b86458cac4b6a31f9b11e08d0`.
20
+ The original authors' license and third-party notices are retained.
21
+
22
+ The application probes the public `chesstb/chesstb` bucket over HTTP byte
23
+ ranges, using the explorer's existing bounded page cache. Sources are
24
+ searched per table with raw generator output under `full/` preferred over
25
+ shipping tables at the bucket root. Castling materials use `full/castling/`
26
+ and `castling/`, respectively.
27
+ Availability of each metric depends on the published tables for that material.
28
+
29
+ `ADMIN_TOKEN` and `FLASK_SECRET_KEY` are configured as Space secrets.
30
+ The probing library follows the maintained `add-chesstb-tablebases` branch
31
+ of `noobpwnftw/python-chess`.
32
+
33
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://chesstb-explorer-render.onrender.com)
34
+
35
+ A single-page web application for exploring **ChessTB** chess endgame
36
+ tablebases. Paste or build any position on an interactive board and
37
+ instantly see every legal move ranked by **Distance to Zeroing (DTZ)**,
38
+ **Distance to Conversion (DTC)**, **Distance to Mate (DTM)**, and **DTM
39
+ under the 50-move rule (DTM50)** - straight from the tablebase, with no
40
+ chess engine involved.
41
+
42
+ The backend is a small Flask application that probes the tablebase through
43
+ a modified fork of [`python-chess`](https://github.com/noobpwnftw/python-chess/tree/add-chesstb-tablebases)
44
+ (the `chess.chesstb` module - see [Installation](#installation)). The frontend is
45
+ a React app built around
46
+ [Chessground](https://github.com/lichess-org/chessground) (lichess.org's
47
+ board component) and [chessops](https://github.com/niklasf/chessops) for
48
+ move generation/validation, supporting both Standard chess and Chess960
49
+ (Fischer Random).
50
+
51
+ > This tool only *displays* tablebase results - it does not generate,
52
+ > verify, or ship any tablebase data itself. See
53
+ > [Getting the tablebase files](#getting-the-tablebase-files) below.
54
+
55
+ > The probe endpoints are unauthenticated by design - evaluating a
56
+ > position is this app's public functionality. The `/admin` cache
57
+ > dashboard is gated behind `ADMIN_TOKEN`, a secret configured by the deployment
58
+ > (see [Security notes](#security-notes)).
59
+
60
+ ---
61
+
62
+ ## Features
63
+
64
+ - **Interactive board**: drag-and-drop moves, click-to-place pieces from
65
+ the spare-piece trays, pawn promotion dialog, board flip (orientation
66
+ persisted locally across visits), undo/redo.
67
+ - **Chess960 (Fischer Random) support**: a **Chess960 Mode** switch in
68
+ the settings panel changes castling interpretation without altering the
69
+ current board position: dragging a king onto its own rook castles the
70
+ Chess960 way, and X-FEN/Shredder-FEN rook-file castling-rights letters
71
+ (e.g. `HAha`) are accepted in the FEN box. Carried through the shared
72
+ URL (`&variant=chess960`), the saved in-progress game, and PGN
73
+ import/export (`[Variant "Chess960"]`). Off by default.
74
+ - **Lock**: a padlock button beside the FEN box that restricts board
75
+ interaction to legal moves only: dragging, click-to-move, and the
76
+ spare-piece trays are limited to the side to move's legal destinations,
77
+ and off-board drops are disabled. Unlocked by default; a manual toggle
78
+ is remembered locally across visits. Auto-play also engages Lock for
79
+ the duration of a run (unless already locked by hand), and disables the
80
+ padlock button until the run stops.
81
+ - **Four ranked move tables side by side**: DTZ, DTC, DTM, and DTM50
82
+ columns, each independently sorted best-move-first. Exact metric ties
83
+ prefer immediate checkmate, then use SAN order with promotions along
84
+ the same path ordered queen, rook, bishop, knight. A better-scoring
85
+ underpromotion still ranks first. Includes:
86
+ - score text colour-coded by outcome (win / cursed win / draw /
87
+ blessed loss / loss)
88
+ - a **warning dot** on any move flagged cursed win or blessed loss,
89
+ with a hover tooltip explaining the 50-move-rule nuance; a DTZ score
90
+ that may still be a ply off after the moves are cross-checked shows a
91
+ second tooltip line
92
+ - an **info dot** on a draw by insufficient material, keeping the score
93
+ text itself reading as a plain "Draw"
94
+ - dot messages on touch or pen input: tapping a dot shows its tooltip
95
+ text as a notice and does not play the move
96
+ - an outcome summary (wins / draws / losses / unknown) across legal moves,
97
+ respecting the halfmove clock and 50-move rule. It uses DTM50 outcomes,
98
+ or DTC when DTM50 is unavailable. Without either, clock-independent
99
+ draws, cursed wins and blessed losses count as draws; unresolved
100
+ wins/losses count as unknown.
101
+ - an optional pinned **Root Row** above rank 1, showing the current
102
+ position's own score for each metric as a reference point - toggled
103
+ from the settings panel (**Show Root Row**, off by default) and
104
+ persisted locally
105
+ - **Best-move arrows**: the top DTZ, DTC, DTM, and DTM50 moves are drawn
106
+ as colour-coded arrows directly on the board.
107
+ - **Auto-play**: automatically plays the best move for any of the four
108
+ metrics on a timer. The per-move delay
109
+ is set from the settings panel (**Autoplay Delay**, 0s–2.5s in 50ms
110
+ steps, 1.25s by default). The countdown starts after the current
111
+ position's tablebase results are displayed, giving you the full delay
112
+ to read them before the next move. A metric's button is disabled when its
113
+ table isn't present for the current material, or when the position is
114
+ already a draw.
115
+ - **PGN import/export**: paste a PGN to load its full move tree, including
116
+ variations, at the starting position. Step through it or click any move
117
+ in the PGN panel, or download the current game as a PGN file.
118
+ - **CSV export** of the current move table, including the Root Row line
119
+ ahead of rank 1 when that setting is on.
120
+ - **Move-list / PGN panel** with full undo/redo and click-to-jump - jumping
121
+ to an earlier point in the line re-uses the cached probe instead of
122
+ re-querying the tablebase.
123
+ - **Shareable positions**: the FEN (and, in Chess960 Mode, the active
124
+ variant) is written to the URL hash on every move, so a link to the
125
+ page reproduces the exact position and ruleset.
126
+ - **Session persistence**: the in-progress game (not just the position)
127
+ survives a trip to the admin dashboard and back.
128
+ - **Board and piece set**: Libre Brown board, CBurnett piece set.
129
+ - **Streaming probes**: `/probe/stream` reports progress via
130
+ Server-Sent Events while child positions are being probed, so the UI
131
+ shows a live progress bar instead of a blank pause on slower lookups.
132
+ - **Hover pre-fetch**: hovering a move in the table warms the cache for
133
+ the resulting position before selection.
134
+ - **Admin cache dashboard** (`/admin`, behind `ADMIN_TOKEN`) - live
135
+ hit-rate stats for both LRU caches, thread-pool configuration, and a
136
+ one-click cache-clear button.
137
+ - **Machine-readable API**: the full HTTP API is described by an OpenAPI
138
+ 3.0 document served at `/openapi.yaml`.
139
+ - **Keyboard shortcuts**: `Enter` Apply, `F` Flip, `C` Clear, `←`/`→`
140
+ Back/Forward. The Board Settings panel lists them on devices with hover.
141
+
142
+ ---
143
+
144
+ ## Architecture at a glance
145
+
146
+ ```
147
+ Browser (React app - see frontend/)
148
+ ├─ engine/board.js - chessops-backed position/rules state, chessground
149
+ │ wrapper (drag/drop, history, arrows), active variant
150
+ ├─ engine/tablebase.js - talks to /probe/stream, exposes results as state
151
+ ├─ engine/theme.js - board/piece-set theming
152
+ ├─ hooks/useExplorer.js - FEN box, PGN, auto-play, settings, keyboard shortcuts
153
+ └─ components/ - the rendered page (board, results table, PGN panel, dialogs)
154
+ │ HTTP (JSON) / SSE (text/event-stream) - {fen, variant}
155
+ ▼
156
+ Flask app (app.py)
157
+ ├─ /probe, /probe/stream - evaluate a FEN under a variant, rank every legal move
158
+ ├─ /admin, /admin/cache/* - cache dashboard + stats API
159
+ └─ /openapi.yaml - API specification
160
+ │
161
+ ▼
162
+ tablebase_router.py - searches TABLEBASE_PATH in order for every material,
163
+ including castling positions and recursive dependencies
164
+ │
165
+ ▼
166
+ chesstb.open_tablebase(...) per source (noobpwnftw's modified python-chess fork)
167
+ │
168
+ ▼
169
+ ChessTB tablebase files - local disk, or a remote http(s) URL (probed
170
+ in place over byte ranges by default, or downloaded and cached to local
171
+ disk per material - see remote/remote_direct.py, remote/remote_fallback.py,
172
+ and "Getting the tablebase files" below)
173
+ ```
174
+
175
+ The React app is built with Vite into `static/dist/` (`app.js`,
176
+ `app.css`), which the Flask template loads as same-origin assets, as the
177
+ Content-Security-Policy (`script-src 'self'`) requires. See
178
+ [Frontend build](#frontend-build) below.
179
+
180
+ This covers the main explorer page's (`index.html`) module graph - see
181
+ [Project structure](#project-structure) below for the complete file set,
182
+ including `admin.js` (the `/admin` dashboard's client script, a plain
183
+ server-rendered page with no build step).
184
+
185
+ The Python modules and the frontend `engine/` modules carry a file-level
186
+ docstring/header comment describing their responsibilities, and the
187
+ move-ranking algorithm itself - including
188
+ its treatment of the 50-move rule - is documented inline in `app.py`
189
+ above `evaluate_all_moves()` and the `_effective_move_wdl`/
190
+ `_effective_distance`/`_order_rank` helpers it calls.
191
+
192
+ ---
193
+
194
+ ## Frontend build
195
+
196
+ The explorer page is a React app (`frontend/`) built with Vite into
197
+ `static/dist/`, which the Flask app serves alongside its own
198
+ static assets. The build output is checked in, so running the app needs
199
+ no frontend server or build step. Rebuild after changing anything under
200
+ `frontend/src/`:
201
+
202
+ ```bash
203
+ cd frontend
204
+ npm ci
205
+ npm run build # writes static/dist/app.js, app.css, admin.css, fonts/
206
+ ```
207
+
208
+ `frontend/src/engine/` and `frontend/src/vendor/` hold the position/rules
209
+ engine (chessops + Chessground) and the tablebase client, consumed by
210
+ the React components in `frontend/src/components/`. Shared UI primitives
211
+ (`Icon`, `Button`, `Segmented`, `Panel`) are in `frontend/src/components/ui/`.
212
+ Piece SVGs and the piece-set stylesheet are in `static/` and are linked
213
+ directly from the page template.
214
+
215
+ ### Styling
216
+
217
+ Stylesheets are in `frontend/src/styles/` and are bundled by Vite into
218
+ `static/dist/app.css` (explorer page) and `static/dist/admin.css` (`/admin`).
219
+ `tokens.css` defines the colour, type, spacing, radius, elevation, motion,
220
+ and control-size custom properties; other files reference them instead of
221
+ literal values. Files are imported into the `reset`, `vendor`, and `app`
222
+ cascade layers by `index.css` and `admin.css`.
223
+
224
+ | Token group | Custom properties |
225
+ |---|---|
226
+ | Type scale | `--text-xs` 12 px, `--text-sm` 13 px, `--text-md` 14 px, `--text-lg` 16 px, `--text-xl` 20 px |
227
+ | Spacing | `--space-1` to `--space-6` (4 px to 32 px) |
228
+ | Controls | `--control-h`, `--control-h-sm` (44 px on touch-primary devices) |
229
+ | Board | `--board-light`, `--board-dark`, `--board-highlight`, read by `engine/theme.js` |
230
+
231
+ Sizes are authored in `rem`, so the large-screen root font size scaling
232
+ applies to all text and controls.
233
+
234
+ ### Page layout
235
+
236
+ | Viewport | Layout |
237
+ |---|---|
238
+ | Above 1180 px | Three-column CSS grid (board, results, PGN). The results and PGN columns take the board column's height and scroll internally. |
239
+ | 768 to 1180 px | Board row above a results and PGN row; the page scrolls. |
240
+ | Below 768 px | Three horizontally swipeable screens (board, results, PGN) with a bottom navigation bar (`ScreenNav`). Header and navigation heights include the device safe-area insets, and screen heights use `dvh`. The header shows the subtitle below the title, and the board is sized to the visible height so the whole board screen fits. |
241
+
242
+ The side-to-move toggle and the board form one group (`.board-group`) as wide
243
+ as the board, with a fixed small gap between them at every screen size. Free
244
+ vertical space in the board column sits above the group.
245
+
246
+ The results panel is a `results` size container. At 49 rem wide and above it
247
+ shows the four metric columns; below that it shows one metric at a time,
248
+ selected with the metric tabs, regardless of viewport width. The desktop
249
+ layout reserves 50 rem for this panel before growing the board, so the
250
+ four-metric view can appear as soon as the three columns fit. The results
251
+ table is a fixed header table above a scrolling body table, so the scrollbar
252
+ starts at the first moves row. The body reserves no scrollbar gutter: while
253
+ its rows overflow, its table is widened by the scrollbar's measured width, so
254
+ columns keep the header's position and width whether or not a scrollbar shows.
255
+ The PGN move-number column width is set by `--pgn-rank-w` in `tokens.css`.
256
+
257
+ ### Display scaling
258
+
259
+ Sizing is CSS-only and is defined in `frontend/src/styles/layout.css`.
260
+
261
+ - **Text:** above 1180 px, the root font size of the explorer page
262
+ (`html.explorer`) is 100% of the browser default up to a 1536 x 780 window
263
+ and rises to at most 125% (20 px), following the window's width and
264
+ height. Browser zoom and the browser default font size apply.
265
+ - **Board:** above 1180 px, `--board-size` fills the window height in
266
+ multiples of 8 px, from 20.5 rem up to `--board-size-max` (720 px, defined
267
+ in `tokens.css`), and leaves at least 40 rem for the results table. The
268
+ left column is as wide as the board, so it is capped at 720 px as well.
269
+ The width the board column does not use goes to the results column, so
270
+ the total content width stays constant.
271
+ - **Tablet layout:** at 1180 px and below, the root font size is the
272
+ browser default and `--board-size` is `--board-size-cap`, defined in
273
+ `tokens.css` as
274
+ `min(720px, clamp(18.75rem, min(23.4375vw, 41.6667vh), 31.5rem))`.
275
+ - **Phone layout:** below 768 px, `--board-size` is the smaller of
276
+ `--board-size-cap` (`min(360px, 88vw)` up to 480 px wide) and the height
277
+ that remains after the header, bottom navigation, and the controls
278
+ above the board, with a floor of 14 rem. The board screen therefore
279
+ fits the visible height regardless of browser toolbar size. Below the
280
+ floor, the screen scrolls from its top.
281
+ - `--board-fit-h` includes the fixed height above the board in the left
282
+ column and requires an update when that height changes. This applies to
283
+ both the desktop definition in `layout.css` and the phone definition in
284
+ `responsive.css`.
285
+ - `html.explorer` is set in `templates/index.html` and `frontend/index.html`.
286
+ `/admin` does not use it.
287
+
288
+ ---
289
+
290
+ ## Prerequisites
291
+
292
+ - **Python 3.10+**
293
+ - **Git**, available on `PATH` - `pip install -r requirements.txt` fetches the
294
+ `python-chess` fork directly from GitHub (`chess @ git+https://...`),
295
+ which requires `git` to be installed even though the package itself is
296
+ Python.
297
+ - **A ChessTB tablebase directory or URL**: either a local directory on
298
+ disk, or an `http(s)://` base URL serving the same layout remotely with
299
+ no local download (see [Getting the tablebase files](#getting-the-tablebase-files)).
300
+ The app starts without one, but every probe will fail until
301
+ `TABLEBASE_PATH` points at the required sources. See
302
+ [Configuration reference](#configuration-reference).
303
+ - A modern browser (the frontend uses `fetch`, `ReadableStream`, and CSS
304
+ container queries).
305
+
306
+ ---
307
+
308
+ ## Installation
309
+
310
+ 1. **Create and activate a virtual environment:**
311
+
312
+ ```bash
313
+ python -m venv .venv
314
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
315
+ ```
316
+
317
+ 2. **Install dependencies:**
318
+
319
+ ```bash
320
+ pip install -r requirements.txt
321
+ ```
322
+
323
+ This pulls in the **modified `python-chess` fork** (not the plain
324
+ PyPI `chess` package; see the comments in `requirements.txt` for why
325
+ that distinction matters).
326
+
327
+ 3. **Get the tablebase files**: see the next section. When using the
328
+ remote (no-download) option requires no fetch; continue to step 4 and
329
+ point `TABLEBASE_PATH` at the URL.
330
+
331
+ 4. **Configure `config.py`**: open it and set `TABLEBASE_PATH` to the
332
+ directory (or URL) from step 3:
333
+
334
+ ```python
335
+ TABLEBASE_PATH = "/data/chesstb"
336
+ ```
337
+
338
+ or, for the remote option:
339
+
340
+ ```python
341
+ TABLEBASE_PATH = "https://huggingface.co/buckets/chesstb/chesstb/resolve"
342
+ ```
343
+
344
+ This alone covers every piece count. See
345
+ [Getting the tablebase files](#getting-the-tablebase-files) for
346
+ the ordered source list, which includes shipping tables for both ordinary
347
+ and castling materials.
348
+
349
+ Every other setting in `config.py` has a default and a comment
350
+ explaining what it does - see
351
+ [Configuration reference](#configuration-reference) for the complete
352
+ list. Edit the values directly. To enable the header's source repository button,
353
+ set `SOURCE_REPOSITORY_URL` there too.
354
+
355
+ 5. **Run the app:**
356
+
357
+ ```bash
358
+ python app.py
359
+ ```
360
+
361
+ By default (`DEBUG = False` in `config.py`) this serves the app via
362
+ **waitress**, a production-grade pure-Python WSGI server - see
363
+ [Running in production](#running-in-production) below. Set
364
+ `DEBUG = True` in `config.py` to use Flask's development server
365
+ (auto-reload, interactive debugger) for local development.
366
+
367
+ 6. Open **http://127.0.0.1:7860** in a browser.
368
+
369
+ The built frontend (`static/dist/`) is checked in; see
370
+ [Frontend build](#frontend-build) when changing `frontend/src/`.
371
+
372
+ ---
373
+
374
+ ## Getting the tablebase files
375
+
376
+ Where the tablebase *data* lives is a separate question from where this
377
+ *app* runs - Option A below points `TABLEBASE_PATH` at a plain
378
+ `http(s)://` URL, so it works identically regardless of which platform
379
+ serves the app itself (Render, Hugging Face Spaces, a local machine,
380
+ etc.); `remote/remote_source.py` speaks plain HTTP byte-range requests,
381
+ nothing more, and has no dependency on any one host. See
382
+ [Deploying to Render](#deploying-to-render) for the app side of that
383
+ distinction.
384
+
385
+ ChessTB tablebase files are available two ways:
386
+
387
+ ### Option A: Remote, no local download (recommended)
388
+
389
+ Tables are available in the [ChessTB bucket](https://huggingface.co/buckets/chesstb/chesstb).
390
+ Shipping tables are grouped by metric; raw generator output is under `full/`.
391
+ Both use `castling/` for tables with castling rights.
392
+ WDL/DTZ cover 3–8 pieces; DTC/DTM/DTM50 cover 3–6 pieces.
393
+
394
+ Set `TABLEBASE_PATH` to the bucket root:
395
+
396
+ ```python
397
+ TABLEBASE_PATH = "https://huggingface.co/buckets/chesstb/chesstb/resolve"
398
+ ```
399
+
400
+ The explorer prefers raw generator output under `full/`, falls back to
401
+ shipping tables, and selects `castling/` paths automatically, including
402
+ for recursive dependencies. This applies to Standard chess and Chess960.
403
+ Local roots use the same layout. An explicit list of source directories
404
+ is also supported.
405
+
406
+ `REMOTE_MODE` selects the remote access mode, which stays fixed for the
407
+ life of the process:
408
+
409
+ - **`"direct"` (default)**: `remote/remote_direct.py` probes the remote
410
+ tables **in place**, fetching only the `REMOTE_PAGE_SIZE_BYTES`-sized
411
+ byte ranges each probe reads and keeping them in an in-memory
412
+ LRU bounded by `REMOTE_PAGE_CACHE_BYTES`. Nothing is written to disk.
413
+ This uses `chess.chesstb`'s table-source seam
414
+ (`Tablebase.WDL_FILE` / `_TableFile._open_source`), which the pinned
415
+ fork (see `requirements.txt`) provides.
416
+ - **`"download"`**: `remote/remote_fallback.py` fetches each table
417
+ **in full** the first time a probe touches its material and caches it
418
+ in a temporary local directory (bounded by
419
+ `REMOTE_PAGE_CACHE_BYTES` as an on-disk budget, evicting
420
+ least-recently-used files, removed entirely when the app stops). Every
421
+ probe after the first is then a local mmap read.
422
+
423
+ `"direct"` is the better default for browsing across many materials, which
424
+ is what this app does. `"download"` is preferable when a long session
425
+ repeatedly probes a small set of materials, or on a high-latency link: a
426
+ ChessTB probe is not a narrow read - a dropped-frame table is reconstructed by walking
427
+ its children, and pawn positions reach promotion sub-tables - so one cold
428
+ probe can open several materials and issue several fetches against each.
429
+ That is also why `REMOTE_PAGE_SIZE_BYTES` should stay large. See each
430
+ module's docstring for the full design.
431
+
432
+ Both remote modes cache successful signed CDN redirects until 60 seconds
433
+ before expiry, allowing subsequent byte-range reads to bypass the resolver.
434
+ A failed cached URL is retried through the original URL to obtain a fresh
435
+ redirect. Resolver and CDN connection pools stay open for reuse.
436
+ Complete cached responses are returned immediately by `/probe/stream`,
437
+ without rebuilding the board or queuing child probes. Queued child work is
438
+ cancelled when a stream disconnects or its pre-warm deadline expires;
439
+ already running probes can finish and warm the shared cache.
440
+ Table contents and paths are assumed to remain fixed; existence
441
+ and size metadata stay cached for the process lifetime, including missing
442
+ files. Restart the app after adding or moving tables.
443
+
444
+ Remote access suits a machine without a couple of terabytes of local disk
445
+ for the full tablebase set. Its costs are probe latency on first touch of
446
+ any given material (depending on the connection to the CDN), temporary
447
+ local disk usage in `"download"` mode, and the requirement of a network
448
+ connection. Remote access uses the `requests` package (see
449
+ `requirements.txt`).
450
+
451
+ ### Option B: Local directory, over FTP
452
+
453
+ The same tables are also distributed over FTP for a fully local, offline
454
+ setup:
455
+
456
+ ```
457
+ ftp://chessdb:chessdb@ftp.chessdb.cn/pub/chesstb/
458
+ ```
459
+
460
+ Any FTP client works, for example:
461
+
462
+ ```bash
463
+ # lftp
464
+ lftp -e "mirror --parallel=4 /pub/chesstb/ /data/chesstb; quit" \
465
+ ftp://chessdb:chessdb@ftp.chessdb.cn
466
+
467
+ # curl (single file)
468
+ curl "ftp://chessdb:chessdb@ftp.chessdb.cn/pub/chesstb/<path-to-file>" -o <local-file>
469
+ ```
470
+
471
+ Tablebase sets are large and grow quickly with piece count - check
472
+ available disk space before mirroring the full archive, and consider
473
+ mirroring only the required subsets (piece counts). Point
474
+ `TABLEBASE_PATH` at the selected directory.
475
+
476
+ ---
477
+
478
+ Regardless of how it is configured, if `TABLEBASE_PATH` is unset, missing/unreachable, or
479
+ points at a location with no usable tables, the app still starts (with a
480
+ warning in the logs) - every `/probe` request will then return an error,
481
+ and `/health` reports `"degraded"`.
482
+
483
+ ---
484
+
485
+ ## Configuration reference
486
+
487
+ All configuration lives in **`config.py`**, as plain Python values. Open
488
+ it and edit the settings directly - each one has a comment above it
489
+ explaining what it does. The table below is the complete reference.
490
+
491
+ The defaults target a single process on Hugging Face CPU Basic
492
+ (2 vCPU, 16 GB RAM). The decoded-block cache has a shared 512 MiB
493
+ budget, and each of the four default remote sources has a 2 GiB page
494
+ cache budget, totaling 8.5 GiB of byte-cache budgets. Caches fill on
495
+ demand; this is not a cap on process memory. Root-response and child-probe
496
+ caches are limited by entry count, with memory use depending on response
497
+ size. The remaining RAM accommodates those results, table/index metadata,
498
+ Python object overhead, and active requests. Revisit the budgets if you
499
+ add remote sources or run multiple application processes.
500
+
501
+ | Setting | Default | Description |
502
+ |-----------------------------|-------------------|-------------|
503
+ | `TABLEBASE_PATH` | Public bucket root (also readable from a `TABLEBASE_PATH` env var) | Directory containing the ChessTB tablebase files, **or** an `http(s)://` base URL serving the same layout remotely (e.g. a Hugging Face storage bucket - see [Getting the tablebase files](#getting-the-tablebase-files)). Required for probing to work. A list of directories and URLs, tried left-to-right (a hybrid source), is accepted as a literal in `config.py` only. On Render, set this from the service's Environment tab (or `render.yaml`'s prompt) instead of editing the file. |
504
+ | `DEBUG` | `False` | `True` runs `app.py` via Flask's own dev server (auto-reload, detailed tracebacks) instead of waitress. Leave `False` - which serves via waitress - for anything reachable from another machine. See [Running in production](#running-in-production). |
505
+ | `HOST` | `"0.0.0.0"` | Interface the server (waitress, or the dev server if `DEBUG = True`) binds to. |
506
+ | `PORT` | `7860` (also readable from a `PORT` env var) | Port the server listens on. Render injects its own `PORT` at deploy time and this picks it up automatically - nothing to set for a Render deployment. |
507
+ | `WAITRESS_THREADS` | `4` | Worker threads in waitress's request-handling pool. Only relevant when `DEBUG = False`. Needs to be more than 1 so a long-lived `/probe/stream` connection can't block other requests. |
508
+ | `PROBE_THREADS` | `2` | Worker threads in the probe thread pool for evaluating a position's legal moves in parallel. Set an explicit integer to match the host's CPU count, or `None` to scale automatically (`min(16, cpu*2)`). |
509
+ | `PROBE_PARALLEL_THRESHOLD` | `4` | Minimum number of child positions before probing switches from sequential to the thread pool. |
510
+ | `PROBE_TIMEOUT_SECS` | `30` | Wall-clock timeout for each probe phase. `/probe` gives the root probe and the child-move batch independent budgets, so worst-case `/probe` latency can approach `2 × PROBE_TIMEOUT_SECS`. Also bounds how long a request waits on another thread's in-flight probe of the same FEN before giving up with a retryable `probe_timeout` error. |
511
+ | `EVALUATE_CACHE_SIZE` | `16384` | Max entries in the root-FEN result cache (full JSON responses). |
512
+ | `PROBE_CACHE_SIZE` | `262144` | Max entries in the child-position probe cache (raw `chess.chesstb.ProbeResult` objects - WDL/DTZ/DTC/DTM/DTM50 together). |
513
+ | `BLOCK_CACHE_BYTES` | `536870912` (512 MiB) | Size, in bytes, of `chesstb`'s own internal cache of decoded/decompressed tablebase blocks (shared across the WDL/DTZ/DTC/DTM/DTM50 tables). Raising this trades RAM for fewer repeated disk reads/HTTP fetches + decompressions across a session - most worthwhile when `PROBE_PARALLEL_THRESHOLD` is set high enough that probing runs mostly serially. |
514
+ | `REMOTE_MODE` | `"direct"` | **Remote tablebase entries only.** `"direct"` probes the remote tables in place over byte ranges (nothing written to disk); `"download"` fetches each table in full on first touch and caches it on local disk. See [Getting the tablebase files](#getting-the-tablebase-files). |
515
+ | `REMOTE_PAGE_CACHE_BYTES` | `2147483648` (2 GiB) | **Remote tablebase entries only.** Soft budget, in bytes, per remote source in `TABLEBASE_PATH`, shared across that source's tables: the in-memory page cache in `"direct"` mode, the on-disk cache of whole downloaded files in `"download"` mode. See [Getting the tablebase files](#getting-the-tablebase-files). |
516
+ | `REMOTE_PAGE_SIZE_BYTES` | `262144` (256 KiB) | **Remote tablebase entries only.** Size, in bytes, of one page. In `"direct"` mode this is the granularity of every fetch, so it trades over-fetching against round trips per probe; in `"download"` mode it is only the chunk size used while streaming a full file down. |
517
+ | `REMOTE_TIMEOUT_SECS` | `20` | **Remote tablebase entries only.** Per-HTTP-request timeout for existence/size checks and the download itself. |
518
+ | `REMOTE_MAX_RETRIES` | `3` | **Remote tablebase entries only.** Attempts for a single remote request before it's treated as failed. |
519
+ | `REMOTE_POOL_MAXSIZE` | `None` | **Remote tablebase entries only.** Size of each remote backend's HTTP connection pool. `None` computes `max(PROBE_THREADS * 2, 20)`. |
520
+ | `PROBE_RATE_LIMIT` | `"60 per minute"` | Per-client-IP request limit on `/probe` and `/probe/stream`, using [flask-limiter](https://flask-limiter.readthedocs.io/)'s string syntax. Left empty, rate limiting is disabled entirely. See [Security notes](#security-notes). |
521
+ | `ADMIN_LOGIN_RATE_LIMIT` | `"5 per minute"` | Per-client-IP request limit on `/admin/login`, same string syntax as `PROBE_RATE_LIMIT`, applied independently of it. Covers both failed and successful login attempts. Left empty, login attempts are not rate limited. See [Security notes](#security-notes). |
522
+ | `PREFETCH_RATE_LIMIT` | `"30 per minute"` | Per-client-IP request limit for hover prefetch requests marked with `X-Prefetch: 1`. Left empty, prefetch requests share `PROBE_RATE_LIMIT`. See [Security notes](#security-notes). |
523
+ | `TRUSTED_PROXY_COUNT` | `1` (also readable from a `TRUSTED_PROXY_COUNT` env var) | Number of trusted reverse-proxy hops in front of this app. In production this configures [waitress's own `trusted_proxy_count`](https://docs.pylonsproject.org/projects/waitress/en/stable/proxy-headers.html) (waitress parses `X-Forwarded-For`/`X-Forwarded-Proto` and corrects `REMOTE_ADDR`/`wsgi.url_scheme` itself, ahead of Flask); [Werkzeug's `ProxyFix`](https://werkzeug.palletsprojects.com/en/latest/middleware/proxy_fix/) makes the same correction for the `DEBUG = True` dev-server path, which doesn't run under waitress. Both rate limiters key on the corrected address, and `request.scheme`/`request.is_secure` read the corrected scheme. `1` matches this project's own `render.yaml` topology (Render's edge/load-balancer, once). Set it to match the deployment's reverse-proxy chain, or set it to `0` for a deployment with no reverse proxy in front of it at all. See [Security notes](#security-notes). |
524
+ | `ADMIN_TOKEN` | `""` (from `ADMIN_TOKEN` env var) | Shared secret required to reach `/admin` and `/admin/cache/*`. Read from the environment rather than hardcoded, so it can be set as a platform secret. Left unset, admin routes respond `503` rather than running unprotected. See [Security notes](#security-notes). |
525
+ | `FLASK_SECRET_KEY` | `""` (from `FLASK_SECRET_KEY` env var) | Key used to sign the admin session cookie. Read from the environment for the same reason as `ADMIN_TOKEN`. Left unset, a random key is generated at process startup instead, so every restart requires logging in again. |
526
+ | `SESSION_COOKIE_SECURE` | `False` (also readable from a `SESSION_COOKIE_SECURE` env var, `"true"`/anything else) | Whether the admin session cookie set by `/admin/login` requires HTTPS. Independent of `DEBUG`. Defaults `False` to match a plain-HTTP local installation; set to `"true"` for any deployment the browser reaches over HTTPS (including behind a TLS-terminating reverse proxy such as Render's - `render.yaml` already sets this explicitly for that deployment). See [Security notes](#security-notes). |
527
+ | `SOURCE_REPOSITORY_URL` | `""` (also readable from a `SOURCE_REPOSITORY_URL` env var) | Repository URL for the source repository button shown in the header of every page. Edited directly like any other non-secret value here, or set as an env var on a given deployment. Left empty, the button is omitted. |
528
+
529
+ Invalid values (wrong type, out of range) cause the app to log an error
530
+ and exit at startup rather than run with a silently-wrong configuration.
531
+
532
+ ---
533
+
534
+ ## Running in production
535
+
536
+ `python app.py` serves the app via **[waitress](https://docs.pylonsproject.org/projects/waitress/)**,
537
+ a production-grade, pure-Python WSGI server, whenever `DEBUG` in
538
+ `config.py` is `False` (the default) - no separate `gunicorn`/`waitress`
539
+ command or extra process is needed on top of the app itself. Setting
540
+ `DEBUG = True` in `config.py` switches `app.py` over to Flask's own dev
541
+ server instead (auto-reload + interactive debugger), which is meant for
542
+ local development only and should not be used for anything reachable
543
+ from another machine.
544
+
545
+ ```bash
546
+ # Production (default): DEBUG = False in config.py: serves via waitress
547
+ python app.py
548
+
549
+ # Local development: set DEBUG = True in config.py first, then run the
550
+ # same command: Flask dev server, auto-reload + debugger
551
+ python app.py
552
+ ```
553
+
554
+ - `HOST` / `PORT` control the interface and port waitress binds to, same
555
+ as for the dev server. `PORT` is also readable from a `PORT`
556
+ environment variable (see [Configuration reference](#configuration-reference)),
557
+ which is what lets the same image bind correctly on Render - or any
558
+ other platform that assigns the port at deploy time - with no edits.
559
+ - `WAITRESS_THREADS` (default `4`) sizes waitress's own pool of
560
+ request-handling threads. This needs to be more than 1 for the same
561
+ reason the dev server needs `threaded=True`: `/probe/stream` holds a
562
+ connection open via Server-Sent Events for the whole duration of a
563
+ probe, and a single-threaded server would let that one connection block
564
+ every other request - including the browser's own concurrent requests
565
+ for CSS/JS/piece images on first page load. It's independent of
566
+ `PROBE_THREADS`, which sizes the thread pool used internally to
567
+ parallelise tablebase probing rather than to serve HTTP requests.
568
+ - `TRUSTED_PROXY_COUNT` (default `1`) configures waitress's own
569
+ `trusted_proxy_count`, so `request.remote_addr` and `request.scheme`
570
+ reflect the real client IP and scheme behind Render's edge/load-balancer
571
+ rather than that proxy's own address/hop - see
572
+ [Security notes](#security-notes).
573
+ - Waitress handles `/probe/stream`'s streamed response natively; no
574
+ additional configuration is needed for SSE to work correctly.
575
+ - The probe endpoints are unauthenticated by design; only `/admin` and
576
+ `/admin/cache/*` require a credential (see
577
+ [Security notes](#security-notes)).
578
+
579
+ ---
580
+
581
+ ## Deploying to Render
582
+
583
+ This project deploys to [Render](https://render.com) as a single Docker
584
+ web service, built straight from this GitHub repository. Render watches
585
+ the connected branch and rebuilds/redeploys automatically on every push.
586
+
587
+ There are two ways to set it up:
588
+
589
+ ### Option 1: Blueprint (`render.yaml`, recommended)
590
+
591
+ This repository ships a [`render.yaml`](render.yaml) at its root
592
+ describing the service (Docker runtime, health check, environment
593
+ variables). Render finds it automatically:
594
+
595
+ 1. Push this repository to GitHub.
596
+ 2. In the [Render Dashboard](https://dashboard.render.com), click
597
+ **New → Blueprint** and connect the repo.
598
+ 3. Render reads `render.yaml` and requests the one setting required
599
+ before the first deploy can run: **`TABLEBASE_PATH`** - the
600
+ `http(s)://` base URL of the remote tablebase (see
601
+ [Getting the tablebase files](#getting-the-tablebase-files)).
602
+ `ADMIN_TOKEN` and `FLASK_SECRET_KEY` are generated automatically.
603
+ 4. Click **Apply** / **Create**. Render builds the image from this
604
+ repo's `Dockerfile` and deploys it.
605
+
606
+ Every subsequent push to the connected branch redeploys automatically.
607
+ To change `TABLEBASE_PATH`, `ADMIN_TOKEN`, or any other setting later,
608
+ edit it from the service's **Environment** tab in the Render Dashboard;
609
+ see [Configuration reference](#configuration-reference) for which
610
+ settings are env-overridable this way versus which live only in
611
+ `config.py`.
612
+
613
+ ### Option 2: Manual Web Service (no `render.yaml`)
614
+
615
+ The service can also be configured manually without the Blueprint file:
616
+
617
+ 1. Push this repository to GitHub.
618
+ 2. In the Render Dashboard, click **New → Web Service** and connect the
619
+ repo.
620
+ 3. Set **Runtime** to **Docker** (Render should detect the `Dockerfile`
621
+ at the repo root automatically).
622
+ 4. Under **Environment**, add:
623
+ - `TABLEBASE_PATH`: required; same as Option 1 above.
624
+ - `ADMIN_TOKEN`: optional but recommended; any secret string, or
625
+ use Render's "Generate" button. While it is unset, `/admin`
626
+ responds `503`.
627
+ - `FLASK_SECRET_KEY`: optional; when omitted, a random key is
628
+ generated at process startup and admin sessions don't survive a
629
+ restart.
630
+ - `SESSION_COOKIE_SECURE`: set to `true`. Render always terminates
631
+ TLS at its edge, so the browser reaches this deployment over HTTPS
632
+ even though the app itself just speaks plain HTTP to Render's
633
+ proxy; `config.py`'s own default (`false`) is tuned for a local
634
+ plain-HTTP installation instead. (Option 1's `render.yaml` sets
635
+ this automatically - it is a manual step here because Option 2
636
+ skips that file.)
637
+ - `SOURCE_REPOSITORY_URL`: optional; the repository URL, which enables the
638
+ header's source repository button.
639
+ - Leave `PORT` alone - Render sets it itself, and `config.py` already
640
+ reads it (see [Configuration reference](#configuration-reference)).
641
+ 5. Under **Health Check Path**, set `/health` (matches the route in
642
+ `app.py`; Render won't mark a deploy healthy until this returns `200`,
643
+ which itself only happens once the sources in `TABLEBASE_PATH` have
644
+ opened successfully).
645
+ 6. Click **Create Web Service**.
646
+
647
+ ### Choosing a plan
648
+
649
+ Probing is CPU-bound (block decompression on every request), so a
650
+ dedicated vCPU - Render's **Starter** tier or above - gives lower probe
651
+ latency than the **Free** tier's shared/limited CPU. Free works
652
+ for casual or low-traffic use; the service spins down after inactivity
653
+ (a slow first request after waking up) and probes are slower under load. Either way, `REMOTE_MODE = "direct"` (the default)
654
+ means no persistent disk is needed regardless of plan - see
655
+ `config.py`'s "Remote (URL) tablebase entries only" section and
656
+ [Getting the tablebase files](#getting-the-tablebase-files).
657
+
658
+ ---
659
+
660
+ ## Usage guide
661
+
662
+ - **Set a position**: type or paste a FEN into the FEN box and press
663
+ `Enter` or **Apply**. The move rankings and best-move arrows update
664
+ automatically. The castling-availability field accepts `-` or any
665
+ combination of `K`, `Q`, `k`, `q` (each requiring a matching king and
666
+ rook on their home squares); in **Chess960 Mode** it additionally
667
+ accepts X-FEN/Shredder-FEN rook-file letters (`a`-`h` / `A`-`H`). A
668
+ position with a castling right is evaluated the same as any other,
669
+ reported as not covered if the loaded tablebase set has no table for it
670
+ (see `TABLEBASE_PATH` in
671
+ [Configuration reference](#configuration-reference)).
672
+ - **Chess960 (Fischer Random)**: toggle **Chess960 Mode** in the settings
673
+ panel to switch castling interpretation. This does not move any pieces
674
+ or randomize a new starting position - it only changes how castling
675
+ moves/rights on the *current* board are interpreted, and drops the
676
+ current move line the same way a board edit does (a line built under
677
+ one variant isn't reinterpreted as the other). With it on, drag a king
678
+ onto its own rook to castle; the destination square and resulting rook
679
+ position follow standard Chess960 rules regardless of where the king
680
+ and rook started. The setting is carried in the shared URL
681
+ (`&variant=chess960`), the saved in-progress game, and PGN
682
+ import/export (a `[Variant "Chess960"]` header).
683
+ - **Edit the board directly**: drag pieces around the board, drag a piece
684
+ off the board to remove it, or click a spare piece in either tray and
685
+ then click a square to place it (click again / press `Esc` to cancel).
686
+ Board edits reset the current move line the same way **Clear** does.
687
+ - **Lock the board**: click the padlock button beside the FEN box to
688
+ restrict drag-and-drop, click-to-move, and the spare-piece trays to
689
+ legal moves for the side to move; the button turns gold while active.
690
+ Click again to unlock. The choice is remembered locally across visits.
691
+ Starting auto-play locks the board automatically for the run (unless
692
+ already locked) and disables the padlock button until it stops.
693
+ - **Play moves**: click any move in one of the four ranked tables to
694
+ play it, or drag a piece on the board through a legal move.
695
+ - **Navigate history**: the Undo/Redo arrow buttons or `←`/`→`, or click
696
+ any move in the PGN panel to jump straight to that point in the line.
697
+ - **Auto-play**: click the ▶ button above the DTZ, DTC, DTM, or DTM50
698
+ columns
699
+ to have the app play that metric's best move on a timer (delay set by
700
+ **Autoplay Delay** in settings, 1.25s by default); click again (the
701
+ button shows ■ while running) to stop. Any manual navigation stops auto-play.
702
+ - **Import/export a game**: **Import** in the PGN panel opens a dialog to
703
+ paste PGN text. **Import Game** loads the full game and its variations
704
+ into the PGN panel, starting before the first move; **Export**
705
+ downloads the full game and its variations as `chesstb_game.pgn` with
706
+ standard headers.
707
+ - **Export the move table**: the **CSV** button downloads the current
708
+ DTZ/DTC/DTM/DTM50 rankings as a CSV file.
709
+ - **Share a position**: the URL updates live with `#fen=...`; sending
710
+ that link reproduces the exact position.
711
+ - **Change settings**: the ⚙ button in the header opens a **Show Root
712
+ Row** switch for the results table, a **Chess960 Mode** switch, and an
713
+ **Autoplay Delay** slider; the choices are persisted across visits.
714
+ - **Admin dashboard**: the gauge icon in the header opens `/admin`, prompting for the
715
+ `ADMIN_TOKEN` on first visit, then showing live hit-rate stats for both
716
+ caches and the thread-pool configuration, auto-refreshing every 5
717
+ seconds.
718
+
719
+ ---
720
+
721
+ ## API
722
+
723
+ The full HTTP API - `/probe`, `/probe/stream`, `/health`,
724
+ `/admin/cache/stats`, `/admin/cache/clear`, `/admin/login`,
725
+ `/admin/logout` - is documented as an OpenAPI 3.0 specification served
726
+ live by the running app at:
727
+
728
+ ```
729
+ http://127.0.0.1:7860/openapi.yaml
730
+ ```
731
+
732
+ `openapi.yaml` documents every endpoint's request/response shapes; the
733
+ ranking algorithm behind `moves_dtz` / `moves_dtc` / `moves_dtm` /
734
+ `moves_dtm50` is documented inline in `app.py`'s own module docstring and
735
+ its `evaluate_all_moves()` function. `/admin/cache/*` require an
736
+ `Authorization: Bearer <ADMIN_TOKEN>` header or an authenticated session
737
+ cookie from `/admin/login` - see [Security notes](#security-notes).
738
+
739
+ Quick example:
740
+
741
+ ```bash
742
+ curl -X POST http://127.0.0.1:7860/probe \
743
+ -H "Content-Type: application/json" \
744
+ -d '{"fen": "4k3/8/8/8/8/8/8/4K2R w - - 0 1"}'
745
+ ```
746
+
747
+ `variant` is optional and defaults to `"standard"` when omitted. It is
748
+ never inferred from the FEN itself - the orthodox starting array is
749
+ itself a legal Chess960 starting position, so the same FEN text is
750
+ ambiguous without it:
751
+
752
+ ```bash
753
+ curl -X POST http://127.0.0.1:7860/probe \
754
+ -H "Content-Type: application/json" \
755
+ -d '{"fen": "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "variant": "chess960"}'
756
+ ```
757
+
758
+ ---
759
+
760
+ ## Project structure
761
+
762
+ ```
763
+ .
764
+ ├── LICENSE # GPL-3.0-or-later license text for this project
765
+ ├── THIRD_PARTY_LICENSES.md # Licenses and attributions for bundled third-party code, fonts, and the piece set
766
+ ├── Dockerfile # Container build used for Render (and any other Docker host)
767
+ ├── render.yaml # Render Blueprint: service definition, health check, env vars
768
+ ├── app.py # Flask backend: routes, probing, caching
769
+ ├── config.py # All configuration, as plain Python values - see "Configuration reference"
770
+ ├── openapi.yaml # OpenAPI 3.0 specification, served at /openapi.yaml
771
+ ├── requirements.txt
772
+ ├── README.md
773
+ ├── .gitattributes, .gitignore, .dockerignore
774
+ ├── tablebase_router.py # Ordered local/remote source lookup for every material
775
+ ├── tablebase_opening.py # Per-material table-opening lock policy, shared by tablebase_router.py and both remote backends
776
+ ├── dtz_anchor.py # Resolves rounded DTZ values by cross-checking the moves of a position
777
+ ├── remote/
778
+ │ ├── remote_source.py # Generic HTTP byte-range client shared by both remote backends - see its own module docstring
779
+ │ ├── remote_direct.py # REMOTE_MODE="direct": probe remote tables in place over byte ranges - see its own module docstring
780
+ │ └── remote_fallback.py # REMOTE_MODE="download": whole-file download, cached to local disk on first touch - see its own module docstring
781
+ ├── templates/
782
+ │ ├── index.html # Main explorer UI - mounts the React app (see frontend/)
783
+ │ └── admin.html # Cache dashboard
784
+ ├── frontend/ # React app for the explorer page - see "Frontend build"
785
+ │ ├── package.json, package-lock.json
786
+ │ ├── vite.config.js # Builds into ../static/dist with fixed filenames (no cache-busting hash)
787
+ │ ├── index.html, admin.html # Build entries (script and stylesheet)
788
+ │ └── src/
789
+ │ ├── main.jsx, App.jsx
790
+ │ ├── engine/ # Position/rules engine and tablebase client (chessops + Chessground)
791
+ │ ├── vendor/ # Vendored Chessground (script and base stylesheet) + chessops, bundled by the build
792
+ │ ├── hooks/useExplorer.js # FEN box, PGN, auto-play, settings, keyboard shortcuts
793
+ │ ├── components/ # Board, results table, PGN panel, dialogs, settings panel
794
+ │ │ └── ui/ # Icon, Button, Segmented, Panel
795
+ │ ├── styles/ # Tokens, base, layout, component and chess stylesheets - see "Styling"
796
+ │ └── assets/fonts/ # Inter and JetBrains Mono (woff2)
797
+ └── static/
798
+ ├── dist/ # Built frontend (see "Frontend build"): app.js, app.css, admin.css, fonts/
799
+ ├── css/
800
+ │ └── pieces-cburnett.css # Piece-set stylesheet linked by templates/index.html
801
+ ├── js/
802
+ │ └── admin.js # Admin dashboard client
803
+ └── pieces/cburnett/ # Piece-set SVGs
804
+ ```
805
+
806
+ ---
807
+
808
+ ## Security notes
809
+
810
+ - The `/probe` and `/probe/stream` endpoints are intentionally
811
+ unauthenticated - evaluating a chess position is this app's public,
812
+ core functionality. `/admin` and `/admin/cache/*` are gated behind
813
+ `ADMIN_TOKEN` (see below); every other route requires no credential.
814
+ - **Rate limiting** on `/probe` and `/probe/stream` is controlled by
815
+ `PROBE_RATE_LIMIT`, and on `/admin/login` separately by
816
+ `ADMIN_LOGIN_RATE_LIMIT` (both in `config.py`'s "Rate limiting"
817
+ section), each applied per client IP via
818
+ [flask-limiter](https://flask-limiter.readthedocs.io/). A client over
819
+ either limit gets a `429` with a JSON error body. `PROBE_RATE_LIMIT`'s
820
+ default, `"60 per minute"`, covers normal browsing and
821
+ autoplay at its default delay - raise it if legitimate autoplay at a
822
+ fast delay setting gets throttled, lower it if the deployment sees
823
+ abuse, or set either limit to `""` to disable it entirely.
824
+ `ADMIN_LOGIN_RATE_LIMIT` defaults to `"5 per minute"`, tight enough to
825
+ slow down repeated `ADMIN_TOKEN` guesses. Both limiters key on
826
+ `request.remote_addr` as corrected by `TRUSTED_PROXY_COUNT` (see
827
+ below) and their state lives in-process (`memory://`), which is
828
+ sufficient for a single-container deployment (e.g. one Render Web
829
+ Service instance) but isn't shared across multiple replicas.
830
+ - **`TRUSTED_PROXY_COUNT`** (`config.py`'s "Rate limiting" section) is
831
+ what `request.remote_addr`, and therefore both rate limiters above,
832
+ is corrected against, so it reads the real client IP from
833
+ `X-Forwarded-For` rather than the address of the reverse proxy
834
+ in front of this app. The same setting also governs whether
835
+ `X-Forwarded-Proto` is trusted, correcting `request.scheme` /
836
+ `request.is_secure` (and any externally-generated URL) when TLS
837
+ terminates at that reverse proxy rather than at this app itself. In
838
+ production (`DEBUG = False`) this configures [waitress's own `trusted_proxy_count`](https://docs.pylonsproject.org/projects/waitress/en/stable/proxy-headers.html):
839
+ waitress parses both headers and corrects `REMOTE_ADDR`/`wsgi.url_scheme`
840
+ itself, before Flask ever sees the request, so it takes priority here.
841
+ [Werkzeug's `ProxyFix`](https://werkzeug.palletsprojects.com/en/latest/middleware/proxy_fix/)
842
+ makes the equivalent correction for the `DEBUG = True` Flask-dev-server
843
+ path, which doesn't run under waitress at all. Defaults to `1`,
844
+ matching this project's own `render.yaml` topology; set it to match
845
+ the deployment if that differs, or to `0` for a deployment with no
846
+ reverse proxy in front of it at all.
847
+ - **Admin access** is controlled by `ADMIN_TOKEN` (`config.py`'s "Admin"
848
+ section), read from the environment rather than hardcoded - set it as
849
+ a platform secret (e.g. a Render environment variable, generated
850
+ automatically by `render.yaml`'s `generateValue: true`) rather than
851
+ committing it. Visiting `/admin` with no active session shows a login
852
+ form; a correct token there starts a signed, `HttpOnly`,
853
+ `SameSite=Lax` session cookie (`Secure` too, where configured - see
854
+ `SESSION_COOKIE_SECURE` below). API clients can instead send
855
+ `Authorization: Bearer <ADMIN_TOKEN>` directly, no session needed.
856
+ Token comparison uses `hmac.compare_digest` (constant-time, resistant
857
+ to timing attacks). Leaving `ADMIN_TOKEN` unset does **not** open the
858
+ admin panel to everyone - every admin route responds `503` instead.
859
+ - **`SESSION_COOKIE_SECURE`** (`config.py`'s "Admin" section) controls
860
+ whether the admin session cookie above requires HTTPS, independently
861
+ of `DEBUG` - `DEBUG` is a development/runtime behavior switch, not a
862
+ reliable indicator of the deployment's transport security, so
863
+ `DEBUG = False` alone does not imply HTTPS is in front of this
864
+ app. Read from a `SESSION_COOKIE_SECURE` env var (`"true"` or anything
865
+ else, case-insensitive), defaulting `False` to match this project's own
866
+ plain-HTTP local installation - a browser won't send a
867
+ `Secure` cookie back over a plain HTTP connection, so `/admin/login`
868
+ would otherwise appear to succeed while every subsequent authenticated
869
+ `/admin/*` request came back `401`. Set to `"true"` for any deployment
870
+ the browser reaches over HTTPS (including behind a TLS-terminating
871
+ reverse proxy - the browser's own connection is what the `Secure`
872
+ attribute governs, not the internal hop between the proxy and this
873
+ process); `render.yaml` already sets this explicitly for the Render
874
+ deployment it configures, since Render always terminates TLS at its
875
+ edge.
876
+ - The app serves via **waitress**, a production-grade WSGI server, by
877
+ default (`DEBUG = False` in `config.py`) - see [Running in production](#running-in-production).
878
+ Only set `DEBUG = True` (which switches to Flask's own dev server) for
879
+ local development, never for anything reachable from another machine.
880
+ - A `Content-Security-Policy` is set on every response: scripts and fonts
881
+ are restricted to `'self'` (no inline scripts, no third-party sources);
882
+ styles allow `'self'` plus `'unsafe-inline'` (needed for inline
883
+ `style` attributes set by the React app and in `admin.html`); images
884
+ allow `'self'` plus `data:`; `connect-src`/`default-src` are `'self'`
885
+ too, so `fetch`/SSE calls can only reach this same origin. `object-src`,
886
+ `base-uri`, and `form-action` are locked to `'none'`/`'self'`/`'self'`,
887
+ and `frame-ancestors 'self' https://huggingface.co` allows the Hugging
888
+ Face Space page to embed the app. `X-Content-Type-Options:
889
+ nosniff` and `Referrer-Policy: strict-origin-when-cross-origin` are set
890
+ too. Request bodies are capped at 4 KB.
891
+ - `config.py` itself holds no secrets - `ADMIN_TOKEN` and
892
+ `FLASK_SECRET_KEY` are the only two secrets, and they are read only from
893
+ the environment so the file stays safe to commit as-is. Every other
894
+ setting is a non-sensitive value.
895
+
896
+ ---
897
+
898
+ ## Troubleshooting
899
+
900
+ | Symptom | Likely cause |
901
+ |---|---|
902
+ | Every probe returns "Position not covered by the loaded tablebase." | Check `TABLEBASE_PATH` and the startup logs. Set the tablebase root; `full/` and `castling/` directories are resolved automatically. For an explicit source list, include every required directory. |
903
+ | A FEN is rejected with "Invalid castling availability" | The submitted castling-availability field is something other than `-` or a combination of `K`, `Q`, `k`, `q` (or, in Chess960 Mode, rook-file letters `a`-`h`/`A`-`H`), or names a castling right without a matching king and rook on the board. |
904
+ | A FEN with `a`-`h`/`A`-`H` castling-rights letters is rejected even though the position looks fine | Those letters are only accepted with **Chess960 Mode** on (settings panel, or `variant: "chess960"` on the API) - Standard mode only accepts `K`, `Q`, `k`, `q`, matching Standard chess's fixed castling-rook squares. |
905
+ | Dragging a king onto its own rook doesn't castle | **Chess960 Mode** is off - enable it in the settings panel (or send `variant: "chess960"` on the API) for Chess960-style king-onto-rook castling; in Standard mode a king can only reach its rook's square by regular king movement. |
906
+ | `/health` returns `503 degraded` | The tablebase failed to open - check `TABLEBASE_PATH` and file permissions. |
907
+ | `ModuleNotFoundError: No module named 'chess.chesstb'` | Plain PyPI `chess` was installed instead of the fork. Re-run `pip install -r requirements.txt` and confirm it pulled `chess` from `noobpwnftw/python-chess` (the `add-chesstb-tablebases` branch) rather than plain PyPI `chess` - see the note at the top of `requirements.txt`. |
908
+ | `ModuleNotFoundError: No module named 'config'` | `config.py` is missing from alongside `app.py`. It ships with the repository - if it was deleted or moved, restore it from the repo (or re-clone) and re-apply any edits, such as `TABLEBASE_PATH`. |
909
+ | App logs `Configuration error: ...` and exits immediately | A value in `config.py` is the wrong type or out of range for that setting (e.g. a string where an integer is expected, or a negative cache size) - the error message names which setting and why. Fix it in `config.py` and re-run `python app.py`. |
910
+ | Admin dashboard panels stuck on "Loading…" | Check the browser console for a CSP violation and verify that the app is running and reachable at the `/admin` URL. |
911
+ | A drag, drop, or spare-piece placement on the board is silently rejected | The board is Locked - the padlock button beside the FEN box is highlighted gold. Lock restricts board interaction to legal moves and disables the spare-piece trays and off-board drops; click the padlock again to unlock and edit freely. |
912
+ | Probing feels slow on positions with many legal moves, or doesn't scale with `PROBE_THREADS` | Confirm `lz4` is installed (`pip show lz4`, or `python -c "import lz4"`) - without it, `chess.chesstb`'s block decompression runs in pure Python and holds the GIL, so `PROBE_THREADS` can't achieve real parallelism. `pip install lz4` (already in `requirements.txt`) and restart. Otherwise, tune `PROBE_THREADS`, `PROBE_PARALLEL_THRESHOLD`, and `PROBE_TIMEOUT_SECS` in `config.py`. Note that `tablebase_router.py` guards each material's first open with a lock keyed by `(kind, material)`, so two threads racing to open the *same* never-before-seen material collapse onto one read, while distinct materials of the same kind open concurrently. If any configured tier uses `REMOTE_MODE="download"`, opens fall back to one lock shared per kind, so several distinct never-before-seen materials of that kind then open one at a time instead - either way, this only affects the very first probe against any given material. |
913
+ | `ModuleNotFoundError: No module named 'waitress'` | The active environment's dependencies are out of date - re-run `pip install -r requirements.txt` to pick up `waitress`. `app.py` imports it unconditionally at the top of the file, before `config.DEBUG` is even read, so setting `DEBUG = True` does **not** avoid this - waitress has to be installed either way. |
914
+ | Every probe on a remote `TABLEBASE_PATH` returns "Position not covered..." or `/health` is `degraded` | Confirm the URL is reachable and correct (try opening `TABLEBASE_PATH/wdl/KQK.lzw` - or any small material's `.lzw` - directly in a browser). Check the startup log's "TABLEBASE_PATH opened remotely at: ..." line (or a warning in its place) for the actual failure. |
915
+ | A probe occasionally returns `503` with `error_code: "probe_timeout"` | Another concurrent request was already probing the same position and didn't finish within `PROBE_TIMEOUT_SECS`. This is transient and retryable - the original probe has usually landed in the child-probe cache before a retry, so the retry is inexpensive. Frequent occurrences suggest raising `PROBE_TIMEOUT_SECS`, or that a slow remote `TABLEBASE_PATH` is the underlying bottleneck. |
916
+ | Remote probing feels slow, or repeated probes against the same material keep hitting the network | Check `REMOTE_TIMEOUT_SECS`/`REMOTE_MAX_RETRIES` aren't causing retries on a slow link, and consider raising `REMOTE_PAGE_CACHE_BYTES` - a too-small budget evicts cached pages (`"direct"`) or downloaded files (`"download"`) before later probes can reuse them. `GET /admin/cache/stats` surfaces this per tier as `tablebase_cache.<low|high|castling>.remote_page_cache` or `.remote_disk_cache` respectively. On a high-latency link also try raising `REMOTE_PAGE_SIZE_BYTES`, or `REMOTE_MODE = "download"`. See [Configuration reference](#configuration-reference). |
917
+ | On Render, the deploy never goes live / stays stuck "deploying" | Render's health check (`healthCheckPath: /health` in `render.yaml`, or the equivalent field on a manually-created service - see [Deploying to Render](#deploying-to-render)) only passes once `/health` returns `200`, which itself requires the sources in `TABLEBASE_PATH` to have opened successfully. Check the service's **Logs** tab for the same "opened remotely at: ..." / warning line described above. |
918
+ | On Render, the service responds but the page never loads / connection refused | Typically a `PORT` mismatch. Confirm `config.py` reads `PORT` from the environment (see [Configuration reference](#configuration-reference)) and that nothing else in a custom start command overrides `HOST`/`PORT`. |
919
+
920
+ ---
921
+
922
+ ## Credits
923
+
924
+ - Tablebase data & format: [ChessTB / chessdb.cn](https://www.chessdb.cn/)
925
+ - Tablebase probing support: [noobpwnftw/python-chess (`add-chesstb-tablebases` branch)](https://github.com/noobpwnftw/python-chess/tree/add-chesstb-tablebases), a fork of [niklasf/python-chess](https://github.com/niklasf/python-chess)
926
+ - Remote (URL) tablebase support for the fork's `chess.chesstb` module - see `remote/remote_source.py`, `remote/remote_direct.py` and `remote/remote_fallback.py`
927
+ - Board UI: [Chessground](https://github.com/lichess-org/chessground)
928
+ - Move generation/validation on the client, for both Standard chess and
929
+ Chess960: [chessops](https://github.com/niklasf/chessops)
930
+ - Production WSGI server: [waitress](https://docs.pylonsproject.org/projects/waitress/)
931
+ - Piece set: [cburnett](https://github.com/lichess-org/lila/blob/master/COPYING.md), a commonly-used community set found in most web chess UIs.
932
+
933
+ ## License
934
+
935
+ ChessTB Explorer is licensed under `GPL-3.0-or-later`; see [`LICENSE`](LICENSE).
936
+ Bundled third-party components and their licenses are listed in
937
+ [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
THIRD_PARTY_LICENSES.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-Party Notices
2
+
3
+ This project bundles or depends on the third-party components listed
4
+ below. Each entry states the component, version, SPDX license
5
+ identifier, and source. This document is provided for attribution and
6
+ license-compliance purposes only and does not constitute legal advice;
7
+ consult qualified counsel before distributing, selling, or publicly
8
+ deploying this software.
9
+
10
+ Every file in this repository not listed in the tables below - including
11
+ `app.py`, `config.py`, `dtz_anchor.py`, `tablebase_opening.py`,
12
+ `tablebase_router.py`, `static/js/admin.js`,
13
+ `static/css/*.css`, `templates/*.html`,
14
+ `frontend/src/engine/`, `frontend/src/hooks/`, `frontend/src/components/`,
15
+ `frontend/src/styles/`, `frontend/src/*.jsx`, and `remote/`, `Dockerfile`, `render.yaml`,
16
+ `.dockerignore`, `requirements.txt`, and `openapi.yaml` - is original and
17
+ licensed under the terms in [`LICENSE`](LICENSE) (`GPL-3.0-or-later`).
18
+
19
+ ## Summary
20
+
21
+ | Component | Version | License (SPDX) | Category |
22
+ |---|---|---|---|
23
+ | [React](https://github.com/facebook/react) | 18.3.1 | `MIT` | Frontend |
24
+ | [React DOM](https://github.com/facebook/react) | 18.3.1 | `MIT` | Frontend |
25
+ | [Scheduler](https://github.com/facebook/react) | 0.23.2 | `MIT` | Frontend |
26
+ | [Chessground](https://github.com/lichess-org/chessground) | 10.1.1 | `GPL-3.0-or-later` | Frontend |
27
+ | [chessops](https://github.com/niklasf/chessops) | 0.15.1 | `GPL-3.0-or-later` | Frontend |
28
+ | [@badrap/result](https://github.com/badrap/result) | 0.3.1 | `MIT` | Frontend |
29
+ | [Inter](https://rsms.me/inter/) | — | `OFL-1.1` | Font |
30
+ | [JetBrains Mono](https://www.jetbrains.com/lp/mono/) | — | `OFL-1.1` | Font |
31
+ | cburnett piece set | — | `GPL-2.0-or-later` | Asset |
32
+ | [chess](https://github.com/noobpwnftw/python-chess) (`add-chesstb-tablebases` fork) | latest on branch | `GPL-3.0-or-later` | Python |
33
+ | [Flask](https://github.com/pallets/flask) | `>=3.0,<4.0` | `BSD-3-Clause` | Python |
34
+ | [lz4](https://github.com/python-lz4/python-lz4) | `>=4.0,<5.0` | `BSD-3-Clause` | Python |
35
+ | [waitress](https://github.com/Pylons/waitress) | `>=3.0,<4.0` | `ZPL-2.1` | Python |
36
+ | [requests](https://github.com/psf/requests) | `>=2.31,<3.0` | `Apache-2.0` | Python |
37
+ | [Flask-Limiter](https://github.com/alisaifee/flask-limiter) | `>=4.0,<5.0` | `MIT` | Python |
38
+
39
+ ## Notices
40
+
41
+ This project is licensed as a whole under `GPL-3.0-or-later` - see
42
+ [`LICENSE`](LICENSE).
43
+
44
+ The following bundled components carry their own `GPL` license terms:
45
+
46
+ - **`chess` / `chess.chesstb`** (`GPL-3.0-or-later`): imported
47
+ directly by `app.py`.
48
+ - **Chessground** (`GPL-3.0-or-later`): vendored in
49
+ `frontend/src/vendor/`, bundled into `static/dist/app.js`, and served
50
+ to every client.
51
+ - **chessops** (`GPL-3.0-or-later`): vendored in
52
+ `frontend/src/vendor/chessops/`, bundled into `static/dist/app.js`,
53
+ and served to every client.
54
+ - **cburnett piece set** (`GPL-2.0-or-later`): bundled in
55
+ `static/pieces/cburnett/` and served to every client.
56
+
57
+ React, React DOM, Scheduler, and `@badrap/result` (`MIT`) are also
58
+ bundled into `static/dist/app.js` - see
59
+ [React / build dependencies](#react--build-dependencies-frontend) below.
60
+
61
+ ## Frontend engine libraries: `frontend/src/vendor/`
62
+
63
+ | File | Component | Version | License | Source |
64
+ |---|---|---|---|---|
65
+ | `chessground.min.js` | Chessground | 10.1.1 | `GPL-3.0-or-later` | [lichess-org/chessground](https://github.com/lichess-org/chessground) |
66
+ | `chessops/*.js` (excl. `badrap-result.js`) | chessops | 0.15.1 | `GPL-3.0-or-later` | [niklasf/chessops](https://github.com/niklasf/chessops) |
67
+ | `chessops/badrap-result.js` | @badrap/result | 0.3.1 | `MIT` | [badrap/result](https://github.com/badrap/result) |
68
+ | `chessground.base.css` | Chessground stylesheet | 10.1.1 | `GPL-3.0-or-later` | [lichess-org/chessground](https://github.com/lichess-org/chessground) |
69
+
70
+ `frontend/src/vendor/` holds the pinned ESM distribution and its
71
+ `@badrap/result` runtime dependency; `vite build` bundles them into
72
+ `static/dist/app.js` together with the frontend's own code. They are
73
+ not fetched from a CDN at request time. `chessground.base.css` is
74
+ bundled into `static/dist/app.css`.
75
+
76
+ ## React / build dependencies: `frontend/`
77
+
78
+ | Component | Version | License | Source |
79
+ |---|---|---|---|
80
+ | React | 18.3.1 | `MIT` | [facebook/react](https://github.com/facebook/react) |
81
+ | React DOM | 18.3.1 | `MIT` | [facebook/react](https://github.com/facebook/react) |
82
+ | Scheduler | 0.23.2 | `MIT` | [facebook/react](https://github.com/facebook/react) (React DOM's runtime dependency) |
83
+
84
+ Installed via `npm install` from `frontend/package.json` and bundled
85
+ into `static/dist/app.js` by `vite build`; not present anywhere else in
86
+ the served application. Vite and `@vitejs/plugin-react` are build-time
87
+ only and are not distributed to clients.
88
+
89
+ ## Fonts: `frontend/src/assets/fonts/`
90
+
91
+ | File(s) | Typeface | License | Author |
92
+ |---|---|---|---|
93
+ | `Inter-Regular.woff2`, `Inter-Medium.woff2`, `Inter-SemiBold.woff2` | Inter | `OFL-1.1` | [Rasmus Andersson](https://rsms.me/inter/) |
94
+ | `JetBrainsMono-Regular.woff2`, `JetBrainsMono-Medium.woff2` | JetBrains Mono | `OFL-1.1` | [JetBrains](https://www.jetbrains.com/lp/mono/) |
95
+
96
+ The build copies the fonts to `static/dist/fonts/`.
97
+
98
+ ## Chess piece set: `static/pieces/` (via lichess.org)
99
+
100
+ | Directory | License | Author | Source |
101
+ |---|---|---|---|
102
+ | `cburnett/` | `GPL-2.0-or-later` | Colin M.L. Burnett | [lila COPYING.md](https://github.com/lichess-org/lila/blob/master/COPYING.md) |
103
+
104
+ ## Python dependencies: `requirements.txt`
105
+
106
+ | Package | License | Notes |
107
+ |---|---|---|
108
+ | `chess` (installed from `noobpwnftw/python-chess`, `add-chesstb-tablebases` branch) | `GPL-3.0-or-later` | Fork of [niklasf/python-chess](https://github.com/niklasf/python-chess). Both the base `chess` package and `chess.chesstb` are imported directly by `app.py`. |
109
+ | Flask | `BSD-3-Clause` | [pallets/flask](https://github.com/pallets/flask) |
110
+ | lz4 | `BSD-3-Clause` | [python-lz4/python-lz4](https://github.com/python-lz4/python-lz4). Optional but recommended: `chess.chesstb` picks it up itself at import time to accelerate its own block decompression, falling back to its own pure-Python decoder if not present. `app.py` doesn't import or reference it directly. |
111
+ | waitress | `ZPL-2.1` | [Pylons/waitress](https://github.com/Pylons/waitress) |
112
+ | requests | `Apache-2.0` | [psf/requests](https://github.com/psf/requests). Imported only when `TABLEBASE_PATH` is a remote `http(s)://` URL (`remote/remote_source.py`, `remote/remote_fallback.py`). |
113
+ | Flask-Limiter | `MIT` | [alisaifee/flask-limiter](https://github.com/alisaifee/flask-limiter). Provides the per-IP rate limiting on `/probe` and `/probe/stream` - see `config.py`'s `PROBE_RATE_LIMIT`. |
114
+
115
+ ## License texts
116
+
117
+ | SPDX identifier | Text |
118
+ |---|---|
119
+ | `GPL-3.0-or-later` | <https://www.gnu.org/licenses/gpl-3.0.txt> |
120
+ | `GPL-2.0-or-later` | <https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt> |
121
+ | `BSD-2-Clause` | <https://spdx.org/licenses/BSD-2-Clause.html> |
122
+ | `BSD-3-Clause` | <https://spdx.org/licenses/BSD-3-Clause.html> |
123
+ | `OFL-1.1` | <https://scripts.sil.org/OFL> |
124
+ | `ZPL-2.1` | <https://opensource.org/license/zpl-2-1/> |
125
+ | `Apache-2.0` | <https://www.apache.org/licenses/LICENSE-2.0.txt> |
126
+ | `MIT` | <https://spdx.org/licenses/MIT.html> |
127
+
128
+ ## Tablebase data
129
+
130
+ Not covered by this document. This project only displays results from
131
+ ChessTB tablebase files supplied by the user (see `README.md` →
132
+ "Getting the tablebase files"). No tablebase data is bundled with the
133
+ app.
app.py ADDED
@@ -0,0 +1,2048 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py: ChessTB Explorer back-end
3
+
4
+ A single-file Flask application that probes a ChessTB endgame tablebase
5
+ for a submitted FEN position and returns every legal move ranked by
6
+ Distance to Zeroing (DTZ), Distance to Conversion (DTC), Distance to
7
+ Mate (DTM), and DTM under the 50-move rule (DTM50).
8
+
9
+ DTC is 50-move-rule-aware in the same way DTM50 is (it is always probed
10
+ at the position's own halfmove clock), but unlike DTM50 it has no
11
+ "flat"/clock-independent sibling column of its own -- there is only ever
12
+ one DTC value for a position. A ``.lzdtc`` table only exists for material
13
+ with at least one pawn; for pawnless material `chess.chesstb` derives an
14
+ equivalent answer from DTZ/WDL instead (see chess.chesstb.ProbeResult),
15
+ so `has_dtc` is False only when a pawnful material's own
16
+ `.lzdtc` file genuinely hasn't been generated/shipped.
17
+
18
+ Routes
19
+ ------
20
+ POST /probe - evaluate a FEN position (JSON)
21
+ POST /probe/stream - evaluate with SSE streaming progress
22
+ GET / - main UI
23
+ GET /health - readiness check
24
+ GET /admin - cache dashboard HTML page, or a login form if not authenticated
25
+ POST /admin/login - start an admin session (JSON {token})
26
+ POST /admin/logout - end the admin session
27
+ POST /admin/cache/clear - purge both LRU caches
28
+ GET /admin/cache/stats - cache hit-rate statistics
29
+ GET /openapi.yaml - OpenAPI 3.0 specification
30
+
31
+ /admin and /admin/cache/* require admin authentication -- either a
32
+ session cookie started via /admin/login, or an "Authorization: Bearer
33
+ <ADMIN_TOKEN>" header. See config.py's "Admin" section and README.md's
34
+ "Security notes" for how ADMIN_TOKEN is configured.
35
+
36
+ /probe and /probe/stream are rate-limited per client IP (config.py's
37
+ PROBE_RATE_LIMIT); a client over the limit gets a 429 with a JSON body.
38
+ /admin/login has its own, separate limit (config.py's
39
+ ADMIN_LOGIN_RATE_LIMIT), covering both failed and successful attempts.
40
+
41
+ Each move entry includes a child_fen so the client can pre-fetch the next
42
+ probe, and each response includes a summary of wins/draws/losses/unknown
43
+ across all legal moves. A position not covered by the loaded tablebase
44
+ raises MissingTableError, which is returned as structured JSON
45
+ ({error_code, piece_count}). Root-JSON and child-probe cache sizes are set
46
+ by config.EVALUATE_CACHE_SIZE / config.PROBE_CACHE_SIZE.
47
+
48
+ Every request to /probe and /probe/stream carries an explicit "variant"
49
+ field ("standard" or "chess960", defaulting to "standard" when omitted)
50
+ alongside "fen" -- the two rulesets are never inferred from a FEN's
51
+ contents, since the orthodox starting arrangement is itself a legal
52
+ Chess960 starting position and would otherwise be ambiguous. A submitted
53
+ FEN's castling-availability field accepts "-" or any combination of "K",
54
+ "Q", "k", "q" for variant="standard", plus rook-file letters ("a"-"h" /
55
+ "A"-"H") for variant="chess960"; a right with no matching king/rook pair
56
+ is rejected as invalid input before the board is ever probed (see
57
+ _validate_and_build_board). A position with castling rights is otherwise
58
+ probed like any other: `chess.chesstb` resolves it when the loaded
59
+ tablebase set carries a table for that material (see config.py's
60
+ TABLEBASE_PATH and tablebase_router.py -- unaffected by variant,
61
+ since a Chess960 board's clean_castling_rights() reports the same way a
62
+ Standard board's does), and reports it as not covered when it doesn't.
63
+
64
+ Running this file directly (`python app.py`) serves the app via waitress
65
+ (a production-grade WSGI server) by default. Set DEBUG = True in
66
+ config.py to use Flask's own dev server instead (auto-reload +
67
+ interactive debugger) for local development. See README.md
68
+ "Installation" / "Running in production" for details.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ from collections import OrderedDict, namedtuple
74
+ from concurrent.futures import (
75
+ ThreadPoolExecutor,
76
+ wait as futures_wait,
77
+ ALL_COMPLETED,
78
+ FIRST_COMPLETED,
79
+ )
80
+ from dataclasses import dataclass
81
+ from flask import (
82
+ Flask, render_template, request, jsonify, Response,
83
+ stream_with_context, send_file, session,
84
+ )
85
+ from flask_limiter import Limiter
86
+ from flask_limiter.util import get_remote_address
87
+ from functools import lru_cache, wraps
88
+ from typing import TypedDict
89
+ from waitress import serve as waitress_serve
90
+ from werkzeug.middleware.proxy_fix import ProxyFix
91
+ import atexit
92
+ import chess
93
+ import config
94
+ import dtz_anchor
95
+ import hmac
96
+ import json
97
+ import os
98
+ import re
99
+ import logging
100
+ import secrets
101
+ import tablebase_router
102
+ import threading
103
+ import time
104
+
105
+ # ── Logging ───────────────────────────────────────────────────────────────────
106
+
107
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
108
+ log = logging.getLogger(__name__)
109
+
110
+
111
+ # ── ChessTB backend ──────────────────────���────────────────────────────────────
112
+
113
+ # chess.chesstb comes from noobpwnftw's add-chesstb-tablebases fork of
114
+ # python-chess (see requirements.txt) - open_tablebase, ProbeResult's raw
115
+ # wdl/dtz/dtc/dtm/dtm50 fields, MissingTableError, and the WIN/CURSED_WIN/
116
+ # DRAW/BLESSED_LOSS/LOSE constants all come from that module.
117
+ import chess.chesstb as chesstb
118
+
119
+ log.info("chess.chesstb backend: noobpwnftw/python-chess (add-chesstb-tablebases)")
120
+
121
+ # Remote (HTTP) tablebase support, in two flavours -- config.REMOTE_MODE
122
+ # picks one (see tablebase_router.open_one):
123
+ # remote/remote_direct.py ("direct") probe in place over byte ranges
124
+ # remote/remote_fallback.py ("download") whole-file download, disk-cached
125
+ # See each file's module docstring for the design and the trade-off.
126
+ # looks_like_remote()/RemoteSourceError come from remote/remote_source.py.
127
+ from remote import remote_direct, remote_fallback, remote_source
128
+
129
+
130
+ # ── Type aliases ──────────────────────────────────────────────────────────────
131
+
132
+ class MoveEntry(TypedDict):
133
+ san: str
134
+ plies: int
135
+ order: int # DTC only: pawn pushes still owed before a conversion (0 for every other metric)
136
+ is_mate: bool
137
+ outcome: str # "win"|"cursed_win"|"draw"|"blessed_loss"|"loss"|"unknown"|"not_available"
138
+ available: bool # False when this move's own metric table isn't present (outcome is then "not_available")
139
+ child_fen: str | None # FEN after this move; None for terminal / unknown
140
+ draw_reason: str | None # "stalemate"|"insufficient_material"|None
141
+
142
+
143
+ class DtzMoveEntry(MoveEntry, total=False):
144
+ rounded: bool # DTZ rows only, when available: plies may be a ply off
145
+
146
+
147
+ @dataclass
148
+ class _DtzMove:
149
+ row: int # index into the DTZ rows
150
+ my_wdl: int
151
+ keeps_class: bool
152
+ option: dtz_anchor.MoveOption
153
+
154
+
155
+ MoveList = list[MoveEntry]
156
+
157
+
158
+ # ── Config validation helpers ─────────────────────────────────────────────────
159
+ # Centralise the "read a config.py value, check its type, validate range"
160
+ # pattern that AppConfig.from_config() below needs once per setting.
161
+
162
+ def _validated_int(name: str, value, *, min_val: int = 1, max_val: int | None = None) -> int:
163
+ if isinstance(value, str):
164
+ # PORT and TRUSTED_PROXY_COUNT are the two config.py settings read
165
+ # as a raw string rather than pre-converted at import time (see
166
+ # config.py's own comments on each) -- specifically so a bad
167
+ # value raises here, inside AppConfig.from_config()'s try/except,
168
+ # rather than as an uncaught ValueError from a bare int() call
169
+ # during module import, before this module has an exception handler
170
+ # in place for it.
171
+ try:
172
+ value = int(value.strip())
173
+ except ValueError:
174
+ raise ValueError(f"config.{name} must be an integer, got {value!r}") from None
175
+ if not isinstance(value, int) or isinstance(value, bool):
176
+ raise ValueError(f"config.{name} must be an integer, got {value!r}")
177
+ if max_val is None:
178
+ if value < min_val:
179
+ raise ValueError(f"config.{name} must be >= {min_val}, got {value}")
180
+ elif not (min_val <= value <= max_val):
181
+ raise ValueError(f"config.{name} must be {min_val}-{max_val}, got {value}")
182
+ return value
183
+
184
+
185
+ def _validated_float(name: str, value) -> float:
186
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
187
+ raise ValueError(f"config.{name} must be a number, got {value!r}")
188
+ value = float(value)
189
+ if value <= 0:
190
+ raise ValueError(f"config.{name} must be > 0, got {value}")
191
+ return value
192
+
193
+
194
+ def _validated_str(name: str, value) -> str:
195
+ if not isinstance(value, str):
196
+ raise ValueError(f"config.{name} must be a string, got {value!r}")
197
+ return value
198
+
199
+
200
+ def _validated_path_spec(name: str, value) -> str | list[str]:
201
+ """TABLEBASE_PATH: a
202
+ single path (local directory or http(s):// URL) or a list of them for
203
+ a hybrid source (see tablebase_router.py). Each list entry must
204
+ itself be a string -- a nested list isn't a valid hybrid source."""
205
+ if isinstance(value, str):
206
+ return value
207
+ if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value):
208
+ return list(value)
209
+ raise ValueError(f"config.{name} must be a string or a list of strings, got {value!r}")
210
+
211
+
212
+ def _validated_choice(name: str, value, allowed: tuple) -> str:
213
+ if value not in allowed:
214
+ raise ValueError(
215
+ f"config.{name} must be one of {', '.join(map(repr, allowed))}, got {value!r}")
216
+ return value
217
+
218
+
219
+ def _validated_bool(name: str, value) -> bool:
220
+ if not isinstance(value, bool):
221
+ raise ValueError(f"config.{name} must be True or False, got {value!r}")
222
+ return value
223
+
224
+
225
+ # ── Validated configuration ───────────────────────────────────────────────────
226
+
227
+ @dataclass(frozen=True)
228
+ class AppConfig:
229
+ tablebase_path: str | list[str]
230
+ probe_threads: int
231
+ parallel_threshold: int
232
+ probe_timeout: float
233
+ host: str
234
+ port: int
235
+ evaluate_cache_size: int
236
+ probe_cache_size: int
237
+ block_cache_bytes: int
238
+ debug: bool
239
+ waitress_threads: int
240
+ # Only used for an http(s):// URL entry in tablebase_path (see "ChessTB
241
+ # backend" above and remote/remote_source.py) -- ignored entirely for
242
+ # a local-directory entry. Bounds an on-disk LRU of whole downloaded
243
+ # table files cached by remote/remote_fallback.py (see that file's
244
+ # module docstring for the full design).
245
+ remote_mode: str
246
+ remote_page_cache_bytes: int
247
+ remote_page_size_bytes: int
248
+ remote_timeout_secs: float
249
+ remote_max_retries: int
250
+ remote_pool_maxsize: int
251
+ admin_token: str
252
+ secret_key: str
253
+ source_repository_url: str
254
+ probe_rate_limit: str
255
+ prefetch_rate_limit: str
256
+ admin_login_rate_limit: str
257
+ trusted_proxy_count: int
258
+ session_cookie_secure: bool
259
+ # Hard-coded limit, not one of config.py's settings -- kept as a
260
+ # dataclass field (rather than a bare module constant) so it reads
261
+ # alongside every other request-validation knob above.
262
+ max_fen_length: int = 100
263
+
264
+ @classmethod
265
+ def from_config(cls) -> "AppConfig":
266
+ # PROBE_THREADS is the one setting with a computed (not literal)
267
+ # default, so a None left in config.py falls back to a
268
+ # CPU-count-based figure instead of going through _validated_int.
269
+ threads = (
270
+ _validated_int("PROBE_THREADS", config.PROBE_THREADS)
271
+ if config.PROBE_THREADS is not None
272
+ else min(16, (os.cpu_count() or 4) * 2)
273
+ )
274
+ # Same "None means auto" shape as PROBE_THREADS above, computed
275
+ # from it: a connection pool smaller than the probe thread count
276
+ # gives up connection reuse for whichever requests land once the
277
+ # pool is exhausted (see remote/remote_source.py).
278
+ pool_maxsize = (
279
+ _validated_int("REMOTE_POOL_MAXSIZE", config.REMOTE_POOL_MAXSIZE)
280
+ if config.REMOTE_POOL_MAXSIZE is not None
281
+ else max(threads * 2, 20)
282
+ )
283
+
284
+ return cls(
285
+ tablebase_path=_validated_path_spec("TABLEBASE_PATH", config.TABLEBASE_PATH),
286
+ probe_threads=threads,
287
+ parallel_threshold=_validated_int("PROBE_PARALLEL_THRESHOLD", config.PROBE_PARALLEL_THRESHOLD),
288
+ probe_timeout=_validated_float("PROBE_TIMEOUT_SECS", config.PROBE_TIMEOUT_SECS),
289
+ host=_validated_str("HOST", config.HOST),
290
+ port=_validated_int("PORT", config.PORT, max_val=65535),
291
+ evaluate_cache_size=_validated_int("EVALUATE_CACHE_SIZE", config.EVALUATE_CACHE_SIZE),
292
+ probe_cache_size=_validated_int("PROBE_CACHE_SIZE", config.PROBE_CACHE_SIZE),
293
+ block_cache_bytes=_validated_int("BLOCK_CACHE_BYTES", config.BLOCK_CACHE_BYTES),
294
+ # DEBUG also selects the server used by
295
+ # `if __name__ == "__main__"` below: the Flask dev server when
296
+ # True, waitress otherwise (see README.md "Running in production").
297
+ debug=_validated_bool("DEBUG", config.DEBUG),
298
+ waitress_threads=_validated_int("WAITRESS_THREADS", config.WAITRESS_THREADS),
299
+ remote_mode=_validated_choice("REMOTE_MODE", config.REMOTE_MODE, ("direct", "download")),
300
+ remote_page_cache_bytes=_validated_int(
301
+ "REMOTE_PAGE_CACHE_BYTES", config.REMOTE_PAGE_CACHE_BYTES),
302
+ remote_page_size_bytes=_validated_int(
303
+ "REMOTE_PAGE_SIZE_BYTES", config.REMOTE_PAGE_SIZE_BYTES),
304
+ remote_timeout_secs=_validated_float("REMOTE_TIMEOUT_SECS", config.REMOTE_TIMEOUT_SECS),
305
+ remote_max_retries=_validated_int("REMOTE_MAX_RETRIES", config.REMOTE_MAX_RETRIES),
306
+ remote_pool_maxsize=pool_maxsize,
307
+ admin_token=_validated_str("ADMIN_TOKEN", config.ADMIN_TOKEN),
308
+ secret_key=_validated_str("FLASK_SECRET_KEY", config.FLASK_SECRET_KEY),
309
+ source_repository_url=_validated_str("SOURCE_REPOSITORY_URL", config.SOURCE_REPOSITORY_URL),
310
+ probe_rate_limit=_validated_str("PROBE_RATE_LIMIT", config.PROBE_RATE_LIMIT),
311
+ prefetch_rate_limit=_validated_str("PREFETCH_RATE_LIMIT", config.PREFETCH_RATE_LIMIT),
312
+ admin_login_rate_limit=_validated_str("ADMIN_LOGIN_RATE_LIMIT", config.ADMIN_LOGIN_RATE_LIMIT),
313
+ trusted_proxy_count=_validated_int(
314
+ "TRUSTED_PROXY_COUNT", config.TRUSTED_PROXY_COUNT, min_val=0, max_val=10),
315
+ session_cookie_secure=_validated_bool("SESSION_COOKIE_SECURE", config.SESSION_COOKIE_SECURE),
316
+ )
317
+
318
+
319
+ try:
320
+ cfg = AppConfig.from_config()
321
+ except (ValueError, AttributeError) as e:
322
+ log.error("Configuration error: %s — fix config.py and restart.", e)
323
+ raise SystemExit(1) from e
324
+
325
+ # Exception type to treat as "couldn't reach the remote tablebase" in the
326
+ # /probe and /probe/stream handlers below.
327
+ _REMOTE_SOURCE_ERROR = remote_source.RemoteSourceError
328
+
329
+ if not cfg.tablebase_path:
330
+ log.warning("TABLEBASE_PATH is not set — tablebase lookups will fail.")
331
+
332
+ if not cfg.admin_token:
333
+ log.warning(
334
+ "ADMIN_TOKEN is not set — /admin and /admin/cache/* will respond "
335
+ "503 until it's configured (see config.py's \"Admin\" section)."
336
+ )
337
+
338
+
339
+ # ── Flask app ─────────────────────────────────────────────────────────────────
340
+
341
+ app = Flask(__name__)
342
+ app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 # 4 KB
343
+
344
+ # secrets.token_hex(32) generated fresh at process startup when
345
+ # FLASK_SECRET_KEY isn't set (see config.py) -- every admin session is then
346
+ # invalidated by a restart, requiring a new login; it never
347
+ # breaks anything the app itself depends on, since sessions here hold
348
+ # nothing but the admin_ok flag set by /admin/login.
349
+ app.secret_key = cfg.secret_key or secrets.token_hex(32)
350
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
351
+ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
352
+ # Independent of cfg.debug -- see config.py's SESSION_COOKIE_SECURE for
353
+ # why: DEBUG is a development/runtime behavior switch, not a reliable
354
+ # indicator of whether the browser reaches this deployment over HTTPS.
355
+ app.config["SESSION_COOKIE_SECURE"] = cfg.session_cookie_secure
356
+
357
+ # Without this, request.remote_addr -- and therefore get_remote_address()
358
+ # below, which keys both rate limiters -- is whichever host made the TCP
359
+ # connection to this process. Behind a reverse proxy (Render's own
360
+ # edge/load-balancer in front of the render.yaml deployment this project
361
+ # ships with, or any other reverse proxy placed in front of it) that's the
362
+ # proxy's own address, not the actual client's, so every client sharing
363
+ # that proxy would collapse onto one rate-limit key. ProxyFix instead
364
+ # trusts exactly cfg.trusted_proxy_count X-Forwarded-For hops closest to
365
+ # this app and reads the real client IP from ahead of them -- set to 0
366
+ # this is a no-op, appropriate only for a deployment with no reverse proxy
367
+ # in front of it at all (an untrusted client's own X-Forwarded-For header
368
+ # would otherwise be trusted, letting it spoof its rate-limit key).
369
+ #
370
+ # x_proto does the equivalent correction for X-Forwarded-Proto, which a
371
+ # TLS-terminating reverse proxy sets to tell this app the scheme the
372
+ # client actually connected with -- without it, request.scheme /
373
+ # request.is_secure (and any URL this app builds with
374
+ # url_for(..., _external=True)) would read as plain http:// even when the
375
+ # client's own connection was https://, since Flask only ever sees the
376
+ # proxy's own (typically-HTTP) hop to this app. Same trusted-hop count as
377
+ # x_for, for the same reason: a value here that doesn't match the real
378
+ # proxy chain either misreads the scheme or lets a client spoof it.
379
+ #
380
+ # This only actually matters for cfg.debug's Flask-dev-server path below --
381
+ # under waitress (cfg.debug False, the production path), waitress parses
382
+ # X-Forwarded-For and corrects REMOTE_ADDR itself before this app ever
383
+ # sees the request, taking priority over -- and making redundant, but
384
+ # harmlessly so -- the correction this makes. See the waitress_serve(...)
385
+ # call below for that side of it.
386
+ if cfg.trusted_proxy_count > 0:
387
+ app.wsgi_app = ProxyFix(
388
+ app.wsgi_app, x_for=cfg.trusted_proxy_count, x_proto=cfg.trusted_proxy_count)
389
+
390
+
391
+ @app.errorhandler(413)
392
+ def request_too_large(e: Exception) -> tuple:
393
+ return jsonify({"error": "Request body too large (max 4 KB)."}), 413
394
+
395
+
396
+ # ── Rate limiting ────────────────────────────────────────────────────────────
397
+ # Per-IP limits on /probe, /probe/stream (config.py's PROBE_RATE_LIMIT, or
398
+ # PREFETCH_RATE_LIMIT for a request tagged "X-Prefetch: 1" -- see
399
+ # _request_probe_limit below) and, separately, /admin/login (config.py's
400
+ # ADMIN_LOGIN_RATE_LIMIT -- see README.md's "Security notes").
401
+ # default_limits=[] means nothing is limited except the routes explicitly
402
+ # decorated with @_probe_rate_limit / @_admin_login_rate_limit below; the
403
+ # in-memory storage is per-process, which is enough for a single-container
404
+ # deployment but doesn't share state across multiple replicas. key_func
405
+ # reads request.remote_addr, which ProxyFix (see the Flask app section
406
+ # above) has already corrected for cfg.trusted_proxy_count reverse-proxy
407
+ # hops.
408
+ limiter = Limiter(
409
+ key_func=get_remote_address, app=app, default_limits=[], storage_uri="memory://",
410
+ headers_enabled=True)
411
+
412
+
413
+ def _request_probe_limit() -> str:
414
+ # X-Prefetch is client-asserted and therefore untrusted as a basis for
415
+ # relaxing enforcement -- it only ever selects a separate, tighter
416
+ # budget than the default, never bypasses PROBE_RATE_LIMIT. A request
417
+ # without the header, or when PREFETCH_RATE_LIMIT is unset, always
418
+ # falls back to the normal probe budget.
419
+ if request.headers.get("X-Prefetch") == "1" and cfg.prefetch_rate_limit:
420
+ return cfg.prefetch_rate_limit
421
+ return cfg.probe_rate_limit
422
+
423
+
424
+ def _probe_rate_limit(view):
425
+ # A plain pass-through when neither limit is configured, rather than
426
+ # a zero/near-zero limiter.limit() call, so "disabled" actually means
427
+ # unlimited rather than "limited to approximately nothing." A
428
+ # callable limit_value (rather than the plain string
429
+ # _admin_login_rate_limit below uses) is what lets a single decorator
430
+ # route a hover-prefetch request to its own, tighter budget -- see
431
+ # _request_probe_limit above -- flask-limiter re-evaluates it on
432
+ # every request rather than once at decoration time.
433
+ if not cfg.probe_rate_limit and not cfg.prefetch_rate_limit:
434
+ return view
435
+ return limiter.limit(_request_probe_limit)(view)
436
+
437
+
438
+ def _admin_login_rate_limit(view):
439
+ # Independent of _probe_rate_limit above -- /admin/login is a
440
+ # brute-forceable shared-secret check, not a CPU-bound probe, so it
441
+ # gets its own (much tighter) limit rather than reusing or being
442
+ # covered by PROBE_RATE_LIMIT.
443
+ if not cfg.admin_login_rate_limit:
444
+ return view
445
+ return limiter.limit(cfg.admin_login_rate_limit)(view)
446
+
447
+
448
+ @app.errorhandler(429)
449
+ def rate_limit_exceeded(e: Exception) -> tuple:
450
+ return jsonify({
451
+ "error": "Too many requests. Please slow down and try again shortly.",
452
+ }), 429
453
+
454
+
455
+ # ── Tablebase + thread pool ───────────────────────────────────────────────────
456
+ # All material, including castling and recursive dependencies, uses the
457
+ # same ordered source list. The adapter shares one decoded-block cache.
458
+ try:
459
+ _tb_source = tablebase_router.open_many(
460
+ cfg.tablebase_path, label="TABLEBASE_PATH", cfg=cfg,
461
+ remote_source=remote_source, remote_direct=remote_direct,
462
+ remote_download=remote_fallback,
463
+ )
464
+ except Exception as e:
465
+ log.warning("Failed to open TABLEBASE_PATH: %s", e)
466
+ _tb_source = None
467
+
468
+ TB = (
469
+ tablebase_router.SourcedTablebase(
470
+ _tb_source, remote_source=remote_source,
471
+ block_cache_bytes=cfg.block_cache_bytes,
472
+ )
473
+ if _tb_source is not None else None
474
+ )
475
+
476
+ _executor = ThreadPoolExecutor(max_workers=cfg.probe_threads, thread_name_prefix="tb_probe")
477
+ log.info(
478
+ "Thread pool: %d workers, parallel_threshold=%d, timeout=%.0fs, "
479
+ "eval_cache=%d, probe_cache=%d, block_cache_bytes=%d",
480
+ cfg.probe_threads, cfg.parallel_threshold, cfg.probe_timeout,
481
+ cfg.evaluate_cache_size, cfg.probe_cache_size, cfg.block_cache_bytes,
482
+ )
483
+
484
+
485
+ @atexit.register
486
+ def _close_tb() -> None:
487
+ try:
488
+ if TB is not None:
489
+ TB.close()
490
+ except Exception as e:
491
+ log.warning("Error closing tablebase: %s", e)
492
+
493
+
494
+ @atexit.register
495
+ def _shutdown_executor() -> None:
496
+ try:
497
+ _executor.shutdown(wait=True, cancel_futures=True)
498
+ except Exception as e:
499
+ log.warning("Error shutting down thread pool: %s", e)
500
+
501
+
502
+ # ── Security headers ──────────────────────────────────────────────────────────
503
+
504
+ @app.after_request
505
+ def set_security_headers(response: Response) -> Response:
506
+ # base-uri and form-action are set explicitly since default-src doesn't
507
+ # cover them; object-src, though covered, is set to 'none' since the app
508
+ # has no use for <object>/<embed>. Allow Hugging Face's Space page
509
+ # to embed the application; CSP controls the permitted ancestors.
510
+ response.headers["Content-Security-Policy"] = (
511
+ "default-src 'self'; script-src 'self'; "
512
+ "style-src 'self' 'unsafe-inline'; "
513
+ "font-src 'self'; img-src 'self' data:; connect-src 'self'; "
514
+ "object-src 'none'; base-uri 'self'; form-action 'self'; "
515
+ "frame-ancestors 'self' https://huggingface.co;"
516
+ )
517
+ response.headers["X-Content-Type-Options"] = "nosniff"
518
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
519
+ return response
520
+
521
+
522
+ # ── Admin authentication ────────────────────────────────────────────────────
523
+ # Gates /admin and /admin/cache/* behind cfg.admin_token (see config.py's
524
+ # "Admin" section). Two ways to authenticate:
525
+ # - Browser: POST /admin/login with the token starts a signed session
526
+ # cookie (see app.secret_key above); subsequent requests from the same
527
+ # browser are authenticated via that cookie automatically.
528
+ # - API client: send "Authorization: Bearer <ADMIN_TOKEN>" directly,
529
+ # no session/cookie needed.
530
+ # If cfg.admin_token is empty, every admin route responds 503 rather than
531
+ # running with no password -- there is no "admin panel open to everyone"
532
+ # fallback.
533
+
534
+ def _admin_token_matches(candidate: str) -> bool:
535
+ # hmac.compare_digest for constant-time comparison -- a plain `==`
536
+ # would leak the token's length/prefix through response-timing
537
+ # differences to an attacker making repeated guesses.
538
+ return bool(cfg.admin_token) and hmac.compare_digest(candidate, cfg.admin_token)
539
+
540
+
541
+ def _is_admin_authenticated() -> bool:
542
+ if session.get("admin_ok"):
543
+ return True
544
+ auth_header = request.headers.get("Authorization", "")
545
+ if auth_header.startswith("Bearer "):
546
+ return _admin_token_matches(auth_header[len("Bearer "):])
547
+ return False
548
+
549
+
550
+ def _require_admin(view):
551
+ @wraps(view)
552
+ def wrapped(*args, **kwargs):
553
+ if not cfg.admin_token:
554
+ return jsonify({
555
+ "error": "Admin panel disabled: ADMIN_TOKEN is not configured.",
556
+ }), 503
557
+ if not _is_admin_authenticated():
558
+ return jsonify({"error": "Admin authentication required."}), 401
559
+ return view(*args, **kwargs)
560
+ return wrapped
561
+
562
+
563
+ # ── FEN normalisation ─────────────────────────────────────────────────────────
564
+
565
+ _FEN_FIELD_DEFAULTS = ("8/8/8/8/8/8/8/8", "w", "-", "-", "0", "1")
566
+
567
+
568
+ def _normalize_fen(fen: str) -> str:
569
+ parts = fen.split()
570
+ while len(parts) < 6:
571
+ parts.append(_FEN_FIELD_DEFAULTS[len(parts)])
572
+ return " ".join(parts[:6])
573
+
574
+
575
+ # ── Sort helpers ──────────────────────────────────────────────────────────────
576
+
577
+ _OUTCOME_RANK: dict[int, int] = {2: 0, 1: 1, 0: 2, -1: 3, -2: 4}
578
+ _PROMOTION_RANK = {"Q": 0, "R": 1, "B": 2, "N": 3}
579
+
580
+
581
+ def _move_tiebreak(san: str) -> tuple[int, str, int, str]:
582
+ """Prefer immediate mate, then SAN order with Q/R/B/N promotions.
583
+
584
+ The SAN prefix groups promotions along the same path. This key is
585
+ used only after the metric's outcome, DTC order, and distance, so a
586
+ better underpromotion still beats a worse queen promotion. Immediate
587
+ mate must beat a tied zeroing/conversion move (DTZ/DTC can score both
588
+ as one ply), including when the mating move is an underpromotion.
589
+ """
590
+ mate_rank = 0 if san.endswith("#") else 1
591
+ prefix, separator, promotion = san.partition("=")
592
+ if separator:
593
+ return mate_rank, prefix, _PROMOTION_RANK[promotion[0]], promotion[1:]
594
+ return mate_rank, san, 0, ""
595
+
596
+
597
+ def _outcome_rank(wdl_val: int) -> int:
598
+ return _OUTCOME_RANK[wdl_val]
599
+
600
+
601
+ def _ply_rank(wdl_val: int, eff_val: int) -> int:
602
+ if wdl_val > 0: return eff_val
603
+ if wdl_val < 0: return -abs(eff_val)
604
+ return 0
605
+
606
+
607
+ def _order_rank(wdl_val: int, order: int) -> int:
608
+ """Sort key for DTC's "pushes still owed" component. DTC's own notion
609
+ of a better line is pushes first, then plies (see ProbeResult's
610
+ docstring in chess.chesstb): fewer pushes owed is better while
611
+ winning, more is better while losing (it's more delay bought before
612
+ the opponent's own clock forces a conversion). order is already an
613
+ unsigned count, so - unlike _ply_rank - there's no magnitude to
614
+ unwrap, only a direction to apply."""
615
+ if wdl_val > 0: return order
616
+ if wdl_val < 0: return -order
617
+ return 0
618
+
619
+
620
+ _WDL_TO_OUTCOME_LABEL: dict[int, str] = {
621
+ 2: "win", 1: "cursed_win", 0: "draw", -1: "blessed_loss", -2: "loss",
622
+ }
623
+
624
+
625
+ def _outcome_label(wdl_val: int) -> str:
626
+ return _WDL_TO_OUTCOME_LABEL[wdl_val]
627
+
628
+
629
+ def _effective_move_wdl(move_wdl: int, root_wdl: int, eff_distance: int) -> int:
630
+ """The win/cursed-win (or loss/blessed-loss) bucket a move belongs in,
631
+ used for both its outcome label and its sort-key rank. A raw win/loss
632
+ (move_wdl 2/-2) downgrades to cursed_win/blessed_loss (1/-1) when its
633
+ own eff_distance exceeds chesstb.MAX_NON_CURSED_DTZ. The result is then capped
634
+ at root_wdl, since a move can never be better than the root position's
635
+ own optimal value - this cap is one-directional, as a move can be
636
+ worse than root_wdl (e.g. a blunder turning a blessed_loss root into a
637
+ pure -2 loss). Draws pass through unchanged."""
638
+ if abs(eff_distance) > chesstb.MAX_NON_CURSED_DTZ:
639
+ if move_wdl == 2:
640
+ move_wdl = 1
641
+ elif move_wdl == -2:
642
+ move_wdl = -1
643
+ return _capped_wdl(move_wdl, root_wdl)
644
+
645
+
646
+ def _effective_distance(wdl: int, raw_distance: int) -> int:
647
+ if wdl != 0 and raw_distance == 0:
648
+ return 1 if wdl > 0 else -1
649
+ if raw_distance > 0:
650
+ return raw_distance + 1
651
+ if raw_distance < 0:
652
+ return raw_distance - 1
653
+ return 0
654
+
655
+
656
+ def _is_cursed_exit(parent: chess.Board, child: chess.Board, child_wdl: int) -> bool:
657
+ """True when a move into a cursed or blessed child (signed child_wdl)
658
+ gives up castling rights."""
659
+ return (abs(child_wdl) == 1
660
+ and chess.popcount(child.clean_castling_rights())
661
+ < chess.popcount(parent.clean_castling_rights()))
662
+
663
+
664
+ def _capped_wdl(move_wdl: int, root_wdl: int) -> int:
665
+ """move_wdl capped at root_wdl: a move can never be better than the root
666
+ position's own optimal value (see _effective_move_wdl). Used by the
667
+ clock-independent DTZ/DTM columns."""
668
+ return root_wdl if move_wdl > root_wdl else move_wdl
669
+
670
+
671
+ # ── Variant ────────────────────────────────────────────────────────────────
672
+ #
673
+ # "standard" | "chess960". Chess960 changes only the interpretation of
674
+ # castling rights/moves -- board geometry, piece movement, and tablebase
675
+ # material selection are unaffected (see tablebase_router.py, unchanged by
676
+ # variant). The two rulesets are never inferred from a FEN's contents: the
677
+ # orthodox starting array is itself one of the 960 legal Chess960 starting
678
+ # arrangements, so the same FEN text is genuinely ambiguous without this
679
+ # explicit field. An omitted variant defaults to "standard".
680
+ _ALLOWED_VARIANTS = frozenset({"standard", "chess960"})
681
+
682
+
683
+ def _normalize_variant(value: object) -> str:
684
+ if value is None:
685
+ return "standard"
686
+ if not isinstance(value, str):
687
+ raise ValueError("variant must be a string.")
688
+ normalized = value.strip().lower()
689
+ if normalized not in _ALLOWED_VARIANTS:
690
+ raise ValueError("Unsupported variant: expected 'standard' or 'chess960'.")
691
+ return normalized
692
+
693
+
694
+ def _build_board(variant: str, fen: str) -> chess.Board:
695
+ """The single place a chess.Board is constructed from a request- or
696
+ move-generation-supplied FEN string. chess960=True changes only how
697
+ the fork parses/serializes the castling field and how it represents
698
+ castling moves -- everything else about the resulting Board (piece
699
+ movement, check/mate detection, piece_map, etc.) behaves identically
700
+ to the variant="standard" path."""
701
+ return chess.Board(fen, chess960=(variant == "chess960"))
702
+
703
+
704
+ # ── FEN pre-validation ────────────────────────────────────────────────────────
705
+
706
+ _EP_PATTERN = re.compile(r"^(-|[a-h][36])$")
707
+ _CASTLING_PATTERN_STANDARD = re.compile(r"^K?Q?k?q?$")
708
+ # Chess960 castling rights use conventional K/Q/k/q where applicable and
709
+ # rook-file letters for explicit rook identification. Semantic validity is
710
+ # checked after board construction.
711
+ _CASTLING_PATTERN_CHESS960 = re.compile(r"^[A-Ha-hKQkq]{1,4}$")
712
+
713
+
714
+ def _validate_fen_parts(variant: str, parts: list[str]) -> str | None:
715
+ if len(parts) < 2:
716
+ return "FEN must have at least a piece-placement field and a side-to-move field."
717
+ if parts[1] not in ("w", "b"):
718
+ return f"Invalid side to move: '{parts[1]}' (expected 'w' or 'b')."
719
+ if len(parts) >= 3 and parts[2] != "-":
720
+ if variant == "chess960":
721
+ if not _CASTLING_PATTERN_CHESS960.match(parts[2]):
722
+ return (
723
+ f"Invalid castling availability: '{parts[2]}' (expected some combination "
724
+ "of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-')."
725
+ )
726
+ elif not _CASTLING_PATTERN_STANDARD.match(parts[2]):
727
+ return (
728
+ f"Invalid castling availability: '{parts[2]}' "
729
+ "(expected some combination of 'K', 'Q', 'k', 'q', or '-')."
730
+ )
731
+ if len(parts) >= 4 and not _EP_PATTERN.match(parts[3]):
732
+ return (
733
+ f"Invalid en-passant square: '{parts[3]}' "
734
+ "(expected '-' or a valid target square such as 'e3' or 'd6')."
735
+ )
736
+ if len(parts) >= 5:
737
+ try:
738
+ hmc = int(parts[4])
739
+ if hmc < 0:
740
+ return "Halfmove clock must be a non-negative integer."
741
+ except ValueError:
742
+ return f"Invalid halfmove clock: '{parts[4]}' (expected a non-negative integer)."
743
+ if len(parts) >= 6:
744
+ try:
745
+ fmc = int(parts[5])
746
+ if fmc < 1:
747
+ return "Fullmove number must be a positive integer."
748
+ except ValueError:
749
+ return f"Invalid fullmove number: '{parts[5]}' (expected a positive integer)."
750
+ return None
751
+
752
+
753
+ # ── Signed-value helpers ──────────────────────────────────────────────────────
754
+ #
755
+ # WIN/CURSED_WIN/DRAW/BLESSED_LOSS/LOSE -> +2/+1/0/-1/-2, matching
756
+ # chess.syzygy's convention -- built from chess.chesstb's public class
757
+ # constants rather than its private _WDL_SIGNED, since this app has no
758
+ # reason to depend on a leading-underscore upstream symbol for a five-entry
759
+ # map it can just as easily own. This is what _probe_board below builds its
760
+ # signed wdl/dtc_wdl/dtm50_wdl fields on.
761
+
762
+ _SIGNED_WDL: dict[int, int] = {
763
+ chesstb.WIN: 2, chesstb.CURSED_WIN: 1, chesstb.DRAW: 0,
764
+ chesstb.BLESSED_LOSS: -1, chesstb.LOSE: -2,
765
+ }
766
+
767
+
768
+ def _signed_wdl(wdl: int) -> int | None:
769
+ """Signed WDL for a raw wdl/dtc_wdl/dtm50_wdl class value."""
770
+ return _SIGNED_WDL.get(wdl)
771
+
772
+
773
+ # ── Raw probe ─────────────────────────────────────────────────────────────────
774
+
775
+ def _probe_board(board: chess.Board) -> chesstb.ProbeResult | None:
776
+ # A single combined probe() call computes WDL + DTZ + DTC + DTM + DTM50
777
+ # together. The raw ProbeResult is returned as-is: its constructor
778
+ # zero-initialises dtz/dtc_order/dtc/dtm/dtm50 and sets
779
+ # wdl/dtc_wdl/dtm50_wdl to ILLEGAL whenever the matching has_* flag is
780
+ # False, and _signed_wdl() maps ILLEGAL to None. WDL is the only metric
781
+ # required - a position whose WDL can't be resolved is not probeable and
782
+ # returns None. DTZ, DTC, DTM, and DTM50 are each independently
783
+ # optional; has_dtz/has_dtc/has_dtm/has_dtm50 report which of the four
784
+ # the loaded tables answered for this position.
785
+ r = TB.probe(board)
786
+ if r.status != "ok" or _signed_wdl(r.wdl) is None:
787
+ return None
788
+ return r
789
+
790
+
791
+ # ── Child-position probe cache ────────────────────────────────────────────────
792
+
793
+ class ProbeInFlightTimeout(Exception):
794
+ """A probe of this FEN was already running and didn't finish in time.
795
+ Retryable, exactly like the probe having timed out directly."""
796
+
797
+
798
+ class _InFlightProbe:
799
+ """Slot for one probe in progress. The thread that creates it does the
800
+ probing; every other thread asking for the same FEN waits on it."""
801
+
802
+ __slots__ = ("done", "result", "error")
803
+
804
+ def __init__(self) -> None:
805
+ self.done = threading.Event()
806
+ self.result: chesstb.ProbeResult | None = None
807
+ self.error: BaseException | None = None
808
+
809
+
810
+ _inflight_lock = threading.Lock()
811
+ _inflight: dict[tuple[str, str], _InFlightProbe] = {} # (variant, fen) -> slot
812
+
813
+ # Set (per calling thread) by _probe_fen_with_deadline below, so
814
+ # _probe_fen_deduped's in-flight wait can use a request's own remaining
815
+ # deadline instead of a fresh cfg.probe_timeout -- see that function's
816
+ # docstring for why a plain module-level value wouldn't work here. .board
817
+ # is set the same way, for the same reason, when the caller already has a
818
+ # chess.Board built from this exact fen in scope -- see
819
+ # _probe_fen_with_deadline's board parameter.
820
+ _probe_tls = threading.local()
821
+
822
+
823
+ def _probe_fen_deduped(variant: str, fen: str) -> chesstb.ProbeResult | None:
824
+ """Runs at most one probe per (variant, fen) at a time, however many
825
+ callers ask.
826
+
827
+ _probe_fen's lru_cache only dedupes probes that have already finished:
828
+ concurrent callers all miss it and all probe the same position. That
829
+ happens routinely: /probe/stream pre-warms a child FEN that
830
+ evaluate_all_moves then asks for again, and a retry after a timeout
831
+ re-asks for a FEN whose first probe is still running. Each duplicate costs
832
+ a full remote probe and occupies a pool worker.
833
+
834
+ A non-owner's wait is bounded by the calling thread's own remaining
835
+ deadline (see _probe_fen_with_deadline) so one hung probe can't pin a
836
+ waiter beyond its own request's budget -- falling back to a fresh
837
+ cfg.probe_timeout when no deadline was set, for a caller reached
838
+ outside a deadline-aware path. The wait expiring raises, which reads
839
+ downstream as a retryable failure just like the probe itself timing out.
840
+
841
+ The owner builds the chess.Board to probe from (variant, fen) itself
842
+ unless the calling thread already left one in _probe_tls.board (see
843
+ _probe_fen_with_deadline's board parameter) -- for a root-fen probe,
844
+ the caller has usually already built one anyway (to validate the FEN,
845
+ render the board, etc.), and re-parsing the same string a second time
846
+ here is pure waste. variant is required even when board is supplied,
847
+ since it is also this function's in-flight/dedup key -- the same FEN
848
+ text probes to a different (and possibly incompatible) result under
849
+ Standard vs Chess960 rules (see the module-level variant comment), so
850
+ a key of fen alone would let one variant's probe be served to the
851
+ other's caller.
852
+ """
853
+ deadline = getattr(_probe_tls, "deadline", None)
854
+ if deadline is not None and time.monotonic() >= deadline:
855
+ raise ProbeInFlightTimeout("Probe deadline expired before work started")
856
+ key = (variant, fen)
857
+ with _inflight_lock:
858
+ slot = _inflight.get(key)
859
+ if slot is None:
860
+ slot = _inflight[key] = _InFlightProbe()
861
+ owner = True
862
+ else:
863
+ owner = False
864
+
865
+ if not owner:
866
+ deadline = getattr(_probe_tls, "deadline", None)
867
+ timeout = (deadline - time.monotonic()) if deadline is not None else cfg.probe_timeout
868
+ if timeout <= 0 or not slot.done.wait(max(0, timeout)):
869
+ raise ProbeInFlightTimeout(f"Timed out waiting on an in-flight probe of variant={variant} {fen}")
870
+ if slot.error is not None:
871
+ raise slot.error
872
+ return slot.result
873
+
874
+ try:
875
+ board = getattr(_probe_tls, "board", None)
876
+ slot.result = _probe_board(board if board is not None else _build_board(variant, fen))
877
+ return slot.result
878
+ except BaseException as exc:
879
+ slot.error = exc
880
+ raise
881
+ finally:
882
+ # Drop the slot before waking the waiters, so a caller arriving in
883
+ # between starts a fresh probe rather than joining a finished one.
884
+ with _inflight_lock:
885
+ _inflight.pop(key, None)
886
+ slot.done.set()
887
+
888
+
889
+ @lru_cache(maxsize=cfg.probe_cache_size)
890
+ def _probe_fen(variant: str, fen: str) -> chesstb.ProbeResult | None:
891
+ return _probe_fen_deduped(variant, fen)
892
+
893
+
894
+ def _probe_fen_with_deadline(
895
+ variant: str, fen: str, deadline: float | None, board: chess.Board | None = None,
896
+ ) -> chesstb.ProbeResult | None:
897
+ """Calls _probe_fen(variant, fen), but first makes `deadline` (and,
898
+ optionally, `board`) visible to _probe_fen_deduped's in-flight wait /
899
+ owner branch on this thread.
900
+
901
+ _probe_fen must stay a (variant, fen)-only lru_cache -- adding deadline
902
+ as a real parameter would make it part of the cache key, and since a
903
+ deadline is different on every call, that would turn the cache into a
904
+ permanent miss. A plain thread-local set by the caller doesn't work
905
+ either when this runs via _executor.submit(_probe_fen_with_deadline,
906
+ variant, fen, ...): submit() runs the call on a pool worker thread, not
907
+ the submitting thread, so a thread-local the submitter set wouldn't be
908
+ visible there. Passing deadline as a real argument to this wrapper --
909
+ called either directly or as the submitted callable itself -- sidesteps
910
+ both problems: it's a normal argument up to here, then becomes this
911
+ worker thread's own thread-local for the duration of the call.
912
+
913
+ board is optional and exists purely to avoid re-parsing a FEN this
914
+ thread already has a chess.Board for -- pass it only when `board` was
915
+ itself built from this exact (variant, fen) pair, right here, in this
916
+ same function call's caller, moments before this call. _probe_fen_deduped
917
+ has no way to verify the two actually match (board.fen() isn't
918
+ reliably equal to a shortened/unpadded input fen string, so comparing
919
+ them isn't a safety net either), so passing a board that doesn't
920
+ correspond to (variant, fen) silently returns a wrong probe result.
921
+ Child boards copied by _collect_move_info() satisfy the same contract.
922
+ """
923
+ _probe_tls.deadline = deadline
924
+ _probe_tls.board = board
925
+ try:
926
+ return _probe_fen(variant, fen)
927
+ finally:
928
+ _probe_tls.deadline = None
929
+ _probe_tls.board = None
930
+
931
+
932
+
933
+ # ── Move evaluation ───────────────────────────────────────────────────────────
934
+
935
+ def _apply_sign(magnitude: int, sign_wdl: int) -> int:
936
+ """Gives an unsigned ply magnitude the sign implied by a signed WDL
937
+ value: positive for a winning sign_wdl, negative for a losing one,
938
+ zero for a draw. Shared by every DTZ/DTC/DTM/DTM50 assembly step in
939
+ evaluate_all_moves below, so the four metrics can't drift apart on
940
+ how a magnitude gets its sign."""
941
+ if sign_wdl > 0:
942
+ return magnitude
943
+ if sign_wdl < 0:
944
+ return -magnitude
945
+ return 0
946
+
947
+
948
+ def _collect_move_info(board: chess.Board) -> list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]]:
949
+ move_info: list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]] = []
950
+ for move in board.legal_moves:
951
+ san = board.san(move)
952
+ # Bypasses expensive Board.is_checkmate() checks by parsing the SAN string directly
953
+ move_is_mate = san.endswith("#")
954
+ move_is_zeroing = board.is_zeroing(move)
955
+ # DTC's own notion of "conversion" (a capture or a promotion) is
956
+ # narrower than move_is_zeroing (capture OR any pawn move): a quiet
957
+ # pawn push zeroes the clock too, but chess.chesstb.Tablebase.
958
+ # _dtc_move_kind does not treat it as a conversion, only as one of
959
+ # the pushes the winning side still owes (see the DTC row below,
960
+ # this field's only consumer). is_capture() rather than a bare
961
+ # to-square occupancy check so an
962
+ # en passant capture - whose destination square is empty - still
963
+ # counts as a conversion here.
964
+ move_is_conversion = board.is_capture(move) or move.promotion is not None
965
+ draw_reason: str | None = None
966
+ child_board: chess.Board | None = None
967
+ board.push(move)
968
+ try:
969
+ if not move_is_mate:
970
+ # Bypasses Board.is_stalemate() by stopping at the first legal move found
971
+ if not any(board.generate_legal_moves()):
972
+ draw_reason = "stalemate"
973
+ elif board.is_insufficient_material():
974
+ draw_reason = "insufficient_material"
975
+ child_fen: str | None = None if (move_is_mate or draw_reason) else board.fen()
976
+ if child_fen is not None:
977
+ # Upstream probes positions, not repetition history. Preserve
978
+ # the clock, en passant and Chess960 state without the stack.
979
+ child_board = board.copy(stack=False)
980
+ finally:
981
+ board.pop()
982
+ move_info.append((san, move_is_mate, move_is_zeroing, move_is_conversion, draw_reason, child_fen, child_board))
983
+ return move_info
984
+
985
+
986
+ def evaluate_all_moves(
987
+ board: chess.Board,
988
+ root_wdl: int,
989
+ variant: str = "standard",
990
+ bypass_parallel: bool = False,
991
+ precomputed_move_info: list[tuple[str, bool, bool, bool, str | None, str | None, chess.Board | None]] | None = None,
992
+ deadline: float | None = None,
993
+ root_dtz: int | None = None,
994
+ root_dtz_rounded: bool = False,
995
+ ) -> tuple[MoveList, MoveList, MoveList, MoveList, bool, dict[str, int], int | None]:
996
+ """Returns the four ranked move lists (DTZ, DTC, DTM, DTM50, in that
997
+ order), a "complete" flag: False when at least one move shows "unknown"
998
+ for a retryable reason (probe timeout or error) rather than because its
999
+ table is genuinely absent (callers use it to decide whether the result
1000
+ is worth caching - see evaluate_fen()), the wins/draws/losses/unknown
1001
+ summary across all legal moves, and the root's exact DTZ when
1002
+ cross-checking the moves resolved it (None otherwise).
1003
+
1004
+ root_dtz/root_dtz_rounded are the root's own DTZ, None when its material
1005
+ has no DTZ table. The DTZ rows' rounded values are resolved against them
1006
+ by dtz_anchor.resolve().
1007
+
1008
+ variant applies to every child position probed below, matching the
1009
+ ruleset `board` itself was built under (see _build_board) -- board.push()/
1010
+ pop() inside _collect_move_info already preserves board.chess960 for
1011
+ move generation and child.fen() serialization for free, since it's the
1012
+ same Board object throughout; variant only needs to be threaded through
1013
+ explicitly here because child positions are (re-)probed from their FEN
1014
+ string, not from the Board object itself (see _probe_fen_with_deadline).
1015
+
1016
+ deadline is an absolute time.monotonic() value bounding child probing."""
1017
+ effective_deadline = deadline if deadline is not None else time.monotonic() + cfg.probe_timeout
1018
+
1019
+ # Phase 1: collect move metadata (no TB calls) - reuses the caller's own
1020
+ # pass over board.legal_moves() when one is supplied, instead of
1021
+ # re-walking every legal move a second time for the same board.
1022
+ move_info = precomputed_move_info if precomputed_move_info is not None else _collect_move_info(board)
1023
+
1024
+ # Phase 2: probe child positions
1025
+ unique_fens: set[str] = {child_fen for _, _, _, _, _, child_fen, _ in move_info if child_fen is not None}
1026
+ probe_cache: dict[str, chesstb.ProbeResult | None] = {}
1027
+ child_boards: dict[str, chess.Board] = {
1028
+ child_fen: child_board
1029
+ for _, _, _, _, _, child_fen, child_board in move_info
1030
+ if child_fen is not None and child_board is not None
1031
+ }
1032
+
1033
+ # Child FENs that failed for a retryable reason. A probe that timed out is
1034
+ # still running in the pool and will populate _probe_fen's own cache, so a
1035
+ # later attempt at this position resolves it; a probe that raised may
1036
+ # succeed on a retry too. Neither is the same as _probe_fen returning None,
1037
+ # which means tb_not_found and won't change however often it's re-probed.
1038
+ transient_failures: set[str] = set()
1039
+
1040
+ # Probe sufficiently large uncached batches through the thread pool.
1041
+ if len(unique_fens) >= cfg.parallel_threshold and not bypass_parallel:
1042
+ futures = {
1043
+ fen: _executor.submit(
1044
+ _probe_fen_with_deadline, variant, fen, effective_deadline, child_boards.get(fen)
1045
+ )
1046
+ for fen in unique_fens
1047
+ }
1048
+ remaining = max(0.0, effective_deadline - time.monotonic())
1049
+ done, _ = futures_wait(
1050
+ futures.values(), timeout=remaining, return_when=ALL_COMPLETED
1051
+ )
1052
+ for fen, fut in futures.items():
1053
+ if fut in done:
1054
+ try:
1055
+ probe_cache[fen] = fut.result()
1056
+ except Exception as exc:
1057
+ log.warning("Probe error for variant=%s %s: %s", variant, fen, exc)
1058
+ probe_cache[fen] = None
1059
+ transient_failures.add(fen)
1060
+ else:
1061
+ fut.cancel() # Drop queued work; running probes can still warm the cache.
1062
+ log.warning("Probe timed out for variant=%s %s", variant, fen)
1063
+ probe_cache[fen] = None
1064
+ transient_failures.add(fen)
1065
+ else:
1066
+ # Probe smaller batches, and batches whose children are already cached,
1067
+ # sequentially to avoid unnecessary thread-pool overhead.
1068
+ for fen in unique_fens:
1069
+ try:
1070
+ probe_cache[fen] = _probe_fen_with_deadline(
1071
+ variant, fen, effective_deadline, child_boards.get(fen)
1072
+ )
1073
+ except Exception as exc:
1074
+ log.warning("Probe error for variant=%s %s: %s", variant, fen, exc)
1075
+ probe_cache[fen] = None
1076
+ transient_failures.add(fen)
1077
+
1078
+ # Phase 3: assemble and sort
1079
+ dtz_rows: list[tuple] = []
1080
+ dtc_rows: list[tuple] = []
1081
+ dtm_rows: list[tuple] = []
1082
+ dtm50_rows: list[tuple] = []
1083
+
1084
+ # Summary outcomes respect each child's halfmove clock. DTM50 and DTC
1085
+ # already resolve the rule; clock-independent WDL cannot establish a
1086
+ # rule50 win or loss when both of those metrics are unavailable.
1087
+ move_outcomes: list[str] = []
1088
+
1089
+ # Sort key/entry pair for a move whose own metric table doesn't cover
1090
+ # it: flatly "Not Available", the same shape for DTZ, DTC, DTM, and
1091
+ # DTM50 alike rather than a value borrowed from another metric.
1092
+ # has_order adds DTC's extra pushes-owed slot to the sort key.
1093
+ def _not_available(
1094
+ san: str, child_fen: str | None, move_is_mate: bool,
1095
+ draw_reason: str | None, has_order: bool = False,
1096
+ ) -> tuple[tuple, MoveEntry]:
1097
+ key = (
1098
+ (_outcome_rank(0) + 10, 0, 0, _move_tiebreak(san)) if has_order
1099
+ else (_outcome_rank(0) + 10, 0, _move_tiebreak(san))
1100
+ )
1101
+ entry: MoveEntry = {
1102
+ "san": san, "plies": 0, "order": 0, "is_mate": move_is_mate, "available": False,
1103
+ "outcome": "not_available", "child_fen": child_fen, "draw_reason": draw_reason,
1104
+ }
1105
+ return key, entry
1106
+
1107
+ def _dtz_row(
1108
+ san: str, my_wdl: int, eff_dtz: int, rounded: bool, move_is_mate: bool,
1109
+ child_fen: str | None, draw_reason: str | None,
1110
+ ) -> tuple[tuple, DtzMoveEntry, int]:
1111
+ eff_wdl = _effective_move_wdl(my_wdl, root_wdl, eff_dtz)
1112
+ key = (_outcome_rank(eff_wdl), _ply_rank(eff_wdl, eff_dtz), _move_tiebreak(san))
1113
+ entry: DtzMoveEntry = {
1114
+ "san": san, "plies": abs(eff_dtz), "order": 0, "is_mate": move_is_mate, "available": True,
1115
+ "outcome": _outcome_label(eff_wdl), "child_fen": child_fen, "draw_reason": draw_reason,
1116
+ "rounded": rounded,
1117
+ }
1118
+ return key, entry, eff_wdl
1119
+
1120
+ dtz_moves: list[_DtzMove] = []
1121
+ dtz_complete = True
1122
+
1123
+ for san, move_is_mate, move_is_zeroing, move_is_conversion, draw_reason, child_fen, child_board in move_info:
1124
+
1125
+ if move_is_mate:
1126
+ opp_wdl = opp_dtc_wdl = opp_dtm50_wdl = -2
1127
+ opp_dtz = opp_dtc = opp_dtc_order = opp_dtm = opp_dtm50 = 0
1128
+ opp_has_dtz = opp_has_dtc = opp_has_dtm = opp_has_dtm50 = True
1129
+ opp_dtz_rounded = False
1130
+
1131
+ elif draw_reason:
1132
+ opp_wdl = opp_dtc_wdl = opp_dtm50_wdl = 0
1133
+ opp_dtz = opp_dtc = opp_dtc_order = opp_dtm = opp_dtm50 = 0
1134
+ opp_has_dtz = opp_has_dtc = opp_has_dtm = opp_has_dtm50 = True
1135
+ opp_dtz_rounded = False
1136
+
1137
+ else:
1138
+ probe_result = probe_cache.get(child_fen)
1139
+ if probe_result is None:
1140
+ log.warning("No probe result for %s (%s); showing unknown.", child_fen, san)
1141
+ stub: MoveEntry = {
1142
+ "san": san, "plies": 0, "order": 0, "is_mate": False, "available": False,
1143
+ "outcome": "unknown", "child_fen": child_fen, "draw_reason": None,
1144
+ }
1145
+ stub_key = (_outcome_rank(0) + 10, 0, _move_tiebreak(san))
1146
+ dtc_stub_key = (_outcome_rank(0) + 10, 0, 0, _move_tiebreak(san))
1147
+ dtz_rows.append((stub_key, stub))
1148
+ dtc_rows.append((dtc_stub_key, stub))
1149
+ dtm_rows.append((stub_key, stub))
1150
+ dtm50_rows.append((stub_key, stub))
1151
+ move_outcomes.append("unknown")
1152
+ dtz_complete = False
1153
+ continue
1154
+ opp_wdl = _signed_wdl(probe_result.wdl)
1155
+ opp_has_dtz, opp_dtz = probe_result.has_dtz, probe_result.dtz
1156
+ opp_dtz_rounded = probe_result.dtz_rounded
1157
+ opp_has_dtc = probe_result.has_dtc
1158
+ opp_dtc_wdl = _signed_wdl(probe_result.dtc_wdl)
1159
+ opp_dtc_order, opp_dtc = probe_result.dtc_order, probe_result.dtc
1160
+ opp_has_dtm, opp_dtm = probe_result.has_dtm, probe_result.dtm
1161
+ opp_has_dtm50 = probe_result.has_dtm50
1162
+ opp_dtm50_wdl = _signed_wdl(probe_result.dtm50_wdl)
1163
+ opp_dtm50 = probe_result.dtm50
1164
+
1165
+ my_wdl = -opp_wdl
1166
+ if opp_has_dtm50:
1167
+ summary_wdl = -opp_dtm50_wdl
1168
+ elif opp_has_dtc:
1169
+ summary_wdl = -opp_dtc_wdl
1170
+ else:
1171
+ # Draws, cursed wins and blessed losses are draws under the
1172
+ # rule even without a clock-aware table. True wins/losses
1173
+ # need one of those tables to resolve the remaining budget.
1174
+ summary_wdl = 0 if abs(my_wdl) <= 1 else None
1175
+ move_outcomes.append(
1176
+ "win" if summary_wdl == 2 else
1177
+ "loss" if summary_wdl == -2 else
1178
+ "unknown" if summary_wdl is None else "draw"
1179
+ )
1180
+
1181
+ # DTZ row.
1182
+ if opp_has_dtz:
1183
+ my_dtz = _apply_sign(opp_dtz, my_wdl)
1184
+ # If this move is itself a zeroing move (capture/pawn-push/
1185
+ # promotion), it is the zeroing move, so its DTZ is 1 ply - not
1186
+ # 1 + the child position's own distance to its next
1187
+ # zeroing move, which is what feeding my_dtz straight into
1188
+ # _effective_distance would compute (double-counting a second
1189
+ # zeroing event nobody asked for). _effective_distance
1190
+ # already has a branch for exactly this ("no further distance
1191
+ # needed, the move itself resolves it"), so call it with
1192
+ # raw_distance=0 instead of reimplementing that branch here.
1193
+ cursed_exit = (not move_is_zeroing and child_board is not None
1194
+ and _is_cursed_exit(board, child_board, opp_wdl))
1195
+ option = dtz_anchor.move_option(opp_dtz, opp_dtz_rounded, move_is_zeroing, cursed_exit)
1196
+ if move_is_zeroing:
1197
+ eff_dtz = _effective_distance(my_wdl, 0)
1198
+ elif cursed_exit:
1199
+ # CORRECTNESS.md Theorem 12.15: a rounded derived child
1200
+ # can be even. Its true exit values then span y+1..y+3;
1201
+ # report y+2 so the displayed error stays within one ply.
1202
+ # option.value still carries the possible true distances
1203
+ # for the anchor, rather than this representative value.
1204
+ exit_dtz = opp_dtz + 2 if opp_dtz_rounded else option.value[0]
1205
+ eff_dtz = _apply_sign(exit_dtz, my_wdl)
1206
+ else:
1207
+ eff_dtz = _effective_distance(my_wdl, my_dtz)
1208
+ dtz_key, dtz_entry, eff_wdl = _dtz_row(
1209
+ san, my_wdl, eff_dtz, len(set(option.value)) > 1, move_is_mate, child_fen, draw_reason,
1210
+ )
1211
+ outcome = dtz_entry["outcome"]
1212
+ dtz_rows.append((dtz_key, dtz_entry))
1213
+ dtz_moves.append(_DtzMove(len(dtz_rows) - 1, my_wdl, eff_wdl == root_wdl, option))
1214
+ else:
1215
+ dtz_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
1216
+ dtz_complete = False
1217
+
1218
+ # DTC row. DTC's own notion of a better move is pushes-owed first, then
1219
+ # plies (see _order_rank's docstring), so the sort key carries both,
1220
+ # unlike DTZ/DTM/DTM50 above and below which only ever compare
1221
+ # plies.
1222
+ #
1223
+ # A capture or a promotion (move_is_conversion) is the conversion,
1224
+ # at this move: 0 pushes still owed, 1 ply to get there - the
1225
+ # child's own pushes/plies describe its next, later conversion,
1226
+ # which is irrelevant once this one has already happened.
1227
+ #
1228
+ # A quiet pawn push zeroes the clock too (move_is_zeroing) but is
1229
+ # not a conversion - chess.chesstb.Tablebase._dtc_move_kind treats
1230
+ # it as spending one of the pushes still owed rather than settling
1231
+ # the count, so the wait still resets to 1 ply the same way a
1232
+ # conversion's does, but pushes owed does not reset to 0.
1233
+ #
1234
+ # The child is probed at its own halfmove clock (see _probe_board)
1235
+ # and its dtc_order counts pushes owed from that position onward,
1236
+ # so it does not include the push spent getting there. For a root
1237
+ # reporting dtc_order=N whose best move is a push, the child
1238
+ # reports dtc_order=N-1; the +1 below restores the push this move
1239
+ # spent.
1240
+ #
1241
+ # Whether the +1 applies depends on which side is pushing: dtc_order
1242
+ # counts the pushes still owed by whichever side is winning, and
1243
+ # only the WINNING side's own push spends one of them. A defensive
1244
+ # push by the losing side leaves that budget untouched, so it
1245
+ # carries the child's order over unchanged, same as a quiet move;
1246
+ # hence the my_dtc_wdl > 0 guard below.
1247
+ if opp_has_dtc:
1248
+ my_dtc_wdl = -opp_dtc_wdl
1249
+ if move_is_mate or draw_reason or move_is_conversion:
1250
+ # A capture, a promotion, checkmate, or an immediate draw
1251
+ # are all fully resolved right here: 0 pushes still owed,
1252
+ # 1 ply to get there. move_is_mate/draw_reason already
1253
+ # hardwire opp_dtc_order/opp_dtc to 0 above, so this is a
1254
+ # no-op arithmetically for those in the ordinary case - it
1255
+ # matters for the case where the move is also a quiet
1256
+ # pawn push, e.g. checkmate delivered by discovered check
1257
+ # from a pawn advance that neither captures nor promotes.
1258
+ # Without this guard that would fall into the push branch
1259
+ # below and pick up a spurious +1: mate ends the game, so
1260
+ # nothing is owed no matter how the pawn got there.
1261
+ raw_order = 0
1262
+ eff_dtc = _effective_distance(my_dtc_wdl, 0)
1263
+ elif move_is_zeroing: # a quiet pawn push, not a conversion
1264
+ raw_order = opp_dtc_order + (1 if my_dtc_wdl > 0 else 0)
1265
+ eff_dtc = _effective_distance(my_dtc_wdl, 0)
1266
+ else:
1267
+ raw_order = opp_dtc_order
1268
+ my_dtc = _apply_sign(opp_dtc, my_dtc_wdl)
1269
+ eff_dtc = _effective_distance(my_dtc_wdl, my_dtc)
1270
+ # A draw has nothing owed regardless of the raw push count
1271
+ # above - e.g. a defensive push into a dead draw (stalemate,
1272
+ # insufficient material, or a budget that no longer fits the
1273
+ # clock) must not report a nonzero push count just because it
1274
+ # happened to be a push; _order_rank sorts it correctly either
1275
+ # way (it treats wdl_val == 0 as rank 0 regardless), but the
1276
+ # value shown to the client should read 0 like plies does.
1277
+ eff_order = raw_order if my_dtc_wdl != 0 else 0
1278
+ dtc_key = (
1279
+ _outcome_rank(my_dtc_wdl),
1280
+ _order_rank(my_dtc_wdl, eff_order),
1281
+ _ply_rank(my_dtc_wdl, eff_dtc),
1282
+ _move_tiebreak(san),
1283
+ )
1284
+ dtc_rows.append((dtc_key, {
1285
+ "san": san, "plies": abs(eff_dtc), "order": eff_order, "is_mate": move_is_mate,
1286
+ "available": True, "outcome": _outcome_label(my_dtc_wdl),
1287
+ "child_fen": child_fen, "draw_reason": draw_reason,
1288
+ }))
1289
+ else:
1290
+ dtc_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason, has_order=True))
1291
+
1292
+ # DTM row.
1293
+ if opp_has_dtm:
1294
+ my_dtm = _apply_sign(opp_dtm, my_wdl)
1295
+ eff_dtm = _effective_distance(my_wdl, my_dtm)
1296
+ if opp_has_dtz:
1297
+ # DTZ and DTM share the same win/cursed-win (loss/blessed-
1298
+ # loss) bucket and label as the DTZ row above, since that
1299
+ # status is a single fact about eff_dtz and root_wdl rather
1300
+ # than a separate one per metric.
1301
+ dtm_key = (_outcome_rank(eff_wdl), _ply_rank(eff_wdl, eff_dtm), _move_tiebreak(san))
1302
+ dtm_outcome = outcome
1303
+ else:
1304
+ # Without DTZ the bucket comes from WDL alone.
1305
+ dtm_eff_wdl = _capped_wdl(my_wdl, root_wdl)
1306
+ dtm_key = (_outcome_rank(dtm_eff_wdl), _ply_rank(dtm_eff_wdl, eff_dtm), _move_tiebreak(san))
1307
+ dtm_outcome = _outcome_label(dtm_eff_wdl)
1308
+ dtm_rows.append((dtm_key, {
1309
+ "san": san, "plies": abs(eff_dtm), "order": 0, "is_mate": move_is_mate, "available": True,
1310
+ "outcome": dtm_outcome, "child_fen": child_fen, "draw_reason": draw_reason,
1311
+ }))
1312
+ else:
1313
+ dtm_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
1314
+
1315
+ # DTM50 row.
1316
+ if opp_has_dtm50:
1317
+ my_dtm50_wdl = -opp_dtm50_wdl
1318
+ my_dtm50 = _apply_sign(opp_dtm50, my_dtm50_wdl)
1319
+ eff_dtm50 = _effective_distance(my_dtm50_wdl, my_dtm50)
1320
+ dtm50_key = (_outcome_rank(my_dtm50_wdl), _ply_rank(my_dtm50_wdl, eff_dtm50), _move_tiebreak(san))
1321
+ dtm50_rows.append((dtm50_key, {
1322
+ "san": san, "plies": abs(eff_dtm50), "order": 0, "is_mate": move_is_mate, "available": True,
1323
+ "outcome": _outcome_label(my_dtm50_wdl), "child_fen": child_fen, "draw_reason": draw_reason,
1324
+ }))
1325
+ else:
1326
+ dtm50_rows.append(_not_available(san, child_fen, move_is_mate, draw_reason))
1327
+
1328
+ # Cross-check rounded DTZ values against each other; skipped when any move
1329
+ # lacks a DTZ, since that move could be the one supplying the root's value.
1330
+ resolved_dtz: int | None = None
1331
+ if dtz_complete and root_dtz is not None and root_wdl != 0:
1332
+ keepers = [m for m in dtz_moves if m.keeps_class]
1333
+ if root_dtz_rounded or any(len(set(m.option.value)) > 1 for m in keepers):
1334
+ resolution = dtz_anchor.resolve(
1335
+ root_dtz, root_dtz_rounded, [m.option for m in keepers], root_wdl > 0,
1336
+ )
1337
+ resolved_dtz = resolution.root
1338
+ for k, value in resolution.pinned.items():
1339
+ move = keepers[k]
1340
+ row = dtz_rows[move.row][1]
1341
+ dtz_rows[move.row] = _dtz_row(
1342
+ row["san"], move.my_wdl, _apply_sign(value, move.my_wdl), False,
1343
+ row["is_mate"], row["child_fen"], row["draw_reason"],
1344
+ )[:2]
1345
+
1346
+ dtz_rows.sort(key=lambda r: r[0])
1347
+ dtc_rows.sort(key=lambda r: r[0])
1348
+ dtm_rows.sort(key=lambda r: r[0])
1349
+ dtm50_rows.sort(key=lambda r: r[0])
1350
+
1351
+ summary = {
1352
+ "wins": sum(1 for o in move_outcomes if o in ("win", "cursed_win")),
1353
+ "draws": sum(1 for o in move_outcomes if o == "draw"),
1354
+ "losses": sum(1 for o in move_outcomes if o in ("loss", "blessed_loss")),
1355
+ "unknown": sum(1 for o in move_outcomes if o == "unknown"),
1356
+ }
1357
+
1358
+ return (
1359
+ [r[1] for r in dtz_rows],
1360
+ [r[1] for r in dtc_rows],
1361
+ [r[1] for r in dtm_rows],
1362
+ [r[1] for r in dtm50_rows],
1363
+ not transient_failures,
1364
+ summary,
1365
+ resolved_dtz,
1366
+ )
1367
+
1368
+
1369
+ # ── Root Probe Calculation ────────────────────────────────────────────────────
1370
+
1371
+ _evaluate_tls = threading.local()
1372
+
1373
+
1374
+ def _validate_and_build_board(variant: str, fen: str) -> chess.Board:
1375
+ """
1376
+ Single authoritative implementation of root-FEN validation, shared by
1377
+ /probe (via evaluate_fen) and /probe/stream (which needs a validated
1378
+ board up front, before it can start emitting SSE progress events).
1379
+ Raises RuntimeError if the tablebase isn't loaded, or ValueError for any
1380
+ FEN/board problem - callers are expected to catch and format those.
1381
+
1382
+ Legality (castling rights matching an actual king/rook pair, no pawns
1383
+ on the back rank, exactly one king per side, etc.) is entirely
1384
+ delegated to board.is_valid() for both variants -- this function adds
1385
+ no Standard-only assumptions of its own (e.g. requiring castling rooks
1386
+ on a1/h1/a8/h8) that would misclassify a legal Chess960 position as
1387
+ invalid. See _build_board for how the variant affects construction.
1388
+ """
1389
+ if TB is None:
1390
+ raise RuntimeError(
1391
+ "Tablebase not initialised. Check TABLEBASE_PATH.")
1392
+
1393
+ parts = fen.split()
1394
+ fmt_error = _validate_fen_parts(variant, parts)
1395
+ if fmt_error:
1396
+ raise ValueError(fmt_error)
1397
+
1398
+ try:
1399
+ board = _build_board(variant, fen)
1400
+ except ValueError as e:
1401
+ raise ValueError(f"Malformed FEN: {e}") from e
1402
+
1403
+ castling_field = parts[2] if len(parts) >= 3 else "-"
1404
+ if variant == "chess960" and castling_field != "-":
1405
+ if board.castling_rights.bit_count() != len(castling_field):
1406
+ raise ValueError("Invalid castling availability: duplicate castling rights.")
1407
+
1408
+ if not board.is_valid():
1409
+ raise ValueError(
1410
+ "Invalid board position (missing kings, adjacent kings, pawn on "
1411
+ "back rank, a castling right with no matching king/rook pair, etc.)."
1412
+ )
1413
+
1414
+ return board
1415
+
1416
+
1417
+ def _missing_table_error_payload(variant: str, fen: str) -> dict:
1418
+ """
1419
+ Single authoritative shape for the "position not covered by the loaded
1420
+ tablebase" error, shared by /probe and /probe/stream so the two
1421
+ transports (plain JSON vs SSE) can't drift out of sync on wording,
1422
+ error_code, or how piece_count is computed.
1423
+ """
1424
+ try:
1425
+ piece_count = len(_build_board(variant, fen).piece_map())
1426
+ except Exception:
1427
+ piece_count = None
1428
+ return {
1429
+ "error": "Position not covered by the loaded tablebase.",
1430
+ "error_code": "missing_table",
1431
+ "piece_count": piece_count,
1432
+ }
1433
+
1434
+
1435
+ class _IncompleteResult(Exception):
1436
+ """Carries a probe result that must not be cached - see evaluate_fen()."""
1437
+
1438
+ def __init__(self, json_str: str) -> None:
1439
+ super().__init__("probe incomplete")
1440
+ self.json_str = json_str
1441
+
1442
+
1443
+ def _evaluate_fen_impl(variant: str, fen: str) -> str:
1444
+ """
1445
+ Probes the tablebase for a root FEN and returns a pre-serialized JSON string.
1446
+ Routes the root probe through the same shared _probe_fen cache used for
1447
+ child positions, so a position that was just probed as a child (e.g. the
1448
+ position the user just moved into) is served from cache instead of
1449
+ re-hitting the tablebase.
1450
+
1451
+ (variant, fen) are the cache key, so a caller that already built a
1452
+ board/move_info for this exact pair -- e.g. probe_stream()'s SSE handler,
1453
+ which needs both up front to size its progress bar -- hands them over via _evaluate_tls
1454
+ instead, letting this call skip redoing that work. Ignored on a cache
1455
+ hit, since the function body below never runs in that case.
1456
+
1457
+ Raises _IncompleteResult (carrying the same JSON) when a move came back
1458
+ unknown for a retryable reason, so evaluate_fen() can serve it without
1459
+ caching it.
1460
+ """
1461
+ precomputed_board = getattr(_evaluate_tls, "board", None)
1462
+ precomputed_move_info = getattr(_evaluate_tls, "move_info", None)
1463
+ board = precomputed_board if precomputed_board is not None else _validate_and_build_board(variant, fen)
1464
+
1465
+ # A fresh cfg.probe_timeout-sized budget for the root probe alone,
1466
+ # independent of how long any earlier phase of the same request (e.g.
1467
+ # probe_stream()'s own pre-warm step) already took, so a slow earlier
1468
+ # phase cannot erode the time this one gets. The child batch below
1469
+ # carries its own equally-sized fresh budget (see child_deadline),
1470
+ # matching the independent-budget-per-phase pattern probe_stream()
1471
+ # applies between its pre-warm step and the root probe above it. The
1472
+ # two independently-budgeted phases put a single /probe request's
1473
+ # worst case at up to 2 * cfg.probe_timeout.
1474
+ root_deadline = time.monotonic() + cfg.probe_timeout
1475
+
1476
+ if board.is_checkmate():
1477
+ return json.dumps({
1478
+ "wdl": -2, "dtz": 0, "dtz_rounded": False, "dtc": [-2, 0, 0], "dtm": 0, "dtm50": [-2, 0],
1479
+ "dtz_available": True, "dtc_available": True, "dtm_available": True, "dtm50_available": True,
1480
+ "moves_dtz": [], "moves_dtc": [], "moves_dtm": [], "moves_dtm50": [],
1481
+ "summary": {"wins": 0, "draws": 0, "losses": 0, "unknown": 0},
1482
+ "draw_reason": None,
1483
+ })
1484
+
1485
+ if board.is_stalemate() or board.is_insufficient_material():
1486
+ return json.dumps({
1487
+ "wdl": 0, "dtz": 0, "dtz_rounded": False, "dtc": [0, 0, 0], "dtm": 0, "dtm50": [0, 0],
1488
+ "dtz_available": True, "dtc_available": True, "dtm_available": True, "dtm50_available": True,
1489
+ "moves_dtz": [], "moves_dtc": [], "moves_dtm": [], "moves_dtm50": [],
1490
+ "summary": {"wins": 0, "draws": 0, "losses": 0, "unknown": 0},
1491
+ "draw_reason": "stalemate" if board.is_stalemate() else "insufficient_material",
1492
+ })
1493
+
1494
+ # Route the root probe through the shared child-probe cache. board was
1495
+ # either handed over via _evaluate_tls or just built above from this
1496
+ # exact fen, so it's passed through here too rather than making
1497
+ # _probe_fen_deduped re-parse the same string a second time.
1498
+ root = _probe_fen_with_deadline(variant, fen, root_deadline, board)
1499
+ if root is None:
1500
+ raise chesstb.MissingTableError(f"Position not in tablebase: variant={variant} {fen}")
1501
+
1502
+ bypass_parallel = getattr(_evaluate_tls, "bypass_parallel", False)
1503
+ # Own fresh budget, not the remainder of root_deadline -- see the
1504
+ # comment above root_deadline.
1505
+ child_deadline = time.monotonic() + cfg.probe_timeout
1506
+ moves_dtz, moves_dtc, moves_dtm, moves_dtm50, complete, summary, resolved_dtz = evaluate_all_moves(
1507
+ board, _signed_wdl(root.wdl), variant, bypass_parallel=bypass_parallel,
1508
+ precomputed_move_info=precomputed_move_info, deadline=child_deadline,
1509
+ root_dtz=root.dtz if root.has_dtz else None, root_dtz_rounded=root.dtz_rounded,
1510
+ )
1511
+ root_dtz, root_dtz_rounded = (
1512
+ (root.dtz, root.dtz_rounded) if resolved_dtz is None else (resolved_dtz, False)
1513
+ )
1514
+
1515
+ # dtz/dtc/dtm/dtm50 are the root's own metrics, each independently
1516
+ # unavailable when this material has no DTZ/DTC/DTM/DTM50 table - see
1517
+ # _probe_board. The paired *_available flag is the authoritative signal
1518
+ # for that; the value fields fall back to null/[null, null(, null)]
1519
+ # rather than being omitted, so every response has the same fixed shape
1520
+ # regardless of coverage. dtc is a 3-tuple [wdl, pushes_owed, plies]
1521
+ # rather than dtm50's pair, since - unlike dtm50 - its distance is
1522
+ # priced in two components, not one; see chess.chesstb.ProbeResult and
1523
+ # the DTC row comment in evaluate_all_moves. root is the raw
1524
+ # ProbeResult _probe_board returns, so its wdl/dtc_wdl/dtm50_wdl are
1525
+ # still the unsigned chess.chesstb class values here - each goes
1526
+ # through _signed_wdl() below, same as every other consumer of a raw
1527
+ # ProbeResult in this file.
1528
+ json_str = json.dumps({
1529
+ "wdl": _signed_wdl(root.wdl),
1530
+ "dtz": root_dtz if root.has_dtz else None,
1531
+ "dtz_available": root.has_dtz,
1532
+ "dtz_rounded": root_dtz_rounded if root.has_dtz else None,
1533
+ "dtc": [_signed_wdl(root.dtc_wdl), root.dtc_order, root.dtc] if root.has_dtc else [None, None, None],
1534
+ "dtc_available": root.has_dtc,
1535
+ "dtm": root.dtm if root.has_dtm else None,
1536
+ "dtm_available": root.has_dtm,
1537
+ "dtm50": [_signed_wdl(root.dtm50_wdl), root.dtm50] if root.has_dtm50 else [None, None],
1538
+ "dtm50_available": root.has_dtm50,
1539
+ "moves_dtz": moves_dtz,
1540
+ "moves_dtc": moves_dtc,
1541
+ "moves_dtm": moves_dtm,
1542
+ "moves_dtm50": moves_dtm50,
1543
+ "summary": summary,
1544
+ "draw_reason": None, # root always has legal moves here, so never terminal
1545
+ })
1546
+ if not complete:
1547
+ raise _IncompleteResult(json_str)
1548
+ return json_str
1549
+
1550
+
1551
+ # An explicit LRU lets SSE check for a complete response before building
1552
+ # boards, walking legal moves or submitting child probes. Failed/incomplete
1553
+ # evaluations never enter this cache. Computation stays outside the lock.
1554
+ _evaluation_cache: OrderedDict[tuple[str, str], str] = OrderedDict()
1555
+ _evaluation_cache_lock = threading.Lock()
1556
+ _evaluation_hits = _evaluation_misses = 0
1557
+ _EvaluationCacheInfo = namedtuple("CacheInfo", "hits misses maxsize currsize")
1558
+
1559
+
1560
+ def _cached_evaluation(variant: str, fen: str, *, count_miss: bool = True) -> str | None:
1561
+ global _evaluation_hits, _evaluation_misses
1562
+ with _evaluation_cache_lock:
1563
+ key = (variant, fen)
1564
+ result = _evaluation_cache.get(key)
1565
+ if result is not None:
1566
+ _evaluation_cache.move_to_end(key)
1567
+ _evaluation_hits += 1
1568
+ elif count_miss:
1569
+ _evaluation_misses += 1
1570
+ return result
1571
+
1572
+
1573
+ def evaluate_fen(variant: str, fen: str) -> str:
1574
+ cached = _cached_evaluation(variant, fen)
1575
+ if cached is not None:
1576
+ return cached
1577
+ try:
1578
+ result = _evaluate_fen_impl(variant, fen)
1579
+ except _IncompleteResult as incomplete:
1580
+ return incomplete.json_str
1581
+ with _evaluation_cache_lock:
1582
+ key = (variant, fen)
1583
+ _evaluation_cache[key] = result
1584
+ _evaluation_cache.move_to_end(key)
1585
+ while len(_evaluation_cache) > cfg.evaluate_cache_size:
1586
+ _evaluation_cache.popitem(last=False)
1587
+ return result
1588
+
1589
+
1590
+ def _clear_evaluation_cache() -> None:
1591
+ global _evaluation_hits, _evaluation_misses
1592
+ with _evaluation_cache_lock:
1593
+ _evaluation_cache.clear()
1594
+ _evaluation_hits = _evaluation_misses = 0
1595
+
1596
+
1597
+ def _evaluation_cache_info():
1598
+ with _evaluation_cache_lock:
1599
+ return _EvaluationCacheInfo(
1600
+ _evaluation_hits, _evaluation_misses, cfg.evaluate_cache_size, len(_evaluation_cache),
1601
+ )
1602
+
1603
+
1604
+ evaluate_fen.cache_clear = _clear_evaluation_cache # type: ignore[attr-defined]
1605
+ evaluate_fen.cache_info = _evaluation_cache_info # type: ignore[attr-defined]
1606
+
1607
+
1608
+ # ── Common (variant, FEN) extraction + validation helper ──────────────────────
1609
+
1610
+ def _extract_probe_request(data: dict | None) -> tuple[str, str, Response | tuple[Response, int] | None]:
1611
+ """Single authoritative request-body parser shared by /probe and
1612
+ /probe/stream, so the two transports can't drift on what counts as a
1613
+ valid variant/FEN or how each is rejected. Returns (variant, fen,
1614
+ error_response); error_response is None on success and variant/fen are
1615
+ both "" when it isn't."""
1616
+ if not data or "fen" not in data:
1617
+ return "", "", (jsonify({"error": "Missing FEN in request body."}), 400)
1618
+ fen_raw = data["fen"]
1619
+ if not isinstance(fen_raw, str):
1620
+ return "", "", (jsonify({"error": "FEN must be a string."}), 400)
1621
+ fen = fen_raw.strip()
1622
+ if not fen:
1623
+ return "", "", (jsonify({"error": "FEN string is empty."}), 400)
1624
+ if len(fen) > cfg.max_fen_length:
1625
+ return "", "", (jsonify({"error": f"FEN string too long (max {cfg.max_fen_length})."}), 400)
1626
+
1627
+ try:
1628
+ variant = _normalize_variant(data.get("variant"))
1629
+ except ValueError as e:
1630
+ return "", "", (jsonify({"error": str(e)}), 400)
1631
+
1632
+ return variant, _normalize_fen(fen), None
1633
+
1634
+
1635
+ # ── Routes ────────────────────────────────────────────────────────────────────
1636
+
1637
+ @app.route("/")
1638
+ def index() -> str:
1639
+ return render_template("index.html", source_repository_url=cfg.source_repository_url)
1640
+
1641
+
1642
+ @app.route("/probe", methods=["POST"])
1643
+ @_probe_rate_limit
1644
+ def probe() -> Response | tuple[Response, int]:
1645
+ body = request.get_json(silent=True)
1646
+ variant, fen, err = _extract_probe_request(body)
1647
+ if err:
1648
+ return err
1649
+
1650
+ try:
1651
+ json_str = evaluate_fen(variant, fen)
1652
+ return app.response_class(json_str, status=200, mimetype="application/json")
1653
+
1654
+ except chesstb.MissingTableError:
1655
+ return jsonify(_missing_table_error_payload(variant, fen)), 400
1656
+
1657
+ except RuntimeError as e:
1658
+ # Tablebase not initialised (TABLEBASE_PATH failed to load) --
1659
+ # same condition and status code as /health's own "degraded"
1660
+ # response for this, and the same message _validate_and_build_board
1661
+ # raises for /probe/stream to report over SSE.
1662
+ return jsonify({"error": str(e)}), 503
1663
+
1664
+ except ValueError as e:
1665
+ return jsonify({"error": str(e)}), 400
1666
+
1667
+ except _REMOTE_SOURCE_ERROR as e:
1668
+ log.warning("Remote tablebase fetch failed probing variant=%s FEN %s: %s", variant, fen, e)
1669
+ return jsonify({
1670
+ "error": "Could not reach the remote tablebase. Try again shortly.",
1671
+ "error_code": "remote_unavailable",
1672
+ }), 502
1673
+
1674
+ except ProbeInFlightTimeout as e:
1675
+ log.warning("%s", e)
1676
+ return jsonify({
1677
+ "error": "Probe timed out. Try again shortly.",
1678
+ "error_code": "probe_timeout",
1679
+ }), 503
1680
+
1681
+ except Exception:
1682
+ log.exception("Unhandled error probing variant=%s FEN: %s", variant, fen)
1683
+ return jsonify({"error": "Internal server error."}), 500
1684
+
1685
+
1686
+ def _stream_error_payload(variant: str, fen: str, exc: BaseException) -> dict:
1687
+ """SSE error payload for an exception raised while probing (variant, fen),
1688
+ keeping wording, error_code, and logging identical no matter which probe
1689
+ step inside probe_stream() raised it."""
1690
+ if isinstance(exc, chesstb.MissingTableError):
1691
+ return {"status": "error", **_missing_table_error_payload(variant, fen)}
1692
+ if isinstance(exc, _REMOTE_SOURCE_ERROR):
1693
+ log.warning("Remote tablebase fetch failed in probe/stream for variant=%s %s: %s", variant, fen, exc)
1694
+ return {
1695
+ "status": "error",
1696
+ "error": "Could not reach the remote tablebase. Try again shortly.",
1697
+ "error_code": "remote_unavailable",
1698
+ }
1699
+ if isinstance(exc, ProbeInFlightTimeout):
1700
+ log.warning("%s", exc)
1701
+ return {
1702
+ "status": "error",
1703
+ "error": "Probe timed out. Try again shortly.",
1704
+ "error_code": "probe_timeout",
1705
+ }
1706
+ log.exception("Error in probe/stream for variant=%s %s", variant, fen)
1707
+ return {"status": "error", "error": str(exc)}
1708
+
1709
+
1710
+ @app.route("/probe/stream", methods=["POST"])
1711
+ @_probe_rate_limit
1712
+ def probe_stream() -> Response | tuple[Response, int]:
1713
+ body = request.get_json(silent=True)
1714
+ variant, fen, err = _extract_probe_request(body)
1715
+ if err:
1716
+ return err
1717
+
1718
+ def _sse(obj: dict) -> str:
1719
+ return f"data: {json.dumps(obj)}\n\n"
1720
+
1721
+ def generate():
1722
+ cached = _cached_evaluation(variant, fen, count_miss=False)
1723
+ if cached is not None:
1724
+ yield 'data: {"status":"done",' + cached[1:] + '\n\n'
1725
+ return
1726
+ # Validated up front (rather than only inside evaluate_fen below) so
1727
+ # the validation error can be reported immediately over SSE instead of
1728
+ # only finding out after pre-warming child positions. Uses the same
1729
+ # _validate_and_build_board() that evaluate_fen() itself calls, so
1730
+ # there's one authoritative implementation of "what makes a FEN
1731
+ # probeable" instead of two hand-written copies that could disagree.
1732
+ try:
1733
+ root_board = _validate_and_build_board(variant, fen)
1734
+ except (RuntimeError, ValueError) as e:
1735
+ yield _sse({"status": "error", "error": str(e)})
1736
+ return
1737
+
1738
+ # A terminal root (checkmate, stalemate, insufficient material) never
1739
+ # reaches the tablebase - see evaluate_fen()'s own early return for
1740
+ # these - so only a non-terminal root needs its coverage confirmed
1741
+ # here. Checking the root before pre-warming any child means a root
1742
+ # the tablebase doesn't cover is caught immediately: no child of an
1743
+ # uncovered root is coverable either, so pre-warming first would only
1744
+ # probe the entire move list to discover that after the fact. This
1745
+ # reads through the same cache evaluate_fen() itself reads below, so
1746
+ # a covered root costs nothing extra downstream.
1747
+ terminal = (
1748
+ root_board.is_checkmate()
1749
+ or root_board.is_stalemate()
1750
+ or root_board.is_insufficient_material()
1751
+ )
1752
+ if not terminal:
1753
+ try:
1754
+ # root_board was just built above from this exact (variant,
1755
+ # fen), so it's passed through here too rather than making
1756
+ # _probe_fen_deduped re-parse the same string a second
1757
+ # time -- see _probe_fen_with_deadline's board parameter.
1758
+ root_probe = _probe_fen_with_deadline(
1759
+ variant, fen, time.monotonic() + cfg.probe_timeout, root_board,
1760
+ )
1761
+ except Exception as e:
1762
+ yield _sse(_stream_error_payload(variant, fen, e))
1763
+ return
1764
+ if root_probe is None:
1765
+ yield _sse({"status": "error", **_missing_table_error_payload(variant, fen)})
1766
+ return
1767
+
1768
+ # Insufficient-material draws can still have legal moves, but
1769
+ # evaluate_fen returns a terminal response without using children.
1770
+ move_info = [] if terminal else _collect_move_info(root_board)
1771
+ child_fens: set[str] = {
1772
+ child_fen for _, _, _, _, _, child_fen, _ in move_info if child_fen is not None
1773
+ }
1774
+ child_boards: dict[str, chess.Board] = {
1775
+ child_fen: child_board
1776
+ for _, _, _, _, _, child_fen, child_board in move_info
1777
+ if child_fen is not None and child_board is not None
1778
+ }
1779
+
1780
+ total = len(child_fens)
1781
+ yield _sse({"status": "probing", "completed": 0, "total": total})
1782
+
1783
+ # Pre-warm child FENs in parallel using the native non-blocking cache.
1784
+ # Always via the thread pool (regardless of cfg.parallel_threshold, which
1785
+ # only governs evaluate_all_moves' own sequential/parallel choice below):
1786
+ # a bare sequential loop here would have no way to bound a single hung
1787
+ # _probe_fen_with_deadline() call, since there'd be no worker thread to
1788
+ # poll against; only a background thread lets this deadline actually
1789
+ # cut the wait short. This budget is independent of the root probe's
1790
+ # own, above - a slow root probe doesn't eat into pre-warm's time.
1791
+ deadline = time.monotonic() + cfg.probe_timeout
1792
+ futures = {
1793
+ cf: _executor.submit(
1794
+ _probe_fen_with_deadline, variant, cf, deadline, child_boards.get(cf)
1795
+ )
1796
+ for cf in child_fens
1797
+ }
1798
+ remaining = set(futures.values())
1799
+ done_count = 0
1800
+ prewarm_complete = True
1801
+ last_event = time.monotonic()
1802
+ try:
1803
+ while done_count < total:
1804
+ time_left = deadline - time.monotonic()
1805
+ if time_left <= 0:
1806
+ log.warning(
1807
+ "Probe pre-warm timed out for variant=%s %s (%d/%d child positions still pending)",
1808
+ variant, fen, total - done_count, total,
1809
+ )
1810
+ prewarm_complete = False
1811
+ break
1812
+ newly_done, remaining = futures_wait(
1813
+ remaining, timeout=min(0.3, time_left), return_when=FIRST_COMPLETED
1814
+ )
1815
+ if any(f.cancelled() or f.exception() is not None for f in newly_done):
1816
+ prewarm_complete = False
1817
+ if newly_done:
1818
+ done_count += len(newly_done)
1819
+ yield _sse({"status": "probing", "completed": done_count, "total": total})
1820
+ last_event = time.monotonic()
1821
+ elif time.monotonic() - last_event >= 1.0:
1822
+ # Keep the stream live and detect disconnects without
1823
+ # making the browser render identical progress updates.
1824
+ yield ": keep-alive\n\n"
1825
+ last_event = time.monotonic()
1826
+ finally:
1827
+ # Also runs when the streaming client disconnects. Running probes
1828
+ # may finish, but queued work should not delay newer requests.
1829
+ for future in remaining:
1830
+ future.cancel()
1831
+
1832
+ # If pre-warming above finished, every child FEN is already cached, so
1833
+ # evaluate_fen() below can fetch them all as cache hits sequentially,
1834
+ # without the overhead of resubmitting each one to the thread pool. If
1835
+ # pre-warming timed out instead, skip that shortcut: evaluate_all_moves()
1836
+ # inside evaluate_fen() then takes its own parallel path, with its own
1837
+ # fresh cfg.probe_timeout budget (see _evaluate_fen_impl), and reports
1838
+ # "unknown" for whatever's still unresolved once that budget passes.
1839
+ _evaluate_tls.bypass_parallel = prewarm_complete
1840
+ # Already built above (root_board, move_info) to size the progress
1841
+ # bar and know which child FENs to pre-warm - handing them to
1842
+ # evaluate_fen() here means a cache-miss FEN doesn't pay for a
1843
+ # second board build + legal-move walk right after this one.
1844
+ _evaluate_tls.board = root_board
1845
+ _evaluate_tls.move_info = move_info
1846
+ try:
1847
+ json_str = evaluate_fen(variant, fen)
1848
+ yield 'data: {"status":"done",' + json_str[1:] + '\n\n'
1849
+
1850
+ except Exception as e:
1851
+ yield _sse(_stream_error_payload(variant, fen, e))
1852
+ finally:
1853
+ _evaluate_tls.bypass_parallel = False
1854
+ _evaluate_tls.board = None
1855
+ _evaluate_tls.move_info = None
1856
+
1857
+ return Response(
1858
+ stream_with_context(generate()),
1859
+ content_type="text/event-stream",
1860
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
1861
+ )
1862
+
1863
+
1864
+ @app.route("/health")
1865
+ def health() -> Response:
1866
+ if TB is None:
1867
+ return jsonify({"status": "degraded", "reason": "Tablebase not initialised"}), 503
1868
+ return jsonify({"status": "ok"}), 200
1869
+
1870
+
1871
+ def _cache_info_dict(info) -> dict:
1872
+ hits, misses = info.hits, info.misses
1873
+ maxsize, currsize = info.maxsize, info.currsize
1874
+ total = hits + misses
1875
+ return {
1876
+ "hits": hits,
1877
+ "misses": misses,
1878
+ "hit_rate": round(hits / total, 4) if total else 0.0,
1879
+ "maxsize": maxsize,
1880
+ "currsize": currsize,
1881
+ }
1882
+
1883
+
1884
+ @app.route("/admin/cache/clear", methods=["POST"])
1885
+ @_require_admin
1886
+ def clear_cache() -> Response:
1887
+ evaluate_fen.cache_clear()
1888
+ _probe_fen.cache_clear()
1889
+ # The two LRUs above are app.py-level caches keyed on (variant, FEN)
1890
+ # pairs. They are not the only cache in the probing pipeline: each tier's
1891
+ # underlying tablebase can keep its own internal decoded-block cache
1892
+ # and, in remote mode, a downloaded-file cache (remote/remote_fallback.py).
1893
+ # SourcedTablebase.clear_caches()/cache_stats() are exposed on TB
1894
+ # itself, but only actually clear/report something for a tier backed
1895
+ # by a remote/remote_fallback.py Tablebase -- a plain local-directory
1896
+ # Tablebase has no such cache. cache_stats() coming back empty is what
1897
+ # distinguishes "nothing to clear" from "cleared something", since
1898
+ # every tier is asked unconditionally either way.
1899
+ tb_cache_info: dict = {}
1900
+ if TB is not None:
1901
+ TB.clear_caches()
1902
+ tb_cache_info = TB.cache_stats()
1903
+ tb_cache_cleared = bool(tb_cache_info)
1904
+ log.info(
1905
+ "All probe caches cleared (tablebase-internal cache %s).",
1906
+ "cleared" if tb_cache_cleared else "not applicable for this tablebase",
1907
+ )
1908
+ response = {
1909
+ "status": "ok",
1910
+ "message": "All probe caches cleared (root JSON cache + child probe cache"
1911
+ + (" + tablebase-internal caches)." if tb_cache_cleared else ")."),
1912
+ "evaluate_fen_cache": _cache_info_dict(evaluate_fen.cache_info()),
1913
+ "probe_fen_cache": _cache_info_dict(_probe_fen.cache_info()),
1914
+ "tablebase_internal_cache_cleared": tb_cache_cleared,
1915
+ }
1916
+ if tb_cache_info:
1917
+ response["tablebase_cache"] = tb_cache_info
1918
+ return jsonify(response)
1919
+
1920
+
1921
+ @app.route("/admin/cache/stats", methods=["GET"])
1922
+ @_require_admin
1923
+ def cache_stats() -> Response:
1924
+ stats = {
1925
+ "evaluate_fen_cache": _cache_info_dict(evaluate_fen.cache_info()),
1926
+ "probe_fen_cache": _cache_info_dict(_probe_fen.cache_info()),
1927
+ "thread_pool": {
1928
+ "max_workers": cfg.probe_threads,
1929
+ "parallel_threshold": cfg.parallel_threshold,
1930
+ "probe_timeout_secs": cfg.probe_timeout,
1931
+ },
1932
+ "config": {
1933
+ "evaluate_cache_size": cfg.evaluate_cache_size,
1934
+ "probe_cache_size": cfg.probe_cache_size,
1935
+ "block_cache_bytes": cfg.block_cache_bytes,
1936
+ },
1937
+ "tablebase": {
1938
+ "configured": TB is not None,
1939
+ },
1940
+ }
1941
+ # SourcedTablebase.cache_stats() is exposed on TB and reports the configured source
1942
+ # -- a source contributes no key when it has no remote-backed cache to
1943
+ # report (see clear_cache() above for why a plain local-directory
1944
+ # Tablebase has none).
1945
+ if TB is not None:
1946
+ tb_cache = TB.cache_stats()
1947
+ if tb_cache:
1948
+ stats["tablebase_cache"] = tb_cache
1949
+ return jsonify(stats)
1950
+
1951
+
1952
+ @app.route("/admin/login", methods=["POST"])
1953
+ @_admin_login_rate_limit
1954
+ def admin_login() -> Response:
1955
+ if not cfg.admin_token:
1956
+ return jsonify({"error": "Admin panel disabled: ADMIN_TOKEN is not configured."}), 503
1957
+ body = request.get_json(silent=True) or {}
1958
+ token = body.get("token", "")
1959
+ if not isinstance(token, str) or not _admin_token_matches(token):
1960
+ return jsonify({"error": "Incorrect admin token."}), 401
1961
+ session["admin_ok"] = True
1962
+ return jsonify({"status": "ok"})
1963
+
1964
+
1965
+ @app.route("/admin/logout", methods=["POST"])
1966
+ def admin_logout() -> Response:
1967
+ session.pop("admin_ok", None)
1968
+ return jsonify({"status": "ok"})
1969
+
1970
+
1971
+ @app.route("/admin", methods=["GET"])
1972
+ def admin_dashboard() -> str | tuple[str, int]:
1973
+ # Renders the dashboard once authenticated (session cookie from
1974
+ # /admin/login); a login form otherwise. Unlike /admin/cache/* below,
1975
+ # this route can't just use _require_admin -- a browser visiting /admin
1976
+ # needs the login form itself, not a bare 401/503 JSON body. Matches
1977
+ # /admin/login and /admin/cache/*'s own 503 when ADMIN_TOKEN is unset,
1978
+ # rather than serving the dashboard shell with a 200.
1979
+ if not cfg.admin_token:
1980
+ return render_template(
1981
+ "admin.html", authenticated=False, admin_configured=False,
1982
+ source_repository_url=cfg.source_repository_url,
1983
+ ), 503
1984
+ return render_template(
1985
+ "admin.html",
1986
+ authenticated=_is_admin_authenticated(),
1987
+ admin_configured=True,
1988
+ source_repository_url=cfg.source_repository_url,
1989
+ )
1990
+
1991
+
1992
+ # ── OpenAPI specification ─────────────────────────────────────────────────────
1993
+ # The specification is maintained in openapi.yaml (project root).
1994
+
1995
+ _OPENAPI_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "openapi.yaml")
1996
+
1997
+
1998
+ @app.route("/openapi.yaml")
1999
+ def openapi_spec() -> Response:
2000
+ return send_file(_OPENAPI_PATH, mimetype="application/yaml")
2001
+
2002
+
2003
+ if __name__ == "__main__":
2004
+ if cfg.debug:
2005
+ # Flask's own dev server: auto-reload + interactive debugger, for
2006
+ # local development only. `threaded=True` is what lets a long-lived
2007
+ # /probe/stream SSE connection coexist with other concurrent
2008
+ # requests on this single-process server (see README.md "Running in
2009
+ # production").
2010
+ log.info("DEBUG = True (config.py) — starting Flask development server on %s:%s", cfg.host, cfg.port)
2011
+ app.run(debug=True, host=cfg.host, port=cfg.port, threaded=True)
2012
+ else:
2013
+ # Production: serve via waitress, a pure-Python production-grade
2014
+ # WSGI server. `threads` gives waitress its own pool of
2015
+ # request-handling threads, playing the same role `threaded=True`
2016
+ # plays for the Flask dev server above - without it, a single
2017
+ # open /probe/stream connection could starve every other request
2018
+ # (see README.md "Running in production").
2019
+ #
2020
+ # trusted_proxy=* / trusted_proxy_count / trusted_proxy_headers:
2021
+ # waitress parses X-Forwarded-For and X-Forwarded-Proto and
2022
+ # rewrites REMOTE_ADDR / wsgi.url_scheme itself, before the
2023
+ # request ever reaches the Flask app object -- so it sits in front
2024
+ # of, and takes priority over, the ProxyFix layer wired onto
2025
+ # app.wsgi_app above. Without this, waitress's default
2026
+ # (trusted_proxy=None) strips both headers entirely as untrusted,
2027
+ # silently discarding them before ProxyFix could ever see them --
2028
+ # ProxyFix alone is not sufficient here. `"*"` trusts whichever
2029
+ # peer connects, rather than one specific IP, because Render's
2030
+ # edge/load-balancer's own address isn't a fixed, documented
2031
+ # value; this is safe under the same assumption
2032
+ # TRUSTED_PROXY_COUNT's docstring already states -- that nothing
2033
+ # but that one proxy hop can reach this process directly. Skipped
2034
+ # entirely when cfg.trusted_proxy_count is 0 (no reverse proxy in
2035
+ # front at all), leaving waitress's own default in place.
2036
+ log.info(
2037
+ "Starting waitress production server on %s:%s (threads=%s)",
2038
+ cfg.host, cfg.port, cfg.waitress_threads,
2039
+ )
2040
+ waitress_kwargs = {}
2041
+ if cfg.trusted_proxy_count > 0:
2042
+ waitress_kwargs.update(
2043
+ trusted_proxy="*",
2044
+ trusted_proxy_count=cfg.trusted_proxy_count,
2045
+ trusted_proxy_headers={"x-forwarded-for", "x-forwarded-proto"},
2046
+ )
2047
+ waitress_serve(
2048
+ app, host=cfg.host, port=cfg.port, threads=cfg.waitress_threads, **waitress_kwargs)
config.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ config.py: ChessTB Explorer configuration
3
+
4
+ Edit the values below directly, then run `python app.py`. This file
5
+ contains the primary application configuration. A subset of settings is
6
+ also readable from the process environment, with the literal value below
7
+ used when the environment variable is unset, allowing one committed
8
+ configuration file to serve multiple deployment platforms.
9
+
10
+ - ADMIN_TOKEN / FLASK_SECRET_KEY: always from the environment only
11
+ (no literal fallback below) so they can be set as platform secrets
12
+ (e.g. a Render "Environment Variable" marked secret, or a Hugging
13
+ Face Space "Secret") without ever committing them - see the "Admin"
14
+ section below.
15
+ - PORT: read from the environment with a literal fallback, because
16
+ platforms like Render assign the port dynamically per-deployment
17
+ (via the platform's PORT variable) rather than using a fixed value;
18
+ see the "Server" section below.
19
+ - TABLEBASE_PATH / SOURCE_REPOSITORY_URL: read from the environment with a literal fallback,
20
+ to allow a deployment (for example, a Render Web Service) to select a
21
+ different tablebase location or repository URL without modifying the
22
+ source file; editing the literal value below remains supported.
23
+
24
+ Invalid values (wrong type, out of range) make the app log an error and
25
+ exit at startup rather than run with a silently-wrong configuration;
26
+ see AppConfig.from_config() in app.py.
27
+ """
28
+
29
+ import os
30
+
31
+ # ── Tablebase location ──────────────────────────────────────────────────────
32
+
33
+ # Bucket URL or local tablebase root. Full tables are preferred over shipping
34
+ # tables; castling directories are selected automatically for each material.
35
+ # A list of explicit source directories is also supported.
36
+ TABLEBASE_PATH = os.environ.get(
37
+ "TABLEBASE_PATH", "https://huggingface.co/buckets/chesstb/chesstb/resolve",
38
+ )
39
+
40
+ # ── Server ───────────────────────────────────────────────────────────────────
41
+
42
+ # Set to True to run the app via Flask's own dev server (auto-reload,
43
+ # detailed error pages, interactive debugger) instead of waitress. Leave
44
+ # False for any deployment reachable from other machines - False is what
45
+ # makes `python app.py` serve via waitress, the production WSGI server
46
+ # this app ships with.
47
+ DEBUG = False
48
+
49
+ # Interface the server (waitress, or the Flask dev server if DEBUG is
50
+ # True) binds to. Set to "0.0.0.0" so the server accepts connections
51
+ # arriving at the container's network interface, as required by Render,
52
+ # Hugging Face Spaces, and any other containerized deployment. Use
53
+ # "127.0.0.1" instead only for single-machine local use with no other
54
+ # access control in front of it - see README.md's "Security notes".
55
+ HOST = "0.0.0.0"
56
+
57
+ # Port the server listens on - applies whether app.py ends up serving via
58
+ # waitress (DEBUG=False) or the Flask dev server (DEBUG=True).
59
+ #
60
+ # Read from a PORT environment variable first, falling back to 7860
61
+ # (Hugging Face Docker Spaces' default routed port) when unset. This
62
+ # matters specifically for Render: a Render web service is assigned its
63
+ # externally-routed port at deploy time via its own PORT variable
64
+ # (commonly 10000, but not guaranteed) rather than a fixed value. The
65
+ # container reads and binds to the injected port. Running locally with no
66
+ # PORT set uses 7860.
67
+ PORT = os.environ.get("PORT", "7860")
68
+
69
+ # Number of worker threads waitress uses to handle concurrent requests
70
+ # (only relevant when DEBUG=False). Needs to be more than 1 so that a
71
+ # long-lived /probe/stream SSE connection can't block every other request;
72
+ # see README.md's "Running in production" section. This is separate
73
+ # from PROBE_THREADS below, which sizes the pool used internally to
74
+ # parallelise tablebase probing, not to serve HTTP requests.
75
+ WAITRESS_THREADS = 4
76
+
77
+ # ── Probing ──────────────────────────────────────────────────────────────────
78
+
79
+ # Worker threads in the probe thread pool for evaluating a position's
80
+ # legal moves in parallel. Set to match a 2-vCPU host (e.g. Hugging
81
+ # Face's cpu-basic tier); raise it on a larger host, or leave as None to
82
+ # scale automatically instead (min(16, cpu_count() * 2)).
83
+ PROBE_THREADS = 2
84
+
85
+ # Minimum number of not-yet-cached child positions before a probe batch
86
+ # switches from sequential to the PROBE_THREADS thread pool. Lower this
87
+ # (or set to 1 to always parallelize) for workloads with many legal moves
88
+ # or when profiling shows sequential probing is a bottleneck. Raise it
89
+ # when thread hand-off overhead outweighs the benefit for the workload.
90
+ PROBE_PARALLEL_THRESHOLD = 4
91
+
92
+ # Wall-clock timeout, in seconds, for each probe phase. /probe gives the
93
+ # root probe and the child-move batch independent budgets, so a worst-case
94
+ # request can consume up to two PROBE_TIMEOUT_SECS intervals. /probe/stream
95
+ # uses the same setting for its root probe and child pre-warm/evaluation
96
+ # phases. Remaining probes are treated as unknown when a phase reaches its
97
+ # deadline rather than blocking the request indefinitely.
98
+ PROBE_TIMEOUT_SECS = 30
99
+
100
+ # ── Caching ──────────────────────────────────────────────────────────────────
101
+
102
+ # Defaults target one process on Hugging Face CPU Basic (2 vCPU, 16 GB
103
+ # RAM). With the four default remote sources, byte-cache budgets total
104
+ # 8.5 GiB: four 2 GiB page caches plus one shared 512 MiB block cache.
105
+ # These fill on demand. Leave the remaining RAM for result caches,
106
+ # table/index metadata, Python object overhead, and active requests.
107
+
108
+ # Max entries in the root-FEN result cache (full JSON responses served by
109
+ # evaluate_fen()). Raising this keeps more distinct root positions warm
110
+ # across a session at the cost of memory.
111
+ EVALUATE_CACHE_SIZE = 16384
112
+
113
+ # Max entries in the child-position probe cache (raw WDL/DTZ/DTC/DTM/DTM50
114
+ # tuples served by _probe_fen()), shared between root probes and every
115
+ # child position probed while ranking moves.
116
+ PROBE_CACHE_SIZE = 262144
117
+
118
+ # Size (in bytes) of chess.chesstb's own internal cache of decoded/
119
+ # decompressed tablebase blocks, shared across the WDL/DTZ/DTC/DTM/DTM50
120
+ # tables.
121
+ # One cache is shared across every source. Keep 512 MiB of decoded
122
+ # blocks to reduce repeated decompression on the 2-vCPU host.
123
+ BLOCK_CACHE_BYTES = 512 * 1024 * 1024
124
+
125
+ # ── Remote (URL) tablebase entries only ─────────────────────────────────────
126
+ # Everything below is ignored for a local-directory entry; it applies to
127
+ # every http(s):// entry in TABLEBASE_PATH (including entries inside
128
+ # a hybrid list - see
129
+ # above). See the module docstrings of remote/remote_direct.py and
130
+ # remote/remote_fallback.py for the full design.
131
+
132
+ # How a remote table's bytes reach the prober:
133
+ #
134
+ # "direct" -- probe the remote tables in place, fetching only the
135
+ # page-sized byte ranges each probe reads, nothing written
136
+ # to disk (remote/remote_direct.py).
137
+ # "download" -- fetch each table file in full on first touch and cache it
138
+ # on local disk (remote/remote_fallback.py). Slower first
139
+ # touch of a material and real disk usage, but every read
140
+ # after it is a local mmap read.
141
+ #
142
+ # "direct" is the preferred mode for browsing across many materials.
143
+ # "download" is preferable when a small set of materials is accessed
144
+ # repeatedly for a long session or when per-request network latency is high.
145
+ #
146
+ # "direct" is also the right choice for a PaaS deployment (e.g. Render)
147
+ # fronting a multi-terabyte tablebase set: it needs no persistent disk at
148
+ # all, so it sidesteps both the platform's per-GB disk cost at that scale
149
+ # and the fact that most platforms' single-service disks aren't sized for
150
+ # terabytes in the first place. "download" would need a disk (or enough
151
+ # ephemeral local storage) large enough for the materials probed during a
152
+ # session. Storage requirements therefore grow with the number and size of
153
+ # materials accessed.
154
+ REMOTE_MODE = "direct"
155
+
156
+ # Soft budget, in bytes, PER remote source in TABLEBASE_PATH, shared
157
+ # across that source's tables: bounds the in-memory page cache in
158
+ # "direct" mode, and the on-disk whole-file cache in "download" mode.
159
+ # Four default sources can retain 8 GiB of pages in total. Revisit this
160
+ # budget when adding sources or running multiple application processes.
161
+ REMOTE_PAGE_CACHE_BYTES = 2 * 1024 * 1024 * 1024
162
+
163
+ # Size, in bytes, of one page. In "direct" mode this is the granularity of
164
+ # every fetch, so it sets the trade between over-fetching (too large) and
165
+ # many round trips per probe (too small) -- 256 KiB keeps a table's whole
166
+ # header + index region within a page or two, which is what makes later
167
+ # probes of the same table cheap. In "download" mode it is only the chunk
168
+ # size used while streaming a full file down, and has no effect on which
169
+ # bytes end up fetched.
170
+ REMOTE_PAGE_SIZE_BYTES = 256 * 1024
171
+
172
+ # Per-HTTP-request timeout, in seconds, for both existence/size checks and
173
+ # the download itself against a remote tablebase.
174
+ REMOTE_TIMEOUT_SECS = 20.0
175
+
176
+ # Number of attempts for a single remote request before giving up (the
177
+ # request that triggered it then fails, surfacing as a probe error).
178
+ REMOTE_MAX_RETRIES = 3
179
+
180
+ # Max size of the HTTP connection pool each remote backend's session
181
+ # keeps open against its entry's host. Left as None so it scales
182
+ # with PROBE_THREADS automatically (max(PROBE_THREADS * 2, 20)) - set an
183
+ # explicit integer to override. Too small a pool under-serves concurrent
184
+ # probing (connections stop being reused once PROBE_THREADS exceeds it);
185
+ # too large rarely hurts beyond a handful of idle sockets, so there's
186
+ # little reason to set this below the auto-computed value.
187
+ REMOTE_POOL_MAXSIZE = None
188
+
189
+ # ── Rate limiting ────────────────────────────────────────────────────────────
190
+
191
+ # Per-client-IP request limit applied to /probe and /probe/stream - the
192
+ # two public, unauthenticated, CPU-bound endpoints - using flask-limiter's
193
+ # string syntax (e.g. "60 per minute", "10 per second"; multiple limits
194
+ # can be combined with ";"). Backed by an in-process memory store, which
195
+ # is enough for a single-container deployment (e.g. one Render Web
196
+ # Service instance) but does not share state across multiple replicas.
197
+ # Left empty, rate limiting is disabled entirely.
198
+ #
199
+ # 60 per minute comfortably covers normal browsing and autoplay at its
200
+ # default 1.25s delay; raise it if legitimate autoplay at a fast delay
201
+ # setting gets throttled, or lower it if the deployment sees abuse.
202
+ PROBE_RATE_LIMIT = "60 per minute"
203
+
204
+ # Per-client-IP limit applied only to requests that identify themselves as
205
+ # background hover-prefetch via the "X-Prefetch: 1" request header (see
206
+ # frontend/src/engine/tablebase.js's hover-prefetch). Deliberately separate from,
207
+ # and tighter than, PROBE_RATE_LIMIT -- a client-supplied header is not
208
+ # trusted enough to exempt a request from limiting altogether (any
209
+ # client could tag every request "prefetch" to bypass PROBE_RATE_LIMIT),
210
+ # only to route it to its own, smaller budget, so a sustained hover sweep
211
+ # can burn through this budget and start getting silently-dropped 429s on
212
+ # prefetches without ever touching the user's own click budget above.
213
+ # Left empty, prefetch-tagged requests fall back to sharing
214
+ # PROBE_RATE_LIMIT with everything else, as if this setting didn't exist.
215
+ PREFETCH_RATE_LIMIT = "30 per minute"
216
+
217
+ # Per-client-IP limit on /admin/login (config.py's ADMIN_TOKEN check),
218
+ # same string syntax as PROBE_RATE_LIMIT above and backed by the same
219
+ # in-process memory store. Separate from PROBE_RATE_LIMIT because
220
+ # /admin/login is a brute-forceable shared-secret check, not a CPU-bound
221
+ # probe -- it needs a tighter limit than "comfortably covers normal
222
+ # browsing" calls for. Applies to both failed and successful login
223
+ # attempts. Left empty, login attempts are not rate limited at all.
224
+ ADMIN_LOGIN_RATE_LIMIT = "5 per minute"
225
+
226
+ # Number of trusted reverse-proxy hops in front of this application.
227
+ # PROBE_RATE_LIMIT and ADMIN_LOGIN_RATE_LIMIT above both key on the
228
+ # client's IP address (request.remote_addr) -- behind a reverse proxy
229
+ # that's the proxy's own address unless it's corrected for, which would
230
+ # collapse every client behind that proxy onto one shared rate-limit key.
231
+ # The same trusted-hop count also governs whether X-Forwarded-Proto is
232
+ # trusted, which corrects request.scheme / request.is_secure (and any
233
+ # externally-generated URL) when TLS terminates at that reverse proxy
234
+ # rather than at this app itself.
235
+ #
236
+ # In production (DEBUG = False), app.py passes this to waitress's own
237
+ # trusted_proxy_count, since waitress -- the actual production WSGI
238
+ # server -- parses X-Forwarded-For/X-Forwarded-Proto and corrects
239
+ # REMOTE_ADDR/wsgi.url_scheme itself, before Flask ever sees the request.
240
+ # app.py also applies Werkzeug's ProxyFix with the same count, which
241
+ # makes the equivalent correction for the DEBUG = True Flask-dev-server
242
+ # path (waitress isn't involved there at all); under waitress it is
243
+ # redundant, since waitress performs the same correction itself.
244
+ #
245
+ # 1 is correct for this project's own render.yaml / "Deploying to Render"
246
+ # topology: Render's platform edge/load-balancer sits in front of a web
247
+ # service exactly once. Increase it when an additional reverse proxy is
248
+ # present in front of the service. Set to 0 when no reverse proxy is present
249
+ # -- a value that does not match the actual proxy chain
250
+ # leaves the rate limiter keying on the wrong address (or the scheme
251
+ # misdetected), or (set too high for no proxy at all) trusting a
252
+ # client-supplied X-Forwarded-For/X-Forwarded-Proto header directly,
253
+ # letting a client spoof its own rate-limit key or apparent scheme.
254
+ TRUSTED_PROXY_COUNT = os.environ.get("TRUSTED_PROXY_COUNT", "1")
255
+
256
+ # ── Admin ────────────────────────────────────────────────────────────────────
257
+
258
+ # Shared secret required to reach /admin, /admin/cache/stats, and
259
+ # /admin/cache/clear. Read from the ADMIN_TOKEN environment variable
260
+ # rather than hardcoded here - set it as a platform secret (e.g. a
261
+ # Render "Environment Variable" marked secret, generated automatically by
262
+ # render.yaml's generateValue: true) rather than committing it here.
263
+ # Left unset, every admin route responds 503 rather than running without
264
+ # a password. Visiting /admin with no active session shows a login form;
265
+ # a correct token there starts a signed session cookie. API clients may
266
+ # instead send "Authorization: Bearer <ADMIN_TOKEN>" directly.
267
+ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
268
+
269
+ # Key Flask uses to sign the admin session cookie. Read from the
270
+ # FLASK_SECRET_KEY environment variable for the same reason as
271
+ # ADMIN_TOKEN above. Left unset, a random key is generated at process
272
+ # startup instead - sessions still work, but every restart invalidates
273
+ # existing ones, requiring the admin to log in again. Set an explicit,
274
+ # persistent value if that's undesirable (e.g. behind a load balancer
275
+ # with multiple replicas, or to survive routine restarts).
276
+ FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "")
277
+
278
+ # Whether the admin session cookie set by /admin/login requires HTTPS.
279
+ # Independent of DEBUG above -- DEBUG is a development/runtime behavior
280
+ # switch, not a reliable indicator of the deployment's transport security,
281
+ # so a deployment can legitimately run DEBUG=False (production-like
282
+ # behavior) while still being served over plain HTTP. Leaving this True
283
+ # for such a deployment sets the cookie Secure, which a browser then
284
+ # won't send back over a plain HTTP connection -- /admin/login would
285
+ # appear to succeed while every subsequent authenticated /admin/* request
286
+ # came back 401.
287
+ #
288
+ # Read from the environment, like TRUSTED_PROXY_COUNT above, so a
289
+ # deployment (e.g. render.yaml's Render blueprint, which sets this to
290
+ # "true" explicitly) can configure it without editing this file. Defaults
291
+ # to False (via SESSION_COOKIE_SECURE unset or anything other than
292
+ # "true") because that's what a local installation needs (README.md's
293
+ # "Installation"): `python app.py` served over http://127.0.0.1:7860 --
294
+ # plain HTTP -- after editing only TABLEBASE_PATH above, with DEBUG and
295
+ # this setting both left at their defaults.
296
+ #
297
+ # Set this to "true" for any deployment the browser reaches over HTTPS,
298
+ # including one behind a TLS-terminating reverse proxy such as Render's
299
+ # (the browser's own connection is what the Secure attribute governs, not
300
+ # the internal hop between the proxy and this process).
301
+ SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "false").strip().lower() == "true"
302
+
303
+ # ── Links ────────────────────────────────────────────────────────────────────
304
+
305
+ # Repository URL for the source repository button shown in the header of every page.
306
+ # Left empty, the button is omitted entirely. Also readable from a
307
+ # SOURCE_REPOSITORY_URL environment variable (falling back to the literal string
308
+ # below when unset), so a platform deployment can set it from its own
309
+ # dashboard without a commit - see render.yaml.
310
+ SOURCE_REPOSITORY_URL = os.environ.get(
311
+ "SOURCE_REPOSITORY_URL",
312
+ "https://huggingface.co/spaces/chesstb/explorer/tree/main",
313
+ )
dtz_anchor.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolves rounded DTZ values by cross-checking a position's moves.
2
+
3
+ A rounded DTZ stands for a few candidate values. The root's DTZ is the best
4
+ value among the moves that keep its class (smallest when winning, largest
5
+ when losing), so root candidates that no consistent set of move values can
6
+ produce are discarded, and a move that must supply the root's value is pinned.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import operator
12
+ from typing import NamedTuple, Sequence
13
+
14
+
15
+ class MoveOption(NamedTuple):
16
+ child: tuple[int, ...] # candidate DTZ values of the position after the move
17
+ value: tuple[int, ...] # the move's DTZ for each candidate in `child`
18
+
19
+
20
+ class Resolution(NamedTuple):
21
+ root: int | None # exact root DTZ, or None when it cannot be determined
22
+ pinned: dict[int, int] # option index -> exact DTZ of that move
23
+
24
+
25
+ def candidates(dtz: int, rounded: bool) -> tuple[int, ...]:
26
+ """True values allowed by CORRECTNESS.md Theorem 12.15.
27
+
28
+ Rounded derived values can be even. An overestimate of one is only
29
+ possible at an odd true value, so an odd report cannot stand for the
30
+ preceding even value. Unrounded reports are exact.
31
+ """
32
+ if not rounded:
33
+ return (dtz,)
34
+ if dtz % 2 == 1:
35
+ return (dtz, dtz + 1)
36
+ return (dtz - 1, dtz, dtz + 1)
37
+
38
+
39
+ def move_option(child_dtz: int, child_rounded: bool, zeroing: bool,
40
+ cursed_exit: bool) -> MoveOption:
41
+ """A zeroing move is worth 1. A quiet move is worth one more than its
42
+ child, forced odd when it is a cursed exit."""
43
+ if zeroing:
44
+ return MoveOption((), (1,))
45
+ child = candidates(child_dtz, child_rounded)
46
+ value = tuple(((c + 1) | 1) if cursed_exit else c + 1 for c in child)
47
+ return MoveOption(child, value)
48
+
49
+
50
+ def resolve(root_dtz: int, root_rounded: bool, options: Sequence[MoveOption],
51
+ winning: bool) -> Resolution:
52
+ """`options` are the moves that keep the root's class."""
53
+ at_least_as_good = operator.ge if winning else operator.le
54
+ feasible = [
55
+ x for x in candidates(root_dtz, root_rounded)
56
+ if all(any(at_least_as_good(v, x) for v in o.value) for o in options)
57
+ and any(x in o.value for o in options)
58
+ ]
59
+ if len(feasible) != 1:
60
+ return Resolution(None, {})
61
+ x = feasible[0]
62
+ pinned: dict[int, int] = {}
63
+ for k, option in enumerate(options):
64
+ if len(set(option.value)) < 2:
65
+ continue
66
+ others_attain = any(x in other.value
67
+ for j, other in enumerate(options) if j != k)
68
+ kept = {v for v in option.value
69
+ if at_least_as_good(v, x) and (v == x or others_attain)}
70
+ if len(kept) == 1:
71
+ pinned[k] = kept.pop()
72
+ return Resolution(x, pinned)
frontend/.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ node_modules/
frontend/admin.html ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <link rel="stylesheet" href="/src/styles/admin.css" />
5
+ </head>
6
+ <body></body>
7
+ </html>
frontend/index.html ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" class="explorer">
3
+ <body>
4
+ <div id="root"></div>
5
+ <script type="module" src="/src/main.jsx"></script>
6
+ </body>
7
+ </html>
frontend/package-lock.json ADDED
@@ -0,0 +1,1698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "chesstb-explorer-frontend",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "chesstb-explorer-frontend",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "react": "^18.3.1",
12
+ "react-dom": "^18.3.1"
13
+ },
14
+ "devDependencies": {
15
+ "@vitejs/plugin-react": "^4.3.4",
16
+ "vite": "^5.4.11"
17
+ }
18
+ },
19
+ "node_modules/@babel/code-frame": {
20
+ "version": "7.29.7",
21
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
22
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
23
+ "dev": true,
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@babel/helper-validator-identifier": "^7.29.7",
27
+ "js-tokens": "^4.0.0",
28
+ "picocolors": "^1.1.1"
29
+ },
30
+ "engines": {
31
+ "node": ">=6.9.0"
32
+ }
33
+ },
34
+ "node_modules/@babel/compat-data": {
35
+ "version": "7.29.7",
36
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
37
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
38
+ "dev": true,
39
+ "license": "MIT",
40
+ "engines": {
41
+ "node": ">=6.9.0"
42
+ }
43
+ },
44
+ "node_modules/@babel/core": {
45
+ "version": "7.29.7",
46
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
47
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
48
+ "dev": true,
49
+ "license": "MIT",
50
+ "dependencies": {
51
+ "@babel/code-frame": "^7.29.7",
52
+ "@babel/generator": "^7.29.7",
53
+ "@babel/helper-compilation-targets": "^7.29.7",
54
+ "@babel/helper-module-transforms": "^7.29.7",
55
+ "@babel/helpers": "^7.29.7",
56
+ "@babel/parser": "^7.29.7",
57
+ "@babel/template": "^7.29.7",
58
+ "@babel/traverse": "^7.29.7",
59
+ "@babel/types": "^7.29.7",
60
+ "@jridgewell/remapping": "^2.3.5",
61
+ "convert-source-map": "^2.0.0",
62
+ "debug": "^4.1.0",
63
+ "gensync": "^1.0.0-beta.2",
64
+ "json5": "^2.2.3",
65
+ "semver": "^6.3.1"
66
+ },
67
+ "engines": {
68
+ "node": ">=6.9.0"
69
+ },
70
+ "funding": {
71
+ "type": "opencollective",
72
+ "url": "https://opencollective.com/babel"
73
+ }
74
+ },
75
+ "node_modules/@babel/generator": {
76
+ "version": "7.29.8",
77
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
78
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
79
+ "dev": true,
80
+ "license": "MIT",
81
+ "dependencies": {
82
+ "@babel/parser": "^7.29.8",
83
+ "@babel/types": "^7.29.8",
84
+ "@jridgewell/gen-mapping": "^0.3.12",
85
+ "@jridgewell/trace-mapping": "^0.3.28",
86
+ "jsesc": "^3.0.2"
87
+ },
88
+ "engines": {
89
+ "node": ">=6.9.0"
90
+ }
91
+ },
92
+ "node_modules/@babel/helper-compilation-targets": {
93
+ "version": "7.29.7",
94
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
95
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
96
+ "dev": true,
97
+ "license": "MIT",
98
+ "dependencies": {
99
+ "@babel/compat-data": "^7.29.7",
100
+ "@babel/helper-validator-option": "^7.29.7",
101
+ "browserslist": "^4.24.0",
102
+ "lru-cache": "^5.1.1",
103
+ "semver": "^6.3.1"
104
+ },
105
+ "engines": {
106
+ "node": ">=6.9.0"
107
+ }
108
+ },
109
+ "node_modules/@babel/helper-globals": {
110
+ "version": "7.29.7",
111
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
112
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
113
+ "dev": true,
114
+ "license": "MIT",
115
+ "engines": {
116
+ "node": ">=6.9.0"
117
+ }
118
+ },
119
+ "node_modules/@babel/helper-module-imports": {
120
+ "version": "7.29.7",
121
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
122
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
123
+ "dev": true,
124
+ "license": "MIT",
125
+ "dependencies": {
126
+ "@babel/traverse": "^7.29.7",
127
+ "@babel/types": "^7.29.7"
128
+ },
129
+ "engines": {
130
+ "node": ">=6.9.0"
131
+ }
132
+ },
133
+ "node_modules/@babel/helper-module-transforms": {
134
+ "version": "7.29.7",
135
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
136
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
137
+ "dev": true,
138
+ "license": "MIT",
139
+ "dependencies": {
140
+ "@babel/helper-module-imports": "^7.29.7",
141
+ "@babel/helper-validator-identifier": "^7.29.7",
142
+ "@babel/traverse": "^7.29.7"
143
+ },
144
+ "engines": {
145
+ "node": ">=6.9.0"
146
+ },
147
+ "peerDependencies": {
148
+ "@babel/core": "^7.0.0"
149
+ }
150
+ },
151
+ "node_modules/@babel/helper-plugin-utils": {
152
+ "version": "7.29.7",
153
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
154
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
155
+ "dev": true,
156
+ "license": "MIT",
157
+ "engines": {
158
+ "node": ">=6.9.0"
159
+ }
160
+ },
161
+ "node_modules/@babel/helper-string-parser": {
162
+ "version": "7.29.7",
163
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
164
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
165
+ "dev": true,
166
+ "license": "MIT",
167
+ "engines": {
168
+ "node": ">=6.9.0"
169
+ }
170
+ },
171
+ "node_modules/@babel/helper-validator-identifier": {
172
+ "version": "7.29.7",
173
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
174
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
175
+ "dev": true,
176
+ "license": "MIT",
177
+ "engines": {
178
+ "node": ">=6.9.0"
179
+ }
180
+ },
181
+ "node_modules/@babel/helper-validator-option": {
182
+ "version": "7.29.7",
183
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
184
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
185
+ "dev": true,
186
+ "license": "MIT",
187
+ "engines": {
188
+ "node": ">=6.9.0"
189
+ }
190
+ },
191
+ "node_modules/@babel/helpers": {
192
+ "version": "7.29.7",
193
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
194
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
195
+ "dev": true,
196
+ "license": "MIT",
197
+ "dependencies": {
198
+ "@babel/template": "^7.29.7",
199
+ "@babel/types": "^7.29.7"
200
+ },
201
+ "engines": {
202
+ "node": ">=6.9.0"
203
+ }
204
+ },
205
+ "node_modules/@babel/parser": {
206
+ "version": "7.29.8",
207
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
208
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
209
+ "dev": true,
210
+ "license": "MIT",
211
+ "dependencies": {
212
+ "@babel/types": "^7.29.8"
213
+ },
214
+ "bin": {
215
+ "parser": "bin/babel-parser.js"
216
+ },
217
+ "engines": {
218
+ "node": ">=6.0.0"
219
+ }
220
+ },
221
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
222
+ "version": "7.29.7",
223
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
224
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
225
+ "dev": true,
226
+ "license": "MIT",
227
+ "dependencies": {
228
+ "@babel/helper-plugin-utils": "^7.29.7"
229
+ },
230
+ "engines": {
231
+ "node": ">=6.9.0"
232
+ },
233
+ "peerDependencies": {
234
+ "@babel/core": "^7.0.0-0"
235
+ }
236
+ },
237
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
238
+ "version": "7.29.7",
239
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
240
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
241
+ "dev": true,
242
+ "license": "MIT",
243
+ "dependencies": {
244
+ "@babel/helper-plugin-utils": "^7.29.7"
245
+ },
246
+ "engines": {
247
+ "node": ">=6.9.0"
248
+ },
249
+ "peerDependencies": {
250
+ "@babel/core": "^7.0.0-0"
251
+ }
252
+ },
253
+ "node_modules/@babel/template": {
254
+ "version": "7.29.7",
255
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
256
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
257
+ "dev": true,
258
+ "license": "MIT",
259
+ "dependencies": {
260
+ "@babel/code-frame": "^7.29.7",
261
+ "@babel/parser": "^7.29.7",
262
+ "@babel/types": "^7.29.7"
263
+ },
264
+ "engines": {
265
+ "node": ">=6.9.0"
266
+ }
267
+ },
268
+ "node_modules/@babel/traverse": {
269
+ "version": "7.29.8",
270
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
271
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
272
+ "dev": true,
273
+ "license": "MIT",
274
+ "dependencies": {
275
+ "@babel/code-frame": "^7.29.7",
276
+ "@babel/generator": "^7.29.8",
277
+ "@babel/helper-globals": "^7.29.7",
278
+ "@babel/parser": "^7.29.8",
279
+ "@babel/template": "^7.29.7",
280
+ "@babel/types": "^7.29.8",
281
+ "debug": "^4.3.1"
282
+ },
283
+ "engines": {
284
+ "node": ">=6.9.0"
285
+ }
286
+ },
287
+ "node_modules/@babel/types": {
288
+ "version": "7.29.8",
289
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
290
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
291
+ "dev": true,
292
+ "license": "MIT",
293
+ "dependencies": {
294
+ "@babel/helper-string-parser": "^7.29.7",
295
+ "@babel/helper-validator-identifier": "^7.29.7"
296
+ },
297
+ "engines": {
298
+ "node": ">=6.9.0"
299
+ }
300
+ },
301
+ "node_modules/@esbuild/aix-ppc64": {
302
+ "version": "0.21.5",
303
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
304
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
305
+ "cpu": [
306
+ "ppc64"
307
+ ],
308
+ "dev": true,
309
+ "license": "MIT",
310
+ "optional": true,
311
+ "os": [
312
+ "aix"
313
+ ],
314
+ "engines": {
315
+ "node": ">=12"
316
+ }
317
+ },
318
+ "node_modules/@esbuild/android-arm": {
319
+ "version": "0.21.5",
320
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
321
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
322
+ "cpu": [
323
+ "arm"
324
+ ],
325
+ "dev": true,
326
+ "license": "MIT",
327
+ "optional": true,
328
+ "os": [
329
+ "android"
330
+ ],
331
+ "engines": {
332
+ "node": ">=12"
333
+ }
334
+ },
335
+ "node_modules/@esbuild/android-arm64": {
336
+ "version": "0.21.5",
337
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
338
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
339
+ "cpu": [
340
+ "arm64"
341
+ ],
342
+ "dev": true,
343
+ "license": "MIT",
344
+ "optional": true,
345
+ "os": [
346
+ "android"
347
+ ],
348
+ "engines": {
349
+ "node": ">=12"
350
+ }
351
+ },
352
+ "node_modules/@esbuild/android-x64": {
353
+ "version": "0.21.5",
354
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
355
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
356
+ "cpu": [
357
+ "x64"
358
+ ],
359
+ "dev": true,
360
+ "license": "MIT",
361
+ "optional": true,
362
+ "os": [
363
+ "android"
364
+ ],
365
+ "engines": {
366
+ "node": ">=12"
367
+ }
368
+ },
369
+ "node_modules/@esbuild/darwin-arm64": {
370
+ "version": "0.21.5",
371
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
372
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
373
+ "cpu": [
374
+ "arm64"
375
+ ],
376
+ "dev": true,
377
+ "license": "MIT",
378
+ "optional": true,
379
+ "os": [
380
+ "darwin"
381
+ ],
382
+ "engines": {
383
+ "node": ">=12"
384
+ }
385
+ },
386
+ "node_modules/@esbuild/darwin-x64": {
387
+ "version": "0.21.5",
388
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
389
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
390
+ "cpu": [
391
+ "x64"
392
+ ],
393
+ "dev": true,
394
+ "license": "MIT",
395
+ "optional": true,
396
+ "os": [
397
+ "darwin"
398
+ ],
399
+ "engines": {
400
+ "node": ">=12"
401
+ }
402
+ },
403
+ "node_modules/@esbuild/freebsd-arm64": {
404
+ "version": "0.21.5",
405
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
406
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
407
+ "cpu": [
408
+ "arm64"
409
+ ],
410
+ "dev": true,
411
+ "license": "MIT",
412
+ "optional": true,
413
+ "os": [
414
+ "freebsd"
415
+ ],
416
+ "engines": {
417
+ "node": ">=12"
418
+ }
419
+ },
420
+ "node_modules/@esbuild/freebsd-x64": {
421
+ "version": "0.21.5",
422
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
423
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
424
+ "cpu": [
425
+ "x64"
426
+ ],
427
+ "dev": true,
428
+ "license": "MIT",
429
+ "optional": true,
430
+ "os": [
431
+ "freebsd"
432
+ ],
433
+ "engines": {
434
+ "node": ">=12"
435
+ }
436
+ },
437
+ "node_modules/@esbuild/linux-arm": {
438
+ "version": "0.21.5",
439
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
440
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
441
+ "cpu": [
442
+ "arm"
443
+ ],
444
+ "dev": true,
445
+ "license": "MIT",
446
+ "optional": true,
447
+ "os": [
448
+ "linux"
449
+ ],
450
+ "engines": {
451
+ "node": ">=12"
452
+ }
453
+ },
454
+ "node_modules/@esbuild/linux-arm64": {
455
+ "version": "0.21.5",
456
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
457
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
458
+ "cpu": [
459
+ "arm64"
460
+ ],
461
+ "dev": true,
462
+ "license": "MIT",
463
+ "optional": true,
464
+ "os": [
465
+ "linux"
466
+ ],
467
+ "engines": {
468
+ "node": ">=12"
469
+ }
470
+ },
471
+ "node_modules/@esbuild/linux-ia32": {
472
+ "version": "0.21.5",
473
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
474
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
475
+ "cpu": [
476
+ "ia32"
477
+ ],
478
+ "dev": true,
479
+ "license": "MIT",
480
+ "optional": true,
481
+ "os": [
482
+ "linux"
483
+ ],
484
+ "engines": {
485
+ "node": ">=12"
486
+ }
487
+ },
488
+ "node_modules/@esbuild/linux-loong64": {
489
+ "version": "0.21.5",
490
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
491
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
492
+ "cpu": [
493
+ "loong64"
494
+ ],
495
+ "dev": true,
496
+ "license": "MIT",
497
+ "optional": true,
498
+ "os": [
499
+ "linux"
500
+ ],
501
+ "engines": {
502
+ "node": ">=12"
503
+ }
504
+ },
505
+ "node_modules/@esbuild/linux-mips64el": {
506
+ "version": "0.21.5",
507
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
508
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
509
+ "cpu": [
510
+ "mips64el"
511
+ ],
512
+ "dev": true,
513
+ "license": "MIT",
514
+ "optional": true,
515
+ "os": [
516
+ "linux"
517
+ ],
518
+ "engines": {
519
+ "node": ">=12"
520
+ }
521
+ },
522
+ "node_modules/@esbuild/linux-ppc64": {
523
+ "version": "0.21.5",
524
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
525
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
526
+ "cpu": [
527
+ "ppc64"
528
+ ],
529
+ "dev": true,
530
+ "license": "MIT",
531
+ "optional": true,
532
+ "os": [
533
+ "linux"
534
+ ],
535
+ "engines": {
536
+ "node": ">=12"
537
+ }
538
+ },
539
+ "node_modules/@esbuild/linux-riscv64": {
540
+ "version": "0.21.5",
541
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
542
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
543
+ "cpu": [
544
+ "riscv64"
545
+ ],
546
+ "dev": true,
547
+ "license": "MIT",
548
+ "optional": true,
549
+ "os": [
550
+ "linux"
551
+ ],
552
+ "engines": {
553
+ "node": ">=12"
554
+ }
555
+ },
556
+ "node_modules/@esbuild/linux-s390x": {
557
+ "version": "0.21.5",
558
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
559
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
560
+ "cpu": [
561
+ "s390x"
562
+ ],
563
+ "dev": true,
564
+ "license": "MIT",
565
+ "optional": true,
566
+ "os": [
567
+ "linux"
568
+ ],
569
+ "engines": {
570
+ "node": ">=12"
571
+ }
572
+ },
573
+ "node_modules/@esbuild/linux-x64": {
574
+ "version": "0.21.5",
575
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
576
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
577
+ "cpu": [
578
+ "x64"
579
+ ],
580
+ "dev": true,
581
+ "license": "MIT",
582
+ "optional": true,
583
+ "os": [
584
+ "linux"
585
+ ],
586
+ "engines": {
587
+ "node": ">=12"
588
+ }
589
+ },
590
+ "node_modules/@esbuild/netbsd-x64": {
591
+ "version": "0.21.5",
592
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
593
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
594
+ "cpu": [
595
+ "x64"
596
+ ],
597
+ "dev": true,
598
+ "license": "MIT",
599
+ "optional": true,
600
+ "os": [
601
+ "netbsd"
602
+ ],
603
+ "engines": {
604
+ "node": ">=12"
605
+ }
606
+ },
607
+ "node_modules/@esbuild/openbsd-x64": {
608
+ "version": "0.21.5",
609
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
610
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
611
+ "cpu": [
612
+ "x64"
613
+ ],
614
+ "dev": true,
615
+ "license": "MIT",
616
+ "optional": true,
617
+ "os": [
618
+ "openbsd"
619
+ ],
620
+ "engines": {
621
+ "node": ">=12"
622
+ }
623
+ },
624
+ "node_modules/@esbuild/sunos-x64": {
625
+ "version": "0.21.5",
626
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
627
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
628
+ "cpu": [
629
+ "x64"
630
+ ],
631
+ "dev": true,
632
+ "license": "MIT",
633
+ "optional": true,
634
+ "os": [
635
+ "sunos"
636
+ ],
637
+ "engines": {
638
+ "node": ">=12"
639
+ }
640
+ },
641
+ "node_modules/@esbuild/win32-arm64": {
642
+ "version": "0.21.5",
643
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
644
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
645
+ "cpu": [
646
+ "arm64"
647
+ ],
648
+ "dev": true,
649
+ "license": "MIT",
650
+ "optional": true,
651
+ "os": [
652
+ "win32"
653
+ ],
654
+ "engines": {
655
+ "node": ">=12"
656
+ }
657
+ },
658
+ "node_modules/@esbuild/win32-ia32": {
659
+ "version": "0.21.5",
660
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
661
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
662
+ "cpu": [
663
+ "ia32"
664
+ ],
665
+ "dev": true,
666
+ "license": "MIT",
667
+ "optional": true,
668
+ "os": [
669
+ "win32"
670
+ ],
671
+ "engines": {
672
+ "node": ">=12"
673
+ }
674
+ },
675
+ "node_modules/@esbuild/win32-x64": {
676
+ "version": "0.21.5",
677
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
678
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
679
+ "cpu": [
680
+ "x64"
681
+ ],
682
+ "dev": true,
683
+ "license": "MIT",
684
+ "optional": true,
685
+ "os": [
686
+ "win32"
687
+ ],
688
+ "engines": {
689
+ "node": ">=12"
690
+ }
691
+ },
692
+ "node_modules/@jridgewell/gen-mapping": {
693
+ "version": "0.3.13",
694
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
695
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
696
+ "dev": true,
697
+ "license": "MIT",
698
+ "dependencies": {
699
+ "@jridgewell/sourcemap-codec": "^1.5.0",
700
+ "@jridgewell/trace-mapping": "^0.3.24"
701
+ }
702
+ },
703
+ "node_modules/@jridgewell/remapping": {
704
+ "version": "2.3.5",
705
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
706
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
707
+ "dev": true,
708
+ "license": "MIT",
709
+ "dependencies": {
710
+ "@jridgewell/gen-mapping": "^0.3.5",
711
+ "@jridgewell/trace-mapping": "^0.3.24"
712
+ }
713
+ },
714
+ "node_modules/@jridgewell/resolve-uri": {
715
+ "version": "3.1.2",
716
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
717
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
718
+ "dev": true,
719
+ "license": "MIT",
720
+ "engines": {
721
+ "node": ">=6.0.0"
722
+ }
723
+ },
724
+ "node_modules/@jridgewell/sourcemap-codec": {
725
+ "version": "1.6.0",
726
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
727
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
728
+ "dev": true,
729
+ "license": "MIT"
730
+ },
731
+ "node_modules/@jridgewell/trace-mapping": {
732
+ "version": "0.3.31",
733
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
734
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
735
+ "dev": true,
736
+ "license": "MIT",
737
+ "dependencies": {
738
+ "@jridgewell/resolve-uri": "^3.1.0",
739
+ "@jridgewell/sourcemap-codec": "^1.4.14"
740
+ }
741
+ },
742
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
743
+ "version": "1.5.1",
744
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
745
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
746
+ "cpu": [
747
+ "x64"
748
+ ],
749
+ "dev": true,
750
+ "license": "MIT",
751
+ "optional": true,
752
+ "os": [
753
+ "linux"
754
+ ],
755
+ "engines": {
756
+ "node": "^22.20 || ^24.12 || >=25"
757
+ }
758
+ },
759
+ "node_modules/@rolldown/pluginutils": {
760
+ "version": "1.0.0-beta.27",
761
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
762
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
763
+ "dev": true,
764
+ "license": "MIT"
765
+ },
766
+ "node_modules/@rollup/rollup-android-arm-eabi": {
767
+ "version": "4.63.3",
768
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
769
+ "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==",
770
+ "cpu": [
771
+ "arm"
772
+ ],
773
+ "dev": true,
774
+ "license": "MIT",
775
+ "optional": true,
776
+ "os": [
777
+ "android"
778
+ ]
779
+ },
780
+ "node_modules/@rollup/rollup-android-arm64": {
781
+ "version": "4.63.3",
782
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz",
783
+ "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==",
784
+ "cpu": [
785
+ "arm64"
786
+ ],
787
+ "dev": true,
788
+ "license": "MIT",
789
+ "optional": true,
790
+ "os": [
791
+ "android"
792
+ ]
793
+ },
794
+ "node_modules/@rollup/rollup-darwin-arm64": {
795
+ "version": "4.63.3",
796
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz",
797
+ "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==",
798
+ "cpu": [
799
+ "arm64"
800
+ ],
801
+ "dev": true,
802
+ "license": "MIT",
803
+ "optional": true,
804
+ "os": [
805
+ "darwin"
806
+ ]
807
+ },
808
+ "node_modules/@rollup/rollup-darwin-x64": {
809
+ "version": "4.63.3",
810
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz",
811
+ "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==",
812
+ "cpu": [
813
+ "x64"
814
+ ],
815
+ "dev": true,
816
+ "license": "MIT",
817
+ "optional": true,
818
+ "os": [
819
+ "darwin"
820
+ ]
821
+ },
822
+ "node_modules/@rollup/rollup-freebsd-arm64": {
823
+ "version": "4.63.3",
824
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz",
825
+ "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==",
826
+ "cpu": [
827
+ "arm64"
828
+ ],
829
+ "dev": true,
830
+ "license": "MIT",
831
+ "optional": true,
832
+ "os": [
833
+ "freebsd"
834
+ ]
835
+ },
836
+ "node_modules/@rollup/rollup-freebsd-x64": {
837
+ "version": "4.63.3",
838
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz",
839
+ "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==",
840
+ "cpu": [
841
+ "x64"
842
+ ],
843
+ "dev": true,
844
+ "license": "MIT",
845
+ "optional": true,
846
+ "os": [
847
+ "freebsd"
848
+ ]
849
+ },
850
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
851
+ "version": "4.63.3",
852
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz",
853
+ "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==",
854
+ "cpu": [
855
+ "arm"
856
+ ],
857
+ "dev": true,
858
+ "license": "MIT",
859
+ "optional": true,
860
+ "os": [
861
+ "linux"
862
+ ]
863
+ },
864
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
865
+ "version": "4.63.3",
866
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz",
867
+ "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==",
868
+ "cpu": [
869
+ "arm"
870
+ ],
871
+ "dev": true,
872
+ "license": "MIT",
873
+ "optional": true,
874
+ "os": [
875
+ "linux"
876
+ ]
877
+ },
878
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
879
+ "version": "4.63.3",
880
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz",
881
+ "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==",
882
+ "cpu": [
883
+ "arm64"
884
+ ],
885
+ "dev": true,
886
+ "license": "MIT",
887
+ "optional": true,
888
+ "os": [
889
+ "linux"
890
+ ]
891
+ },
892
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
893
+ "version": "4.63.3",
894
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz",
895
+ "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==",
896
+ "cpu": [
897
+ "arm64"
898
+ ],
899
+ "dev": true,
900
+ "license": "MIT",
901
+ "optional": true,
902
+ "os": [
903
+ "linux"
904
+ ]
905
+ },
906
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
907
+ "version": "4.63.3",
908
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz",
909
+ "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==",
910
+ "cpu": [
911
+ "loong64"
912
+ ],
913
+ "dev": true,
914
+ "license": "MIT",
915
+ "optional": true,
916
+ "os": [
917
+ "linux"
918
+ ]
919
+ },
920
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
921
+ "version": "4.63.3",
922
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz",
923
+ "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==",
924
+ "cpu": [
925
+ "loong64"
926
+ ],
927
+ "dev": true,
928
+ "license": "MIT",
929
+ "optional": true,
930
+ "os": [
931
+ "linux"
932
+ ]
933
+ },
934
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
935
+ "version": "4.63.3",
936
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz",
937
+ "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==",
938
+ "cpu": [
939
+ "ppc64"
940
+ ],
941
+ "dev": true,
942
+ "license": "MIT",
943
+ "optional": true,
944
+ "os": [
945
+ "linux"
946
+ ]
947
+ },
948
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
949
+ "version": "4.63.3",
950
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz",
951
+ "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==",
952
+ "cpu": [
953
+ "ppc64"
954
+ ],
955
+ "dev": true,
956
+ "license": "MIT",
957
+ "optional": true,
958
+ "os": [
959
+ "linux"
960
+ ]
961
+ },
962
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
963
+ "version": "4.63.3",
964
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz",
965
+ "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==",
966
+ "cpu": [
967
+ "riscv64"
968
+ ],
969
+ "dev": true,
970
+ "license": "MIT",
971
+ "optional": true,
972
+ "os": [
973
+ "linux"
974
+ ]
975
+ },
976
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
977
+ "version": "4.63.3",
978
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz",
979
+ "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==",
980
+ "cpu": [
981
+ "riscv64"
982
+ ],
983
+ "dev": true,
984
+ "license": "MIT",
985
+ "optional": true,
986
+ "os": [
987
+ "linux"
988
+ ]
989
+ },
990
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
991
+ "version": "4.63.3",
992
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz",
993
+ "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==",
994
+ "cpu": [
995
+ "s390x"
996
+ ],
997
+ "dev": true,
998
+ "license": "MIT",
999
+ "optional": true,
1000
+ "os": [
1001
+ "linux"
1002
+ ]
1003
+ },
1004
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1005
+ "version": "4.63.3",
1006
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz",
1007
+ "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==",
1008
+ "cpu": [
1009
+ "x64"
1010
+ ],
1011
+ "dev": true,
1012
+ "license": "MIT",
1013
+ "optional": true,
1014
+ "os": [
1015
+ "linux"
1016
+ ]
1017
+ },
1018
+ "node_modules/@rollup/rollup-linux-x64-musl": {
1019
+ "version": "4.63.3",
1020
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz",
1021
+ "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==",
1022
+ "cpu": [
1023
+ "x64"
1024
+ ],
1025
+ "dev": true,
1026
+ "license": "MIT",
1027
+ "optional": true,
1028
+ "os": [
1029
+ "linux"
1030
+ ]
1031
+ },
1032
+ "node_modules/@rollup/rollup-openbsd-x64": {
1033
+ "version": "4.63.3",
1034
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz",
1035
+ "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==",
1036
+ "cpu": [
1037
+ "x64"
1038
+ ],
1039
+ "dev": true,
1040
+ "license": "MIT",
1041
+ "optional": true,
1042
+ "os": [
1043
+ "openbsd"
1044
+ ]
1045
+ },
1046
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1047
+ "version": "4.63.3",
1048
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz",
1049
+ "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==",
1050
+ "cpu": [
1051
+ "arm64"
1052
+ ],
1053
+ "dev": true,
1054
+ "license": "MIT",
1055
+ "optional": true,
1056
+ "os": [
1057
+ "openharmony"
1058
+ ]
1059
+ },
1060
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1061
+ "version": "4.63.3",
1062
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz",
1063
+ "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==",
1064
+ "cpu": [
1065
+ "arm64"
1066
+ ],
1067
+ "dev": true,
1068
+ "license": "MIT",
1069
+ "optional": true,
1070
+ "os": [
1071
+ "win32"
1072
+ ]
1073
+ },
1074
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1075
+ "version": "4.63.3",
1076
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz",
1077
+ "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==",
1078
+ "cpu": [
1079
+ "ia32"
1080
+ ],
1081
+ "dev": true,
1082
+ "license": "MIT",
1083
+ "optional": true,
1084
+ "os": [
1085
+ "win32"
1086
+ ]
1087
+ },
1088
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1089
+ "version": "4.63.3",
1090
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz",
1091
+ "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==",
1092
+ "cpu": [
1093
+ "x64"
1094
+ ],
1095
+ "dev": true,
1096
+ "license": "MIT",
1097
+ "optional": true,
1098
+ "os": [
1099
+ "win32"
1100
+ ]
1101
+ },
1102
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1103
+ "version": "4.63.3",
1104
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz",
1105
+ "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==",
1106
+ "cpu": [
1107
+ "x64"
1108
+ ],
1109
+ "dev": true,
1110
+ "license": "MIT",
1111
+ "optional": true,
1112
+ "os": [
1113
+ "win32"
1114
+ ]
1115
+ },
1116
+ "node_modules/@types/babel__core": {
1117
+ "version": "7.20.5",
1118
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1119
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1120
+ "dev": true,
1121
+ "license": "MIT",
1122
+ "dependencies": {
1123
+ "@babel/parser": "^7.20.7",
1124
+ "@babel/types": "^7.20.7",
1125
+ "@types/babel__generator": "*",
1126
+ "@types/babel__template": "*",
1127
+ "@types/babel__traverse": "*"
1128
+ }
1129
+ },
1130
+ "node_modules/@types/babel__generator": {
1131
+ "version": "7.27.0",
1132
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1133
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1134
+ "dev": true,
1135
+ "license": "MIT",
1136
+ "dependencies": {
1137
+ "@babel/types": "^7.0.0"
1138
+ }
1139
+ },
1140
+ "node_modules/@types/babel__template": {
1141
+ "version": "7.4.4",
1142
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1143
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1144
+ "dev": true,
1145
+ "license": "MIT",
1146
+ "dependencies": {
1147
+ "@babel/parser": "^7.1.0",
1148
+ "@babel/types": "^7.0.0"
1149
+ }
1150
+ },
1151
+ "node_modules/@types/babel__traverse": {
1152
+ "version": "7.28.0",
1153
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1154
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1155
+ "dev": true,
1156
+ "license": "MIT",
1157
+ "dependencies": {
1158
+ "@babel/types": "^7.28.2"
1159
+ }
1160
+ },
1161
+ "node_modules/@types/estree": {
1162
+ "version": "1.0.9",
1163
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1164
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1165
+ "dev": true,
1166
+ "license": "MIT"
1167
+ },
1168
+ "node_modules/@vitejs/plugin-react": {
1169
+ "version": "4.7.0",
1170
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1171
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1172
+ "dev": true,
1173
+ "license": "MIT",
1174
+ "dependencies": {
1175
+ "@babel/core": "^7.28.0",
1176
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1177
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1178
+ "@rolldown/pluginutils": "1.0.0-beta.27",
1179
+ "@types/babel__core": "^7.20.5",
1180
+ "react-refresh": "^0.17.0"
1181
+ },
1182
+ "engines": {
1183
+ "node": "^14.18.0 || >=16.0.0"
1184
+ },
1185
+ "peerDependencies": {
1186
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1187
+ }
1188
+ },
1189
+ "node_modules/baseline-browser-mapping": {
1190
+ "version": "2.11.25",
1191
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
1192
+ "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
1193
+ "dev": true,
1194
+ "license": "Apache-2.0",
1195
+ "bin": {
1196
+ "baseline-browser-mapping": "dist/cli.cjs"
1197
+ },
1198
+ "engines": {
1199
+ "node": ">=6.0.0"
1200
+ }
1201
+ },
1202
+ "node_modules/browserslist": {
1203
+ "version": "4.29.0",
1204
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
1205
+ "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
1206
+ "dev": true,
1207
+ "funding": [
1208
+ {
1209
+ "type": "opencollective",
1210
+ "url": "https://opencollective.com/browserslist"
1211
+ },
1212
+ {
1213
+ "type": "tidelift",
1214
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1215
+ },
1216
+ {
1217
+ "type": "github",
1218
+ "url": "https://github.com/sponsors/ai"
1219
+ }
1220
+ ],
1221
+ "license": "MIT",
1222
+ "dependencies": {
1223
+ "baseline-browser-mapping": "^2.11.23",
1224
+ "caniuse-lite": "^1.0.30001810",
1225
+ "electron-to-chromium": "^1.5.427",
1226
+ "node-releases": "^2.0.55",
1227
+ "update-browserslist-db": "^1.3.3"
1228
+ },
1229
+ "bin": {
1230
+ "browserslist": "cli.js"
1231
+ },
1232
+ "engines": {
1233
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1234
+ }
1235
+ },
1236
+ "node_modules/caniuse-lite": {
1237
+ "version": "1.0.30001810",
1238
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
1239
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
1240
+ "dev": true,
1241
+ "funding": [
1242
+ {
1243
+ "type": "opencollective",
1244
+ "url": "https://opencollective.com/browserslist"
1245
+ },
1246
+ {
1247
+ "type": "tidelift",
1248
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1249
+ },
1250
+ {
1251
+ "type": "github",
1252
+ "url": "https://github.com/sponsors/ai"
1253
+ }
1254
+ ],
1255
+ "license": "CC-BY-4.0"
1256
+ },
1257
+ "node_modules/convert-source-map": {
1258
+ "version": "2.0.0",
1259
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1260
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1261
+ "dev": true,
1262
+ "license": "MIT"
1263
+ },
1264
+ "node_modules/debug": {
1265
+ "version": "4.4.3",
1266
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1267
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1268
+ "dev": true,
1269
+ "license": "MIT",
1270
+ "dependencies": {
1271
+ "ms": "^2.1.3"
1272
+ },
1273
+ "engines": {
1274
+ "node": ">=6.0"
1275
+ },
1276
+ "peerDependenciesMeta": {
1277
+ "supports-color": {
1278
+ "optional": true
1279
+ }
1280
+ }
1281
+ },
1282
+ "node_modules/electron-to-chromium": {
1283
+ "version": "1.5.430",
1284
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
1285
+ "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==",
1286
+ "dev": true,
1287
+ "license": "ISC"
1288
+ },
1289
+ "node_modules/esbuild": {
1290
+ "version": "0.21.5",
1291
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1292
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1293
+ "dev": true,
1294
+ "hasInstallScript": true,
1295
+ "license": "MIT",
1296
+ "bin": {
1297
+ "esbuild": "bin/esbuild"
1298
+ },
1299
+ "engines": {
1300
+ "node": ">=12"
1301
+ },
1302
+ "optionalDependencies": {
1303
+ "@esbuild/aix-ppc64": "0.21.5",
1304
+ "@esbuild/android-arm": "0.21.5",
1305
+ "@esbuild/android-arm64": "0.21.5",
1306
+ "@esbuild/android-x64": "0.21.5",
1307
+ "@esbuild/darwin-arm64": "0.21.5",
1308
+ "@esbuild/darwin-x64": "0.21.5",
1309
+ "@esbuild/freebsd-arm64": "0.21.5",
1310
+ "@esbuild/freebsd-x64": "0.21.5",
1311
+ "@esbuild/linux-arm": "0.21.5",
1312
+ "@esbuild/linux-arm64": "0.21.5",
1313
+ "@esbuild/linux-ia32": "0.21.5",
1314
+ "@esbuild/linux-loong64": "0.21.5",
1315
+ "@esbuild/linux-mips64el": "0.21.5",
1316
+ "@esbuild/linux-ppc64": "0.21.5",
1317
+ "@esbuild/linux-riscv64": "0.21.5",
1318
+ "@esbuild/linux-s390x": "0.21.5",
1319
+ "@esbuild/linux-x64": "0.21.5",
1320
+ "@esbuild/netbsd-x64": "0.21.5",
1321
+ "@esbuild/openbsd-x64": "0.21.5",
1322
+ "@esbuild/sunos-x64": "0.21.5",
1323
+ "@esbuild/win32-arm64": "0.21.5",
1324
+ "@esbuild/win32-ia32": "0.21.5",
1325
+ "@esbuild/win32-x64": "0.21.5"
1326
+ }
1327
+ },
1328
+ "node_modules/escalade": {
1329
+ "version": "3.2.0",
1330
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1331
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1332
+ "dev": true,
1333
+ "license": "MIT",
1334
+ "engines": {
1335
+ "node": ">=6"
1336
+ }
1337
+ },
1338
+ "node_modules/fsevents": {
1339
+ "version": "2.3.3",
1340
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1341
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1342
+ "dev": true,
1343
+ "hasInstallScript": true,
1344
+ "license": "MIT",
1345
+ "optional": true,
1346
+ "os": [
1347
+ "darwin"
1348
+ ],
1349
+ "engines": {
1350
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1351
+ }
1352
+ },
1353
+ "node_modules/gensync": {
1354
+ "version": "1.0.0-beta.2",
1355
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1356
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1357
+ "dev": true,
1358
+ "license": "MIT",
1359
+ "engines": {
1360
+ "node": ">=6.9.0"
1361
+ }
1362
+ },
1363
+ "node_modules/js-tokens": {
1364
+ "version": "4.0.0",
1365
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1366
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1367
+ "license": "MIT"
1368
+ },
1369
+ "node_modules/jsesc": {
1370
+ "version": "3.1.0",
1371
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1372
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1373
+ "dev": true,
1374
+ "license": "MIT",
1375
+ "bin": {
1376
+ "jsesc": "bin/jsesc"
1377
+ },
1378
+ "engines": {
1379
+ "node": ">=6"
1380
+ }
1381
+ },
1382
+ "node_modules/json5": {
1383
+ "version": "2.2.3",
1384
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1385
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1386
+ "dev": true,
1387
+ "license": "MIT",
1388
+ "bin": {
1389
+ "json5": "lib/cli.js"
1390
+ },
1391
+ "engines": {
1392
+ "node": ">=6"
1393
+ }
1394
+ },
1395
+ "node_modules/loose-envify": {
1396
+ "version": "1.4.0",
1397
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1398
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1399
+ "license": "MIT",
1400
+ "dependencies": {
1401
+ "js-tokens": "^3.0.0 || ^4.0.0"
1402
+ },
1403
+ "bin": {
1404
+ "loose-envify": "cli.js"
1405
+ }
1406
+ },
1407
+ "node_modules/lru-cache": {
1408
+ "version": "5.1.1",
1409
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1410
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1411
+ "dev": true,
1412
+ "license": "ISC",
1413
+ "dependencies": {
1414
+ "yallist": "^3.0.2"
1415
+ }
1416
+ },
1417
+ "node_modules/ms": {
1418
+ "version": "2.1.3",
1419
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1420
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1421
+ "dev": true,
1422
+ "license": "MIT"
1423
+ },
1424
+ "node_modules/nanoid": {
1425
+ "version": "3.3.19",
1426
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
1427
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
1428
+ "dev": true,
1429
+ "funding": [
1430
+ {
1431
+ "type": "github",
1432
+ "url": "https://github.com/sponsors/ai"
1433
+ }
1434
+ ],
1435
+ "license": "MIT",
1436
+ "bin": {
1437
+ "nanoid": "bin/nanoid.cjs"
1438
+ },
1439
+ "engines": {
1440
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1441
+ }
1442
+ },
1443
+ "node_modules/node-releases": {
1444
+ "version": "2.0.56",
1445
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
1446
+ "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
1447
+ "dev": true,
1448
+ "license": "MIT",
1449
+ "engines": {
1450
+ "node": ">=18"
1451
+ }
1452
+ },
1453
+ "node_modules/picocolors": {
1454
+ "version": "1.1.1",
1455
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1456
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1457
+ "dev": true,
1458
+ "license": "ISC"
1459
+ },
1460
+ "node_modules/postcss": {
1461
+ "version": "8.5.28",
1462
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
1463
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
1464
+ "dev": true,
1465
+ "funding": [
1466
+ {
1467
+ "type": "opencollective",
1468
+ "url": "https://opencollective.com/postcss/"
1469
+ },
1470
+ {
1471
+ "type": "tidelift",
1472
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1473
+ },
1474
+ {
1475
+ "type": "github",
1476
+ "url": "https://github.com/sponsors/ai"
1477
+ }
1478
+ ],
1479
+ "license": "MIT",
1480
+ "dependencies": {
1481
+ "nanoid": "^3.3.18",
1482
+ "picocolors": "^1.1.1",
1483
+ "source-map-js": "^1.2.1"
1484
+ },
1485
+ "engines": {
1486
+ "node": "^10 || ^12 || >=14"
1487
+ }
1488
+ },
1489
+ "node_modules/react": {
1490
+ "version": "18.3.1",
1491
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1492
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1493
+ "license": "MIT",
1494
+ "dependencies": {
1495
+ "loose-envify": "^1.1.0"
1496
+ },
1497
+ "engines": {
1498
+ "node": ">=0.10.0"
1499
+ }
1500
+ },
1501
+ "node_modules/react-dom": {
1502
+ "version": "18.3.1",
1503
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1504
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1505
+ "license": "MIT",
1506
+ "dependencies": {
1507
+ "loose-envify": "^1.1.0",
1508
+ "scheduler": "^0.23.2"
1509
+ },
1510
+ "peerDependencies": {
1511
+ "react": "^18.3.1"
1512
+ }
1513
+ },
1514
+ "node_modules/react-refresh": {
1515
+ "version": "0.17.0",
1516
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1517
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1518
+ "dev": true,
1519
+ "license": "MIT",
1520
+ "engines": {
1521
+ "node": ">=0.10.0"
1522
+ }
1523
+ },
1524
+ "node_modules/rollup": {
1525
+ "version": "4.63.3",
1526
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
1527
+ "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==",
1528
+ "dev": true,
1529
+ "license": "MIT",
1530
+ "dependencies": {
1531
+ "@types/estree": "1.0.9"
1532
+ },
1533
+ "bin": {
1534
+ "rollup": "dist/bin/rollup"
1535
+ },
1536
+ "engines": {
1537
+ "node": ">=18.0.0",
1538
+ "npm": ">=8.0.0"
1539
+ },
1540
+ "optionalDependencies": {
1541
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1542
+ "@rollup/rollup-android-arm-eabi": "4.63.3",
1543
+ "@rollup/rollup-android-arm64": "4.63.3",
1544
+ "@rollup/rollup-darwin-arm64": "4.63.3",
1545
+ "@rollup/rollup-darwin-x64": "4.63.3",
1546
+ "@rollup/rollup-freebsd-arm64": "4.63.3",
1547
+ "@rollup/rollup-freebsd-x64": "4.63.3",
1548
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.3",
1549
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.3",
1550
+ "@rollup/rollup-linux-arm64-gnu": "4.63.3",
1551
+ "@rollup/rollup-linux-arm64-musl": "4.63.3",
1552
+ "@rollup/rollup-linux-loong64-gnu": "4.63.3",
1553
+ "@rollup/rollup-linux-loong64-musl": "4.63.3",
1554
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.3",
1555
+ "@rollup/rollup-linux-ppc64-musl": "4.63.3",
1556
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.3",
1557
+ "@rollup/rollup-linux-riscv64-musl": "4.63.3",
1558
+ "@rollup/rollup-linux-s390x-gnu": "4.63.3",
1559
+ "@rollup/rollup-linux-x64-gnu": "4.63.3",
1560
+ "@rollup/rollup-linux-x64-musl": "4.63.3",
1561
+ "@rollup/rollup-openbsd-x64": "4.63.3",
1562
+ "@rollup/rollup-openharmony-arm64": "4.63.3",
1563
+ "@rollup/rollup-win32-arm64-msvc": "4.63.3",
1564
+ "@rollup/rollup-win32-ia32-msvc": "4.63.3",
1565
+ "@rollup/rollup-win32-x64-gnu": "4.63.3",
1566
+ "@rollup/rollup-win32-x64-msvc": "4.63.3",
1567
+ "fsevents": "~2.3.2"
1568
+ }
1569
+ },
1570
+ "node_modules/scheduler": {
1571
+ "version": "0.23.2",
1572
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1573
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1574
+ "license": "MIT",
1575
+ "dependencies": {
1576
+ "loose-envify": "^1.1.0"
1577
+ }
1578
+ },
1579
+ "node_modules/semver": {
1580
+ "version": "6.3.1",
1581
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1582
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1583
+ "dev": true,
1584
+ "license": "ISC",
1585
+ "bin": {
1586
+ "semver": "bin/semver.js"
1587
+ }
1588
+ },
1589
+ "node_modules/source-map-js": {
1590
+ "version": "1.2.1",
1591
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1592
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1593
+ "dev": true,
1594
+ "license": "BSD-3-Clause",
1595
+ "engines": {
1596
+ "node": ">=0.10.0"
1597
+ }
1598
+ },
1599
+ "node_modules/update-browserslist-db": {
1600
+ "version": "1.3.3",
1601
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
1602
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
1603
+ "dev": true,
1604
+ "funding": [
1605
+ {
1606
+ "type": "opencollective",
1607
+ "url": "https://opencollective.com/browserslist"
1608
+ },
1609
+ {
1610
+ "type": "tidelift",
1611
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1612
+ },
1613
+ {
1614
+ "type": "github",
1615
+ "url": "https://github.com/sponsors/ai"
1616
+ }
1617
+ ],
1618
+ "license": "MIT",
1619
+ "dependencies": {
1620
+ "escalade": "^3.2.0",
1621
+ "picocolors": "^1.1.1"
1622
+ },
1623
+ "bin": {
1624
+ "update-browserslist-db": "cli.js"
1625
+ },
1626
+ "peerDependencies": {
1627
+ "browserslist": ">= 4.21.0"
1628
+ }
1629
+ },
1630
+ "node_modules/vite": {
1631
+ "version": "5.4.21",
1632
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1633
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1634
+ "dev": true,
1635
+ "license": "MIT",
1636
+ "dependencies": {
1637
+ "esbuild": "^0.21.3",
1638
+ "postcss": "^8.4.43",
1639
+ "rollup": "^4.20.0"
1640
+ },
1641
+ "bin": {
1642
+ "vite": "bin/vite.js"
1643
+ },
1644
+ "engines": {
1645
+ "node": "^18.0.0 || >=20.0.0"
1646
+ },
1647
+ "funding": {
1648
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1649
+ },
1650
+ "optionalDependencies": {
1651
+ "fsevents": "~2.3.3"
1652
+ },
1653
+ "peerDependencies": {
1654
+ "@types/node": "^18.0.0 || >=20.0.0",
1655
+ "less": "*",
1656
+ "lightningcss": "^1.21.0",
1657
+ "sass": "*",
1658
+ "sass-embedded": "*",
1659
+ "stylus": "*",
1660
+ "sugarss": "*",
1661
+ "terser": "^5.4.0"
1662
+ },
1663
+ "peerDependenciesMeta": {
1664
+ "@types/node": {
1665
+ "optional": true
1666
+ },
1667
+ "less": {
1668
+ "optional": true
1669
+ },
1670
+ "lightningcss": {
1671
+ "optional": true
1672
+ },
1673
+ "sass": {
1674
+ "optional": true
1675
+ },
1676
+ "sass-embedded": {
1677
+ "optional": true
1678
+ },
1679
+ "stylus": {
1680
+ "optional": true
1681
+ },
1682
+ "sugarss": {
1683
+ "optional": true
1684
+ },
1685
+ "terser": {
1686
+ "optional": true
1687
+ }
1688
+ }
1689
+ },
1690
+ "node_modules/yallist": {
1691
+ "version": "3.1.1",
1692
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1693
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1694
+ "dev": true,
1695
+ "license": "ISC"
1696
+ }
1697
+ }
1698
+ }
frontend/package.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "chesstb-explorer-frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1"
14
+ },
15
+ "devDependencies": {
16
+ "@vitejs/plugin-react": "^4.3.4",
17
+ "vite": "^5.4.11"
18
+ }
19
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useExplorer } from './hooks/useExplorer.js';
2
+ import Header from './components/Header.jsx';
3
+ import FenBar from './components/FenBar.jsx';
4
+ import BoardArea from './components/BoardArea.jsx';
5
+ import ResultsPanel from './components/ResultsPanel.jsx';
6
+ import PgnPanel from './components/PgnPanel.jsx';
7
+ import PgnDialog from './components/PgnDialog.jsx';
8
+ import SettingsPanel from './components/SettingsPanel.jsx';
9
+ import Toasts from './components/Toasts.jsx';
10
+ import PromotionDialog from './components/PromotionDialog.jsx';
11
+ import ScreenNav from './components/ScreenNav.jsx';
12
+
13
+ export default function App({ sourceRepositoryUrl }) {
14
+ const explorer = useExplorer();
15
+
16
+ return (
17
+ <>
18
+ <Header sourceRepositoryUrl={sourceRepositoryUrl} setSettingsOpen={explorer.setSettingsOpen} />
19
+
20
+ <main className="content" role="main">
21
+ <div className="left-col">
22
+ <FenBar explorer={explorer} />
23
+ <BoardArea explorer={explorer} />
24
+ </div>
25
+
26
+ <div className="right-col">
27
+ <ResultsPanel explorer={explorer} />
28
+ </div>
29
+
30
+ <div className="pgn-col">
31
+ <PgnPanel explorer={explorer} />
32
+ </div>
33
+ </main>
34
+
35
+ <ScreenNav />
36
+
37
+ <PromotionDialog />
38
+ <PgnDialog explorer={explorer} />
39
+ <SettingsPanel explorer={explorer} />
40
+ <Toasts toasts={explorer.toasts} />
41
+ </>
42
+ );
43
+ }
frontend/src/assets/fonts/Inter-Medium.woff2 ADDED
Binary file (24.3 kB). View file
 
frontend/src/assets/fonts/Inter-Regular.woff2 ADDED
Binary file (23.7 kB). View file
 
frontend/src/assets/fonts/Inter-SemiBold.woff2 ADDED
Binary file (24.5 kB). View file
 
frontend/src/assets/fonts/JetBrainsMono-Medium.woff2 ADDED
Binary file (21.8 kB). View file
 
frontend/src/assets/fonts/JetBrainsMono-Regular.woff2 ADDED
Binary file (21.2 kB). View file
 
frontend/src/components/BoardArea.jsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Segmented from './ui/Segmented.jsx';
2
+
3
+ const SPARE_BLACK = ['bP', 'bN', 'bB', 'bR', 'bQ', 'bK'];
4
+ const SPARE_WHITE = ['wP', 'wN', 'wB', 'wR', 'wQ', 'wK'];
5
+
6
+ export default function BoardArea({ explorer }) {
7
+ const { fen, actions } = explorer;
8
+ const turn = fen.split(' ')[1] === 'b' ? 'b' : 'w';
9
+
10
+ return (
11
+ <div className="board-group">
12
+ <Segmented
13
+ className="turn-row" itemClassName="turn-btn" label="Side to move"
14
+ value={turn} onChange={actions.setTurn}
15
+ options={[
16
+ { value: 'w', id: 'turn-white', title: 'White to move', content: <><span className="turn-pip turn-pip--w" aria-hidden="true"></span>White to move</> },
17
+ { value: 'b', id: 'turn-black', title: 'Black to move', content: <><span className="turn-pip turn-pip--b" aria-hidden="true"></span>Black to move</> },
18
+ ]}
19
+ />
20
+ <div className="board-wrap" aria-label="Chess board">
21
+ <div id="board-wrap-inner" className="board-wrap-inner">
22
+ <div className="spare-tray spare-tray--black" data-color="b">
23
+ {SPARE_BLACK.map(code => (
24
+ <button key={code} className="spare-piece" data-piece={code} type="button">
25
+ <img src="" draggable="false" alt="" />
26
+ </button>
27
+ ))}
28
+ </div>
29
+ <div id="board"></div>
30
+ <div className="spare-tray spare-tray--white" data-color="w">
31
+ {SPARE_WHITE.map(code => (
32
+ <button key={code} className="spare-piece" data-piece={code} type="button">
33
+ <img src="" draggable="false" alt="" />
34
+ </button>
35
+ ))}
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
frontend/src/components/FenBar.jsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Button from './ui/Button.jsx';
2
+ import Icon from './ui/Icon.jsx';
3
+
4
+ export default function FenBar({ explorer }) {
5
+ const { fenInputRef, actions, ready, fen, locked, lockAutoplayDisabled, canBack, canForward, fenError, tb } = explorer;
6
+ const errorMessage = fenError ?? tb.error?.message ?? '';
7
+ const isCoverageError = !fenError && !!tb.error?.isCoverage;
8
+
9
+ function onFenKeyDown(e) {
10
+ if (e.key === 'Enter') { e.preventDefault(); actions.applyFen(e.currentTarget.value.trim()); }
11
+ }
12
+ function onApplyClick() {
13
+ const v = fenInputRef.current?.value.trim();
14
+ if (v) actions.applyFen(v);
15
+ }
16
+ function onLockClick(e) {
17
+ if (e.currentTarget.getAttribute('aria-disabled') === 'true') return;
18
+ actions.setLockedAction(!locked);
19
+ }
20
+
21
+ return (
22
+ <>
23
+ <div className="fen-block">
24
+ <div className="fen-input-wrap">
25
+ <span className="fen-input-wrap__label" aria-hidden="true">FEN</span>
26
+ <input
27
+ id="fen-input" className="fen-input" type="text"
28
+ ref={fenInputRef} defaultValue={fen}
29
+ placeholder="Paste FEN here…" disabled={!ready}
30
+ autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck="false"
31
+ aria-label="FEN position string"
32
+ onKeyDown={onFenKeyDown}
33
+ />
34
+ <button id="lock-btn" type="button"
35
+ className={`btn btn--icon-only btn--lock${locked ? ' is-locked' : ''}${lockAutoplayDisabled ? ' is-autoplay-disabled' : ''}`}
36
+ aria-pressed={locked} aria-disabled={lockAutoplayDisabled}
37
+ aria-label="Lock board to legal moves only"
38
+ title={lockAutoplayDisabled ? 'Stop auto-play to change Lock' : (locked ? 'Unlock board' : 'Lock board (legal moves only)')}
39
+ onClick={onLockClick}>
40
+ <Icon name="lockOpen" className="btn__icon lock-icon lock-icon--unlocked" />
41
+ <Icon name="lockClosed" className="btn__icon lock-icon lock-icon--locked" />
42
+ </button>
43
+ </div>
44
+
45
+ <div className="fen-actions" role="toolbar" aria-label="Board actions">
46
+ <Button id="apply-btn" variant="primary" grow icon="check" title="Apply FEN (Enter)" disabled={!ready} onClick={onApplyClick}>Apply</Button>
47
+ <Button id="flip-btn" grow icon="flip" title="Flip board (F)" onClick={() => actions.flip()}>Flip</Button>
48
+ <Button id="clear-btn" grow icon="trash" title="Clear board (C)" onClick={() => actions.clearBoard()}>Clear</Button>
49
+ <Button id="copy-btn" grow icon="copy" title="Copy FEN to clipboard" aria-label="Copy FEN to clipboard" onClick={() => actions.copyFen()}>Copy</Button>
50
+ <Button id="back-btn" iconOnly icon="arrowLeft" title="Undo (←)" aria-label="Undo" disabled={!canBack} onClick={() => actions.goBack()} />
51
+ <Button id="forward-btn" iconOnly icon="arrowRight" title="Redo (→)" aria-label="Redo" disabled={!canForward} onClick={() => actions.goForward()} />
52
+ </div>
53
+ </div>
54
+
55
+ <div id="error-line" className={`error-line${isCoverageError ? ' is-coverage-error' : ''}`} role="alert" aria-live="polite">
56
+ {errorMessage}
57
+ </div>
58
+ </>
59
+ );
60
+ }
frontend/src/components/Header.jsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Icon from './ui/Icon.jsx';
2
+
3
+ function Logo() {
4
+ return (
5
+ <svg className="site-header__logo" viewBox="0 0 24 24" aria-hidden="true">
6
+ <circle cx="12" cy="12" r="12" fill="var(--gold)" />
7
+ <g fill="var(--bg)">
8
+ <circle cx="12" cy="8.3" r="2.6" />
9
+ <path d="M10.4 11.2h3.2c.2 2 .9 3.3 2.2 5.4H8.2c1.3-2.1 2-3.4 2.2-5.4z" />
10
+ <rect x="7.2" y="16.6" width="9.6" height="1.9" rx="0.95" />
11
+ </g>
12
+ </svg>
13
+ );
14
+ }
15
+
16
+ export default function Header({ sourceRepositoryUrl, setSettingsOpen }) {
17
+ return (
18
+ <header className="site-header" role="banner">
19
+ <Logo />
20
+
21
+ <div className="site-header__brand">
22
+ <a href="https://noobpwnftw.github.io/chesstb/" className="site-header__title"
23
+ title="Project home — code and downloads">ChessTB</a>
24
+ <span className="site-header__sep" aria-hidden="true">·</span>
25
+ <span className="site-header__sub">Explorer</span>
26
+ </div>
27
+
28
+ <nav className="site-header__nav" aria-label="Site links">
29
+ {sourceRepositoryUrl && (
30
+ <a href={sourceRepositoryUrl} className="site-header__link site-header__link--icon"
31
+ title="Source repository" aria-label="Source repository" target="_blank" rel="noopener noreferrer">
32
+ <Icon name="repository" />
33
+ </a>
34
+ )}
35
+ <a href="/admin" className="site-header__link site-header__link--icon"
36
+ title="Cache dashboard" aria-label="Admin cache dashboard">
37
+ <Icon name="gauge" />
38
+ </a>
39
+ <a href="/openapi.yaml" className="site-header__link site-header__link--icon"
40
+ title="API specification" aria-label="API specification" target="_blank" rel="noopener noreferrer">
41
+ <Icon name="code" />
42
+ </a>
43
+ <button id="settings-btn" className="site-header__link site-header__link--icon"
44
+ title="Board settings" aria-label="Open board settings"
45
+ onClick={(e) => { e.stopPropagation(); setSettingsOpen(o => !o); }}>
46
+ <Icon name="settings" />
47
+ </button>
48
+ </nav>
49
+ </header>
50
+ );
51
+ }
frontend/src/components/PgnDialog.jsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import Button from './ui/Button.jsx';
3
+
4
+ export default function PgnDialog({ explorer }) {
5
+ const { pgnDialogOpen, pgnError, actions } = explorer;
6
+ const textareaRef = useRef(null);
7
+
8
+ useEffect(() => {
9
+ if (pgnDialogOpen) textareaRef.current?.focus();
10
+ }, [pgnDialogOpen]);
11
+
12
+ useEffect(() => {
13
+ if (!pgnDialogOpen) return;
14
+ function onKeydown(e) {
15
+ if (e.key === 'Escape') { e.preventDefault(); actions.closePgnDialog(); }
16
+ }
17
+ document.addEventListener('keydown', onKeydown);
18
+ return () => document.removeEventListener('keydown', onKeydown);
19
+ }, [pgnDialogOpen, actions]);
20
+
21
+ function onBackdropClick(e) {
22
+ if (e.target.id === 'pgn-dialog') actions.closePgnDialog();
23
+ }
24
+ function onParseClick() {
25
+ actions.parsePgnAction(textareaRef.current?.value || '');
26
+ }
27
+
28
+ return (
29
+ <div id="pgn-dialog" className={`pgn-dialog${pgnDialogOpen ? ' is-open' : ''}`}
30
+ role="dialog" aria-modal="true" aria-label="Import PGN" onClick={onBackdropClick}>
31
+ <div className="pgn-dialog__inner">
32
+ <div className="pgn-dialog__header">
33
+ <h2 className="pgn-dialog__title">Import PGN</h2>
34
+ <button id="pgn-close-btn" className="pgn-dialog__close" aria-label="Close" onClick={() => actions.closePgnDialog()}>✕</button>
35
+ </div>
36
+ <textarea id="pgn-input" className="pgn-dialog__textarea" ref={textareaRef}
37
+ placeholder="Paste PGN here… e.g. 1. e4 e5 2. Nf3 Nc6 3. Bc4"
38
+ rows={7} spellCheck="false" />
39
+ <div id="pgn-error" className="pgn-dialog__error" role="alert" aria-live="polite">{pgnError}</div>
40
+ <div className="pgn-dialog__actions">
41
+ <Button id="pgn-parse-btn" onClick={onParseClick}>Import Game</Button>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ );
46
+ }
frontend/src/components/PgnPanel.jsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import { parseFenMeta } from '../engine/pgnFormat.js';
3
+ import { buildMoveListItems, buildVariation } from '../engine/moveLine.js';
4
+ import { scrollIntoViewWithin } from '../engine/scroll.js';
5
+ import Button from './ui/Button.jsx';
6
+ import Panel from './ui/Panel.jsx';
7
+
8
+ function MoveButton({ node, isCurrent, chip, onGoTo }) {
9
+ const cls = `move-line__move${chip ? ' move-line__move--chip' : ''}${isCurrent ? ' is-current' : ''}`;
10
+ return <button className={cls} onClick={onGoTo ? () => onGoTo(node.id) : undefined}>{node.san}</button>;
11
+ }
12
+
13
+ function renderItems(items, nodes, rootChildren, currentId, onGoTo, keyPrefix) {
14
+ return items.flatMap((item, idx) => {
15
+ const key = `${keyPrefix}-${idx}`;
16
+ if (item.kind === 'row') {
17
+ const whiteEmpty = !item.white || item.white.placeholder;
18
+ return [
19
+ <div className="move-line__row" key={key}>
20
+ <span className="move-line__num">{item.num}.</span>
21
+ <span
22
+ className={`move-line__cell move-line__cell--white${whiteEmpty ? ' move-line__cell--empty' : ''}${item.white?.placeholder ? ' move-line__cell--placeholder' : ''}${item.white?.isCurrent ? ' is-current' : ''}`}
23
+ onClick={item.white && !item.white.placeholder ? () => onGoTo(item.white.node.id) : undefined}
24
+ >
25
+ {item.white?.placeholder ? '...' : null}
26
+ {item.white && !item.white.placeholder && <MoveButton node={item.white.node} isCurrent={item.white.isCurrent} />}
27
+ </span>
28
+ <span
29
+ className={`move-line__cell move-line__cell--black${!item.black ? ' move-line__cell--empty' : ''}${item.black?.isCurrent ? ' is-current' : ''}`}
30
+ onClick={item.black ? () => onGoTo(item.black.node.id) : undefined}
31
+ >
32
+ {item.black && <MoveButton node={item.black.node} isCurrent={item.black.isCurrent} />}
33
+ </span>
34
+ </div>,
35
+ ];
36
+ }
37
+ if (item.kind === 'para') {
38
+ return [
39
+ <div className="move-line__variation" key={key} style={{ paddingLeft: `${1.5 * item.depth}rem` }}>
40
+ {item.chips.map((chip, ci) => chip.kind === 'num'
41
+ ? <span className="move-line__num move-line__num--inline" key={ci}>{chip.text}</span>
42
+ : <MoveButton key={ci} node={chip.node} isCurrent={chip.isCurrent} chip onGoTo={onGoTo} />)}
43
+ </div>,
44
+ ];
45
+ }
46
+ // 'variation' marker - expand lazily so nested forks don't need to
47
+ // be walked until they're actually reached.
48
+ const varItems = buildVariation(nodes, rootChildren, item.nodeIds, item.whiteToMove, item.moveNum, currentId, item.depth);
49
+ return renderItems(varItems, nodes, rootChildren, currentId, onGoTo, `${key}-v`);
50
+ });
51
+ }
52
+
53
+ export default function PgnPanel({ explorer }) {
54
+ const { moveHistory, startFen, historyInitialized, actions } = explorer;
55
+ const { nodes, rootChildren, currentId } = moveHistory;
56
+ const scrollRef = useRef(null);
57
+
58
+ useEffect(() => {
59
+ const container = scrollRef.current;
60
+ const current = container?.querySelector('.is-current');
61
+ scrollIntoViewWithin(current, container);
62
+ }, [currentId, nodes]);
63
+
64
+ let content = null;
65
+ if (!historyInitialized) {
66
+ content = null; // no render until Board reports its first move-tree change
67
+ } else if (!rootChildren || rootChildren.length === 0) {
68
+ content = <span className="move-line__empty">No moves yet</span>;
69
+ } else {
70
+ const { turn, fullmove } = parseFenMeta(startFen);
71
+ const items = buildMoveListItems(nodes, rootChildren, currentId, turn, fullmove);
72
+ content = renderItems(items, nodes, rootChildren, currentId, actions.goToNode, 'm');
73
+ }
74
+
75
+ return (
76
+ <Panel
77
+ className="pgn-panel" label="Game PGN" title="PGN"
78
+ end={
79
+ <>
80
+ <Button id="pgn-btn" compact icon="upload" title="Import PGN game" onClick={() => actions.openPgnDialog()}>Import</Button>
81
+ <Button id="pgn-export-btn" compact icon="download" title="Download game as PGN" onClick={() => actions.exportPgn()}>Export</Button>
82
+ </>
83
+ }
84
+ >
85
+ <div className="pgn-moves-scroll" role="region" aria-label="Move history" tabIndex={0} ref={scrollRef}>
86
+ <div id="move-line" className="move-line" role="list" aria-label="Played moves">
87
+ {content}
88
+ </div>
89
+ </div>
90
+ </Panel>
91
+ );
92
+ }
frontend/src/components/PromotionDialog.jsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function PromotionDialog() {
2
+ return (
3
+ <div id="promotion-dialog" className="promotion-dialog" role="dialog" aria-modal="true" aria-label="Choose promotion piece">
4
+ <div className="promotion-tray">
5
+ <span className="promotion-tray__title">Promote pawn to:</span>
6
+ <div className="promotion-tray__pieces">
7
+ <button className="promotion-piece-btn" data-piece="q" aria-label="Queen">
8
+ <img src="" alt="Queen" draggable="false" />
9
+ </button>
10
+ <button className="promotion-piece-btn" data-piece="r" aria-label="Rook">
11
+ <img src="" alt="Rook" draggable="false" />
12
+ </button>
13
+ <button className="promotion-piece-btn" data-piece="b" aria-label="Bishop">
14
+ <img src="" alt="Bishop" draggable="false" />
15
+ </button>
16
+ <button className="promotion-piece-btn" data-piece="n" aria-label="Knight">
17
+ <img src="" alt="Knight" draggable="false" />
18
+ </button>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ );
23
+ }
frontend/src/components/ResultsPanel.jsx ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import { Tablebase } from '../engine/tablebase.js';
3
+ import { initMovesGutterSync } from '../engine/scroll.js';
4
+ import Button from './ui/Button.jsx';
5
+ import Panel from './ui/Panel.jsx';
6
+ import Segmented from './ui/Segmented.jsx';
7
+
8
+ const METRICS = ['dtz', 'dtc', 'dtm', 'dtm50'];
9
+ const METRIC_LABEL = { dtz: 'DTZ', dtc: 'DTC', dtm: 'DTM', dtm50: 'DTM50' };
10
+ const METRIC_OPTIONS = METRICS.map(m => ({ value: m, label: METRIC_LABEL[m] }));
11
+
12
+ const COLUMN_CLASSES = [
13
+ 'col-rank',
14
+ 'group-dtz col-move', 'group-dtz col-score',
15
+ 'group-dtc col-move', 'group-dtc col-score',
16
+ 'group-dtm col-move', 'group-dtm col-score',
17
+ 'group-dtm50 col-move', 'group-dtm50 col-score',
18
+ ];
19
+
20
+ function MetricColGroup() {
21
+ return (
22
+ <colgroup>
23
+ {COLUMN_CLASSES.map(cls => <col key={cls} className={cls} />)}
24
+ </colgroup>
25
+ );
26
+ }
27
+
28
+ function DotIcon({ prefix }) {
29
+ return (
30
+ <svg viewBox="0 0 16 16" aria-hidden="true" className={`${prefix}__icon`}>
31
+ <circle cx="8" cy="8" r="8" className={`${prefix}__bg`} />
32
+ <circle cx="8" cy="4.3" r="1.05" className={`${prefix}__mark`} />
33
+ <rect x="7.1" y="6.5" width="1.8" height="6.2" rx="0.9" className={`${prefix}__mark`} />
34
+ </svg>
35
+ );
36
+ }
37
+
38
+ function Cell({ cell, idx }) {
39
+ const props = { key: idx, className: cell.className };
40
+ if (cell.title) props.title = cell.title;
41
+ if (cell.headers) props.headers = cell.headers;
42
+ if (cell.interactive) {
43
+ props['data-san'] = cell.dataSan;
44
+ props['data-child-fen'] = cell.dataChildFen || '';
45
+ props.tabIndex = 0;
46
+ props.role = 'button';
47
+ props['aria-label'] = cell.kind === 'score'
48
+ ? `${cell.title}; Play ${cell.dataSan}` : `Play ${cell.dataSan}`;
49
+ } else if (cell.kind === 'score' && cell.title) {
50
+ props['aria-label'] = cell.title;
51
+ }
52
+ if (cell.outcome !== undefined) props['data-outcome'] = cell.outcome;
53
+ return (
54
+ <td {...props}>
55
+ {cell.text}
56
+ {cell.warning && <span className="warning-dot" title={cell.warning}><DotIcon prefix="warning-dot" /></span>}
57
+ {cell.info && <span className="info-dot" title={cell.info}><DotIcon prefix="info-dot" /></span>}
58
+ </td>
59
+ );
60
+ }
61
+
62
+ function AutoplayButton({ metric, explorer }) {
63
+ const { autoplayMetric, autoplayAvailability, actions } = explorer;
64
+ const info = autoplayAvailability[metric];
65
+ const isPlaying = autoplayMetric === metric;
66
+ return (
67
+ <button id={`autoplay-btn-${metric}`} className={`btn btn--col-autoplay${isPlaying ? ' is-playing' : ''}`}
68
+ disabled={info.disabled}
69
+ title={isPlaying ? `Stop ${METRIC_LABEL[metric]} auto-play` : info.title}
70
+ aria-label={`Auto-play best ${METRIC_LABEL[metric]} move`}
71
+ onClick={() => actions.toggleAutoPlay(metric)}>
72
+ <svg className="autoplay-icon autoplay-icon--play" viewBox="0 0 12 12" aria-hidden="true">
73
+ <path d="M3 2.1 L9.6 6 L3 9.9 Z" />
74
+ </svg>
75
+ <svg className="autoplay-icon autoplay-icon--stop" viewBox="0 0 12 12" aria-hidden="true">
76
+ <rect x="2.4" y="2.4" width="7.2" height="7.2" rx="1" />
77
+ </svg>
78
+ </button>
79
+ );
80
+ }
81
+
82
+ export default function ResultsPanel({ explorer }) {
83
+ const { tb, activeMetric, actions } = explorer;
84
+ const tbodyRef = useRef(null);
85
+ const cleanupRef = useRef(null);
86
+
87
+ useEffect(() => {
88
+ cleanupRef.current = Tablebase.attachTableEvents(tbodyRef.current);
89
+ return () => cleanupRef.current?.();
90
+ }, []);
91
+
92
+ useEffect(() => initMovesGutterSync(), []);
93
+
94
+ useEffect(() => {
95
+ Tablebase.notifyTableRendered();
96
+ }, [tb.rows, tb.rootRow]);
97
+
98
+ return (
99
+ <Panel
100
+ className={`results-panel${tb.pending ? ' is-pending' : ''}${tb.loading ? ' is-loading' : ''}`}
101
+ label="Tablebase results" title="Tablebase Result"
102
+ start={
103
+ <div className="probe-status" style={{ display: tb.progress.visible ? 'flex' : 'none' }}>
104
+ <span id="probe-progress-label" className="probe-progress-label" aria-live="polite">{tb.progress.label}</span>
105
+ <div id="probe-progress" className={`probe-progress${tb.progress.pct === null ? ' is-indeterminate' : ''}`} aria-hidden="true">
106
+ <div className="probe-progress__fill" style={{ width: tb.progress.pct === null ? undefined : `${tb.progress.pct}%` }}></div>
107
+ </div>
108
+ </div>
109
+ }
110
+ end={
111
+ <>
112
+ <span id="move-count" className="panel-header__count" aria-live="polite" aria-atomic="true">{tb.moveCount}</span>
113
+ <Button id="export-btn" compact iconOnly icon="download" title="Download move table as CSV" onClick={() => actions.exportCsv()}>CSV</Button>
114
+ </>
115
+ }
116
+ >
117
+ <Segmented
118
+ className="metric-tabs" itemClassName="metric-tab" variant="tabs" label="Tablebase metric"
119
+ options={METRIC_OPTIONS} value={activeMetric} onChange={actions.setActiveMetric}
120
+ />
121
+
122
+ <div className="moves-view">
123
+ <div className="moves-head">
124
+ <table className="moves-table moves-table--head" data-active-metric={activeMetric}>
125
+ <MetricColGroup />
126
+ <thead>
127
+ <tr className="group-row">
128
+ <th></th>
129
+ {METRICS.map(m => (
130
+ <th key={m} id={`th-${m}-grp`} className={`group-${m}`} colSpan={2}>
131
+ <span className="group-row-inner">
132
+ <span className="group-label">{METRIC_LABEL[m]}</span>
133
+ <AutoplayButton metric={m} explorer={explorer} />
134
+ </span>
135
+ </th>
136
+ ))}
137
+ </tr>
138
+ <tr className="col-row">
139
+ <th id="th-rank" className="col-rank" scope="col" aria-label="Rank">#</th>
140
+ <th id="th-dtz-move" className="group-dtz col-move" scope="col">Move</th>
141
+ <th id="th-dtz-score" className="group-dtz col-score" scope="col">Score</th>
142
+ <th id="th-dtc-move" className="group-dtc col-move" scope="col">Move</th>
143
+ <th id="th-dtc-score" className="group-dtc col-score" scope="col">Score/Order</th>
144
+ <th id="th-dtm-move" className="group-dtm col-move" scope="col">Move</th>
145
+ <th id="th-dtm-score" className="group-dtm col-score" scope="col">Score</th>
146
+ <th id="th-dtm50-move" className="group-dtm50 col-move" scope="col">Move</th>
147
+ <th id="th-dtm50-score" className="group-dtm50 col-score" scope="col">Score</th>
148
+ </tr>
149
+ <tr id="root-row" className={`root-row${tb.rootRow ? '' : ' is-hidden'}`}>
150
+ <td className="col-rank"></td>
151
+ {(tb.rootRow?.cells ?? []).map((cell, idx) => <Cell key={idx} cell={cell} idx={idx} />)}
152
+ </tr>
153
+ </thead>
154
+ </table>
155
+ </div>
156
+
157
+ <div className="moves-body-scroll" role="region" aria-label="Move rankings" tabIndex={0}>
158
+ <table className="moves-table moves-table--body" data-active-metric={activeMetric} aria-label="Moves ranked by tablebase metric">
159
+ <caption className="sr-only">
160
+ When Show Root Row is enabled in Settings, the current position&apos;s own score is shown
161
+ first, followed by legal moves ranked by Distance to Zeroing, Distance to Conversion,
162
+ Distance to Mate, and Distance to Mate with the 50-move rule
163
+ </caption>
164
+ <MetricColGroup />
165
+ <tbody id="moves-tbody" ref={tbodyRef}>
166
+ {tb.rows.map(row => (
167
+ <tr key={row.rank}>
168
+ <td className="col-rank" headers="th-rank">{row.rank}</td>
169
+ {row.cells.map((cell, idx) => <Cell key={idx} cell={cell} idx={idx} />)}
170
+ </tr>
171
+ ))}
172
+ </tbody>
173
+ </table>
174
+
175
+ <div id="moves-empty-state" className="moves-empty-state" style={{ display: tb.emptyState ? 'flex' : 'none' }} aria-live="polite">
176
+ <span className="moves-empty-state__icon" aria-hidden="true">♟</span>
177
+ <p>{tb.emptyMessage}</p>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </Panel>
182
+ );
183
+ }
frontend/src/components/ScreenNav.jsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+
3
+ const SCREENS = ['Board', 'Results', 'Moves'];
4
+
5
+ export default function ScreenNav() {
6
+ const [current, setCurrent] = useState(0);
7
+
8
+ useEffect(() => {
9
+ const content = document.querySelector('.content');
10
+ if (!content) return undefined;
11
+ const onScroll = () => setCurrent(Math.round(content.scrollLeft / content.clientWidth));
12
+ content.addEventListener('scroll', onScroll, { passive: true });
13
+ return () => content.removeEventListener('scroll', onScroll);
14
+ }, []);
15
+
16
+ function goTo(index) {
17
+ const content = document.querySelector('.content');
18
+ if (!content) return;
19
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
20
+ content.scrollTo({ left: index * content.clientWidth, behavior: reduced ? 'auto' : 'smooth' });
21
+ }
22
+
23
+ return (
24
+ <nav className="screen-nav" aria-label="Screens">
25
+ {SCREENS.map((label, i) => (
26
+ <button key={label} type="button" className="screen-nav__item"
27
+ aria-current={i === current ? 'page' : undefined} onClick={() => goTo(i)}>
28
+ {label}
29
+ </button>
30
+ ))}
31
+ </nav>
32
+ );
33
+ }
frontend/src/components/SettingsPanel.jsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import { formatAutoPlayDelay } from '../hooks/useExplorer.js';
3
+ import Button from './ui/Button.jsx';
4
+
5
+ const SHORTCUTS = [['Enter', 'Apply'], ['F', 'Flip'], ['C', 'Clear'], ['←', 'Back'], ['→', 'Forward']];
6
+
7
+ const DELAY_MIN = 0;
8
+ const DELAY_MAX = 2500;
9
+
10
+ export default function SettingsPanel({ explorer }) {
11
+ const { settingsOpen, setSettingsOpen, showRootRow, variant, autoplayDelayMs, actions } = explorer;
12
+ const panelRef = useRef(null);
13
+ const chess960 = variant === 'chess960';
14
+
15
+ useEffect(() => {
16
+ if (!settingsOpen) return;
17
+ function onDocClick(e) {
18
+ if (panelRef.current && !panelRef.current.contains(e.target) && !e.target.closest('#settings-btn')) {
19
+ setSettingsOpen(false);
20
+ }
21
+ }
22
+ function onKeydown(e) {
23
+ if (e.key === 'Escape') { e.preventDefault(); setSettingsOpen(false); }
24
+ }
25
+ document.addEventListener('click', onDocClick);
26
+ document.addEventListener('keydown', onKeydown);
27
+ return () => {
28
+ document.removeEventListener('click', onDocClick);
29
+ document.removeEventListener('keydown', onKeydown);
30
+ };
31
+ }, [settingsOpen, setSettingsOpen]);
32
+
33
+ const pct = ((autoplayDelayMs - DELAY_MIN) / (DELAY_MAX - DELAY_MIN)) * 100;
34
+
35
+ return (
36
+ <div id="settings-panel" ref={panelRef} className={`settings-panel${settingsOpen ? ' is-open' : ''}`}
37
+ role="dialog" aria-modal="false" aria-label="Board settings">
38
+ <div className="settings-panel__header">
39
+ <span className="settings-panel__title">Board Settings</span>
40
+ <Button id="settings-close-btn" compact iconOnly icon="close" aria-label="Close settings"
41
+ onClick={() => setSettingsOpen(false)} />
42
+ </div>
43
+
44
+ <div className="settings-row settings-row--toggle">
45
+ <label className="settings-toggle" htmlFor="show-root-row-toggle">
46
+ <span className="settings-toggle__label">Show Root Row</span>
47
+ <span className="toggle-switch">
48
+ <input type="checkbox" id="show-root-row-toggle" className="toggle-switch__input" role="switch"
49
+ checked={showRootRow} onChange={(e) => actions.setShowRootRowAction(e.target.checked)} />
50
+ <span className="toggle-switch__track" aria-hidden="true"></span>
51
+ </span>
52
+ </label>
53
+ </div>
54
+
55
+ <div className="settings-row settings-row--toggle">
56
+ <label className="settings-toggle" htmlFor="chess960-mode-toggle">
57
+ <span className="settings-toggle__label">Chess960 Mode</span>
58
+ <span className="toggle-switch">
59
+ <input type="checkbox" id="chess960-mode-toggle" className="toggle-switch__input" role="switch"
60
+ checked={chess960} onChange={(e) => actions.setChess960Action(e.target.checked)} />
61
+ <span className="toggle-switch__track" aria-hidden="true"></span>
62
+ </span>
63
+ </label>
64
+ </div>
65
+
66
+ <div className="settings-row settings-row--slider">
67
+ <span className="settings-slider__label">Autoplay Delay</span>
68
+ <div className="settings-slider__control">
69
+ <input type="range" id="autoplay-delay-slider" className="range-slider"
70
+ min={DELAY_MIN} max={DELAY_MAX} step={50} value={autoplayDelayMs}
71
+ style={{ '--range-fill': `${pct}%` }}
72
+ aria-label="Autoplay delay" aria-valuetext={formatAutoPlayDelay(autoplayDelayMs)}
73
+ onChange={(e) => actions.setAutoplayDelayAction(parseInt(e.target.value, 10))} />
74
+ <div className="range-slider__ticks" aria-hidden="true">
75
+ <span>0s</span><span>1.25s</span><span>2.5s</span>
76
+ </div>
77
+ </div>
78
+ <span className="settings-slider__value" id="autoplay-delay-value">{formatAutoPlayDelay(autoplayDelayMs)}</span>
79
+ </div>
80
+
81
+ <div className="settings-row settings-row--shortcuts">
82
+ <span className="settings-shortcuts__title">Keyboard Shortcuts</span>
83
+ <ul className="settings-shortcuts__list">
84
+ {SHORTCUTS.map(([key, action]) => (
85
+ <li key={key}><kbd>{key}</kbd> {action}</li>
86
+ ))}
87
+ </ul>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
frontend/src/components/Toasts.jsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export default function Toasts({ toasts }) {
2
+ return (
3
+ <div id="toast-container" className="toast-container" aria-live="polite" aria-atomic="false">
4
+ {toasts.map(t => (
5
+ <div key={t.id} className={`toast${t.leaving ? ' is-out' : ''}`}>{t.message}</div>
6
+ ))}
7
+ </div>
8
+ );
9
+ }
frontend/src/components/ui/Button.jsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Icon from './Icon.jsx';
2
+
3
+ export default function Button({ variant, compact, grow, iconOnly, icon, className = '', children, ...rest }) {
4
+ const cls = [
5
+ 'btn',
6
+ variant === 'primary' && 'btn--apply',
7
+ compact && 'btn--compact',
8
+ grow && 'btn--grow',
9
+ iconOnly && 'btn--icon-only',
10
+ className,
11
+ ].filter(Boolean).join(' ');
12
+ return (
13
+ <button type="button" className={cls} {...rest}>
14
+ {icon && <Icon name={icon} className="btn__icon" />}
15
+ {children}
16
+ </button>
17
+ );
18
+ }
frontend/src/components/ui/Icon.jsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const STROKE_WIDTH = { check: 2.5, close: 2.5 };
2
+
3
+ const PATHS = {
4
+ repository: <><path d="M7 8.5v7m10-7V10a4 4 0 0 1-4 4H7" /><circle cx="7" cy="6" r="2.5" /><circle cx="7" cy="18" r="2.5" /><circle cx="17" cy="6" r="2.5" /></>,
5
+ check: <polyline points="20 6 9 17 4 12" />,
6
+ close: <><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></>,
7
+ flip: <><path d="M7 16V4m0 0L3 8m4-4 4 4" /><path d="M17 8v12m0 0 4-4m-4 4-4-4" /></>,
8
+ trash: <><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></>,
9
+ copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>,
10
+ arrowLeft: <><path d="M19 12H5" /><polyline points="12 19 5 12 12 5" /></>,
11
+ arrowRight: <><path d="M5 12h14" /><polyline points="12 5 19 12 12 19" /></>,
12
+ download: <><path d="M12 3v12" /><polyline points="7 10 12 15 17 10" /><path d="M4 19h16" /></>,
13
+ upload: <><path d="M12 21V9" /><polyline points="7 14 12 9 17 14" /><path d="M4 5h16" /></>,
14
+ lockOpen: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0" /></>,
15
+ lockClosed: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
16
+ gauge: <><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" /><path d="M12 14 16 8" /><path d="M5 19a9 9 0 1 1 14 0" /></>,
17
+ code: <><polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" /></>,
18
+ settings: (
19
+ <>
20
+ <circle cx="12" cy="12" r="3" />
21
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
22
+ </>
23
+ ),
24
+ };
25
+
26
+ const GITHUB_PATH = 'M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z';
27
+
28
+ export default function Icon({ name, className = '' }) {
29
+ const cls = `icon${className ? ` ${className}` : ''}`;
30
+ if (name === 'github') {
31
+ return (
32
+ <svg className={cls} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
33
+ <path fillRule="evenodd" clipRule="evenodd" d={GITHUB_PATH} />
34
+ </svg>
35
+ );
36
+ }
37
+ return (
38
+ <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor"
39
+ strokeWidth={STROKE_WIDTH[name] ?? 2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
40
+ {PATHS[name]}
41
+ </svg>
42
+ );
43
+ }
frontend/src/components/ui/Panel.jsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function Panel({ label, title, start, end, className = '', children }) {
2
+ return (
3
+ <section className={`panel${className ? ` ${className}` : ''}`} aria-label={label}>
4
+ <div className="panel-header">
5
+ <div className="panel-header__start">
6
+ <span className="panel-header__title">{title}</span>
7
+ {start}
8
+ </div>
9
+ <div className="panel-header__end">{end}</div>
10
+ </div>
11
+ {children}
12
+ </section>
13
+ );
14
+ }
frontend/src/components/ui/Segmented.jsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef } from 'react';
2
+
3
+ export default function Segmented({ label, options, value, onChange, variant = 'radio', className = '', itemClassName = '' }) {
4
+ const groupRef = useRef(null);
5
+ const tabs = variant === 'tabs';
6
+
7
+ function onKeyDown(e) {
8
+ const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
9
+ if (!step) return;
10
+ e.preventDefault();
11
+ e.stopPropagation();
12
+ const i = options.findIndex(o => o.value === value);
13
+ const next = options[(i + step + options.length) % options.length];
14
+ onChange(next.value);
15
+ groupRef.current?.querySelector(`[data-value="${next.value}"]`)?.focus();
16
+ }
17
+
18
+ return (
19
+ <div ref={groupRef} className={className} role={tabs ? 'tablist' : 'radiogroup'} aria-label={label} onKeyDown={onKeyDown}>
20
+ {options.map(o => {
21
+ const active = o.value === value;
22
+ return (
23
+ <button
24
+ key={o.value} id={o.id} type="button" data-value={o.value}
25
+ className={`${itemClassName}${active ? ' is-active' : ''}`}
26
+ role={tabs ? 'tab' : 'radio'}
27
+ {...(tabs ? { 'aria-selected': active } : { 'aria-checked': active })}
28
+ tabIndex={active ? 0 : -1}
29
+ title={o.title}
30
+ onClick={() => onChange(o.value)}
31
+ >
32
+ {o.content ?? o.label}
33
+ </button>
34
+ );
35
+ })}
36
+ </div>
37
+ );
38
+ }
frontend/src/engine/board.js ADDED
@@ -0,0 +1,1282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * board.js: Chessground wrapper
3
+ *
4
+ * Wraps the vendor Chessground() instance and a chessops position, and owns:
5
+ * - A branching move tree (_nodes/_rootChildren) used by the PGN panel:
6
+ * playing a move after navigating back adds a variation instead of
7
+ * overwriting the continuation from that point. A plain
8
+ * undo/redo FEN stack (_history/_future) sits alongside it as a
9
+ * fallback for Back/Forward once navigation walks off the tree (e.g.
10
+ * past a board edit that started a fresh one).
11
+ * - Best-move arrows for DTZ/DTC/DTM/DTM50, drawn via chessground's
12
+ * native drawable.autoShapes.
13
+ * - Click-to-place editing: click a spare piece, then click a square to
14
+ * place it. Drag-from-tray is also supported (chessground's
15
+ * dragNewPiece()).
16
+ * - goToNode() - jump to any node in the current move tree (used by
17
+ * the PGN panel, including variation moves) without resetting the tree
18
+ * or re-triggering a probe for a position that's already been evaluated.
19
+ * - Board/piece theming: the piece image path is owned by theme.js
20
+ * (Theme.pieceThemeFn()). reconstruct() updates the piece
21
+ * CSS <link> and repopulates the spare-tray/promotion-dialog images;
22
+ * chessground is never destroyed and rebuilt for theming, since piece
23
+ * images are plain CSS.
24
+ * - The active rules variant (Standard / Chess960) and the FEN protocol
25
+ * boundary between them. chessops's own `Chess` class implements both
26
+ * rulesets' move generation/legality/SAN identically and has no
27
+ * variant concept of its own - castling legality, paths, and rook
28
+ * squares are computed the same way regardless of variant (see
29
+ * chessgroundDests() below for the one place chessops is told which
30
+ * variant is active, purely to choose which destinations chessground
31
+ * is offered). The application's Standard-vs-Chess960 FEN contract is
32
+ * stricter than chessops's generic one: chessops's FEN parser resolves
33
+ * a 'K'/'Q'/'k'/'q' castling letter to whichever rook sits on the
34
+ * correct side of the king wherever the king happens to be (needed for
35
+ * Chess960, where the king isn't always on the e-file) and also accepts
36
+ * Shredder-FEN/X-FEN rook-file letters unconditionally - neither of
37
+ * which is a legal Standard-chess castling encoding. _buildPosition()
38
+ * below enforces the application's Standard-mode castling contract
39
+ * (only 'K'/'Q'/'k'/'q', each requiring the king on its e1/e8 home
40
+ * square and the resolved rook on its exact a1/h1/a8/h8 home square)
41
+ * before trusting chessops's more permissive parse, so the frontend
42
+ * and the backend (see app.py's _validate_fen_parts and python-chess's
43
+ * own chess960=False castling validation) agree on whether a given FEN
44
+ * is valid for the active variant.
45
+ *
46
+ * Every position-changing action - setPosition(), clear(), playMove(),
47
+ * goBack()/goForward(), board edits (off-board drop, board-setup drag,
48
+ * click-to-place), setVariant() - records itself (in the tree or the
49
+ * undo/redo stack, whichever applies) and invokes the onPositionChange
50
+ * callback itself, so callers never need to remember to do either one
51
+ * manually.
52
+ *
53
+ * The chessops position (_position) is the single source of truth for
54
+ * position/legality - chessground is a dumb, controlled rendering surface.
55
+ * Every position-changing function ends with a call to _syncChessground(),
56
+ * which pushes _position's current FEN (plus turn colour, check state, and
57
+ * last-move squares) into chessground in one shot. The core mutation
58
+ * handler (_handleBoardMutation) is idempotent rather than tracking which
59
+ * of chessground's several event hooks handled a given user action,
60
+ * because chessground fires multiple, differently-scoped events for what
61
+ * is logically one move. For an ordinary relocation, events.move fires
62
+ * first, then events.change, then movable.events.after (see board.ts's
63
+ * baseMove()/userMove()). events.change carries no orig/dest, so if it won
64
+ * the idempotency race it would take the generic "board edit" branch and
65
+ * leave movable.events.after's orig/dest with nothing left to act on.
66
+ * events.move is therefore the primary handler: it fires before
67
+ * events.change and, unlike movable.events.after, carries orig/dest (plus
68
+ * capture info), so it wins the idempotency race with enough information
69
+ * to classify the move correctly. events.change and movable.events.after
70
+ * are wired as idempotent fallbacks, and events.change is the only hook
71
+ * that fires for an off-board deletion (drag.ts's deleteOnDropOff branch
72
+ * calls it directly, with no accompanying events.move/
73
+ * movable.events.after).
74
+ */
75
+
76
+ import { Chessground } from '../vendor/chessground.min.js';
77
+ import { Theme } from './theme.js';
78
+ import * as Storage from './storage.js';
79
+
80
+ import { Chess, Castles, castlingSide } from '../vendor/chessops/chess.js';
81
+ import { parseFen, makeFen } from '../vendor/chessops/fen.js';
82
+ import { chessgroundDests } from '../vendor/chessops/compat.js';
83
+ import { parseSan, makeSan } from '../vendor/chessops/san.js';
84
+ import { parseSquare, makeSquare } from '../vendor/chessops/util.js';
85
+
86
+ // ── Variant ──────────────────────────────────────────────────────────────
87
+ // The application's rules-mode state. Distinct from chessops's own `rules`
88
+ // property (always 'chess' for both Standard and Chess960 - see the
89
+ // module doc comment above): this is application metadata that travels
90
+ // alongside the position, never derived from it.
91
+ const VARIANTS = Object.freeze({ STANDARD: 'standard', CHESS960: 'chess960' });
92
+
93
+ const Board = (() => {
94
+
95
+ let cg = null; // Chessground instance
96
+ let _position = null; // chessops Chess position - see module doc comment
97
+ let _variant = VARIANTS.STANDARD;
98
+ let _onPositionChange = null;
99
+ let _onMoveHistoryChange = null;
100
+
101
+ let _pendingPromotion = null;
102
+
103
+ // ── Lock mode ────────────────────────────────────────────────────────────
104
+ // When true, the board only accepts legal moves for the side to move;
105
+ // spare-piece placement and off-board deletion are disabled. Toggled via
106
+ // setLocked(). Moves played through the ranked-moves table, auto-play, or
107
+ // the PGN panel are unaffected, since those are already always legal.
108
+ let _locked = false;
109
+
110
+ // [orig, dest] of the most recently played/navigated-to move, or
111
+ // undefined. Fed into chessground's `lastMove` config on every sync.
112
+ // Tracked separately from the move tree because Back/Forward/goToNode
113
+ // land on a node that already has its own from/to squares recorded;
114
+ // this variable is what lets _syncChessground() highlight the right
115
+ // squares for those navigations too, not just for a freshly played move.
116
+ let _lastMoveKeys = undefined;
117
+
118
+ // ── Undo / redo stacks ────────────────────────────────────────────────────
119
+ // Plain chronological FEN log, used only as a Back/Forward fallback once
120
+ // navigation walks off the current move tree entirely (e.g. past a board
121
+ // edit - edits aren't part of the tree, see _resetMoveLine below). While
122
+ // the current position is inside the tree, goBack()/goForward() use the
123
+ // tree's parent/activeChild pointers instead (see below).
124
+ let _history = [];
125
+ let _future = [];
126
+ const _MAX_HISTORY = 100;
127
+
128
+ function _pushHistory(fen) {
129
+ _history.push(fen);
130
+ if (_history.length > _MAX_HISTORY) _history.shift();
131
+ _future = []; // any new position discards the redo stack
132
+ }
133
+
134
+ // ── Move tree ──────────────────────────────────────────────────────────────
135
+ // Only legal moves via playMove() (and the promotion/drag-move
136
+ // equivalents) are recorded here. setPosition() / clear() / board edits /
137
+ // setVariant() reset the tree.
138
+ //
139
+ // Each node is {id, san, fen, from, to, parentId, children, activeChild}.
140
+ // `id` is also the node's index in _nodes. parentId === ROOT (-1) means
141
+ // the node replies to the tree's start position (_startFen). `children`
142
+ // lists every move tried from that node - children[0] renders as the
143
+ // "main" continuation, children[1+] as parenthesised variations.
144
+ // `activeChild` is whichever child Forward should follow from that node
145
+ // (whichever branch was most recently played/clicked into) - not
146
+ // necessarily children[0]. `from`/`to` are the move's origin/destination
147
+ // squares exactly as played (for a Chess960 castle, the king's origin
148
+ // and whichever destination square chessground/chessops reported,
149
+ // never assumed to be a fixed c/g-file square), recorded alongside
150
+ // san/fen so lastMove highlighting works when navigating the tree, not
151
+ // just on a freshly-played move.
152
+ //
153
+ // The PGN panel and PGN export walk this tree recursively so alternate
154
+ // moves are preserved as variations instead of overwriting history.
155
+ const ROOT = -1;
156
+
157
+ let _nodes = []; // [{id, san, fen, from, to, parentId, children:[], activeChild}]
158
+ let _rootChildren = []; // ids of moves played directly from _startFen
159
+ let _rootActiveChild = null;
160
+ let _currentId = ROOT; // id of the node currently shown, or ROOT for _startFen itself
161
+ let _startFen = null; // FEN the current tree began from (for PGN formatting)
162
+
163
+ function _childrenOf(id) { return id === ROOT ? _rootChildren : _nodes[id].children; }
164
+ function _activeChildOf(id) { return id === ROOT ? _rootActiveChild : _nodes[id].activeChild; }
165
+ function _setActiveChild(id, childId) {
166
+ if (id === ROOT) _rootActiveChild = childId;
167
+ else _nodes[id].activeChild = childId;
168
+ }
169
+
170
+ function _resetMoveLine() {
171
+ _nodes = [];
172
+ _rootChildren = [];
173
+ _rootActiveChild = null;
174
+ _currentId = ROOT;
175
+ _future = [];
176
+ _startFen = _position ? currentFen() : null;
177
+ if (_onMoveHistoryChange) _emitHistory();
178
+ }
179
+
180
+ function _emitHistory() {
181
+ if (_onMoveHistoryChange) {
182
+ _onMoveHistoryChange({
183
+ nodes: _nodes, rootChildren: _rootChildren,
184
+ rootActiveChild: _rootActiveChild, currentId: _currentId,
185
+ }, _startFen);
186
+ }
187
+ }
188
+
189
+ // Records a played move as a child of the current node. If the current
190
+ // node already has a child with this exact SAN (the user replayed a move
191
+ // already tried from this point), that existing node is reused instead
192
+ // of creating a duplicate. Otherwise a new node is created and appended
193
+ // to the end of the children list, so an already-explored continuation
194
+ // stays the main line and the newly-played move is recorded as a
195
+ // parenthesised variation.
196
+ function _addMove(san, fen, from, to) {
197
+ _future = []; // starting a fresh line invalidates any pre-tree redo
198
+ const kids = _childrenOf(_currentId);
199
+ let childId = kids.find(id => _nodes[id].san === san);
200
+ if (childId === undefined) {
201
+ childId = _nodes.length;
202
+ _nodes.push({ id: childId, san, fen, from, to, parentId: _currentId, children: [], activeChild: null });
203
+ kids.push(childId);
204
+ }
205
+ // (else: reused an existing child - same SAN replayed from this node)
206
+ _setActiveChild(_currentId, childId);
207
+ _currentId = childId;
208
+ return childId;
209
+ }
210
+
211
+ // ── Piece theme ───────────────────────────────────────────────────────────
212
+ // Delegates to theme.js, which owns the active piece set. Kept as a local
213
+ // wrapper (rather than calling Theme.pieceThemeFn() inline everywhere)
214
+ // so callers below don't need to know theme.js exists. Resolved once on
215
+ // first use rather than re-fetched from Theme on every call.
216
+ let _pieceThemeFn = null;
217
+ function _pieceTheme(piece) {
218
+ if (!_pieceThemeFn) _pieceThemeFn = Theme.pieceThemeFn();
219
+ return _pieceThemeFn(piece);
220
+ }
221
+
222
+ // ── chessops adapter ─────────────────────────────────────────────────────
223
+ // Small translation layer between this file's FEN-string/algebraic-key
224
+ // vocabulary and chessops's Setup/Position/numeric-square vocabulary.
225
+ // Contains no chess rules of its own - every legality, castling, and
226
+ // check/mate determination below is delegated to chessops.
227
+
228
+ function currentFen() { return makeFen(_position.toSetup()); }
229
+ function _turnChar(pos) { return pos.turn === 'white' ? 'w' : 'b'; }
230
+
231
+ function _fenErrorMessage(err) {
232
+ switch (err && err.message) {
233
+ case 'ERR_CASTLING':
234
+ return "Invalid castling availability: expected some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'.";
235
+ case 'ERR_TURN': return 'Invalid FEN: side to move must be "w" or "b".';
236
+ case 'ERR_EP_SQUARE': return 'Invalid FEN: en passant target square is not valid.';
237
+ case 'ERR_HALFMOVES': return 'Invalid FEN: halfmove clock is not valid.';
238
+ case 'ERR_FULLMOVES': return 'Invalid FEN: fullmove number is not valid.';
239
+ case 'ERR_BOARD': return 'Invalid FEN: board part is not valid.';
240
+ default: return 'Invalid FEN: position violates chess rules.';
241
+ }
242
+ }
243
+ function _positionErrorMessage(err) {
244
+ switch (err && err.message) {
245
+ case 'ERR_KINGS': return 'Position must have exactly one king per side.';
246
+ case 'ERR_OPPOSITE_CHECK': return 'Invalid position: the side not to move is already in check.';
247
+ case 'ERR_PAWNS_ON_BACKRANK': return 'Invalid position: a pawn is on the back rank.';
248
+ case 'ERR_EMPTY': return 'Position has no pieces.';
249
+ default: return 'Invalid FEN: position violates chess rules.';
250
+ }
251
+ }
252
+
253
+ // A castling-availability letter with no matching king/rook pair on the
254
+ // board (e.g. "K" with no white king and rook on their home squares) is
255
+ // syntactically valid but not a legal right - chessops's own parser
256
+ // silently drops it rather than rejecting the FEN (see parseCastlingFen
257
+ // in fen.js), so that has to be checked here by comparing what was
258
+ // requested against what the constructed position actually granted.
259
+ // This works the same way for Standard 'KQkq' and Chess960 file-letter
260
+ // rights, since both are resolved by chessops before this comparison;
261
+ // no Standard-only assumption is made about which squares are valid.
262
+ function _missingCastlingRights(setup, pos) {
263
+ const missing = [];
264
+ for (const square of setup.castlingRights) {
265
+ if (!pos.castles.castlingRights.has(square)) missing.push(makeSquare(square));
266
+ }
267
+ return missing;
268
+ }
269
+
270
+ // Standard chess's castling letters mean something fixed: 'K'/'Q' is
271
+ // the White rook on h1/a1, with the White king on e1 - never "whichever
272
+ // rook is right/left of the king" (chessops's own, Chess960-capable,
273
+ // resolution rule; see the module doc comment). A right that chessops
274
+ // resolves to any other square is a Chess960-only reading of the same
275
+ // letter and must be rejected here, in Standard mode, even though
276
+ // chessops itself parsed it without complaint. Chess960 mode has no
277
+ // "home square" of its own - any resolved king/rook pair is
278
+ // acceptable there, which _missingCastlingRights above already covers.
279
+ const _HOME_SQUARES = {
280
+ white: { king: parseSquare('e1'), rook: { a: parseSquare('a1'), h: parseSquare('h1') } },
281
+ black: { king: parseSquare('e8'), rook: { a: parseSquare('a8'), h: parseSquare('h8') } },
282
+ };
283
+ function _nonOrthodoxCastlingRights(pos) {
284
+ const offending = [];
285
+ for (const color of ['white', 'black']) {
286
+ const kingSquare = pos.board.kingOf(color);
287
+ for (const side of ['a', 'h']) {
288
+ const rookSquare = pos.castles.rook[color][side];
289
+ if (rookSquare === undefined) continue;
290
+ if (kingSquare !== _HOME_SQUARES[color].king || rookSquare !== _HOME_SQUARES[color].rook[side]) {
291
+ offending.push(makeSquare(rookSquare));
292
+ }
293
+ }
294
+ }
295
+ return offending;
296
+ }
297
+
298
+ // Standard mode accepts only 'K'/'Q'/'k'/'q' (each optional, in that
299
+ // order) or '-'; Chess960 additionally accepts Shredder-FEN/X-FEN
300
+ // rook-file letters. Mirrors app.py's _validate_fen_parts exactly:
301
+ // same accepted grammar, same rejected input, so a FEN the frontend
302
+ // accepts is never one the backend then rejects, or vice versa.
303
+ const _CASTLING_SYNTAX_STANDARD = /^K?Q?k?q?$/;
304
+ const _CASTLING_SYNTAX_CHESS960 = /^[A-Ha-hKQkq]{1,4}$/;
305
+ function _castlingSyntaxError(variant, field) {
306
+ if (field === '-') return null;
307
+ const pattern = variant === VARIANTS.CHESS960 ? _CASTLING_SYNTAX_CHESS960 : _CASTLING_SYNTAX_STANDARD;
308
+ if (pattern.test(field)) return null;
309
+ const expected = variant === VARIANTS.CHESS960
310
+ ? "some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'"
311
+ : "some combination of 'K', 'Q', 'k', 'q', or '-'";
312
+ return `Invalid castling availability: '${field}' (expected ${expected}).`;
313
+ }
314
+
315
+ // Parses and fully validates a FEN into a chessops position under the
316
+ // given variant (defaulting to the currently-active one), or returns a
317
+ // user-facing reason it was rejected. Rejects a syntactically-valid
318
+ // chessops parse that isn't actually a legal encoding for the
319
+ // requested variant (see the two checks above and the module doc
320
+ // comment) - the same FEN text can be accepted under one variant and
321
+ // rejected under the other.
322
+ function _buildPosition(fen, variant = _variant) {
323
+ const fields = fen.split(' ');
324
+ if (fields.length >= 3) {
325
+ const syntaxError = _castlingSyntaxError(variant, fields[2]);
326
+ if (syntaxError) return { ok: false, reason: syntaxError };
327
+ }
328
+ const parsed = parseFen(fen);
329
+ if (parsed.isErr) return { ok: false, reason: _fenErrorMessage(parsed.error) };
330
+ const setup = parsed.value;
331
+ const built = Chess.fromSetup(setup);
332
+ if (built.isErr) return { ok: false, reason: _positionErrorMessage(built.error) };
333
+ const pos = built.value;
334
+ const missing = _missingCastlingRights(setup, pos);
335
+ if (missing.length) {
336
+ return { ok: false, reason: `Invalid castling availability: no matching king and rook for ${missing.join(', ')}.` };
337
+ }
338
+ if (variant !== VARIANTS.CHESS960) {
339
+ const nonOrthodox = _nonOrthodoxCastlingRights(pos);
340
+ if (nonOrthodox.length) {
341
+ return {
342
+ ok: false,
343
+ reason: `Invalid castling availability: Standard chess requires the king and rook on their home squares (${nonOrthodox.join(', ')} is not one).`,
344
+ };
345
+ }
346
+ }
347
+ return { ok: true, pos };
348
+ }
349
+
350
+ function _pieceCodeOf(piece) {
351
+ if (!piece) return null;
352
+ const letter = piece.role === 'knight' ? 'N' : piece.role[0].toUpperCase();
353
+ return (piece.color === 'white' ? 'w' : 'b') + letter;
354
+ }
355
+
356
+ // Total legal move count, counting each underpromotion choice as a
357
+ // separate move (matching the count a full legal-move generator would
358
+ // report) rather than one destination square per promoting pawn move.
359
+ // Includes Chess960 castling moves - they're just another destination
360
+ // chessops's own move generation supplies (see chessgroundDests below).
361
+ function _countLegalMoves(pos) {
362
+ const ctx = pos.ctx();
363
+ let count = 0;
364
+ for (const [from, squares] of pos.allDests(ctx)) {
365
+ const promotes = pos.board.pawn.has(from);
366
+ for (const to of squares) {
367
+ const isPromotion = promotes && (to < 8 || to >= 56);
368
+ count += isPromotion ? 4 : 1;
369
+ }
370
+ }
371
+ return count;
372
+ }
373
+
374
+ // Builds chessground's `movable.dests` shape from the active position,
375
+ // using the variant-aware compatibility bridge. Standard mode exposes
376
+ // orthodox king-destination castling only; Chess960 exposes the
377
+ // king-to-rook destination.
378
+ function _legalDestsMap() {
379
+ if (!_position) return new Map();
380
+ const dests = chessgroundDests(_position, { chess960: _variant === VARIANTS.CHESS960 });
381
+ if (_variant === VARIANTS.CHESS960) return dests;
382
+
383
+ const filtered = new Map();
384
+ for (const [from, squares] of dests) {
385
+ const fromSquare = parseSquare(from);
386
+ const allowed = squares.filter(to => {
387
+ const toSquare = parseSquare(to);
388
+ const side = castlingSide(_position, { from: fromSquare, to: toSquare });
389
+ if (!side) return true;
390
+ return toSquare !== _position.castles.rook[_position.turn][side];
391
+ });
392
+ if (allowed.length) filtered.set(from, allowed);
393
+ }
394
+ return filtered;
395
+ }
396
+
397
+ // ── Chessground sync ──────────────────────────────────────────────────────
398
+ // The single point where _position's state is pushed into chessground.
399
+ // Called unconditionally after every position-changing action - even a
400
+ // "legal move" one, since chessground's own baseMove() has no concept of
401
+ // en passant, promotion, or Chess960 castling and can leave its internal
402
+ // state briefly chess-wise-incorrect (e.g. not removing the captured
403
+ // pawn on an en passant capture, leaving a pawn on the back rank instead
404
+ // of the promoted piece, or leaving the rook wherever it was dragged
405
+ // from instead of its final castled square) until this call corrects
406
+ // it. Chessground's internal state is not authoritative;
407
+ // this call always overwrites it from _position.
408
+ function _syncChessground() {
409
+ if (!cg || !_position) return;
410
+ cg.set({
411
+ fen: currentFen(),
412
+ turnColor: _position.turn,
413
+ check: _position.isCheck() ? _position.turn : false,
414
+ lastMove: _lastMoveKeys,
415
+ // Locked: restrict dragging/click-move to the side to move's
416
+ // legal destinations. Unlocked: free editing.
417
+ movable: {
418
+ free: !_locked,
419
+ color: _locked ? _position.turn : 'both',
420
+ dests: _locked ? _legalDestsMap() : undefined,
421
+ },
422
+ // Off-board drop-to-delete is disabled while locked, same as the
423
+ // spare trays below.
424
+ draggable: { deleteOnDropOff: !_locked },
425
+ });
426
+ }
427
+
428
+ // ── Promotion ─────────────────────────────────────────────────────────────
429
+ // pieceCode is a two-letter code like 'wP' (colour + uppercase type),
430
+ // matching the shape the rest of this file (and theme.js) already uses.
431
+ function _isPromotionMove(target, pieceCode) {
432
+ if (!pieceCode || pieceCode[1] !== 'P') return false;
433
+ const color = pieceCode[0], rank = target[1];
434
+ return (color === 'w' && rank === '8') || (color === 'b' && rank === '1');
435
+ }
436
+ // Maps the promotion dialog's single-letter piece codes to chessops's
437
+ // full role names.
438
+ const ROLE_OF = { P: 'pawn', N: 'knight', B: 'bishop', R: 'rook', Q: 'queen', K: 'king' };
439
+
440
+ function _showPromotionDialog(source, target, color) {
441
+ _pendingPromotion = { source, target };
442
+ const dialog = document.getElementById('promotion-dialog');
443
+ ['q','r','b','n'].forEach(p => {
444
+ const btn = dialog.querySelector(`.promotion-piece-btn[data-piece="${p}"]`);
445
+ const img = btn && btn.querySelector('img');
446
+ if (img) img.src = _pieceTheme(color + p.toUpperCase());
447
+ });
448
+ dialog.classList.add('is-visible');
449
+ dialog.querySelector('.promotion-piece-btn')?.focus();
450
+ }
451
+ function _hidePromotionDialog() {
452
+ document.getElementById('promotion-dialog').classList.remove('is-visible');
453
+ }
454
+ // Cancelling (Escape / backdrop click) needs to resync, since
455
+ // chessground has already visually relocated the pawn to the target
456
+ // square by the time the dialog is showing (its own drag/drop already
457
+ // moved the piece before this handler ever runs). This call reverts
458
+ // that visual relocation by pushing _position's still-unchanged state
459
+ // back into chessground.
460
+ function _cancelPromotion() {
461
+ _hidePromotionDialog();
462
+ _pendingPromotion = null;
463
+ _syncChessground();
464
+ }
465
+ function _onPromotionSelect(pieceLetter) {
466
+ _hidePromotionDialog();
467
+ if (!_pendingPromotion) return;
468
+ const { source, target } = _pendingPromotion;
469
+ _pendingPromotion = null;
470
+ const move = { from: parseSquare(source), to: parseSquare(target), promotion: ROLE_OF[pieceLetter.toUpperCase()] };
471
+ if (!_position.isLegal(move)) { _syncChessground(); return; } // revert the visual pawn-on-back-rank relocation
472
+ const san = makeSan(_position, move);
473
+ _position.play(move);
474
+ _lastMoveKeys = [source, target];
475
+ _addMove(san, currentFen(), source, target);
476
+ _emitHistory();
477
+ _syncChessground();
478
+ // immediate=true: a completed promotion is a single, definite move
479
+ // (see playMove()'s own comment on this parameter), not part of a
480
+ // rapid-fire stream of changes the debounce below it is meant to
481
+ // guard against.
482
+ if (_onPositionChange) _onPositionChange(currentFen(), true);
483
+ }
484
+
485
+ // ── Core sync algorithm ──────────────────────────────────────────────────
486
+ // Chessground fires multiple, differently-scoped callbacks for what a
487
+ // user experiences as a single action (events.move, events.change,
488
+ // movable.events.after, events.dropNewPiece), so rather than track
489
+ // which-callback-handled-what with suppression flags, this handler is
490
+ // idempotent: it compares chessground's current placement against
491
+ // _position's, and no-ops if they already match.
492
+ //
493
+ // events.move is wired as the informative hook (the one that passes
494
+ // origHint/destHint below) rather than movable.events.after, because of
495
+ // chessground's event-firing order for an ordinary relocation:
496
+ // events.move fires first (scheduled from within board.ts's baseMove()),
497
+ // then events.change (also scheduled by baseMove(), immediately after),
498
+ // then movable.events.after (scheduled later by userMove()) - all via
499
+ // setTimeout(fn,1), so same-delay-timer FIFO ordering makes this
500
+ // deterministic. events.change carries no orig/dest, so if it were the
501
+ // informative hook it would always take the generic "board edit" branch
502
+ // below first, leaving movable.events.after's real orig/dest with
503
+ // nothing left to act on once it fires - the idempotency guard would
504
+ // already see the position as synced. events.move fires before
505
+ // events.change and, unlike movable.events.after, carries orig/dest
506
+ // (plus capture info), so it reliably wins the idempotency race with
507
+ // enough information to classify the move correctly.
508
+ // events.change and movable.events.after are wired below as idempotent
509
+ // fallbacks/no-ops for a relocation; events.change is load-bearing for
510
+ // the one case with no orig/dest-carrying hook at all - an off-board
511
+ // deletion (drag.ts's deleteOnDropOff branch).
512
+ //
513
+ // Standard mode accepts only the orthodox king-destination castle;
514
+ // Chess960 accepts the king-to-rook representation.
515
+ function _handleBoardMutation(origHint, destHint) {
516
+ if (!cg || !_position) return;
517
+ const cgPlacement = cg.getFen(); // chessground's own current placement-only FEN
518
+ const ourPlacement = currentFen().split(' ')[0];
519
+ if (cgPlacement === ourPlacement) return; // already synced by another callback this tick
520
+
521
+ if (origHint && destHint) {
522
+ // _position hasn't moved yet at this point - only chessground
523
+ // has - so the piece that was at origHint is still readable
524
+ // from _position.
525
+ const pieceCode = _pieceCodeOf(_position.board.get(parseSquare(origHint)));
526
+ if (pieceCode && _isPromotionMove(destHint, pieceCode)) {
527
+ _showPromotionDialog(origHint, destHint, pieceCode[0]);
528
+ return; // resolved later by _onPromotionSelect()/_cancelPromotion()
529
+ }
530
+ const move = { from: parseSquare(origHint), to: parseSquare(destHint) };
531
+ if (_variant === VARIANTS.STANDARD) {
532
+ const side = castlingSide(_position, move);
533
+ if (side && move.to === _position.castles.rook[_position.turn][side]) {
534
+ _syncChessground();
535
+ return;
536
+ }
537
+ }
538
+ if (_position.isLegal(move)) {
539
+ const san = makeSan(_position, move);
540
+ _position.play(move);
541
+ _lastMoveKeys = [origHint, destHint];
542
+ _addMove(san, currentFen(), origHint, destHint);
543
+ _emitHistory();
544
+ _syncChessground();
545
+ // immediate=true: see the comment on the equivalent call in
546
+ // _onPromotionSelect() above - a completed drag-and-drop
547
+ // move is a single, definite move too.
548
+ if (_onPositionChange) _onPositionChange(currentFen(), true);
549
+ return;
550
+ }
551
+ // Not legal - fall through to the generic edit path below, which
552
+ // reads the placement chessground already committed rather than
553
+ // origHint/destHint.
554
+ }
555
+
556
+ // Board-edit path: spare-piece drop, off-board delete, or an
557
+ // illegal relocation. Castling rights and the en passant square are
558
+ // always dropped here because an edited placement's rights can no
559
+ // longer be assumed valid.
560
+ const prevFen = currentFen();
561
+ const nextFen = `${cgPlacement} ${_turnChar(_position)} - - 0 1`;
562
+ const built = _buildPosition(nextFen);
563
+ if (!built.ok) { _syncChessground(); return; } // invalid resulting position - snap back
564
+ _position = built.pos;
565
+ _pushHistory(prevFen);
566
+ _lastMoveKeys = undefined;
567
+ _resetMoveLine();
568
+ _syncChessground();
569
+ if (_onPositionChange) _onPositionChange(currentFen());
570
+ }
571
+
572
+ function _onCgSelect(key) {
573
+ if (!_clickPlacePiece) return; // not in placement mode - let chessground handle the click normally
574
+ cg.selectSquare(null); // clear any selection chessground itself may have started,
575
+ // so a piece "armed" for placement never gets relocated
576
+ // by chessground's own click-to-move instead
577
+ const role = ROLE_OF[_clickPlacePiece[1]];
578
+ const color = _clickPlacePiece[0] === 'w' ? 'white' : 'black';
579
+ const square = parseSquare(key);
580
+ if (role === 'king') {
581
+ const existing = _position.board.kingOf(color);
582
+ if (existing !== undefined && existing !== square) return; // rejected - a king of this colour already exists elsewhere
583
+ }
584
+ const prevFen = currentFen();
585
+ // Trial-mutate a clone rather than _position directly, so an
586
+ // illegal result (e.g. two kings adjacent, or leaving the side not
587
+ // to move in check) can be refused before anything commits - the
588
+ // same validate-then-commit gate _handleBoardMutation's edit path
589
+ // below already runs every other board edit through.
590
+ const trial = _position.clone();
591
+ trial.board.set(square, { role, color, promoted: false });
592
+ // Re-derive castling rights from the edited board rather than
593
+ // leaving stale rook/king square references behind (e.g. a right
594
+ // pointing at a square this edit just overwrote).
595
+ trial.castles = Castles.fromSetup({ board: trial.board, castlingRights: _position.castles.castlingRights });
596
+ const nextFen = makeFen(trial.toSetup());
597
+ if (nextFen === prevFen) return; // identity placement (e.g. re-placing the same piece already there) - nothing to record
598
+ const built = _buildPosition(nextFen);
599
+ if (!built.ok) { setClickPlacePiece(null); return; } // illegal result - refuse, board stays as it was
600
+ _position = built.pos;
601
+ _pushHistory(prevFen);
602
+ _lastMoveKeys = undefined;
603
+ _resetMoveLine();
604
+ _syncChessground();
605
+ if (_onPositionChange) _onPositionChange(currentFen());
606
+ }
607
+
608
+ // ── Best-move arrows (DTZ / DTC / DTM / DTM50) ────────────────────────────
609
+ // Native chessground feature (drawable.autoShapes) - square keys, not
610
+ // pixel coordinates, so chessground handles orientation/resizing itself.
611
+ // bestMoves: { dtz: entry|null, dtc: entry|null, dtm: entry|null,
612
+ // dtm50: entry|null } - the top-ranked move for each metric (any of
613
+ // which may be missing, e.g. for a terminal or not-yet-probed position).
614
+ function drawArrows(bestMoves) {
615
+ if (!cg) return;
616
+ if (!bestMoves) { cg.setAutoShapes([]); return; }
617
+ const shapes = [];
618
+ ['dtz', 'dtc', 'dtm', 'dtm50'].forEach(metric => {
619
+ const entry = bestMoves[metric];
620
+ if (!entry) return;
621
+ const move = parseSan(_position, entry.san);
622
+ if (!move) return;
623
+ const side = _variant === VARIANTS.STANDARD ? castlingSide(_position, move) : undefined;
624
+ const dest = side
625
+ ? `${side === 'a' ? 'c' : 'g'}${_position.turn === 'white' ? '1' : '8'}`
626
+ : makeSquare(move.to);
627
+ shapes.push({ orig: makeSquare(move.from), dest, brush: metric });
628
+ });
629
+ cg.setAutoShapes(shapes);
630
+ }
631
+ function clearArrows() {
632
+ if (!cg) return;
633
+ cg.setAutoShapes([]);
634
+ }
635
+
636
+ // ��─ Click-to-place + spare-piece trays ───────────────────────────────────
637
+ let _clickPlacePiece = null; // e.g. 'wQ', 'bK', or null
638
+
639
+ function _toCgPiece(code /* e.g. 'wQ' */) {
640
+ return { color: code[0] === 'w' ? 'white' : 'black', role: ROLE_OF[code[1]] };
641
+ }
642
+
643
+ function _populateTrayImages() {
644
+ document.querySelectorAll('.spare-piece').forEach(btn => {
645
+ const code = btn.dataset.piece;
646
+ const img = btn.querySelector('img');
647
+ if (img && code) img.src = _pieceTheme(code);
648
+ });
649
+ }
650
+
651
+ // Two interaction paths, both needed: click-to-place (click a tray
652
+ // piece, then click a destination square) and drag-from-tray (native
653
+ // HTML5 drag onto the board, handled via chessground's dragNewPiece()).
654
+ // The spare-piece trays are expected to support both at once, so
655
+ // neither wiring below can assume the other is absent.
656
+ function _initSpareTrays() {
657
+ document.querySelectorAll('.spare-piece').forEach(btn => {
658
+ const code = btn.dataset.piece;
659
+
660
+ // Path 1: click tray piece, then click a square - handled by
661
+ // this click listener (arms _clickPlacePiece) plus _onCgSelect()
662
+ // (does the actual placement), wired in init()'s Config.events.select.
663
+ // Disabled while locked (tray is also dimmed - see setLocked()).
664
+ btn.addEventListener('click', () => {
665
+ if (_locked) return;
666
+ setClickPlacePiece(_clickPlacePiece === code ? null : code);
667
+ });
668
+
669
+ // Path 2: drag a tray piece directly onto the board. `force:
670
+ // true` is required - cg.dragNewPiece() without it refuses to
671
+ // overwrite an occupied square, and dropping a spare piece onto
672
+ // an occupied square is expected to replace it. Disabled while locked.
673
+ btn.addEventListener('pointerdown', e => {
674
+ if (_locked) return;
675
+ if (e.button !== undefined && e.button !== 0) return; // primary pointer only
676
+ cg.dragNewPiece(_toCgPiece(code), e, /* force */ true);
677
+ });
678
+ });
679
+
680
+ // Escape clears click-to-place selection
681
+ document.addEventListener('keydown', e => {
682
+ if (e.key === 'Escape' && _clickPlacePiece) setClickPlacePiece(null);
683
+ });
684
+ }
685
+
686
+ function setClickPlacePiece(piece) {
687
+ _clickPlacePiece = piece;
688
+ // Visual feedback
689
+ document.querySelectorAll('.spare-piece.cp-selected').forEach(el => el.classList.remove('cp-selected'));
690
+ if (piece) {
691
+ document.querySelectorAll(`.spare-piece[data-piece="${piece}"]`).forEach(el => el.classList.add('cp-selected'));
692
+ }
693
+ // Update cursor on board
694
+ const boardEl = document.getElementById('board');
695
+ if (boardEl) {
696
+ boardEl.classList.toggle('cp-active', !!piece);
697
+ }
698
+ }
699
+
700
+ // ── Public API ────────────────────────────────────────────────────────────
701
+
702
+ function _restoredOrientation() {
703
+ return Storage.read('chesstb_orientation') === 'black' ? 'black' : 'white';
704
+ }
705
+
706
+ function _restoredLocked() {
707
+ return Storage.read('chesstb_locked') === 'true';
708
+ }
709
+
710
+ // variant, if given, must be VARIANTS.STANDARD or VARIANTS.CHESS960 and
711
+ // takes precedence over any caller-independent default - callers that
712
+ // already know the intended variant (a URL share-link, a restored saved
713
+ // game) pass it explicitly so the position below is constructed under
714
+ // the correct ruleset context from the very first render, per this
715
+ // file's variant-before-position invariant. Omitted or invalid values
716
+ // fall back to Standard.
717
+ function init(onPositionChange, initialFen, variant) {
718
+ _onPositionChange = onPositionChange;
719
+ _variant = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
720
+
721
+ // Same validation setPosition() applies to every later position
722
+ // change - an invalid initialFen (bad king count, unusable castling
723
+ // rights, or anything else chessops rejects) falls back to the
724
+ // default reset position, and the reason is returned below so the
725
+ // caller can tell the user, instead of silently substituting a
726
+ // different position with no feedback.
727
+ const built = _buildPosition(initialFen);
728
+ let loadError = null;
729
+ if (built.ok) {
730
+ _position = built.pos;
731
+ } else {
732
+ loadError = built.reason;
733
+ _position = Chess.default();
734
+ }
735
+ _startFen = currentFen();
736
+
737
+ // Built from _startFen (the validated/fallback position), not the
738
+ // raw initialFen - otherwise an invalid initialFen leaves the
739
+ // visible board showing the rejected placement while _position
740
+ // already holds the fallback, a desync baked in right at startup.
741
+ cg = Chessground(document.getElementById('board'), {
742
+ fen: _startFen,
743
+ orientation: _restoredOrientation(),
744
+ turnColor: _position.turn,
745
+ check: _position.isCheck() ? _position.turn : false,
746
+ lastMove: undefined,
747
+ coordinates: true,
748
+ // coordinatesOnSquares stays false: that mode prints a combined
749
+ // "a8"-style label on all 64 squares, whereas this app wants the
750
+ // classic look - a rank number down the left column and a file
751
+ // letter along the bottom row only, both sitting inset at the
752
+ // board's edges. Chessground's native coords.ranks/coords.files
753
+ // strips (one rank-only label per row, one file-only label per
754
+ // column) already produce that layout once repositioned via CSS
755
+ // (see chess/board.css) to sit inside the board edge instead of outside
756
+ // it, so the combined per-square mode isn't needed here.
757
+ coordinatesOnSquares: false,
758
+ // Castling is driven by the chessops position (_position), not
759
+ // chessground's own king-two-squares heuristic, so chessground
760
+ // should never second-guess or auto-correct a king move into a
761
+ // castle itself - this also holds for Chess960's king-to-rook
762
+ // representation, which chessground has no native concept of.
763
+ autoCastle: false,
764
+ viewOnly: false,
765
+ disableContextMenu: true,
766
+ highlight: { lastMove: true, check: true },
767
+ animation: { enabled: true, duration: 200 },
768
+ movable: {
769
+ // Any drag is provisionally accepted here and classified
770
+ // afterward by _handleBoardMutation, rather than gating
771
+ // drags up front with a separate edit-mode toggle.
772
+ free: true,
773
+ color: 'both',
774
+ showDests: true,
775
+ events: { after: (orig, dest) => _handleBoardMutation(orig, dest) },
776
+ },
777
+ premovable: { enabled: false },
778
+ draggable: {
779
+ enabled: true,
780
+ showGhost: true,
781
+ deleteOnDropOff: true,
782
+ },
783
+ selectable: { enabled: true },
784
+ events: {
785
+ move: (orig, dest) => _handleBoardMutation(orig, dest),
786
+ change: () => _handleBoardMutation(),
787
+ dropNewPiece: () => _handleBoardMutation(),
788
+ select: (key) => _onCgSelect(key),
789
+ },
790
+ drawable: {
791
+ enabled: true,
792
+ visible: true,
793
+ brushes: {
794
+ dtz: { key: 'dtz', color: '#2451c4', opacity: 0.80, lineWidth: 10 },
795
+ dtc: { key: 'dtc', color: '#d113c6', opacity: 0.80, lineWidth: 10 },
796
+ dtm: { key: 'dtm', color: '#8b93a1', opacity: 0.80, lineWidth: 10 },
797
+ dtm50: { key: 'dtm50', color: '#e1453a', opacity: 0.80, lineWidth: 10 },
798
+ },
799
+ },
800
+ });
801
+
802
+ const dialog = document.getElementById('promotion-dialog');
803
+ dialog.querySelectorAll('.promotion-piece-btn').forEach(btn => {
804
+ btn.addEventListener('click', () => _onPromotionSelect(btn.dataset.piece));
805
+ });
806
+ dialog.addEventListener('click', e => {
807
+ if (e.target === dialog) _cancelPromotion();
808
+ });
809
+ document.addEventListener('keydown', e => {
810
+ if (e.key !== 'Escape' || !dialog.classList.contains('is-visible')) return;
811
+ _cancelPromotion();
812
+ // The promotion dialog sits on top of everything else - stop
813
+ // this Escape from also reaching the PGN dialog/settings-panel
814
+ // handlers (see PgnDialog.jsx, SettingsPanel.jsx), which would otherwise close those too
815
+ // if either happened to be open underneath it.
816
+ e.stopImmediatePropagation();
817
+ });
818
+
819
+ _populateTrayImages();
820
+ _initSpareTrays();
821
+ _syncTrayOrientation();
822
+
823
+ // Restored the same way orientation is above - persist:false since
824
+ // this is a stored preference being applied on load, not a new one
825
+ // being set, so it's not written straight back to the same key.
826
+ setLocked(_restoredLocked(), false);
827
+
828
+ return loadError ? { ok: false, reason: loadError } : { ok: true };
829
+ }
830
+
831
+ /**
832
+ * Set the piece-set CSS <link> and populate the spare-tray and
833
+ * promotion-dialog images. Registered with theme.js via
834
+ * Theme.init(reconstruct) and invoked once at bootstrap, after theme.js
835
+ * injects the board's theme <style> tag.
836
+ *
837
+ * This never touches the Chessground instance itself - piece images are
838
+ * plain CSS (`.cg-wrap piece.<role>.<color>`), not baked into a
839
+ * constructor closure.
840
+ */
841
+ function reconstruct() {
842
+ if (!cg || !_position) return;
843
+ const link = document.getElementById('chesstb-piece-css');
844
+ if (link) link.href = `/static/css/pieces-${Theme.currentPieceSet()}.css`;
845
+ _populateTrayImages();
846
+ }
847
+
848
+ function setPosition(fen) {
849
+ const prevFen = _position ? currentFen() : null;
850
+ // Re-submitting the FEN already on the board (e.g. pressing Enter in
851
+ // the FEN box without editing it) is a no-op - skip the undo-stack
852
+ // push and tree reset so the current move line survives it.
853
+ if (fen === prevFen) return { ok: true };
854
+ const built = _buildPosition(fen);
855
+ if (!built.ok) return { ok: false, reason: built.reason };
856
+ _position = built.pos;
857
+ if (prevFen !== null) _pushHistory(prevFen);
858
+ _hidePromotionDialog();
859
+ _pendingPromotion = null;
860
+ _lastMoveKeys = undefined;
861
+ _resetMoveLine();
862
+ _syncChessground();
863
+ // Position-change callback, as in playMove()/goBack()/goForward()/
864
+ // goToNode()/clear().
865
+ const nextFen = currentFen();
866
+ if (_onPositionChange) _onPositionChange(nextFen);
867
+ return { ok: true };
868
+ }
869
+
870
+ // Sets a new FEN and variant together in a single step - for a caller
871
+ // that is replacing the position anyway (a shared URL, PGN import,
872
+ // restoreTree()'s fallback path), so there is no "current position"
873
+ // to preserve or validate against the new variant; the new FEN is
874
+ // validated directly against the new variant instead (see
875
+ // _buildPosition). Use setVariant() instead when the FEN is meant to
876
+ // stay exactly as it is and only its interpretation should change
877
+ // (e.g. the settings-panel toggle).
878
+ function setPositionAndVariant(fen, variant) {
879
+ const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
880
+ const prevFen = _position ? currentFen() : null;
881
+ if (fen === prevFen && next === _variant) return { ok: true }; // true no-op
882
+ const built = _buildPosition(fen, next);
883
+ if (!built.ok) return { ok: false, reason: built.reason };
884
+ _variant = next;
885
+ _position = built.pos;
886
+ if (prevFen !== null) _pushHistory(prevFen);
887
+ _hidePromotionDialog();
888
+ _pendingPromotion = null;
889
+ _lastMoveKeys = undefined;
890
+ _resetMoveLine();
891
+ _syncChessground();
892
+ if (_onPositionChange) _onPositionChange(currentFen());
893
+ return { ok: true };
894
+ }
895
+
896
+ function clear() {
897
+ const prevFen = currentFen();
898
+ const resetFen = '4k3/8/8/8/8/8/8/4K3 w - - 0 1';
899
+ const built = _buildPosition(resetFen);
900
+ console.assert(built.ok, 'clear(): resetFen must always be valid');
901
+ _pushHistory(prevFen);
902
+ _position = built.pos;
903
+ _hidePromotionDialog();
904
+ _pendingPromotion = null;
905
+ _lastMoveKeys = undefined;
906
+ _resetMoveLine();
907
+ _syncChessground();
908
+ if (_onPositionChange) _onPositionChange(resetFen);
909
+ return resetFen;
910
+ }
911
+
912
+ // Mirrors chessground's own orientation as a class on #board-wrap-inner;
913
+ // chess/board.css uses it to flip flex-direction so the spare-piece trays swap
914
+ // sides visually without moving them in the DOM.
915
+ function _syncTrayOrientation() {
916
+ const wrapEl = document.getElementById('board-wrap-inner');
917
+ if (wrapEl) wrapEl.classList.toggle('orientation-black', cg.state.orientation === 'black');
918
+ }
919
+
920
+ function flip() {
921
+ cg.toggleOrientation();
922
+ _syncTrayOrientation();
923
+ Storage.write('chesstb_orientation', cg.state.orientation);
924
+ clearArrows(); // arrows are orientation-dependent; caller (useExplorer.js) redraws right after flip
925
+ }
926
+
927
+ // ── Lock mode (public) ───────────────────────────────────────────────────
928
+ // Toggles legal-moves-only board interaction. Persisted to localStorage
929
+ // the same way orientation is (see flip()) when persist defaults to
930
+ // true; callers applying an already-known value (init() restoring on
931
+ // load, or useExplorer.js's auto-play engaging/releasing Lock) pass persist:false.
932
+ function setLocked(locked, persist = true) {
933
+ _locked = !!locked;
934
+ if (_locked) setClickPlacePiece(null); // drop any armed spare piece
935
+ document.querySelectorAll('.spare-tray').forEach(el => {
936
+ el.classList.toggle('is-locked', _locked);
937
+ if (_locked) el.setAttribute('title', 'Board is locked');
938
+ else el.removeAttribute('title');
939
+ });
940
+ if (persist) Storage.write('chesstb_locked', _locked);
941
+ _syncChessground();
942
+ }
943
+ function isLocked() { return _locked; }
944
+
945
+ // ── Variant (public) ─────────────────────────��───────────────────────────
946
+ // Persistence and toggle-panel wiring live in useExplorer.js and
947
+ // SettingsPanel.jsx (Chess960 Mode is a Settings-panel item, alongside
948
+ // Autoplay Delay and Show Root Row) - this module only holds the
949
+ // current value and reacts to it changing.
950
+ function setVariant(variant) {
951
+ const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
952
+ if (next === _variant) return { ok: true };
953
+ // This reinterprets the current position under a different
954
+ // variant - the FEN itself is not changing (see
955
+ // setPositionAndVariant() above for the "also loading a new
956
+ // position" case) - so unlike every other _buildPosition() call in
957
+ // this file, failure here must not fall back to a default
958
+ // position or otherwise change what's on the board: the position
959
+ // simply isn't representable under the requested variant's
960
+ // castling rules (e.g. Chess960-only castling rights present, or
961
+ // Chess960 castling rights on non-home squares - see
962
+ // _nonOrthodoxCastlingRights), and the switch is refused outright,
963
+ // leaving variant and position exactly as they were.
964
+ if (_position) {
965
+ const trial = _buildPosition(currentFen(), next);
966
+ if (!trial.ok) {
967
+ const modeName = next === VARIANTS.CHESS960 ? 'Chess960' : 'Standard';
968
+ return { ok: false, reason: `Can't switch to ${modeName} mode: ${trial.reason}` };
969
+ }
970
+ }
971
+ _variant = next;
972
+ // The move tree belongs to the active variant; changing variants
973
+ // starts a fresh tree from the current position.
974
+ _lastMoveKeys = undefined;
975
+ _resetMoveLine();
976
+ _syncChessground();
977
+ // Position change with no actual FEN change - still routed through
978
+ // onPositionChange so callers re-probe under the new variant and
979
+ // (via onPositionChange's own guard) auto-play is stopped rather
980
+ // than left running against a variant it started under.
981
+ if (_onPositionChange) _onPositionChange(currentFen());
982
+ return { ok: true };
983
+ }
984
+ function getVariant() { return _variant; }
985
+ function isChess960() { return _variant === VARIANTS.CHESS960; }
986
+
987
+ // Legal move count for the current position, counting each
988
+ // underpromotion choice separately (see _countLegalMoves above) and
989
+ // including Chess960 castling.
990
+ function legalMoveCount() { return _position ? _countLegalMoves(_position) : 0; }
991
+ function isInsufficientMaterial() { return !!_position && _position.isInsufficientMaterial(); }
992
+
993
+ // Result token for an arbitrary FEN, independent of the live board
994
+ // state - used by pgnFormat.js's PGN-result detection. Returns '*'
995
+ // (unknown/in progress) for a FEN that fails to parse.
996
+ function outcomeFromFen(fen) {
997
+ const built = _buildPosition(fen);
998
+ if (!built.ok) return '*';
999
+ const pos = built.pos;
1000
+ if (pos.isCheckmate()) return pos.turn === 'white' ? '0-1' : '1-0';
1001
+ if (pos.isStalemate() || pos.isInsufficientMaterial()) return '1/2-1/2';
1002
+ return '*';
1003
+ }
1004
+
1005
+ function playMove(san) {
1006
+ if (!_position) return false;
1007
+ const move = parseSan(_position, san);
1008
+ if (!move) return false;
1009
+ const fromKey = makeSquare(move.from), toKey = makeSquare(move.to);
1010
+ const playedSan = makeSan(_position, move);
1011
+ _position.play(move);
1012
+
1013
+ _lastMoveKeys = [fromKey, toKey];
1014
+
1015
+ // Track in the move tree (branches instead of overwriting if the
1016
+ // user had navigated back before playing this move).
1017
+ _addMove(playedSan, currentFen(), fromKey, toKey);
1018
+ _emitHistory();
1019
+ _syncChessground();
1020
+
1021
+ // immediate=true: this is a single, definite move (played by
1022
+ // auto-play or by clicking a move in the ranked-moves table, the
1023
+ // two callers of playMove() - see useExplorer.js), not part of a
1024
+ // rapid-fire stream of changes the 300ms debounce guards against.
1025
+ if (_onPositionChange) _onPositionChange(currentFen(), true);
1026
+ return true;
1027
+ }
1028
+
1029
+ // Back/Forward walk the move tree directly (parent / activeChild) so
1030
+ // stepping through a line that includes variations works the same as
1031
+ // stepping through the main line. The plain _history/_future FEN stack
1032
+ // is only consulted once navigation reaches the tree's root (id ROOT)
1033
+ // and goes further back than the tree itself - i.e. undoing past
1034
+ // whatever move/edit started the current tree (see _resetMoveLine).
1035
+ function goBack() {
1036
+ if (_currentId !== ROOT) {
1037
+ const parentId = _nodes[_currentId].parentId;
1038
+ const fen = parentId === ROOT ? _startFen : _nodes[parentId].fen;
1039
+ const built = _buildPosition(fen);
1040
+ if (!built.ok) { _syncChessground(); return false; }
1041
+ _position = built.pos;
1042
+ _lastMoveKeys = parentId === ROOT ? undefined : [_nodes[parentId].from, _nodes[parentId].to];
1043
+ _currentId = parentId;
1044
+ } else {
1045
+ if (_history.length === 0) return false;
1046
+ const prevFen = _history.pop();
1047
+ _future.push(currentFen());
1048
+ const built = _buildPosition(prevFen);
1049
+ if (!built.ok) {
1050
+ _future.pop();
1051
+ _history.push(prevFen);
1052
+ _syncChessground();
1053
+ return false;
1054
+ }
1055
+ _position = built.pos;
1056
+ // The plain _history/_future stack holds FENs only, with no
1057
+ // accompanying from/to squares (unlike tree nodes), so there's
1058
+ // nothing to highlight as the last move here.
1059
+ _lastMoveKeys = undefined;
1060
+ // The loaded position does not match the tree's _startFen, so
1061
+ // the tree is reset. _resetMoveLine() also clears _future, so the
1062
+ // stack is saved and restored around the reset.
1063
+ const _pendingFuture = _future.slice();
1064
+ _resetMoveLine();
1065
+ _future = _pendingFuture;
1066
+ }
1067
+ _hidePromotionDialog();
1068
+ _pendingPromotion = null;
1069
+ _syncChessground();
1070
+ _emitHistory();
1071
+ if (_onPositionChange) _onPositionChange(currentFen());
1072
+ return true;
1073
+ }
1074
+
1075
+ function goForward() {
1076
+ const activeChildId = _activeChildOf(_currentId);
1077
+ if (activeChildId !== null && activeChildId !== undefined) {
1078
+ const node = _nodes[activeChildId];
1079
+ const built = _buildPosition(node.fen);
1080
+ if (!built.ok) { _syncChessground(); return false; }
1081
+ _position = built.pos;
1082
+ _lastMoveKeys = [node.from, node.to];
1083
+ _currentId = activeChildId;
1084
+ } else if (_currentId === ROOT && _future.length > 0) {
1085
+ const nextFen = _future.pop();
1086
+ const built = _buildPosition(nextFen);
1087
+ if (!built.ok) {
1088
+ _future.push(nextFen);
1089
+ _syncChessground();
1090
+ return false;
1091
+ }
1092
+ _history.push(currentFen());
1093
+ _position = built.pos;
1094
+ _lastMoveKeys = undefined;
1095
+ // Mirrors the goBack() fallback above: the position just loaded
1096
+ // isn't described by the current (already-reset) tree either, so
1097
+ // restart the tree here too and preserve any remaining redo
1098
+ // entries across the reset.
1099
+ const _pendingFuture = _future.slice();
1100
+ _resetMoveLine();
1101
+ _future = _pendingFuture;
1102
+ } else {
1103
+ return false;
1104
+ }
1105
+ _hidePromotionDialog();
1106
+ _pendingPromotion = null;
1107
+ _syncChessground();
1108
+ _emitHistory();
1109
+ if (_onPositionChange) _onPositionChange(currentFen());
1110
+ return true;
1111
+ }
1112
+
1113
+ // goToNode() jumps to an arbitrary node in the current move tree (used
1114
+ // by the PGN panel - including variation moves, not just the main line).
1115
+ // Unlike setPosition(), this never calls _resetMoveLine(). targetId ===
1116
+ // ROOT (-1) means "start of the tree" (the position before any move was
1117
+ // played). Clicking any node marks it - and every ancestor on the path
1118
+ // to it - as its parent's activeChild, so Forward continues along
1119
+ // whichever branch was just brought into view.
1120
+ function goToNode(targetId) {
1121
+ if (targetId !== ROOT && !_nodes[targetId]) return false;
1122
+ if (targetId === _currentId) return true;
1123
+
1124
+ // Walked before touching _position/_syncChessground, and bounded to
1125
+ // at most one hop per node, so a corrupt parentId chain (see
1126
+ // _isValidTree) refuses the jump instead of looping forever.
1127
+ const chain = [];
1128
+ let walk = targetId;
1129
+ let guard = _nodes.length + 1;
1130
+ while (walk !== ROOT) {
1131
+ if (guard-- <= 0 || !_nodes[walk]) return false;
1132
+ chain.unshift(walk);
1133
+ walk = _nodes[walk].parentId;
1134
+ }
1135
+
1136
+ const fen = targetId === ROOT ? _startFen : _nodes[targetId].fen;
1137
+ const built = _buildPosition(fen);
1138
+ if (!built.ok) return false;
1139
+ _position = built.pos;
1140
+
1141
+ let parent = ROOT;
1142
+ chain.forEach(id => { _setActiveChild(parent, id); parent = id; });
1143
+ _currentId = targetId;
1144
+ _lastMoveKeys = targetId === ROOT ? undefined : [_nodes[targetId].from, _nodes[targetId].to];
1145
+
1146
+ _hidePromotionDialog();
1147
+ _pendingPromotion = null;
1148
+ _syncChessground();
1149
+ _emitHistory();
1150
+ if (_onPositionChange) _onPositionChange(currentFen());
1151
+ return true;
1152
+ }
1153
+
1154
+ // A legitimately-built tree (see _addMove above) only ever gives a node
1155
+ // a parentId that's ROOT or an already-existing, strictly smaller id,
1156
+ // and only ever lists children/activeChild with a strictly larger one,
1157
+ // so parentId chains and children references can never cycle or dangle.
1158
+ // Persisted state coming back out of localStorage bypasses that
1159
+ // construction path entirely; this re-checks the same invariant before
1160
+ // trusting it, since violating it would let goToNode()'s parentId walk
1161
+ // (or moveLine.js's tree walk) loop forever or index past the array.
1162
+ function _isValidNodeRef(id, nodesLength) {
1163
+ return typeof id === 'number' && Number.isInteger(id) && id >= 0 && id < nodesLength;
1164
+ }
1165
+ function _isValidTree(nodes, rootChildren) {
1166
+ if (!Array.isArray(nodes) || !Array.isArray(rootChildren)) return false;
1167
+ for (let id = 0; id < nodes.length; id++) {
1168
+ const node = nodes[id];
1169
+ if (!node || typeof node.san !== 'string' || typeof node.fen !== 'string') return false;
1170
+ if (node.parentId !== ROOT && !(_isValidNodeRef(node.parentId, nodes.length) && node.parentId < id)) {
1171
+ return false;
1172
+ }
1173
+ if (!Array.isArray(node.children)) return false;
1174
+ for (const childId of node.children) {
1175
+ if (!_isValidNodeRef(childId, nodes.length) || childId <= id) return false;
1176
+ }
1177
+ if (node.activeChild !== null && node.activeChild !== undefined &&
1178
+ !(_isValidNodeRef(node.activeChild, nodes.length) && node.activeChild > id)) {
1179
+ return false;
1180
+ }
1181
+ }
1182
+ return rootChildren.every(childId => _isValidNodeRef(childId, nodes.length));
1183
+ }
1184
+
1185
+ // restoreTree() rehydrates a full move tree (start position + every
1186
+ // node + current node) from persisted state in a single board update,
1187
+ // without replaying each move or triggering individual probes.
1188
+ // The active variant must already be established (via init()'s variant
1189
+ // argument) before this is called - this function only restores
1190
+ // position/tree data, never variant.
1191
+ function restoreTree(startFen, nodes, rootChildren, rootActiveChild, currentId) {
1192
+ if (!_position || !startFen) return false;
1193
+ const validTree = _isValidTree(nodes, rootChildren);
1194
+ const safeNodes = validTree ? nodes : [];
1195
+ const safeRootChildren = validTree ? rootChildren : [];
1196
+ const safeCurrentId = (currentId === ROOT || (validTree && _isValidNodeRef(currentId, safeNodes.length)))
1197
+ ? currentId : ROOT;
1198
+
1199
+ const loadFen = safeCurrentId === ROOT ? startFen : safeNodes[safeCurrentId].fen;
1200
+ const built = _buildPosition(loadFen);
1201
+ if (!built.ok) {
1202
+ // Saved state doesn't parse (e.g. corrupted storage) - fall back
1203
+ // to just the start position with an empty tree.
1204
+ const fallback = _buildPosition(startFen);
1205
+ _position = fallback.ok ? fallback.pos : Chess.default();
1206
+ _startFen = currentFen();
1207
+ _nodes = [];
1208
+ _rootChildren = [];
1209
+ _rootActiveChild = null;
1210
+ _currentId = ROOT;
1211
+ _history = [];
1212
+ _future = [];
1213
+ _lastMoveKeys = undefined;
1214
+ if (cg) {
1215
+ _hidePromotionDialog();
1216
+ _syncChessground();
1217
+ }
1218
+ _emitHistory();
1219
+ return false;
1220
+ }
1221
+ _position = built.pos;
1222
+
1223
+ _startFen = startFen;
1224
+ _nodes = safeNodes;
1225
+ _rootChildren = safeRootChildren;
1226
+ _rootActiveChild = _isValidNodeRef(rootActiveChild, safeNodes.length) ? rootActiveChild : null;
1227
+ _currentId = safeCurrentId;
1228
+ _history = [];
1229
+ _future = [];
1230
+ _lastMoveKeys = (safeCurrentId === ROOT || !safeNodes[safeCurrentId])
1231
+ ? undefined
1232
+ : [safeNodes[safeCurrentId].from, safeNodes[safeCurrentId].to];
1233
+
1234
+ _hidePromotionDialog();
1235
+ _pendingPromotion = null;
1236
+ if (cg) _syncChessground();
1237
+ _emitHistory();
1238
+ return true;
1239
+ }
1240
+
1241
+ // Install a parsed PGN in one update. Only the displayed start position
1242
+ // is probed; every imported move stays available for navigation/export.
1243
+ function importGame(tree, variant) {
1244
+ const next = variant === VARIANTS.CHESS960 ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
1245
+ const built = _buildPosition(tree.startFen, next);
1246
+ if (!built.ok) return { ok: false, reason: built.reason };
1247
+ if (!_isValidTree(tree.nodes, tree.rootChildren)) {
1248
+ return { ok: false, reason: 'Invalid move tree in PGN.' };
1249
+ }
1250
+ _variant = next;
1251
+ _position = built.pos;
1252
+ restoreTree(tree.startFen, tree.nodes, tree.rootChildren, tree.rootActiveChild, ROOT);
1253
+ if (_onPositionChange) _onPositionChange(currentFen(), true);
1254
+ return { ok: true };
1255
+ }
1256
+
1257
+ function canGoBack() { return _currentId !== ROOT || _history.length > 0; }
1258
+ function canGoForward() {
1259
+ const activeChildId = _activeChildOf(_currentId);
1260
+ return (activeChildId !== null && activeChildId !== undefined) || (_currentId === ROOT && _future.length > 0);
1261
+ }
1262
+
1263
+ function getMoveHistory() {
1264
+ return { nodes: _nodes, rootChildren: _rootChildren, rootActiveChild: _rootActiveChild, currentId: _currentId };
1265
+ }
1266
+ function getStartFen() { return _startFen; } // PGN formatting
1267
+ function setOnMoveHistoryChange(fn) {
1268
+ _onMoveHistoryChange = fn;
1269
+ }
1270
+
1271
+ return {
1272
+ init, reconstruct, setPosition, setPositionAndVariant, clear, flip, currentFen,
1273
+ playMove, goBack, goForward, goToNode, restoreTree, importGame, canGoBack, canGoForward,
1274
+ getMoveHistory, getStartFen, setOnMoveHistoryChange,
1275
+ drawArrows, clearArrows,
1276
+ setLocked, isLocked,
1277
+ setVariant, getVariant, isChess960, legalMoveCount, isInsufficientMaterial, outcomeFromFen,
1278
+ };
1279
+
1280
+ })();
1281
+
1282
+ export { Board, VARIANTS };
frontend/src/engine/gameState.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * gameState.js: URL-hash sharing and persisted game state
3
+ *
4
+ * The hash is rewritten on every position change so the address bar stays
5
+ * share-able, and is read back on load (and on hashchange) to restore a
6
+ * shared link. Persisting the full move tree to localStorage additionally
7
+ * lets a round-trip through a different route (e.g. /admin, which has no
8
+ * #fen= of its own) restore the board position and PGN panel together.
9
+ */
10
+
11
+ import { VARIANTS } from './board.js';
12
+ import * as Storage from './storage.js';
13
+
14
+ export function writeHash(fen, variant) {
15
+ try {
16
+ let hash = '#fen=' + encodeURIComponent(fen);
17
+ if (variant === VARIANTS.CHESS960) hash += '&variant=' + VARIANTS.CHESS960;
18
+ history.replaceState(null, '', hash);
19
+ } catch { /* sandboxed context */ }
20
+ }
21
+
22
+ export function readHashFen() {
23
+ try {
24
+ const m = location.hash.match(/[#&]fen=([^&]+)/);
25
+ return m ? decodeURIComponent(m[1]) : null;
26
+ } catch { return null; }
27
+ }
28
+
29
+ // Returns VARIANTS.CHESS960 / VARIANTS.STANDARD, or null (Standard) if the
30
+ // hash has no (or an unrecognized) variant param. Never inferred from the
31
+ // FEN itself.
32
+ export function readHashVariant() {
33
+ try {
34
+ const m = location.hash.match(/[#&]variant=([^&]+)/);
35
+ if (!m) return null;
36
+ const v = decodeURIComponent(m[1]);
37
+ if (v === VARIANTS.CHESS960) return VARIANTS.CHESS960;
38
+ if (v === VARIANTS.STANDARD) return VARIANTS.STANDARD;
39
+ return null;
40
+ } catch { return null; }
41
+ }
42
+
43
+ const _LS_GAME = 'chesstb_last_game';
44
+
45
+ export function saveGameState(history, startFen, variant) {
46
+ Storage.writeJson(_LS_GAME, {
47
+ variant,
48
+ startFen,
49
+ nodes: history.nodes,
50
+ rootChildren: history.rootChildren,
51
+ rootActiveChild: history.rootActiveChild,
52
+ currentId: history.currentId,
53
+ });
54
+ }
55
+
56
+ export function readLastGame() {
57
+ const parsed = Storage.readJson(_LS_GAME);
58
+ if (!parsed || typeof parsed.startFen !== 'string' || !Array.isArray(parsed.nodes)) {
59
+ return null;
60
+ }
61
+ parsed.variant = parsed.variant === VARIANTS.CHESS960 ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
62
+ const node = parsed.nodes[parsed.currentId];
63
+ parsed.currentFen = (!node || typeof node.fen !== 'string') ? parsed.startFen : node.fen;
64
+ return parsed;
65
+ }
66
+
67
+ // Client-side mirror of the backend's own FEN padding (app.py's
68
+ // _normalize_fen): pads a partial FEN out to all six fields with the
69
+ // standard defaults.
70
+ export function normalizeFen(s) {
71
+ const trimmed = s.trim();
72
+ const defaults = ['8/8/8/8/8/8/8/8', 'w', '-', '-', '0', '1'];
73
+ const parts = trimmed ? trimmed.split(/\s+/) : [];
74
+ while (parts.length < 6) parts.push(defaults[parts.length]);
75
+ return parts.slice(0, 6).join(' ');
76
+ }
frontend/src/engine/moveLine.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * moveLine.js: move-tree → render items
3
+ *
4
+ * Walks the branching move tree the same way the PGN exporter does (see
5
+ * pgnFormat.js's childrenOf/collectMainPath) and produces a flat list of
6
+ * render items for the move-list panel: 'row' items for the main line's
7
+ * Rank/White/Black grid, and 'variation' items for parenthesised
8
+ * alternatives, each carrying enough to expand into its own paragraph
9
+ * without re-walking the tree.
10
+ */
11
+
12
+ import { ROOT_ID, childrenOf, collectMainPath } from './pgnFormat.js';
13
+
14
+ // Builds the main line as row items: one full move per row. A move with
15
+ // alternatives gets a 'variation' item right after it instead of packed
16
+ // into its own cell - since a variation spans the full row width, the
17
+ // next row after it always starts fresh.
18
+ export function buildMainLine(nodes, rootChildren, nodeIds, parentId, whiteToMove, moveNum, currentId) {
19
+ const items = [];
20
+ let row = { kind: 'row', num: moveNum, white: whiteToMove ? null : { placeholder: true }, black: null };
21
+ items.push(row);
22
+
23
+ nodeIds.forEach((id, i) => {
24
+ const node = nodes[id];
25
+ const parentForThis = i === 0 ? parentId : nodeIds[i - 1];
26
+ const kids = childrenOf(nodes, rootChildren, parentForThis);
27
+ const hasVariation = kids.length > 1;
28
+
29
+ if (whiteToMove) row.white = { node, isCurrent: node.id === currentId };
30
+ else row.black = { node, isCurrent: node.id === currentId };
31
+
32
+ const wasWhite = whiteToMove;
33
+ const thisMoveNum = moveNum;
34
+ if (!whiteToMove) moveNum++;
35
+ whiteToMove = !whiteToMove;
36
+
37
+ if (hasVariation) {
38
+ for (let v = 1; v < kids.length; v++) {
39
+ const varPath = collectMainPath(nodes, kids[v]);
40
+ items.push({ kind: 'variation', nodeIds: varPath, whiteToMove: wasWhite, moveNum: thisMoveNum, depth: 1 });
41
+ }
42
+ }
43
+
44
+ if (i !== nodeIds.length - 1) {
45
+ if (wasWhite && !hasVariation) {
46
+ // continues into this row's still-empty black cell
47
+ } else {
48
+ row = { kind: 'row', num: moveNum, white: whiteToMove ? null : { placeholder: true }, black: null };
49
+ items.push(row);
50
+ }
51
+ }
52
+ });
53
+ return items;
54
+ }
55
+
56
+ // Builds one variation's body as a sequence of paragraphs (chip lists):
57
+ // a move within it that itself has alternatives interrupts the paragraph:
58
+ // the nested variation(s) become their own, more deeply indented items,
59
+ // and this variation's remaining moves continue in a fresh paragraph at
60
+ // the same depth.
61
+ export function buildVariation(nodes, rootChildren, nodeIds, whiteToMove, moveNum, currentId, depth) {
62
+ const items = [];
63
+ let para = { kind: 'para', depth, chips: [] };
64
+ items.push(para);
65
+
66
+ nodeIds.forEach((id, i) => {
67
+ const node = nodes[id];
68
+ // i === 0 is the move that spawned this variation body - its
69
+ // siblings were already enumerated by the caller as separate
70
+ // variation items, so this doesn't re-derive them here.
71
+ const parentForThis = i === 0 ? null : nodeIds[i - 1];
72
+ const kids = parentForThis !== null ? childrenOf(nodes, rootChildren, parentForThis) : null;
73
+ const hasVariation = !!(kids && kids.length > 1);
74
+
75
+ if (whiteToMove || i === 0) {
76
+ para.chips.push({ kind: 'num', text: moveNum + (whiteToMove ? '.' : '…') });
77
+ }
78
+ para.chips.push({ kind: 'move', node, isCurrent: node.id === currentId });
79
+
80
+ const wasWhite = whiteToMove;
81
+ const thisMoveNum = moveNum;
82
+ if (!whiteToMove) moveNum++;
83
+ whiteToMove = !whiteToMove;
84
+
85
+ if (hasVariation) {
86
+ for (let v = 1; v < kids.length; v++) {
87
+ const varPath = collectMainPath(nodes, kids[v]);
88
+ items.push({ kind: 'variation', nodeIds: varPath, whiteToMove: wasWhite, moveNum: thisMoveNum, depth: depth + 1 });
89
+ }
90
+ if (i !== nodeIds.length - 1) {
91
+ para = { kind: 'para', depth, chips: [] };
92
+ items.push(para);
93
+ }
94
+ }
95
+ });
96
+ return items;
97
+ }
98
+
99
+ export function buildMoveListItems(nodes, rootChildren, currentId, turn, fullmove) {
100
+ if (!rootChildren || rootChildren.length === 0) return [];
101
+ const mainPath = collectMainPath(nodes, rootChildren[0]);
102
+ return buildMainLine(nodes, rootChildren, mainPath, ROOT_ID, turn === 'w', fullmove, currentId);
103
+ }
frontend/src/engine/pgnFormat.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * pgnFormat.js: move-tree helpers, PGN export text, and PGN import parsing
3
+ *
4
+ * The tree-walking helpers (childrenOf/collectMainPath) are shared by the
5
+ * move-list panel and the PGN exporter so the two can't disagree about
6
+ * where a variation forks.
7
+ */
8
+
9
+ import { Board, VARIANTS } from './board.js';
10
+ import { Chess } from '../vendor/chessops/chess.js';
11
+ import { parseFen, makeFen } from '../vendor/chessops/fen.js';
12
+ import { parsePgnStrict, PgnError } from '../vendor/chessops/pgn.js';
13
+ import { parseSan, makeSan } from '../vendor/chessops/san.js';
14
+ import { makeSquare } from '../vendor/chessops/util.js';
15
+
16
+ export const ROOT_ID = -1;
17
+
18
+ export function childrenOf(nodes, rootChildren, id) {
19
+ return id === ROOT_ID ? rootChildren : nodes[id].children;
20
+ }
21
+
22
+ // Follows children[0] repeatedly from startId - one line's worth of node
23
+ // ids (its own "main" continuation).
24
+ export function collectMainPath(nodes, startId) {
25
+ const ids = [];
26
+ let cur = startId;
27
+ while (cur !== null && cur !== undefined) {
28
+ ids.push(cur);
29
+ const kids = nodes[cur].children;
30
+ cur = kids.length ? kids[0] : null;
31
+ }
32
+ return ids;
33
+ }
34
+
35
+ // Derives {turn, fullmove} from a FEN's 2nd/6th fields, for numbering a
36
+ // move line that doesn't start from a fresh game.
37
+ export function parseFenMeta(fen) {
38
+ const parts = String(fen || '').trim().split(/\s+/);
39
+ const turn = parts[1] === 'b' ? 'b' : 'w';
40
+ const fullmove = parseInt(parts[5], 10);
41
+ return { turn, fullmove: Number.isFinite(fullmove) && fullmove > 0 ? fullmove : 1 };
42
+ }
43
+
44
+ // Same recursive shape as the move-list panel's own rendering - builds PGN
45
+ // movetext tokens (including variations, in "(...)" notation) instead of
46
+ // DOM/JSX nodes.
47
+ function buildMoveTokens(nodes, rootChildren, nodeIds, parentId, whiteToMove, moveNum, skipFirstFork) {
48
+ const tokens = [];
49
+ nodeIds.forEach((id, i) => {
50
+ const node = nodes[id];
51
+ if (whiteToMove) {
52
+ tokens.push(`${moveNum}.`, node.san);
53
+ } else if (i === 0) {
54
+ tokens.push(`${moveNum}...`, node.san);
55
+ } else {
56
+ tokens.push(node.san);
57
+ }
58
+ if (!(i === 0 && skipFirstFork)) {
59
+ const parentForThis = i === 0 ? parentId : nodeIds[i - 1];
60
+ const kids = childrenOf(nodes, rootChildren, parentForThis);
61
+ for (let v = 1; v < kids.length; v++) {
62
+ const varPath = collectMainPath(nodes, kids[v]);
63
+ const varTokens = buildMoveTokens(nodes, rootChildren, varPath, parentForThis, whiteToMove, moveNum, true);
64
+ tokens.push(`(${varTokens.join(' ')})`);
65
+ }
66
+ }
67
+ if (!whiteToMove) moveNum++;
68
+ whiteToMove = !whiteToMove;
69
+ });
70
+ return tokens;
71
+ }
72
+
73
+ // Builds a PGN export: the standard tag-roster header block (plus
74
+ // FEN/SetUp, since the explorer's starting position is rarely the default
75
+ // array) followed by movetext ending in a result token.
76
+ export function buildPgnText() {
77
+ const { nodes, rootChildren } = Board.getMoveHistory();
78
+ if (!rootChildren || rootChildren.length === 0) return '';
79
+
80
+ const startFen = Board.getStartFen();
81
+ const mainPath = collectMainPath(nodes, rootChildren[0]);
82
+ const finalFen = nodes[mainPath[mainPath.length - 1]].fen;
83
+ const result = Board.outcomeFromFen(finalFen);
84
+
85
+ const today = new Date();
86
+ const pad = n => String(n).padStart(2, '0');
87
+ const dateStr = `${today.getFullYear()}.${pad(today.getMonth() + 1)}.${pad(today.getDate())}`;
88
+
89
+ const headers = [
90
+ ['Event', 'ChessTB Explorer'],
91
+ ['Site', '?'],
92
+ ['Date', dateStr],
93
+ ['Round', '?'],
94
+ ['White', '?'],
95
+ ['Black', '?'],
96
+ ['Result', result],
97
+ ];
98
+ if (Board.isChess960()) headers.push(['Variant', 'Chess960']);
99
+ headers.push(['FEN', startFen], ['SetUp', '1']);
100
+ const headerBlock = headers.map(([k, v]) => `[${k} "${v}"]`).join('\n');
101
+
102
+ const { turn, fullmove } = parseFenMeta(startFen);
103
+ const tokens = buildMoveTokens(nodes, rootChildren, mainPath, ROOT_ID, turn === 'w', fullmove, false);
104
+ tokens.push(result);
105
+
106
+ return `${headerBlock}\n\n${tokens.join(' ')}`;
107
+ }
108
+
109
+ // Case-insensitive PGN tag lookup - chessops preserves whatever case the
110
+ // source PGN used.
111
+ function pgnHeaderValue(headers, name) {
112
+ const wanted = name.toLowerCase();
113
+ for (const key of headers.keys()) {
114
+ if (key.toLowerCase() === wanted) return headers.get(key);
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // chessops's own PGN Variant parsing maps every Chess960-like name to the
120
+ // same generic rules Standard uses, so Chess960 identity is read from the
121
+ // raw header text instead.
122
+ function isChess960VariantHeader(value) {
123
+ return /chess\s*960|fischer\s*random/i.test(value || '');
124
+ }
125
+
126
+ // Parses and validates the entire game tree, including variations, into
127
+ // Board's move-history format and the variant declared by its header.
128
+ // Returns { ok: false, error } on any rejection.
129
+ export function parsePgn(text) {
130
+ const trimmed = text.trim();
131
+ if (!trimmed) return { ok: false, error: '' };
132
+
133
+ let games = [];
134
+ try {
135
+ games = parsePgnStrict(trimmed);
136
+ } catch (error) {
137
+ const msg = (error instanceof PgnError && error.message === 'ERR_PGN_MULTIPLE_GAMES')
138
+ ? 'PGN contains multiple games. Paste one game at a time.'
139
+ : 'Could not parse PGN. Check the format and try again.';
140
+ return { ok: false, error: msg };
141
+ }
142
+ const game = games[0];
143
+ const moves = game ? [...game.moves.mainline()] : [];
144
+ if (!game || (game.headers.size === 0 && moves.length === 0)) {
145
+ return { ok: false, error: 'Could not parse PGN. Check the format and try again.' };
146
+ }
147
+
148
+ const variant = isChess960VariantHeader(pgnHeaderValue(game.headers, 'Variant'))
149
+ ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
150
+
151
+ const fenHeader = pgnHeaderValue(game.headers, 'FEN');
152
+ if (variant === VARIANTS.CHESS960 && !fenHeader) {
153
+ return { ok: false, error: 'Chess960 PGN requires a FEN starting position.' };
154
+ }
155
+ let pos = null;
156
+ if (fenHeader) {
157
+ const parsed = parseFen(fenHeader);
158
+ const built = parsed.isErr ? null : Chess.fromSetup(parsed.value);
159
+ pos = (built && !built.isErr) ? built.value : null;
160
+ if (!pos) return { ok: false, error: 'Invalid FEN starting position in PGN.' };
161
+ }
162
+ if (!pos) pos = Chess.default();
163
+ const startFen = makeFen(pos.toSetup());
164
+
165
+ const tree = { startFen, nodes: [], rootChildren: [], rootActiveChild: null, currentId: ROOT_ID };
166
+ const pending = [{ source: game.moves, parentId: ROOT_ID, pos }];
167
+ // An explicit stack handles long lines without recursion limits.
168
+ while (pending.length) {
169
+ const frame = pending.pop();
170
+ const children = frame.source.children;
171
+ const next = [];
172
+ for (const child of children) {
173
+ const childPos = frame.pos.clone();
174
+ const move = parseSan(childPos, child.data.san);
175
+ if (!move) return { ok: false, error: `Invalid move in PGN: ${child.data.san}` };
176
+ const san = makeSan(childPos, move);
177
+ childPos.play(move);
178
+ const id = tree.nodes.length;
179
+ tree.nodes.push({
180
+ id, san, fen: makeFen(childPos.toSetup()),
181
+ from: makeSquare(move.from), to: makeSquare(move.to),
182
+ parentId: frame.parentId, children: [], activeChild: null,
183
+ });
184
+ if (frame.parentId === ROOT_ID) {
185
+ tree.rootChildren.push(id);
186
+ if (tree.rootActiveChild === null) tree.rootActiveChild = id;
187
+ } else {
188
+ const parent = tree.nodes[frame.parentId];
189
+ parent.children.push(id);
190
+ if (parent.activeChild === null) parent.activeChild = id;
191
+ }
192
+ next.push({ source: child, parentId: id, pos: childPos });
193
+ }
194
+ // Visit the main continuation first, preserving source branch order.
195
+ pending.push(...next.reverse());
196
+ }
197
+ return { ok: true, tree, variant };
198
+ }
frontend/src/engine/scroll.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Scrolls `el` into view within `container` only - never any ancestor
2
+ // beyond it (unlike Element.scrollIntoView(), which would also drag the
3
+ // mobile screen-carousel over to whichever panel is calling this).
4
+ export function scrollIntoViewWithin(el, container) {
5
+ if (!el || !container) return;
6
+ const elRect = el.getBoundingClientRect();
7
+ const containerRect = container.getBoundingClientRect();
8
+ if (elRect.top < containerRect.top) {
9
+ container.scrollTop -= (containerRect.top - elRect.top);
10
+ } else if (elRect.bottom > containerRect.bottom) {
11
+ container.scrollTop += (elRect.bottom - containerRect.bottom);
12
+ }
13
+ if (elRect.left < containerRect.left) {
14
+ container.scrollLeft -= (containerRect.left - elRect.left);
15
+ } else if (elRect.right > containerRect.right) {
16
+ container.scrollLeft += (elRect.right - containerRect.right);
17
+ }
18
+ }
19
+
20
+ // While .moves-body-scroll overflows vertically, its scrollbar narrows the
21
+ // content box. The body table is widened by the scrollbar's measured width
22
+ // so its columns keep the same pixel widths as the header table's.
23
+ export function syncMovesGutter() {
24
+ const scroller = document.querySelector('.moves-body-scroll');
25
+ if (!scroller) return;
26
+ if (scroller.scrollHeight <= scroller.clientHeight + 1) {
27
+ scroller.classList.remove('has-vscroll');
28
+ scroller.style.removeProperty('--moves-vscroll-width');
29
+ return;
30
+ }
31
+ const scrollbarWidth = Math.max(0, scroller.offsetWidth - scroller.clientWidth);
32
+ scroller.style.setProperty('--moves-vscroll-width', `${scrollbarWidth}px`);
33
+ scroller.classList.add('has-vscroll');
34
+ }
35
+
36
+ export function initMovesGutterSync() {
37
+ const scroller = document.querySelector('.moves-body-scroll');
38
+ const tbody = document.getElementById('moves-tbody');
39
+ if (!scroller || !tbody) return () => {};
40
+ syncMovesGutter();
41
+ const observer = new ResizeObserver(syncMovesGutter);
42
+ observer.observe(scroller);
43
+ observer.observe(tbody);
44
+ window.addEventListener('resize', syncMovesGutter, { passive: true });
45
+ return () => {
46
+ observer.disconnect();
47
+ window.removeEventListener('resize', syncMovesGutter);
48
+ };
49
+ }
frontend/src/engine/storage.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * storage.js: localStorage helpers
3
+ *
4
+ * Every read/write goes through try/catch, since localStorage can throw in
5
+ * a sandboxed or private-browsing context. A failed read returns `fallback`;
6
+ * a failed write is silently ignored - callers treat persistence as
7
+ * best-effort.
8
+ */
9
+
10
+ export function read(key, fallback = null) {
11
+ try {
12
+ const raw = localStorage.getItem(key);
13
+ return raw === null ? fallback : raw;
14
+ } catch (_) {
15
+ return fallback;
16
+ }
17
+ }
18
+
19
+ export function write(key, value) {
20
+ try { localStorage.setItem(key, String(value)); } catch (_) { /* unavailable or full */ }
21
+ }
22
+
23
+ export function readJson(key, fallback = null) {
24
+ try {
25
+ const raw = localStorage.getItem(key);
26
+ if (raw === null) return fallback;
27
+ const parsed = JSON.parse(raw);
28
+ return parsed === null || parsed === undefined ? fallback : parsed;
29
+ } catch (_) {
30
+ return fallback;
31
+ }
32
+ }
33
+
34
+ export function writeJson(key, value) {
35
+ try { localStorage.setItem(key, JSON.stringify(value)); } catch (_) { /* unavailable or full */ }
36
+ }
frontend/src/engine/tablebase.js ADDED
@@ -0,0 +1,735 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * tablebase.js - /probe/stream SSE client and results state
3
+ *
4
+ * Owns the SSE client for /probe/stream and all state for the four ranked
5
+ * move tables (DTZ / DTC / DTM / DTM50):
6
+ * - setResultHandler(fn) - callback after each successful probe, used for
7
+ * auto-play.
8
+ * - setNoteHandler(fn) - callback with the message of a warning or info
9
+ * dot tapped with touch or pen input.
10
+ * - setViewStateHandler(fn) - callback with a plain object describing
11
+ * everything the results panel needs to render (loading/progress/error,
12
+ * the Root row, and the ranked move rows). Rendering itself is the
13
+ * caller's responsibility; this module never touches the DOM to draw
14
+ * the table.
15
+ * - attachTableEvents(tbody) - wires click-to-play, hover pairing, and
16
+ * hover-triggered prefetch onto the actual rendered <tbody>. Returns a
17
+ * cleanup function.
18
+ * - Prefetch on hover: warms the cache for the child position of a
19
+ * hovered move.
20
+ * - The Root row: the current position's own score for each metric,
21
+ * included in the view state ahead of the ranked child moves when the
22
+ * "Show Root Row" setting is on (see setShowRootRow/getShowRootRow).
23
+ * - A coverage indicator: distinct state when error_code === "missing_table".
24
+ * - An info flag on draws by insufficient material, and a warning flag on
25
+ * a cursed win / blessed loss outcome - both carry a tooltip string for
26
+ * the caller to render. The warning tooltip has a second line when the
27
+ * DTZ score is rounded.
28
+ * - CSV export of the current move table.
29
+ * - A result cache keyed by (variant, FEN), so revisiting an already-
30
+ * probed position reuses the cached result instead of issuing a new
31
+ * /probe/stream call.
32
+ */
33
+
34
+ import { Board, VARIANTS } from './board.js';
35
+ import * as Storage from './storage.js';
36
+
37
+ // ── Bounded LRU cache ────────────────────────────────────────────────────
38
+ const _MAX_CACHE_ENTRIES = 500;
39
+
40
+ class _BoundedCache {
41
+ constructor(maxSize) {
42
+ this._maxSize = maxSize;
43
+ this._map = new Map();
44
+ }
45
+ has(key) { return this._map.has(key); }
46
+ get(key) {
47
+ if (!this._map.has(key)) return undefined;
48
+ const value = this._map.get(key);
49
+ this._map.delete(key);
50
+ this._map.set(key, value); // refresh recency
51
+ return value;
52
+ }
53
+ set(key, value) {
54
+ this._map.delete(key);
55
+ this._map.set(key, value);
56
+ if (this._map.size > this._maxSize) {
57
+ this._map.delete(this._map.keys().next().value); // evict oldest
58
+ }
59
+ }
60
+ delete(key) { this._map.delete(key); }
61
+ }
62
+
63
+ const Tablebase = (() => {
64
+
65
+ let _currentController = null;
66
+ let _probeSeq = 0;
67
+ let _loadingTimer = null;
68
+ let _pendingProgress = null;
69
+ let _onMoveSelect = null;
70
+ let _onNote = null;
71
+ let _onResult = null;
72
+ let _onViewState = null;
73
+ let _lastData = null;
74
+ let _lastFen = null; // FEN of the currently-applied results
75
+ let _lastVariant = null; // variant the currently-applied results were probed under
76
+ let _prefetchCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,child_fen) → probe result data
77
+ let _resultCache = new _BoundedCache(_MAX_CACHE_ENTRIES); // (variant,fen) → last probe result
78
+
79
+ // Hover-prefetch state - module-scoped so probe() can see and cancel an
80
+ // in-flight prefetch for the exact position it's about to probe itself.
81
+ let _prefetchTimer = null;
82
+ let _prefetchController = null; // in-flight prefetch fetch, if any
83
+ let _prefetchKey = null; // cache key for the timer/fetch above
84
+ let _prefetchRun = null; // identity token for the current pending/in-flight run
85
+ let _prefetchPromise = null; // current run's eventual { ok, data|error, aborted } outcome
86
+ let _prefetchAttached = false; // true while a probe() call is awaiting _prefetchPromise
87
+
88
+ function _cancelPrefetch() {
89
+ clearTimeout(_prefetchTimer);
90
+ _prefetchTimer = null;
91
+ if (_prefetchAttached) return;
92
+ if (_prefetchController) { _prefetchController.abort(); _prefetchController = null; }
93
+ _prefetchKey = null;
94
+ _prefetchRun = null;
95
+ _prefetchPromise = null;
96
+ }
97
+
98
+ function _cacheKey(fen, variant) { return `${variant || VARIANTS.STANDARD}\u0000${fen}`; }
99
+
100
+ // ── Show Root Row setting ────────────────────────────────────────────
101
+ const _LS_SHOW_ROOT_ROW = 'chesstb_show_root_row';
102
+ function _readShowRootRowPref() { return Storage.read(_LS_SHOW_ROOT_ROW) === 'true'; }
103
+ let _showRootRow = _readShowRootRowPref();
104
+
105
+ const _ROOT_LABEL = 'Root';
106
+
107
+ // ── WDL / formatting helpers ─────────────────────────────────────────
108
+ function _wdlToOutcome(wdl) {
109
+ return { 2:'win', 1:'cursed_win', 0:'draw', '-1':'blessed_loss', '-2':'loss' }[wdl] ?? 'unknown';
110
+ }
111
+ function _plural(n) { return Math.abs(n) === 1 ? 'ply' : 'plies'; }
112
+ function _pliesText(n) {
113
+ const a = Math.abs(n);
114
+ return `${a} ${_plural(a)}`;
115
+ }
116
+ function _dtcRootRaw(data) {
117
+ return {
118
+ san: _ROOT_LABEL,
119
+ plies: data.dtc_available ? data.dtc[2] : 0,
120
+ order: data.dtc_available ? data.dtc[1] : 0,
121
+ outcome: data.dtc_available ? _wdlToOutcome(data.dtc[0]) : 'not_available',
122
+ available: data.dtc_available,
123
+ };
124
+ }
125
+ function _csvPlies(entry) { return entry.available === false ? 'N/A' : entry.plies; }
126
+
127
+ function _isPawnless(fen) {
128
+ if (!fen) return false;
129
+ return !/[Pp]/.test(fen.split(' ')[0]);
130
+ }
131
+
132
+ function _dtcDisplayEntry(entry, ownFen) {
133
+ if (!entry) return null;
134
+ if (!entry.available) return { ...entry, orderText: 'N/A' };
135
+ return _isPawnless(ownFen)
136
+ ? { ...entry, orderText: '-' }
137
+ : { ...entry, orderText: String(entry.order) };
138
+ }
139
+
140
+ function _dtcScoreText(entry) {
141
+ const n = Math.abs(entry.plies);
142
+ return `${n} ${_plural(n)}/${entry.orderText}`;
143
+ }
144
+
145
+ const _ROUNDED_NOTE = 'Rounded Score — this could be off by 1 ply.';
146
+
147
+ function _warningText(entry) {
148
+ const text = entry.outcome === 'cursed_win'
149
+ ? 'Cursed Win — a win in principle, but a draw under the 50-move rule.'
150
+ : entry.outcome === 'blessed_loss'
151
+ ? 'Blessed Loss — a loss in principle, but a draw under the 50-move rule.'
152
+ : null;
153
+ return text && entry.rounded ? `${text}\n${_ROUNDED_NOTE}` : text;
154
+ }
155
+
156
+ // ── Cell descriptors ──────────────────────────────────────────────────
157
+ // Same shape/formatting rules as a rendered <td> pair, as plain data;
158
+ // the caller renders these into cells (see ResultsPanel). `metric` and
159
+ // `groupClass` drive the CSS grouping; `interactive` gates whether the
160
+ // cell is clickable/prefetchable (false for the Root row).
161
+ function _createMoveCells(entry, groupClass, interactive = true, dtcFormat = false) {
162
+ const metric = groupClass.slice('group-'.length);
163
+ const headers = interactive
164
+ ? { move: `th-${metric}-grp th-${metric}-move`, score: `th-${metric}-grp th-${metric}-score` }
165
+ : null;
166
+ if (!entry) {
167
+ return [
168
+ { kind: 'move', className: `${groupClass} col-move is-empty-cell`, headers: headers?.move ?? null, text: '—', title: null, interactive: false },
169
+ { kind: 'score', className: `${groupClass} col-score is-empty-cell`, headers: headers?.score ?? null, text: '—', title: null, interactive: false },
170
+ ];
171
+ }
172
+
173
+ let scoreText;
174
+ if (entry.is_mate) scoreText = 'Checkmate';
175
+ else if (entry.draw_reason === 'stalemate') scoreText = 'Stalemate';
176
+ else if (entry.outcome === 'draw') scoreText = 'Draw';
177
+ else if (entry.outcome === 'unknown') scoreText = 'Unknown';
178
+ else if (entry.outcome === 'not_available') scoreText = 'Not Available';
179
+ else if (dtcFormat) scoreText = _dtcScoreText(entry);
180
+ else scoreText = _pliesText(entry.plies);
181
+
182
+ const outcomeLabel = {
183
+ win: 'Win', loss: 'Loss', cursed_win: 'Cursed win', blessed_loss: 'Blessed loss',
184
+ }[entry.outcome];
185
+ const scoreLabel = outcomeLabel && !entry.is_mate
186
+ ? `${outcomeLabel}: ${scoreText}` : scoreText;
187
+
188
+ const moveCell = {
189
+ kind: 'move',
190
+ className: `${groupClass} col-move${interactive ? '' : ' is-root'}`,
191
+ headers: headers?.move ?? null,
192
+ text: entry.san,
193
+ title: entry.san,
194
+ interactive,
195
+ dataSan: interactive ? entry.san : null,
196
+ dataChildFen: interactive ? (entry.child_fen || '') : null,
197
+ };
198
+ const scoreCell = {
199
+ kind: 'score',
200
+ className: `${groupClass} col-score${entry.is_mate ? ' is-mate' : ''}`,
201
+ headers: headers?.score ?? null,
202
+ text: scoreText,
203
+ title: scoreLabel,
204
+ interactive,
205
+ dataSan: interactive ? entry.san : null,
206
+ dataChildFen: interactive ? (entry.child_fen || '') : null,
207
+ outcome: entry.outcome,
208
+ warning: _warningText(entry),
209
+ info: entry.draw_reason === 'insufficient_material' ? 'Draw by Insufficient Material' : null,
210
+ };
211
+ return [moveCell, scoreCell];
212
+ }
213
+
214
+ // ── Root row entries ──────────────────────────────────────────────────
215
+ function _rootEntries(data, fen) {
216
+ const maxRows = Math.max(
217
+ (data.moves_dtz || []).length,
218
+ (data.moves_dtc || []).length,
219
+ (data.moves_dtm || []).length,
220
+ (data.moves_dtm50 || []).length,
221
+ );
222
+ const rootIsMate = maxRows === 0 && data.wdl !== 0;
223
+
224
+ const dtzEntry = {
225
+ san: _ROOT_LABEL, plies: data.dtz_available ? data.dtz : 0,
226
+ outcome: data.dtz_available ? _wdlToOutcome(data.wdl) : 'not_available',
227
+ available: data.dtz_available,
228
+ rounded: data.dtz_rounded === true,
229
+ is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
230
+ };
231
+ const dtcRaw = {
232
+ ..._dtcRootRaw(data),
233
+ is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
234
+ };
235
+ const dtcEntry = _dtcDisplayEntry(dtcRaw, fen);
236
+ const dtmEntry = {
237
+ san: _ROOT_LABEL, plies: data.dtm_available ? data.dtm : 0,
238
+ outcome: data.dtm_available ? _wdlToOutcome(data.wdl) : 'not_available',
239
+ available: data.dtm_available,
240
+ is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
241
+ };
242
+ const dtm50Entry = {
243
+ san: _ROOT_LABEL, plies: data.dtm50_available ? data.dtm50[1] : 0,
244
+ outcome: data.dtm50_available ? _wdlToOutcome(data.dtm50[0]) : 'not_available',
245
+ available: data.dtm50_available,
246
+ is_mate: rootIsMate, draw_reason: data.draw_reason, child_fen: null,
247
+ };
248
+ return { dtzEntry, dtcEntry, dtmEntry, dtm50Entry };
249
+ }
250
+
251
+ // ── View state ────────────────────────────────────────────────────────
252
+ // Single object describing everything the results panel renders.
253
+ // Rebuilt on every meaningful change and pushed to _onViewState.
254
+ let _viewState = {
255
+ pending: false,
256
+ loading: false,
257
+ progress: { visible: false, pct: 0, label: '' },
258
+ error: null, // { message, isCoverage } | null
259
+ moveCount: '',
260
+ showRootRow: _showRootRow,
261
+ rootRow: null, // { cells: [...] } | null
262
+ rows: [], // [{ rank, cells: [...] }]
263
+ emptyState: true,
264
+ emptyMessage: 'Choose a position to see its best moves.',
265
+ };
266
+ function _publish(patch) {
267
+ _viewState = { ..._viewState, ...patch };
268
+ if (_onViewState) _onViewState(_viewState);
269
+ }
270
+
271
+ function _beginLoading(mySeq) {
272
+ clearTimeout(_loadingTimer);
273
+ _pendingProgress = { visible: true, pct: null, label: 'Checking position…' };
274
+ // Fast and cached responses should not flash through an empty table.
275
+ // Disable old move actions immediately, then show loading if needed.
276
+ _publish({ pending: true, error: null, ...(_viewState.loading ? { progress: _pendingProgress } : {}) });
277
+ _loadingTimer = setTimeout(() => {
278
+ if (mySeq !== _probeSeq) return;
279
+ _blankResults();
280
+ _publish({ loading: true, progress: _pendingProgress });
281
+ }, 180);
282
+ }
283
+
284
+ function _rootRowState(data, fen) {
285
+ if (!_showRootRow) return null;
286
+ if (!data) {
287
+ const cells = ['group-dtz', 'group-dtc', 'group-dtm', 'group-dtm50']
288
+ .flatMap(g => _createMoveCells(null, g));
289
+ return { cells };
290
+ }
291
+ const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(data, fen);
292
+ const cells = [
293
+ ..._createMoveCells(dtzEntry, 'group-dtz', false),
294
+ ..._createMoveCells(dtcEntry, 'group-dtc', false, true),
295
+ ..._createMoveCells(dtmEntry, 'group-dtm', false),
296
+ ..._createMoveCells(dtm50Entry, 'group-dtm50', false),
297
+ ];
298
+ return { cells };
299
+ }
300
+
301
+ function _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50) {
302
+ const maxRows = Math.max(movesDtz.length, movesDtc.length, movesDtm.length, movesDtm50.length);
303
+ const rows = [];
304
+ for (let i = 0; i < maxRows; i++) {
305
+ const dtcEntry = _dtcDisplayEntry(movesDtc[i] || null, (movesDtc[i] || {}).child_fen);
306
+ const cells = [
307
+ ..._createMoveCells(movesDtz[i] || null, 'group-dtz'),
308
+ ..._createMoveCells(dtcEntry, 'group-dtc', true, true),
309
+ ..._createMoveCells(movesDtm[i] || null, 'group-dtm'),
310
+ ..._createMoveCells(movesDtm50[i] || null, 'group-dtm50'),
311
+ ];
312
+ rows.push({ rank: i + 1, cells });
313
+ }
314
+ return rows;
315
+ }
316
+
317
+ function _blankResults() {
318
+ _publish({
319
+ rootRow: _rootRowState(null, _lastFen),
320
+ rows: [],
321
+ emptyState: false,
322
+ moveCount: _viewState.moveCount, // set client-side from the FEN - untouched here
323
+ });
324
+ }
325
+
326
+ function _isComplete(data) {
327
+ return !(data && data.summary && data.summary.unknown > 0);
328
+ }
329
+
330
+ function _applyResults(data, fen, variant) {
331
+ clearTimeout(_loadingTimer);
332
+ _lastData = data;
333
+ _lastFen = fen;
334
+ _lastVariant = variant;
335
+ if (_isComplete(data)) _resultCache.set(_cacheKey(fen, variant), data);
336
+
337
+ const movesDtz = data.moves_dtz || [];
338
+ const movesDtc = data.moves_dtc || [];
339
+ const movesDtm = data.moves_dtm || [];
340
+ const movesDtm50 = data.moves_dtm50 || [];
341
+ const rows = _rowsState(movesDtz, movesDtc, movesDtm, movesDtm50);
342
+ const maxRows = rows.length;
343
+
344
+ _publish({
345
+ pending: false,
346
+ loading: false,
347
+ progress: { visible: false, pct: 0, label: '' },
348
+ error: null,
349
+ rootRow: _rootRowState(data, fen),
350
+ rows,
351
+ emptyState: maxRows === 0,
352
+ emptyMessage: data.draw_reason === 'insufficient_material'
353
+ ? 'Draw by insufficient material.'
354
+ : data.draw_reason === 'stalemate'
355
+ ? 'Stalemate. No legal moves.'
356
+ : data.wdl === -2 && maxRows === 0
357
+ ? 'Checkmate. No legal moves.'
358
+ : data.wdl === 0
359
+ ? 'Draw.'
360
+ : 'No ranked moves for this position.',
361
+ moveCount: maxRows > 0 ? `${maxRows} legal move${maxRows === 1 ? '' : 's'}` : '',
362
+ });
363
+ if (_onResult) _onResult(data);
364
+ }
365
+
366
+ function _applyCachedResult(data, fen, variant, mySeq) {
367
+ clearTimeout(_loadingTimer);
368
+ Promise.resolve().then(() => {
369
+ if (mySeq !== _probeSeq) return;
370
+ _applyResults(data, fen, variant);
371
+ });
372
+ }
373
+
374
+ function _reset() {
375
+ clearTimeout(_loadingTimer);
376
+ _publish({ pending: false, loading: false, progress: { visible: false, pct: 0, label: '' }, rootRow: _rootRowState(null, _lastFen), rows: [], emptyState: false, error: null });
377
+ _lastData = null;
378
+ _lastFen = null;
379
+ _lastVariant = null;
380
+ }
381
+
382
+ function _showCoverageError(errData) {
383
+ const pc = errData.piece_count;
384
+ const note = pc ? ` (position has ${pc} pieces)` : '';
385
+ _publish({ error: { message: `Not in tablebase${note} — load a tablebase with more piece coverage.`, isCoverage: true } });
386
+ }
387
+
388
+ function _handleError(errData, onError) {
389
+ const msg = errData.error || 'Unknown error.';
390
+ const isCoverageError = errData.error_code === 'missing_table';
391
+ if (isCoverageError) {
392
+ _showCoverageError(errData);
393
+ } else {
394
+ _publish({ error: { message: msg, isCoverage: false } });
395
+ }
396
+ if (onError) onError(msg, isCoverageError);
397
+ }
398
+
399
+ // ── SSE streaming client ──────────────────────────────────────────────
400
+ async function* _sseEvents(response) {
401
+ if (!response.body) throw { error: 'Network error.' };
402
+ const reader = response.body.getReader();
403
+ const decoder = new TextDecoder();
404
+ let buffer = '';
405
+ while (true) {
406
+ const { done, value } = await reader.read();
407
+ if (done) break;
408
+ buffer += decoder.decode(value, { stream: true });
409
+ const events = buffer.split('\n\n');
410
+ buffer = events.pop() || '';
411
+ for (const rawEvent of events) {
412
+ const line = rawEvent.trim();
413
+ if (!line.startsWith('data: ')) continue;
414
+ try { yield JSON.parse(line.slice(6)); } catch { /* skip malformed frame */ }
415
+ }
416
+ }
417
+ }
418
+
419
+ async function _probeStream(fen, variant, signal, mySeq) {
420
+ const response = await fetch('/probe/stream', {
421
+ method: 'POST',
422
+ headers: { 'Content-Type': 'application/json' },
423
+ body: JSON.stringify({ fen, variant }),
424
+ signal,
425
+ });
426
+ if (!response.ok) {
427
+ const err = await response.json().catch(() => ({ error: 'Network error.' }));
428
+ throw err;
429
+ }
430
+ for await (const evtData of _sseEvents(response)) {
431
+ if (signal.aborted || mySeq !== _probeSeq) throw new DOMException('Cancelled', 'AbortError');
432
+ if (evtData.status === 'probing') {
433
+ const total = evtData.total;
434
+ _pendingProgress = {
435
+ visible: true,
436
+ pct: total > 0 ? Math.round((evtData.completed / total) * 100) : null,
437
+ label: total > 0 ? `Evaluating moves · ${evtData.completed}/${total}` : 'Preparing result…',
438
+ };
439
+ if (_viewState.loading) _publish({ progress: _pendingProgress });
440
+ } else if (evtData.status === 'done') {
441
+ return evtData;
442
+ } else if (evtData.status === 'error') {
443
+ throw evtData;
444
+ }
445
+ }
446
+ throw { error: 'Stream ended unexpectedly.' };
447
+ }
448
+
449
+ function probe(fen, variant, onError) {
450
+ const mySeq = ++_probeSeq;
451
+ clearTimeout(_loadingTimer);
452
+ if (_currentController) { _currentController.abort(); _currentController = null; }
453
+ const key = _cacheKey(fen, variant);
454
+
455
+ if (_prefetchKey === key) {
456
+ if (_prefetchController && _prefetchPromise) {
457
+ _prefetchAttached = true;
458
+ const waitingOn = _prefetchPromise;
459
+ _beginLoading(mySeq);
460
+ waitingOn.then(outcome => {
461
+ _prefetchAttached = false;
462
+ if (mySeq !== _probeSeq) return;
463
+ if (outcome.ok) {
464
+ _applyResults(outcome.data, fen, variant);
465
+ } else {
466
+ _reset();
467
+ if (!outcome.aborted) _handleError(outcome.error && outcome.error.error ? outcome.error : { error: 'Cannot reach backend.' }, onError);
468
+ }
469
+ });
470
+ return;
471
+ }
472
+ _cancelPrefetch();
473
+ }
474
+
475
+ if (_prefetchCache.has(key)) {
476
+ const cached = _prefetchCache.get(key);
477
+ _prefetchCache.delete(key);
478
+ _applyCachedResult(cached, fen, variant, mySeq);
479
+ return;
480
+ }
481
+ if (_resultCache.has(key)) {
482
+ _applyCachedResult(_resultCache.get(key), fen, variant, mySeq);
483
+ return;
484
+ }
485
+
486
+ _currentController = new AbortController();
487
+ const myController = _currentController;
488
+ const signal = myController.signal;
489
+
490
+ _beginLoading(mySeq);
491
+
492
+ _probeStream(fen, variant, signal, mySeq)
493
+ .then(data => {
494
+ if (mySeq !== _probeSeq) return;
495
+ if (_currentController === myController) _currentController = null;
496
+ if (data.error) {
497
+ _reset();
498
+ _handleError(data, onError);
499
+ } else {
500
+ _applyResults(data, fen, variant);
501
+ }
502
+ })
503
+ .catch(err => {
504
+ if (err && err.name === 'AbortError') return;
505
+ if (mySeq !== _probeSeq) return;
506
+ if (_currentController === myController) _currentController = null;
507
+ _reset();
508
+ _handleError(err && err.error ? err : { error: 'Cannot reach backend.' }, onError);
509
+ console.error('[Tablebase]', err);
510
+ });
511
+ }
512
+
513
+ // ── CSV export ────────────────────────────────────────────────────────
514
+ function _csvField(value) {
515
+ const s = String(value);
516
+ return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
517
+ }
518
+
519
+ function exportCsv() {
520
+ if (!_lastData) return;
521
+ const dtz = _lastData.moves_dtz || [];
522
+ const dtc = _lastData.moves_dtc || [];
523
+ const dtm = _lastData.moves_dtm || [];
524
+ const dtm50 = _lastData.moves_dtm50 || [];
525
+ const rows = Math.max(dtz.length, dtc.length, dtm.length, dtm50.length);
526
+ const lines = [];
527
+ if (_lastFen) lines.push(`[FEN "${_lastFen}"]`);
528
+ lines.push('Rank,DTZ Move,DTZ Plies,DTZ Outcome,DTC Move,DTC Plies,DTC Order,DTC Outcome,DTM Move,DTM Plies,DTM Outcome,DTM50 Move,DTM50 Plies,DTM50 Outcome');
529
+ if (_showRootRow) {
530
+ const { dtzEntry, dtcEntry, dtmEntry, dtm50Entry } = _rootEntries(_lastData, _lastFen);
531
+ lines.push([
532
+ '',
533
+ _ROOT_LABEL, _csvPlies(dtzEntry), dtzEntry.outcome,
534
+ _ROOT_LABEL, _csvPlies(dtcEntry), dtcEntry.orderText, dtcEntry.outcome,
535
+ _ROOT_LABEL, _csvPlies(dtmEntry), dtmEntry.outcome,
536
+ _ROOT_LABEL, _csvPlies(dtm50Entry), dtm50Entry.outcome,
537
+ ].map(_csvField).join(','));
538
+ }
539
+ for (let i = 0; i < rows; i++) {
540
+ const d0 = dtz[i], d2 = dtm[i], d3 = dtm50[i];
541
+ const d1 = _dtcDisplayEntry(dtc[i] || null, (dtc[i] || {}).child_fen);
542
+ lines.push([
543
+ i + 1,
544
+ d0 ? d0.san : '', d0 ? _csvPlies(d0) : '', d0 ? d0.outcome : '',
545
+ d1 ? d1.san : '', d1 ? _csvPlies(d1) : '', d1 ? d1.orderText : '', d1 ? d1.outcome : '',
546
+ d2 ? d2.san : '', d2 ? _csvPlies(d2) : '', d2 ? d2.outcome : '',
547
+ d3 ? d3.san : '', d3 ? _csvPlies(d3) : '', d3 ? d3.outcome : '',
548
+ ].map(_csvField).join(','));
549
+ }
550
+ const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv' });
551
+ const a = document.createElement('a');
552
+ a.href = URL.createObjectURL(blob);
553
+ a.download = 'chesstb_moves.csv';
554
+ a.click();
555
+ URL.revokeObjectURL(a.href);
556
+ }
557
+
558
+ // ── Public API ────────────────────────────────────────────────────────
559
+ function setMoveSelectHandler(fn) { _onMoveSelect = fn; }
560
+ function setNoteHandler(fn) { _onNote = fn; }
561
+ function setResultHandler(fn) { _onResult = fn; }
562
+ function setViewStateHandler(fn) { _onViewState = fn; if (fn) fn(_viewState); }
563
+ function getViewState() { return _viewState; }
564
+ function getLastData() { return _lastData; }
565
+ function getLastFen() { return _lastFen; }
566
+ function getLastVariant() { return _lastVariant; }
567
+
568
+ function setShowRootRow(show) {
569
+ _showRootRow = !!show;
570
+ Storage.write(_LS_SHOW_ROOT_ROW, _showRootRow);
571
+ _publish({ showRootRow: _showRootRow, rootRow: _rootRowState(_lastData, _lastFen) });
572
+ }
573
+ function getShowRootRow() { return _showRootRow; }
574
+
575
+ function clearErrorLine() { _publish({ error: null }); }
576
+
577
+ // Sets the legal-move count text ahead of any probe result - the count
578
+ // is computed client-side from the FEN, not from probe data.
579
+ function setMoveCount(text) { _publish({ moveCount: text }); }
580
+
581
+ // ── Table DOM wiring (hover pairing + prefetch + click-to-play) ───────
582
+ // Attached to the rendered <tbody> by the results panel component.
583
+ // Pointer position is tracked so a table rebuild under a stationary
584
+ // pointer (e.g. a probe result arriving mid-hover) can re-evaluate
585
+ // what is underneath it.
586
+ function attachTableEvents(tbody) {
587
+ if (!tbody) return () => {};
588
+ let hoveredPair = [];
589
+ let lastPointerX = null;
590
+ let lastPointerY = null;
591
+ let lastPointerType = '';
592
+
593
+ function pairCells(cell) {
594
+ if (cell.classList.contains('col-move')) {
595
+ const next = cell.nextElementSibling;
596
+ return next && next.dataset.san ? [cell, next] : [cell];
597
+ }
598
+ if (cell.classList.contains('col-score')) {
599
+ const prev = cell.previousElementSibling;
600
+ return prev && prev.dataset.san ? [prev, cell] : [cell];
601
+ }
602
+ return [cell];
603
+ }
604
+ function updatePairHover(target) {
605
+ const cell = target.closest('td[data-san]');
606
+ hoveredPair.forEach(c => c.classList.remove('is-pair-hover'));
607
+ hoveredPair = [];
608
+ if (cell) {
609
+ hoveredPair = pairCells(cell);
610
+ hoveredPair.forEach(c => c.classList.add('is-pair-hover'));
611
+ }
612
+ }
613
+ function updatePrefetch(target) {
614
+ const cell = target.closest('td[data-child-fen]');
615
+ if (!cell || !cell.dataset.childFen) { _cancelPrefetch(); return; }
616
+ const childFen = cell.dataset.childFen;
617
+ const variant = Board.getVariant() || VARIANTS.STANDARD;
618
+ const key = _cacheKey(childFen, variant);
619
+ if (key === _prefetchKey) return;
620
+ _cancelPrefetch();
621
+ if (_prefetchCache.has(key) || _resultCache.has(key)) return;
622
+ _prefetchKey = key;
623
+ const run = {};
624
+ _prefetchRun = run;
625
+ _prefetchTimer = setTimeout(() => {
626
+ const controller = new AbortController();
627
+ _prefetchController = controller;
628
+ _prefetchPromise = fetch('/probe/stream', {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json', 'X-Prefetch': '1' },
631
+ body: JSON.stringify({ fen: childFen, variant }),
632
+ signal: controller.signal,
633
+ })
634
+ .then(async r => {
635
+ if (!r.ok) {
636
+ const err = await r.json().catch(() => ({ error: 'Network error.' }));
637
+ return { ok: false, error: err };
638
+ }
639
+ for await (const d of _sseEvents(r)) {
640
+ if (d.status === 'done') {
641
+ if (_isComplete(d)) _prefetchCache.set(key, d);
642
+ return { ok: true, data: d };
643
+ }
644
+ if (d.status === 'error') return { ok: false, error: d };
645
+ }
646
+ return { ok: false, error: { error: 'Stream ended unexpectedly.' } };
647
+ })
648
+ .catch(err => ({ ok: false, error: err, aborted: err && err.name === 'AbortError' }))
649
+ .finally(() => {
650
+ if (_prefetchRun === run) {
651
+ _prefetchController = null; _prefetchKey = null;
652
+ _prefetchRun = null; _prefetchPromise = null;
653
+ }
654
+ });
655
+ }, 400);
656
+ }
657
+ function reapplyHoverAtPointer() {
658
+ if (lastPointerX === null) return;
659
+ const el = document.elementFromPoint(lastPointerX, lastPointerY);
660
+ if (!el || !tbody.contains(el)) {
661
+ hoveredPair.forEach(c => c.classList.remove('is-pair-hover'));
662
+ hoveredPair = [];
663
+ return;
664
+ }
665
+ updatePairHover(el);
666
+ updatePrefetch(el);
667
+ }
668
+
669
+ function onPointerdown(e) { lastPointerType = e.pointerType; }
670
+ function onClick(e) {
671
+ if (_viewState.pending) return;
672
+ const type = e.pointerType || lastPointerType;
673
+ lastPointerType = '';
674
+ const dot = e.target.closest('.warning-dot, .info-dot');
675
+ if (dot && (type === 'touch' || type === 'pen')) {
676
+ if (_onNote && dot.title) _onNote(dot.title);
677
+ return;
678
+ }
679
+ const cell = e.target.closest('td[data-san]');
680
+ if (cell && _onMoveSelect) _onMoveSelect(cell.dataset.san);
681
+ }
682
+ function onKeydown(e) {
683
+ if (_viewState.pending) return;
684
+ if (e.key !== 'Enter' && e.key !== ' ') return;
685
+ const cell = e.target.closest('td[data-san]');
686
+ if (cell && _onMoveSelect) { e.preventDefault(); _onMoveSelect(cell.dataset.san); }
687
+ }
688
+ function onMousemove(e) { lastPointerX = e.clientX; lastPointerY = e.clientY; }
689
+ function onMouseover(e) {
690
+ lastPointerX = e.clientX; lastPointerY = e.clientY;
691
+ updatePairHover(e.target); updatePrefetch(e.target);
692
+ }
693
+ function onMouseleave() {
694
+ lastPointerX = null; lastPointerY = null;
695
+ hoveredPair.forEach(c => c.classList.remove('is-pair-hover'));
696
+ hoveredPair = [];
697
+ _cancelPrefetch();
698
+ }
699
+
700
+ tbody.addEventListener('pointerdown', onPointerdown);
701
+ tbody.addEventListener('click', onClick);
702
+ tbody.addEventListener('keydown', onKeydown);
703
+ tbody.addEventListener('mousemove', onMousemove);
704
+ tbody.addEventListener('mouseover', onMouseover);
705
+ tbody.addEventListener('mouseleave', onMouseleave);
706
+
707
+ // The pointer may already be sitting over a cell that just
708
+ // (re)appeared without itself moving - re-check on every render.
709
+ reapplyHoverAtPointer();
710
+ _reapplyOnNextRender = reapplyHoverAtPointer;
711
+
712
+ return () => {
713
+ tbody.removeEventListener('pointerdown', onPointerdown);
714
+ tbody.removeEventListener('click', onClick);
715
+ tbody.removeEventListener('keydown', onKeydown);
716
+ tbody.removeEventListener('mousemove', onMousemove);
717
+ tbody.removeEventListener('mouseover', onMouseover);
718
+ tbody.removeEventListener('mouseleave', onMouseleave);
719
+ if (_reapplyOnNextRender === reapplyHoverAtPointer) _reapplyOnNextRender = () => {};
720
+ };
721
+ }
722
+ let _reapplyOnNextRender = () => {};
723
+ // Called by the results panel after each render commits, so hover
724
+ // state re-syncs against the freshly-rendered cells.
725
+ function notifyTableRendered() { _reapplyOnNextRender(); }
726
+
727
+ return {
728
+ probe, setMoveSelectHandler, setNoteHandler, setResultHandler, getLastData, getLastFen, getLastVariant,
729
+ setShowRootRow, getShowRootRow, clearErrorLine, setMoveCount, exportCsv,
730
+ setViewStateHandler, getViewState, attachTableEvents, notifyTableRendered,
731
+ };
732
+
733
+ })();
734
+
735
+ export { Tablebase };
frontend/src/engine/theme.js ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * theme.js: Board and piece theming module
3
+ *
4
+ * Owns the chess board's visual styling: square colours, rank/file label
5
+ * colours, last-move highlight colour, and the piece-set image path. Colours
6
+ * are read from the --board-* tokens in styles/tokens.css. The board uses
7
+ * Libre Brown and the piece set uses CBurnett.
8
+ * Exposes a small public API consumed by board.js and useExplorer.js.
9
+ *
10
+ * The CSS-generation function below targets chessground's DOM/class model
11
+ * (`cg-board`, `coords.ranks`/`coords.files`) - see the comment above
12
+ * _coordLabelCss() for how coordinate-label positioning and colouring are
13
+ * derived against that model.
14
+ *
15
+ * Public API:
16
+ * Theme.init(reconstructBoard) - register board reconstruction callback
17
+ * Theme.apply() - inject the board's theme <style> tag
18
+ * Theme.currentPieceSet() - return the active piece set id string
19
+ * Theme.pieceThemeFn() - return piece-code → image-path function
20
+ * (used by board.js's spare trays and
21
+ * promotion dialog)
22
+ */
23
+
24
+ function _token(name) {
25
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
26
+ }
27
+
28
+ function _boardColors() {
29
+ const light = _token('--board-light');
30
+ const dark = _token('--board-dark');
31
+ return {
32
+ id: 'libre-brown',
33
+ lightSquare: light,
34
+ darkSquare: dark,
35
+ labelLight: dark, // label colour on light-square corners
36
+ labelDark: light, // label colour on dark-square corners
37
+ highlight: _token('--board-highlight'),
38
+ };
39
+ }
40
+
41
+ const PIECE_SET = 'cburnett';
42
+
43
+ let _reconstructBoard = null; // callback registered via init()
44
+
45
+ // ── Private: CSS generation ───────────────────────────────────────────────
46
+ //
47
+ // Chessground has no per-square light/dark CSS classes - the checkerboard
48
+ // pattern is a single background-image on `cg-board`. It is rendered here
49
+ // as a tiny tiled SVG data URI (_checkerboardDataUri). Rank/file labels
50
+ // are chessground's own `coords.ranks`/`coords.files` elements, positioned
51
+ // by static CSS in chess/board.css to sit inset at the board's corners
52
+ // rather than chessground's default outside-the-board placement. Colour is
53
+ // done with :nth-child selectors scoped by orientation, not the
54
+ // `.coord-light`/`.coord-dark` classes renderCoords() (wrap.ts) puts on
55
+ // each label: those classes don't correspond to the actual square each
56
+ // label overlays once repositioned this way (chessground's own official
57
+ // theme CSS doesn't use them for coords.ranks/coords.files either, for the
58
+ // same reason: they're only meaningful for chessground's default
59
+ // outside-the-board placement). See the matching comment in
60
+ // chess/board.css for the full derivation.
61
+
62
+ function _checkerboardDataUri(light, dark) {
63
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='2' height='2'>
64
+ <rect width='1' height='1' fill='${light}'/>
65
+ <rect x='1' width='1' height='1' fill='${dark}'/>
66
+ <rect y='1' width='1' height='1' fill='${dark}'/>
67
+ <rect x='1' y='1' width='1' height='1' fill='${light}'/>
68
+ </svg>`;
69
+ return `data:image/svg+xml;base64,${btoa(svg)}`;
70
+ }
71
+
72
+ // A light-square label gets c.labelLight, a dark-square label gets
73
+ // c.labelDark. class + type + type + :nth-child, matched on all four
74
+ // orientation/parity combinations, carries the specificity needed to
75
+ // override chess/board.css's own default coord-label rule (see the comment there
76
+ // for why .coord-light/.coord-dark aren't used) regardless of source
77
+ // order. For white orientation the odd nth-child positions land on the
78
+ // a-file's dark squares (a1/a3/a5/a7, per chess/board.css's comment) and so take
79
+ // c.labelDark; the even positions (a2/a4/a6/a8, light squares) take
80
+ // c.labelLight. No text-shadow: the label colour itself carries enough
81
+ // contrast against its own square.
82
+ function _coordLabelCss(c) {
83
+ return `.orientation-white coords.ranks coord:nth-child(odd),
84
+ .orientation-white coords.files coord:nth-child(odd) { color: ${c.labelDark} !important; }
85
+ .orientation-white coords.ranks coord:nth-child(even),
86
+ .orientation-white coords.files coord:nth-child(even) { color: ${c.labelLight} !important; }
87
+ .orientation-black coords.ranks coord:nth-child(odd),
88
+ .orientation-black coords.files coord:nth-child(odd) { color: ${c.labelLight} !important; }
89
+ .orientation-black coords.ranks coord:nth-child(even),
90
+ .orientation-black coords.files coord:nth-child(even) { color: ${c.labelDark} !important; }`;
91
+ }
92
+
93
+ function _cssForBoard(c) {
94
+ return `/* chesstb-theme: ${c.id} */
95
+ cg-board {
96
+ background-image: url("${_checkerboardDataUri(c.lightSquare, c.darkSquare)}");
97
+ /* _checkerboardDataUri() returns a 2x2 tile (2 squares per axis).
98
+ 25% 25% sizes it to 2/8 of the board per axis, so the tile repeats
99
+ 4x4 (background-repeat's default) to fill all 64 squares; matches
100
+ the base checkerboard sizing in chess/board.css. */
101
+ background-size: 25% 25%;
102
+ }
103
+ ${_coordLabelCss(c)}
104
+ cg-board square.last-move { background-color: ${c.highlight} !important; }`;
105
+ }
106
+
107
+ function _applyCSS() {
108
+ let tag = document.getElementById('chesstb-theme');
109
+ if (!tag) {
110
+ tag = document.createElement('style');
111
+ tag.id = 'chesstb-theme';
112
+ document.head.appendChild(tag);
113
+ }
114
+ tag.textContent = `@layer app {\n${_cssForBoard(_boardColors())}\n}`;
115
+ }
116
+
117
+ // ── Public API ────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Register the board reconstruction callback from board.js.
121
+ * Must be called before Board.init().
122
+ */
123
+ function init(reconstructBoard) {
124
+ _reconstructBoard = reconstructBoard;
125
+ }
126
+
127
+ /**
128
+ * Inject the board's theme <style> tag and populate the piece-set-dependent
129
+ * tray/promotion images.
130
+ */
131
+ function apply() {
132
+ _applyCSS();
133
+ if (_reconstructBoard) _reconstructBoard();
134
+ }
135
+
136
+ /** Return the active piece set id string. */
137
+ function currentPieceSet() { return PIECE_SET; }
138
+
139
+ // Built once - pieceThemeFn() below always returns this same function,
140
+ // rather than allocating a fresh closure per call.
141
+ function _pieceThemePath(piece) {
142
+ return `/static/pieces/${PIECE_SET}/${piece}.svg`;
143
+ }
144
+
145
+ /**
146
+ * Return the piece-code → image-path function for the active piece set
147
+ * (e.g. 'wQ' -> '/static/pieces/cburnett/wQ.svg'). Called by board.js to
148
+ * populate the spare-tray and promotion-dialog images.
149
+ */
150
+ function pieceThemeFn() {
151
+ return _pieceThemePath;
152
+ }
153
+
154
+ export const Theme = {
155
+ init, apply,
156
+ currentPieceSet,
157
+ pieceThemeFn,
158
+ };
frontend/src/engine/utils.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * utils.js: Shared helpers
3
+ */
4
+
5
+ /**
6
+ * Returns a debounced version of fn that fires after `delay` ms of inactivity.
7
+ * The returned function also exposes cancel(), so a caller that's about to
8
+ * issue an equivalent call immediately (bypassing the debounce) can drop any
9
+ * pending trailing call instead of leaving it to fire later against a
10
+ * position that's no longer current.
11
+ * @param {Function} fn
12
+ * @param {number} delay
13
+ */
14
+ export function debounce(fn, delay) {
15
+ let timer;
16
+ const debounced = function (...args) {
17
+ clearTimeout(timer);
18
+ timer = setTimeout(() => fn.apply(this, args), delay);
19
+ };
20
+ debounced.cancel = () => clearTimeout(timer);
21
+ return debounced;
22
+ }
frontend/src/hooks/useExplorer.js ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Board, VARIANTS } from '../engine/board.js';
3
+ import { Tablebase } from '../engine/tablebase.js';
4
+ import { Theme } from '../engine/theme.js';
5
+ import * as Storage from '../engine/storage.js';
6
+ import { debounce } from '../engine/utils.js';
7
+ import {
8
+ writeHash, readHashFen, readHashVariant, readLastGame, saveGameState, normalizeFen,
9
+ } from '../engine/gameState.js';
10
+ import { buildPgnText, parsePgn } from '../engine/pgnFormat.js';
11
+
12
+ const DEFAULT_FEN = '4k3/8/8/8/8/8/8/4K2R w - - 0 1';
13
+
14
+ const METRIC_CONFIG = {
15
+ dtz: { dataKey: 'moves_dtz', label: 'DTZ' },
16
+ dtc: { dataKey: 'moves_dtc', label: 'DTC' },
17
+ dtm: { dataKey: 'moves_dtm', label: 'DTM' },
18
+ dtm50: { dataKey: 'moves_dtm50', label: 'DTM50' },
19
+ };
20
+ const AUTO_PLAY_METRICS = Object.keys(METRIC_CONFIG);
21
+
22
+ const AUTO_PLAY_DELAY_MIN_MS = 0;
23
+ const AUTO_PLAY_DELAY_MAX_MS = 2500;
24
+ const AUTO_PLAY_DELAY_STEP_MS = 50;
25
+ const AUTO_PLAY_DELAY_DEFAULT_MS = 1250;
26
+ const LS_AUTOPLAY_DELAY_MS = 'chesstb_autoplay_delay_ms';
27
+
28
+ function clampAutoPlayDelayMs(ms) {
29
+ if (!Number.isFinite(ms)) return AUTO_PLAY_DELAY_DEFAULT_MS;
30
+ const snapped = Math.round(ms / AUTO_PLAY_DELAY_STEP_MS) * AUTO_PLAY_DELAY_STEP_MS;
31
+ return Math.min(AUTO_PLAY_DELAY_MAX_MS, Math.max(AUTO_PLAY_DELAY_MIN_MS, snapped));
32
+ }
33
+ function readAutoPlayDelayMs() {
34
+ const raw = Storage.read(LS_AUTOPLAY_DELAY_MS);
35
+ const ms = raw === null ? NaN : parseInt(raw, 10);
36
+ return Number.isFinite(ms) ? clampAutoPlayDelayMs(ms) : AUTO_PLAY_DELAY_DEFAULT_MS;
37
+ }
38
+ export function formatAutoPlayDelay(ms) {
39
+ return ms === 0 ? '0s' : `${parseFloat((ms / 1000).toFixed(2))}s`;
40
+ }
41
+
42
+ let _nextToastId = 1;
43
+ const NOTE_MS = 4500;
44
+
45
+ export function useExplorer() {
46
+ const [ready, setReady] = useState(false);
47
+ const [fen, setFen] = useState(DEFAULT_FEN);
48
+ const [variant, setVariant] = useState(VARIANTS.STANDARD);
49
+ const [locked, setLockedState] = useState(false);
50
+ const [canBack, setCanBack] = useState(false);
51
+ const [canForward, setCanForward] = useState(false);
52
+ const [moveHistory, setMoveHistory] = useState({ nodes: [], rootChildren: [], rootActiveChild: null, currentId: -1 });
53
+ const [startFen, setStartFen] = useState(DEFAULT_FEN);
54
+ // The PGN panel stays blank (not even "No moves yet") until Board
55
+ // reports a move-tree change for the first time - a fresh session
56
+ // with nothing restored from a link or localStorage never fires that
57
+ // callback until the player's first action.
58
+ const [historyInitialized, setHistoryInitialized] = useState(false);
59
+ const [fenError, setFenError] = useState(null);
60
+
61
+ const [tb, setTb] = useState(() => Tablebase.getViewState());
62
+ const [autoplayMetric, setAutoplayMetric] = useState(null);
63
+ const [autoplayAvailability, setAutoplayAvailability] = useState(
64
+ () => Object.fromEntries(AUTO_PLAY_METRICS.map(m => [m, { disabled: false, title: `Auto-play best ${METRIC_CONFIG[m].label} move` }]))
65
+ );
66
+ const [lockAutoplayDisabled, setLockAutoplayDisabled] = useState(false);
67
+
68
+ const [showRootRow, setShowRootRowState] = useState(() => Tablebase.getShowRootRow());
69
+ const [autoplayDelayMs, setAutoplayDelayMsState] = useState(() => readAutoPlayDelayMs());
70
+
71
+ const [settingsOpen, setSettingsOpen] = useState(false);
72
+ const [pgnDialogOpen, setPgnDialogOpen] = useState(false);
73
+ const [pgnError, setPgnError] = useState('');
74
+ const [toasts, setToasts] = useState([]);
75
+ const [activeMetric, setActiveMetric] = useState('dtz');
76
+
77
+ const fenInputRef = useRef(null);
78
+ const tbodyRef = useRef(null);
79
+ const actionsRef = useRef({});
80
+ const bootedRef = useRef(false);
81
+
82
+ // ── Toasts ──────────────────────────────────────────────────────────
83
+ function toast(message, durationMs = 1800) {
84
+ const id = _nextToastId++;
85
+ setToasts(prev => [...prev, { id, message, leaving: false }]);
86
+ setTimeout(() => {
87
+ setToasts(prev => prev.map(t => (t.id === id ? { ...t, leaving: true } : t)));
88
+ setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 400);
89
+ }, durationMs);
90
+ }
91
+
92
+ const activeNotesRef = useRef(new Set());
93
+
94
+ // ── One-time bootstrap ────────────────────────────────────────────────
95
+ useEffect(() => {
96
+ if (bootedRef.current) return;
97
+ bootedRef.current = true;
98
+
99
+ const hashFen = readHashFen();
100
+ const hashVariant = readHashVariant();
101
+ const lastGame = readLastGame();
102
+
103
+ let initialFen, initialVariant, restoreGame;
104
+ if (lastGame && hashFen &&
105
+ normalizeFen(hashFen) === lastGame.currentFen &&
106
+ (hashVariant || VARIANTS.STANDARD) === lastGame.variant) {
107
+ initialFen = normalizeFen(lastGame.startFen);
108
+ initialVariant = lastGame.variant;
109
+ restoreGame = lastGame;
110
+ } else if (hashFen) {
111
+ initialFen = normalizeFen(hashFen);
112
+ initialVariant = hashVariant || VARIANTS.STANDARD;
113
+ restoreGame = null;
114
+ } else if (lastGame) {
115
+ initialFen = normalizeFen(lastGame.startFen);
116
+ initialVariant = lastGame.variant;
117
+ restoreGame = lastGame;
118
+ } else {
119
+ initialFen = DEFAULT_FEN;
120
+ initialVariant = VARIANTS.STANDARD;
121
+ restoreGame = null;
122
+ }
123
+
124
+ const debouncedProbe = debounce(Tablebase.probe, 300);
125
+
126
+ // ── Auto-play state (refs - pure bookkeeping, no re-render needed) ──
127
+ const autoPlayTimerRef = { current: null };
128
+ const autoPlayCycleIdRef = { current: 0 };
129
+ const autoPlayPacingReadyRef = { current: false };
130
+ const autoPlayDataReadyRef = { current: false };
131
+ const autoPlayMoveInFlightRef = { current: false };
132
+ const autoPlayLockEngagedRef = { current: false };
133
+ const autoPlayMetricRef = { current: null }; // mirrors autoplayMetric state for synchronous checks
134
+
135
+ function bestMovesForArrows(data) {
136
+ return {
137
+ dtz: data?.dtz_available ? (data.moves_dtz || [])[0] || null : null,
138
+ dtc: data?.dtc_available ? (data.moves_dtc || [])[0] || null : null,
139
+ dtm: data?.dtm_available ? (data.moves_dtm || [])[0] || null : null,
140
+ dtm50: data?.dtm50_available ? (data.moves_dtm50 || [])[0] || null : null,
141
+ };
142
+ }
143
+
144
+ function applySetLocked(next, persist = true) {
145
+ Board.setLocked(next, persist);
146
+ setLockedState(next);
147
+ }
148
+
149
+ function stopAutoPlay() {
150
+ const prevMetric = autoPlayMetricRef.current;
151
+ autoPlayMetricRef.current = null;
152
+ setAutoplayMetric(null);
153
+ autoPlayCycleIdRef.current++;
154
+ clearTimeout(autoPlayTimerRef.current);
155
+ autoPlayTimerRef.current = null;
156
+ autoPlayPacingReadyRef.current = false;
157
+ autoPlayDataReadyRef.current = false;
158
+ if (autoPlayLockEngagedRef.current) {
159
+ autoPlayLockEngagedRef.current = false;
160
+ applySetLocked(false, false);
161
+ }
162
+ setLockAutoplayDisabled(false);
163
+ return prevMetric;
164
+ }
165
+
166
+ function armAutoPlayWait(dataAlreadyReady) {
167
+ const cycleId = ++autoPlayCycleIdRef.current;
168
+ autoPlayPacingReadyRef.current = false;
169
+ autoPlayDataReadyRef.current = dataAlreadyReady;
170
+ clearTimeout(autoPlayTimerRef.current);
171
+ autoPlayTimerRef.current = null;
172
+ if (!dataAlreadyReady) return;
173
+ autoPlayTimerRef.current = setTimeout(() => {
174
+ if (cycleId !== autoPlayCycleIdRef.current) return;
175
+ autoPlayPacingReadyRef.current = true;
176
+ tryPlayAutoPlayMove(cycleId);
177
+ }, actionsRef.current.getAutoplayDelayMs());
178
+ }
179
+
180
+ function tryPlayAutoPlayMove(cycleId) {
181
+ if (cycleId !== autoPlayCycleIdRef.current) return;
182
+ if (!autoPlayMetricRef.current) return;
183
+ if (!autoPlayPacingReadyRef.current || !autoPlayDataReadyRef.current) return;
184
+
185
+ const metric = autoPlayMetricRef.current;
186
+ const data = Tablebase.getLastData();
187
+ if (!data) return;
188
+ const moves = data[METRIC_CONFIG[metric].dataKey] || [];
189
+ const best = moves.find(m => m.outcome !== 'unknown' && m.available !== false);
190
+ if (!best) { stopAutoPlay(); return; }
191
+ autoPlayMoveInFlightRef.current = true;
192
+ const played = Board.playMove(best.san);
193
+ autoPlayMoveInFlightRef.current = false;
194
+ if (!played) { stopAutoPlay(); return; }
195
+ if (best.is_mate || best.outcome === 'draw') { stopAutoPlay(); return; }
196
+ armAutoPlayWait(false);
197
+ }
198
+
199
+ function onAutoPlayResult() {
200
+ if (!autoPlayMetricRef.current || autoPlayDataReadyRef.current) return;
201
+ armAutoPlayWait(true);
202
+ }
203
+
204
+ function startAutoPlay(metric) {
205
+ if (!METRIC_CONFIG[metric]) return;
206
+ stopAutoPlay();
207
+ autoPlayMetricRef.current = metric;
208
+ setAutoplayMetric(metric);
209
+ if (!Board.isLocked()) {
210
+ autoPlayLockEngagedRef.current = true;
211
+ applySetLocked(true, false);
212
+ }
213
+ setLockAutoplayDisabled(true);
214
+ const dataAlreadyReady = Tablebase.getLastFen() === Board.currentFen() &&
215
+ Tablebase.getLastVariant() === Board.getVariant();
216
+ armAutoPlayWait(dataAlreadyReady);
217
+ }
218
+
219
+ function updateAutoplayAvailability(data) {
220
+ const availableByMetric = {
221
+ dtz: data?.dtz_available, dtc: data?.dtc_available,
222
+ dtm: data?.dtm_available, dtm50: data?.dtm50_available,
223
+ };
224
+ const rootWdlByMetric = {
225
+ dtz: data?.wdl,
226
+ dtc: Array.isArray(data?.dtc) ? data.dtc[0] : null,
227
+ dtm: data?.wdl,
228
+ dtm50: Array.isArray(data?.dtm50) ? data.dtm50[0] : null,
229
+ };
230
+ const next = {};
231
+ AUTO_PLAY_METRICS.forEach(metric => {
232
+ const unavailable = availableByMetric[metric] === false;
233
+ const isDraw = !unavailable && rootWdlByMetric[metric] === 0;
234
+ if (unavailable || isDraw) {
235
+ if (autoPlayMetricRef.current === metric) stopAutoPlay();
236
+ next[metric] = {
237
+ disabled: true,
238
+ title: unavailable
239
+ ? `Auto-play unavailable — no ${METRIC_CONFIG[metric].label} table for this material`
240
+ : `${METRIC_CONFIG[metric].label} auto-play unavailable — position is a draw`,
241
+ };
242
+ } else {
243
+ next[metric] = { disabled: false, title: `Auto-play best ${METRIC_CONFIG[metric].label} move` };
244
+ }
245
+ });
246
+ setAutoplayAvailability(next);
247
+ }
248
+
249
+ // ── Position-change callback ────────────────────────────────────
250
+ function onPositionChange(nextFen, immediate) {
251
+ if (autoPlayMetricRef.current && !autoPlayMoveInFlightRef.current) stopAutoPlay();
252
+
253
+ if (fenInputRef.current) {
254
+ fenInputRef.current.value = nextFen;
255
+ fenInputRef.current.classList.remove('is-invalid');
256
+ }
257
+ setFenError(null);
258
+ Tablebase.clearErrorLine();
259
+ setFen(nextFen);
260
+ setVariant(Board.getVariant());
261
+ setCanBack(Board.canGoBack());
262
+ setCanForward(Board.canGoForward());
263
+ writeHash(nextFen, Board.getVariant());
264
+ Board.clearArrows();
265
+
266
+ const count = Board.isInsufficientMaterial() ? 0 : Board.legalMoveCount();
267
+ Tablebase.setMoveCount(count > 0 ? `${count} legal move${count === 1 ? '' : 's'}` : '');
268
+
269
+ const onProbeError = () => setReady(true);
270
+ const activeVariant = Board.getVariant();
271
+ if (immediate) {
272
+ debouncedProbe.cancel();
273
+ Tablebase.probe(nextFen, activeVariant, onProbeError);
274
+ } else {
275
+ debouncedProbe(nextFen, activeVariant, onProbeError);
276
+ }
277
+ }
278
+
279
+ Board.setOnMoveHistoryChange((history, sFen) => {
280
+ setMoveHistory(history);
281
+ setStartFen(sFen);
282
+ setHistoryInitialized(true);
283
+ setCanBack(Board.canGoBack());
284
+ setCanForward(Board.canGoForward());
285
+ saveGameState(history, sFen, Board.getVariant());
286
+ });
287
+
288
+ Tablebase.setResultHandler(data => {
289
+ Board.drawArrows(bestMovesForArrows(data));
290
+ updateAutoplayAvailability(data);
291
+ setReady(true);
292
+ });
293
+ Tablebase.setMoveSelectHandler(san => {
294
+ const played = Board.playMove(san);
295
+ if (!played) toast('Illegal move — this position may have changed.');
296
+ });
297
+ Tablebase.setNoteHandler(message => {
298
+ const active = activeNotesRef.current;
299
+ if (active.has(message)) return;
300
+ active.add(message);
301
+ setTimeout(() => active.delete(message), NOTE_MS + 400);
302
+ toast(message, NOTE_MS);
303
+ });
304
+ Tablebase.setViewStateHandler(setTb);
305
+
306
+ // ── Board bootstrap ──────────────────────────────────────────────
307
+ Theme.init(Board.reconstruct);
308
+ setReady(false);
309
+ const boardInit = Board.init(onPositionChange, initialFen, initialVariant);
310
+ if (!boardInit.ok) toast(boardInit.reason || 'Invalid starting position — loaded the default position instead.');
311
+ if (restoreGame) {
312
+ Board.restoreTree(restoreGame.startFen, restoreGame.nodes, restoreGame.rootChildren, restoreGame.rootActiveChild, restoreGame.currentId);
313
+ }
314
+ Theme.apply();
315
+
316
+ setVariant(Board.getVariant());
317
+ setLockedState(Board.isLocked());
318
+ setFen(Board.currentFen());
319
+ setStartFen(Board.getStartFen());
320
+ setCanBack(Board.canGoBack());
321
+ setCanForward(Board.canGoForward());
322
+ if (fenInputRef.current) fenInputRef.current.value = Board.currentFen();
323
+
324
+ // ── Public actions ───────────────────────────────────────────────
325
+ function applyFen(rawFen) {
326
+ const norm = normalizeFen(rawFen);
327
+ const result = Board.setPosition(norm);
328
+ if (!result.ok) {
329
+ if (fenInputRef.current) fenInputRef.current.classList.add('is-invalid');
330
+ setFenError(result.reason || 'Invalid position.');
331
+ return;
332
+ }
333
+ if (fenInputRef.current) fenInputRef.current.classList.remove('is-invalid');
334
+ setFenError(null);
335
+ Tablebase.clearErrorLine();
336
+ }
337
+
338
+ function setTurn(turnChar) {
339
+ const parts = Board.currentFen().split(' ');
340
+ if (parts[1] === turnChar) return;
341
+ parts[1] = turnChar;
342
+ applyFen(parts.join(' '));
343
+ }
344
+
345
+ function copyFen() {
346
+ navigator.clipboard.writeText(Board.currentFen())
347
+ .then(() => toast('FEN copied!'))
348
+ .catch(() => {
349
+ fenInputRef.current?.select();
350
+ document.execCommand('copy');
351
+ toast('FEN copied!');
352
+ });
353
+ }
354
+
355
+ function exportPgn() {
356
+ const text = buildPgnText();
357
+ if (!text) { toast('Play a move to export a PGN.'); return; }
358
+ const url = URL.createObjectURL(new Blob([text + '\n'], { type: 'application/x-chess-pgn;charset=utf-8' }));
359
+ const link = document.createElement('a');
360
+ link.href = url;
361
+ link.download = 'chesstb_game.pgn';
362
+ document.body.appendChild(link);
363
+ link.click();
364
+ link.remove();
365
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
366
+ }
367
+
368
+ function flip() {
369
+ Board.flip();
370
+ Board.drawArrows(bestMovesForArrows(Tablebase.getLastData()));
371
+ }
372
+
373
+ function clearBoard() { Board.clear(); }
374
+ function goBack() { Board.goBack(); }
375
+ function goForward() { Board.goForward(); }
376
+ function goToNode(id) { Board.goToNode(id); }
377
+
378
+ function setLockedAction(next) {
379
+ if (autoPlayMetricRef.current !== null) return; // soft-disabled during auto-play
380
+ applySetLocked(next);
381
+ }
382
+
383
+ function toggleAutoPlay(metric) {
384
+ if (autoPlayMetricRef.current === metric) stopAutoPlay();
385
+ else startAutoPlay(metric);
386
+ }
387
+
388
+ function setShowRootRowAction(next) { Tablebase.setShowRootRow(next); setShowRootRowState(next); }
389
+
390
+ function setChess960Action(next) {
391
+ const result = Board.setVariant(next ? VARIANTS.CHESS960 : VARIANTS.STANDARD);
392
+ if (!result.ok) { toast(result.reason || "Can't switch modes for this position.", 3000); return; }
393
+ setVariant(Board.getVariant());
394
+ }
395
+
396
+ function setAutoplayDelayAction(ms) {
397
+ const clamped = clampAutoPlayDelayMs(ms);
398
+ Storage.write(LS_AUTOPLAY_DELAY_MS, clamped);
399
+ setAutoplayDelayMsState(clamped);
400
+ }
401
+ function getAutoplayDelayMs() { return actionsRef.current.autoplayDelayMsLive; }
402
+
403
+ function openPgnDialog() { setPgnDialogOpen(true); setPgnError(''); }
404
+ function closePgnDialog() { setPgnDialogOpen(false); }
405
+ function parsePgnAction(text) {
406
+ const result = parsePgn(text);
407
+ if (!result.ok) { setPgnError(result.error); return; }
408
+ const loaded = Board.importGame(result.tree, result.variant);
409
+ if (!loaded.ok) { setPgnError(loaded.reason); return; }
410
+ setPgnError('');
411
+ closePgnDialog();
412
+ }
413
+
414
+ // Exposed to components via the stable actionsRef object.
415
+ Object.assign(actionsRef.current, {
416
+ applyFen, setTurn, copyFen, exportPgn, flip, clearBoard, goBack, goForward, goToNode,
417
+ setLockedAction, toggleAutoPlay, setShowRootRowAction, setChess960Action,
418
+ setAutoplayDelayAction, getAutoplayDelayMs, onAutoPlayResult, openPgnDialog, closePgnDialog,
419
+ parsePgnAction, toast, setActiveMetric,
420
+ exportCsv: Tablebase.exportCsv,
421
+ autoplayDelayMsLive: readAutoPlayDelayMs(),
422
+ });
423
+
424
+ // Very first probe of the session runs immediately (no debounce).
425
+ onPositionChange(Board.currentFen(), true);
426
+ }, []);
427
+
428
+ // Wait for the results to commit and paint before starting the delay.
429
+ // Two frames also let a zero-delay run show each position's results.
430
+ useEffect(() => {
431
+ if (!autoplayMetric || tb.loading) return;
432
+ let frame = requestAnimationFrame(() => {
433
+ frame = requestAnimationFrame(() => actionsRef.current.onAutoPlayResult());
434
+ });
435
+ return () => cancelAnimationFrame(frame);
436
+ }, [tb, autoplayMetric]);
437
+
438
+ // Keep the ref mirror of autoplayDelayMs current for armAutoPlayWait's
439
+ // synchronous read (it can't see React state directly from inside a
440
+ // closure captured once at bootstrap).
441
+ useEffect(() => {
442
+ actionsRef.current.autoplayDelayMsLive = autoplayDelayMs;
443
+ }, [autoplayDelayMs]);
444
+
445
+ // ── Keyboard shortcuts ───────────���──────────────────────────────────
446
+ useEffect(() => {
447
+ function onKeydown(e) {
448
+ const tag = document.activeElement?.tagName;
449
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
450
+ if (document.activeElement?.isContentEditable) return;
451
+ if (document.getElementById('promotion-dialog')?.classList.contains('is-visible')) return;
452
+ if (pgnDialogOpen) return;
453
+ if (settingsOpen) return;
454
+
455
+ if (e.key === 'ArrowLeft' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.goBack(); }
456
+ if (e.key === 'ArrowRight' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.goForward(); }
457
+ if (e.key.toLowerCase() === 'f' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.flip(); }
458
+ if (e.key.toLowerCase() === 'c' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); actionsRef.current.clearBoard(); }
459
+ }
460
+ document.addEventListener('keydown', onKeydown);
461
+ return () => document.removeEventListener('keydown', onKeydown);
462
+ }, [pgnDialogOpen, settingsOpen]);
463
+
464
+ // ── hashchange (shared links opened while the tab is open) ───────────
465
+ useEffect(() => {
466
+ function onHashChange() {
467
+ const newFen = readHashFen();
468
+ if (!newFen) return;
469
+ const newVariant = readHashVariant() || VARIANTS.STANDARD;
470
+ const result = Board.setPositionAndVariant(normalizeFen(newFen), newVariant);
471
+ if (!result.ok) {
472
+ actionsRef.current.toast(result.reason || 'Invalid position in URL.');
473
+ writeHash(Board.currentFen(), Board.getVariant());
474
+ }
475
+ }
476
+ window.addEventListener('hashchange', onHashChange);
477
+ return () => window.removeEventListener('hashchange', onHashChange);
478
+ }, []);
479
+
480
+ const actions = useMemo(() => new Proxy({}, {
481
+ get: (_t, prop) => (...args) => actionsRef.current[prop]?.(...args),
482
+ }), []);
483
+
484
+ return {
485
+ ready, fen, variant, locked, canBack, canForward, moveHistory, startFen, historyInitialized, fenError,
486
+ tb, autoplayMetric, autoplayAvailability, lockAutoplayDisabled,
487
+ showRootRow, autoplayDelayMs, settingsOpen, setSettingsOpen,
488
+ pgnDialogOpen, pgnError, toasts, activeMetric,
489
+ fenInputRef, tbodyRef, actions,
490
+ };
491
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import './styles/index.css';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App.jsx';
4
+
5
+ const rootEl = document.getElementById('root');
6
+ const sourceRepositoryUrl = rootEl?.dataset.sourceRepositoryUrl || '';
7
+
8
+ createRoot(rootEl).render(<App sourceRepositoryUrl={sourceRepositoryUrl} />);
frontend/src/styles/admin-page.css ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Admin page ────────────────────────────────────────────────────────
2
+ The dashboard is an ordinary scrolling document. Selectors are scoped
3
+ under `.admin-page` on the page's <body>. */
4
+
5
+ body.admin-page {
6
+ height: auto;
7
+ min-height: 100vh;
8
+ overflow: auto;
9
+ padding: var(--space-6);
10
+ }
11
+
12
+ .admin-page h1 {
13
+ font-size: var(--text-xl);
14
+ color: var(--text);
15
+ margin-bottom: var(--space-5);
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 10px;
19
+ }
20
+ /* Colour and hover come from the global `a` rule in base.css. Targeted by
21
+ class rather than `h1 a` because h1 may also hold the GitHub icon
22
+ button (site-header__link--icon). */
23
+ .admin-page__back-link { font-size: var(--text-sm); margin-left: auto; }
24
+
25
+ /* Card-section labels, sized to match the app's other panel-title rows
26
+ (`.pgn-panel-title`, `.panel-header__title`) rather than a one-off
27
+ value. */
28
+ .admin-page h2 {
29
+ font-size: var(--text-xs);
30
+ font-weight: 700;
31
+ text-transform: uppercase;
32
+ letter-spacing: 0.08em;
33
+ color: var(--text-muted);
34
+ margin-bottom: var(--space-3);
35
+ }
36
+
37
+ .admin-page .grid {
38
+ display: grid;
39
+ grid-template-columns: 1fr 1fr;
40
+ gap: var(--space-4);
41
+ margin-bottom: var(--space-4);
42
+ }
43
+
44
+ /* Same background/border/radius recipe as `.pgn-panel` / `.results-panel`. */
45
+ .admin-page .card {
46
+ background: var(--surface);
47
+ border: 1px solid var(--border);
48
+ border-radius: var(--r-lg);
49
+ padding: 20px;
50
+ }
51
+
52
+ .admin-page .stat {
53
+ display: flex;
54
+ justify-content: space-between;
55
+ padding: 6px 0;
56
+ border-bottom: 1px solid var(--border);
57
+ font-size: var(--text-sm);
58
+ }
59
+ .admin-page .stat:last-child { border-bottom: none; }
60
+ .admin-page .stat .label { color: var(--text-dim); }
61
+ .admin-page .stat .value {
62
+ color: var(--text);
63
+ font-variant-numeric: tabular-nums;
64
+ font-family: var(--font-mono);
65
+ }
66
+
67
+ .admin-page .bar-wrap {
68
+ height: 8px;
69
+ background: var(--border);
70
+ border-radius: 4px;
71
+ overflow: hidden;
72
+ margin: var(--space-2) 0 var(--space-3);
73
+ }
74
+ /* Same gradient as the results panel's `.probe-progress__fill`. */
75
+ .admin-page .bar-fill {
76
+ height: 100%;
77
+ background: linear-gradient(90deg, var(--progress-from), var(--progress-to));
78
+ border-radius: 4px;
79
+ transition: width 0.4s;
80
+ }
81
+
82
+ .admin-page .toolbar-row {
83
+ display: flex;
84
+ gap: var(--space-2);
85
+ margin-bottom: 20px;
86
+ }
87
+
88
+ /* Destructive-action modifier for the shared `.btn` component (the
89
+ Refresh button next to it needs no modifier - plain `.btn` already
90
+ matches). Follows the same pattern as `.btn--apply`'s gold accent,
91
+ using the `--loss-glow` token for the hover fill. */
92
+ .admin-page .btn.danger { border-color: var(--loss); color: var(--loss); }
93
+ .admin-page .btn.danger:hover { background: var(--loss-glow); border-color: var(--loss); }
94
+
95
+ .admin-page .status {
96
+ font-size: var(--text-xs);
97
+ color: var(--text-muted);
98
+ margin-top: 10px;
99
+ min-height: 18px;
100
+ }
101
+ .admin-page .status.ok { color: var(--win); }
102
+ .admin-page .status.err { color: var(--loss); }
103
+
104
+ .admin-page .refresh-row {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: var(--space-3);
108
+ margin-bottom: 20px;
109
+ font-size: var(--text-xs);
110
+ color: var(--text-muted);
111
+ }
112
+
113
+ /* Login card for GET /admin without a valid session. Centered fixed-width
114
+ panel; reuses `.card`'s background, border and radius. */
115
+ .admin-page .login-card {
116
+ max-width: 320px;
117
+ margin: 15vh auto 0;
118
+ background: var(--surface);
119
+ border: 1px solid var(--border);
120
+ border-radius: var(--r-lg);
121
+ padding: 28px;
122
+ text-align: center;
123
+ }
124
+ .admin-page .login-card h1 { justify-content: center; margin-bottom: var(--space-2); }
125
+ .admin-page .login-card__hint {
126
+ font-size: var(--text-xs);
127
+ color: var(--text-muted);
128
+ margin-bottom: var(--space-4);
129
+ }
130
+ .admin-page .login-card__back { display: block; margin-top: var(--space-4); font-size: var(--text-sm); }
131
+
132
+ /* Same recipe as FenBar.jsx's `.fen-input`, minus the icon padding;
133
+ this field has no leading icon. */
134
+ .admin-page .login-input {
135
+ width: 100%;
136
+ height: 34px;
137
+ padding: 0 10px;
138
+ margin-bottom: var(--space-3);
139
+ font-family: var(--font-mono);
140
+ font-size: var(--text-xs);
141
+ color: var(--text);
142
+ background: var(--surface-2);
143
+ border: 1px solid var(--border);
144
+ border-radius: var(--r-md);
145
+ outline: none;
146
+ text-align: center;
147
+ transition: border-color var(--tr), box-shadow var(--tr);
148
+ }
149
+ .admin-page .login-input:focus { border-color: var(--gold-dim); box-shadow: 0 0 0 2px var(--gold-glow); }
150
+
151
+ @media (max-width: 640px) {
152
+ .admin-page .grid { grid-template-columns: 1fr; }
153
+ }
154
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
155
+
156
+ @media (hover: none) and (pointer: coarse) {
157
+ .admin-page .login-input { font-size: var(--text-lg); }
158
+ }
frontend/src/styles/admin.css ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ @layer reset, vendor, app;
2
+ @import "./reset.css" layer(reset);
3
+ @import "./fonts.css";
4
+ @import "./tokens.css" layer(app);
5
+ @import "./base.css" layer(app);
6
+ @import "./components/button.css" layer(app);
7
+ @import "./components/header.css" layer(app);
8
+ @import "./admin-page.css" layer(app);
frontend/src/styles/base.css ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Reading-text baseline: the browser's default font size. Layout sizing
2
+ (e.g. --board-size above) responds to the viewport separately. */
3
+ html { font-size: 100%; }
4
+
5
+ /* Body fills the viewport height at minimum and scrolls if its content
6
+ ever needs more room than that. */
7
+ html, body { height: 100%; }
8
+ body {
9
+ min-height: 100%;
10
+ overflow-y: auto;
11
+ overflow-x: hidden;
12
+ font-family: var(--font-ui);
13
+ font-size: var(--text-lg);
14
+ line-height: 1.5;
15
+ background: var(--bg);
16
+ color: var(--text);
17
+ -webkit-font-smoothing: antialiased;
18
+ }
19
+
20
+ button { cursor: pointer; font: inherit; touch-action: manipulation; }
21
+ a { color: var(--text-dim); text-decoration: none; }
22
+ a:hover { color: var(--text); }
23
+ :focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
24
+
25
+ /* Suppress the tap flash Android/iOS draw on touch, and the ~300ms
26
+ delay before a tap registers as a click, on every interactive
27
+ control. touch-action: manipulation still allows scrolling - only
28
+ double-tap-to-zoom is disabled. */
29
+ button, .btn, a { -webkit-tap-highlight-color: transparent; }
30
+
31
+ /* ── Screen-reader utility ──────────────────────────────────────────── */
32
+ .sr-only {
33
+ position: absolute; width: 0.0625rem; height: 0.0625rem;
34
+ padding: 0; margin: -0.0625rem; overflow: hidden;
35
+ clip: rect(0,0,0,0); white-space: nowrap; border: 0;
36
+ }
37
+
38
+ /* ── Scrollbars ─────────────────────────────────────────────────────── */
39
+ * { scrollbar-width: thin; scrollbar-color: var(--border-2) transparent; }
40
+ ::-webkit-scrollbar { width: 0.375rem; height: 0.375rem; }
41
+ ::-webkit-scrollbar-track { background: transparent; }
42
+ ::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 0.1875rem; }
43
+
44
+ .icon { width: 1rem; height: 1rem; flex-shrink: 0; }
frontend/src/styles/chess/board.css ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Board group ────────────────────────────────────────────────────── */
2
+ /* Keep the side-to-move toggle and board directly below the FEN controls. */
3
+ .board-group {
4
+ margin-top: 0;
5
+ width: var(--board-size);
6
+ flex-shrink: 0;
7
+ display: flex;
8
+ flex-direction: column;
9
+ gap: 0.375rem;
10
+ }
11
+
12
+ /* ── Board wrap ─────────────────────────────────────────────────────── */
13
+ .board-wrap {
14
+ border-radius: var(--r-md);
15
+ overflow: hidden;
16
+ box-shadow: var(--shadow-board);
17
+ width: var(--board-size);
18
+ flex-shrink: 0;
19
+ }
20
+ .board-wrap-inner {
21
+ position: relative;
22
+ width: var(--board-size);
23
+ display: flex;
24
+ flex-direction: column;
25
+ }
26
+ /* Set by board.js's _syncTrayOrientation() to mirror chessground's own
27
+ orientation. column-reverse visually swaps the black/white trays around
28
+ #board without moving them in the DOM. */
29
+ .board-wrap-inner.orientation-black {
30
+ flex-direction: column-reverse;
31
+ }
32
+
33
+ /* ── Chessground sizing ─────────────────────────────────────────────── */
34
+ /* Chessground's own CSS (chessground.base.css) sizes cg-wrap/cg-board/
35
+ piece/square as percentages of their container. #board is the
36
+ percentage-sizing root; height is set explicitly here since chessground
37
+ doesn't compute it from width in JS. Both dimensions come from the
38
+ shared --board-size custom property (see tokens.css), so the board resizes
39
+ together with .left-col/.board-wrap/.board-wrap-inner/.spare-tray
40
+ without drifting out of alignment with them.
41
+
42
+ Chessground has its own rounding step one DOM layer deeper: render.ts's
43
+ updateBounds() reads #board's rendered width, then deliberately snaps
44
+ cg-container's width down to a whole number of device pixels evenly
45
+ divisible by 8 (so all 8 squares render crisply, with no anti-aliased
46
+ sub-pixel square boundaries),
47
+ `Math.floor((bounds.width * devicePixelRatio) / 8) * 8 / devicePixelRatio`,
48
+ and sets that as an inline style on cg-container. At integer device
49
+ pixel ratios (1x, 2x) this is a no-op whenever #board's current rendered
50
+ width happens to divide evenly by 8, but at fractional ratios (1.5x is
51
+ common on Windows displays) it usually doesn't: at --board-size's
52
+ ceiling (31.5rem = 504px), 504 * 1.5 = 756, 756 / 8 = 94.5,
53
+ floor'd to 94 * 8 = 752, back to 752 / 1.5 ≈ 501.3px - about 2.7px short
54
+ of #board's actual width on the right/bottom edge. Squares/pieces are
55
+ plain CSS percentages with no JS-computed inline style of their own, so
56
+ pinning cg-container's own width and height with !important (overriding
57
+ chessground's inline style) to the same --board-size as #board is
58
+ enough to keep the whole board exact and stable regardless of device
59
+ pixel ratio or the current viewport size. */
60
+ #board {
61
+ width: var(--board-size);
62
+ height: var(--board-size);
63
+ }
64
+ cg-container, cg-board {
65
+ width: var(--board-size) !important;
66
+ height: var(--board-size) !important;
67
+ }
68
+
69
+ /* ── Board square colours - Lichess brown ───────────────────────────── */
70
+ /* Fallback board colours. theme.js overrides these values after the board
71
+ is initialized, while the defaults prevent an unthemed initial render. */
72
+ cg-board {
73
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyJyBoZWlnaHQ9JzInPgo8cmVjdCB3aWR0aD0nMScgaGVpZ2h0PScxJyBmaWxsPScjZjBkOWI1Jy8+CjxyZWN0IHg9JzEnIHdpZHRoPScxJyBoZWlnaHQ9JzEnIGZpbGw9JyNiNTg4NjMnLz4KPHJlY3QgeT0nMScgd2lkdGg9JzEnIGhlaWdodD0nMScgZmlsbD0nI2I1ODg2MycvPgo8cmVjdCB4PScxJyB5PScxJyB3aWR0aD0nMScgaGVpZ2h0PScxJyBmaWxsPScjZjBkOWI1Jy8+Cjwvc3ZnPg==");
74
+ /* The SVG above is a 2x2 tile (2 squares per axis). 25% 25% sizes it
75
+ to 2/8 of the board per axis, so the tile repeats 4x4
76
+ (background-repeat's default) to fill all 64 squares. */
77
+ background-size: 25% 25%;
78
+ }
79
+ /* ── Rank/file coordinate labels - inset onto board edges ─────────── */
80
+ /* board.js sets coordinatesOnSquares: false, which uses chessground's
81
+ native coords.ranks/coords.files strips - one rank-only label per row,
82
+ one file-only label per column - rather than a combined "a8"-style
83
+ label rendered on all 64 squares. Chessground positions these outside
84
+ the board by default (which .board-wrap's overflow:hidden would clip
85
+ anyway), so they're repositioned here to sit inset at the board's own
86
+ edges, at a small (0.125rem) corner inset.
87
+
88
+ Colour is not done via renderCoords()'s own .coord-light/.coord-dark
89
+ classes (chessground's source, wrap.ts): those classes don't
90
+ correspond to the actual square each label overlays once repositioned
91
+ this way, and chessground's own official theme CSS
92
+ (chessground.brown.css) doesn't use them for coords.ranks/coords.files
93
+ either - it uses structural :nth-child selectors scoped by an
94
+ .orientation-white/.orientation-black class on the wrap element
95
+ instead (that class exists for lichess's own outside-the-board label
96
+ rhythm, not square-accurate contrast, so its odd/even color pairing
97
+ isn't reusable here - only the selector structure is). ranks/files are fixed
98
+ DOM arrays ['1'..'8']/['a'..'h'] (types.ts), so :nth-child parity is
99
+ deterministic; the odd/even -> colour mapping below follows the board
100
+ square colours (a1 dark, a8 light, etc.) for each orientation rather than
101
+ chessground's coordinate colour convention. */
102
+ .orientation-white coords.ranks coord:nth-child(odd),
103
+ .orientation-white coords.files coord:nth-child(odd) {
104
+ color: var(--board-light) !important; /* light text - these sit on dark squares */
105
+ }
106
+ .orientation-white coords.ranks coord:nth-child(even),
107
+ .orientation-white coords.files coord:nth-child(even) {
108
+ color: var(--board-dark) !important; /* dark text - these sit on light squares */
109
+ }
110
+ .orientation-black coords.ranks coord:nth-child(odd),
111
+ .orientation-black coords.files coord:nth-child(odd) {
112
+ color: var(--board-dark) !important; /* flipped board: parity-to-square-colour swaps */
113
+ }
114
+ .orientation-black coords.ranks coord:nth-child(even),
115
+ .orientation-black coords.files coord:nth-child(even) {
116
+ color: var(--board-light) !important;
117
+ }
118
+ coords.ranks coord, coords.files coord {
119
+ font-size: max(0.5625rem, calc(var(--board-size) / 56)) !important;
120
+ font-weight: 600 !important;
121
+ font-family: var(--font-ui) !important;
122
+ }
123
+ .cg-wrap coords.ranks {
124
+ left: 0 !important;
125
+ top: 0 !important;
126
+ width: max(0.875rem, calc(var(--board-size) / 38)) !important;
127
+ height: 100% !important;
128
+ padding-left: 0.125rem !important; /* safe on this axis: one 0.875rem-wide column, not tiled horizontally */
129
+ opacity: 1 !important;
130
+ }
131
+ .cg-wrap coords.ranks coord {
132
+ transform: none !important;
133
+ /* Chessground's own coord rule is `flex: 1 1 auto` - each cell's
134
+ basis comes from its own glyph's content width/height, so 8 cells
135
+ sharing the board's row/column don't land on exact one-square (1/8)
136
+ multiples; the shortfall accumulates top-to-bottom (or left-to-right
137
+ for files), and since labels are aligned within their own cell, that
138
+ accumulating error reads as the label drifting further from the
139
+ square's true edge the further along the row/column it is (barely
140
+ visible at '1'/'2', a couple of px off by '8'). flex:0 0 12.5% pins
141
+ every cell to exactly one square-width (12.5% of the board)
142
+ regardless of its glyph's own size, eliminating that drift.
143
+ The corner inset itself is deliberately per-cell padding (below),
144
+ not container padding: padding on the container would shrink the
145
+ width available for the 8 tiled cells, so flex:0 0 12.5% would land
146
+ on smaller, still-uneven cells instead of a true one-eighth each. */
147
+ flex: 0 0 12.5% !important;
148
+ padding-top: 0.125rem !important;
149
+ }
150
+ .cg-wrap coords.files {
151
+ left: 0 !important;
152
+ bottom: 0 !important;
153
+ width: 100% !important;
154
+ height: max(0.875rem, calc(var(--board-size) / 38)) !important;
155
+ text-align: right !important;
156
+ text-transform: none !important;
157
+ /* align-items:flex-end, not padding-bottom on this container: files is
158
+ a row-direction flex container, so vertical is its cross axis, and
159
+ container padding on the cross axis doesn't push each cell's own
160
+ content toward the bottom the way main-axis padding does.
161
+ flex-end makes each cell
162
+ shrink-to-content and sit at the bottom of the row; the actual
163
+ corner inset is per-cell padding-bottom below, mirroring how
164
+ coords.ranks coord's padding-top is per-cell (its main axis, where
165
+ per-item padding works predictably). */
166
+ align-items: flex-end !important;
167
+ opacity: 1 !important;
168
+ }
169
+ .cg-wrap coords.files coord {
170
+ flex: 0 0 12.5% !important; /* see the fuller comment on coords.ranks coord above */
171
+ padding-right: 0.125rem !important;
172
+ padding-bottom: 0.125rem !important;
173
+ }
174
+
175
+ /* ── Last-move highlight ──────────────────────────────────────────────── */
176
+ cg-board square.last-move {
177
+ background-color: var(--board-highlight);
178
+ }
179
+
180
+ /* ── Check / checkmate halo ──────────────────────────────────────────── */
181
+ cg-board square.check {
182
+ background-image: radial-gradient(
183
+ ellipse at center,
184
+ rgba(255, 0, 0, 0.90) 0%,
185
+ rgba(231, 0, 0, 0.70) 25%,
186
+ rgba(169, 0, 0, 0.00) 89%,
187
+ rgba(158, 0, 0, 0.00) 100%
188
+ );
189
+ }
190
+
191
+ /* ── Spare-piece trays ───────────────────────────────��───────────── */
192
+ .spare-tray {
193
+ background-color: var(--tray-bg);
194
+ border: none;
195
+ padding: 0.3125rem 0;
196
+ margin: 0;
197
+ display: flex;
198
+ justify-content: center;
199
+ gap: 0;
200
+ width: var(--board-size);
201
+ box-sizing: border-box;
202
+ }
203
+ /* Each tray's border sits on whichever edge faces the board. By default
204
+ (white orientation) that's the black tray's bottom edge and the white
205
+ tray's top edge. .board-wrap-inner.orientation-black (set by board.js's
206
+ _syncTrayOrientation() to mirror chessground's orientation) reverses
207
+ the trays' stacking order via flex-direction: column-reverse, so the
208
+ rules below mirror which edge faces the board in that orientation. */
209
+ .spare-tray--black { border-bottom: 2px solid var(--tray-border); }
210
+ .spare-tray--white { border-top: 2px solid var(--tray-border); }
211
+ .board-wrap-inner.orientation-black .spare-tray--black {
212
+ border-bottom: none;
213
+ border-top: 2px solid var(--tray-border);
214
+ }
215
+ .board-wrap-inner.orientation-black .spare-tray--white {
216
+ border-top: none;
217
+ border-bottom: 2px solid var(--tray-border);
218
+ }
219
+ .spare-piece {
220
+ width: var(--spare-size);
221
+ height: var(--spare-size);
222
+ border: 0;
223
+ padding: 0;
224
+ margin: 0;
225
+ background: none;
226
+ cursor: grab;
227
+ }
228
+ .spare-piece img {
229
+ width: 100%;
230
+ height: 100%;
231
+ pointer-events: none;
232
+ }
233
+ .spare-piece.cp-selected {
234
+ outline: 2px solid var(--gold);
235
+ outline-offset: 1px;
236
+ border-radius: 0.125rem;
237
+ }
238
+ /* Crosshair cursor on the board while a spare piece is armed for placement */
239
+ #board.cp-active { cursor: crosshair; }
240
+ #board.cp-active square { cursor: crosshair; }
241
+
242
+ /* Drag ghost and arrow overlay: no CSS needed here - both are native
243
+ chessground features (draggable.showGhost / drawable.autoShapes),
244
+ styled via chessground.base.css + the drawable.brushes config in
245
+ board.js. */