chesspal 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +45 -82
  2. package/dist/cli/chat-hook.js +1 -1
  3. package/dist/cli/command-line.d.ts +1 -2
  4. package/dist/cli/command-line.js +2 -3
  5. package/dist/commands/agents.d.ts +26 -0
  6. package/dist/commands/agents.js +72 -0
  7. package/dist/commands/ai-move.d.ts +5 -14
  8. package/dist/commands/ai-move.js +28 -35
  9. package/dist/commands/ai-say.d.ts +3 -1
  10. package/dist/commands/ai-say.js +24 -6
  11. package/dist/commands/analyse-game.d.ts +4 -0
  12. package/dist/commands/analyse-game.js +43 -16
  13. package/dist/commands/annotate-game.d.ts +4 -0
  14. package/dist/commands/annotate-game.js +65 -12
  15. package/dist/commands/answer-lines.d.ts +28 -0
  16. package/dist/commands/answer-lines.js +144 -0
  17. package/dist/commands/coach.d.ts +45 -0
  18. package/dist/commands/coach.js +344 -0
  19. package/dist/commands/context.d.ts +16 -5
  20. package/dist/commands/context.js +1 -0
  21. package/dist/commands/delete-game.d.ts +7 -0
  22. package/dist/commands/delete-game.js +30 -0
  23. package/dist/commands/delete-review.d.ts +8 -0
  24. package/dist/commands/delete-review.js +34 -0
  25. package/dist/commands/drawings.d.ts +0 -2
  26. package/dist/commands/drawings.js +5 -3
  27. package/dist/commands/end-game.js +5 -2
  28. package/dist/commands/export-game.d.ts +1 -1
  29. package/dist/commands/export-game.js +4 -3
  30. package/dist/commands/game-summary.d.ts +21 -38
  31. package/dist/commands/game-summary.js +30 -167
  32. package/dist/commands/index.d.ts +15 -0
  33. package/dist/commands/index.js +47 -2
  34. package/dist/commands/init.d.ts +3 -3
  35. package/dist/commands/init.js +4 -4
  36. package/dist/commands/lead-input.d.ts +8 -0
  37. package/dist/commands/lead-input.js +21 -0
  38. package/dist/commands/look.js +4 -1
  39. package/dist/commands/new-game.d.ts +2 -3
  40. package/dist/commands/new-game.js +7 -4
  41. package/dist/commands/play-move.d.ts +8 -15
  42. package/dist/commands/play-move.js +5 -13
  43. package/dist/commands/player-move.d.ts +2 -6
  44. package/dist/commands/player-move.js +13 -6
  45. package/dist/commands/player-say.d.ts +1 -1
  46. package/dist/commands/player-say.js +1 -1
  47. package/dist/commands/ponder.d.ts +10 -0
  48. package/dist/commands/ponder.js +33 -21
  49. package/dist/commands/position.d.ts +1 -1
  50. package/dist/commands/position.js +3 -1
  51. package/dist/commands/profiles.d.ts +13 -4
  52. package/dist/commands/profiles.js +49 -15
  53. package/dist/commands/registry.d.ts +8 -3
  54. package/dist/commands/registry.js +12 -11
  55. package/dist/commands/say.d.ts +1 -1
  56. package/dist/commands/say.js +16 -16
  57. package/dist/commands/speech.d.ts +8 -0
  58. package/dist/commands/speech.js +20 -0
  59. package/dist/commands/status.d.ts +9 -3
  60. package/dist/commands/status.js +40 -15
  61. package/dist/commands/study-games.js +1 -1
  62. package/dist/commands/study.js +3 -19
  63. package/dist/core/facts.js +15 -15
  64. package/dist/core/game.d.ts +3 -1
  65. package/dist/core/game.js +8 -4
  66. package/dist/core/illegal.js +1 -1
  67. package/dist/core/index.d.ts +5 -4
  68. package/dist/core/index.js +5 -4
  69. package/dist/core/lead.d.ts +15 -0
  70. package/dist/core/lead.js +40 -0
  71. package/dist/core/levels.d.ts +1 -1
  72. package/dist/core/levels.js +1 -1
  73. package/dist/core/moods.d.ts +0 -2
  74. package/dist/core/moods.js +2 -2
  75. package/dist/core/openings.d.ts +0 -2
  76. package/dist/core/openings.js +1 -1
  77. package/dist/core/profile.js +1 -1
  78. package/dist/core/record.d.ts +1 -1
  79. package/dist/core/record.js +1 -1
  80. package/dist/core/review.d.ts +18 -3
  81. package/dist/core/review.js +52 -9
  82. package/dist/games/ai-model.d.ts +4 -0
  83. package/dist/games/ai-model.js +9 -0
  84. package/dist/games/analysis.d.ts +1 -1
  85. package/dist/games/archive.d.ts +25 -4
  86. package/dist/games/archive.js +99 -25
  87. package/dist/games/coach.d.ts +57 -0
  88. package/dist/games/coach.js +144 -0
  89. package/dist/games/current-game.d.ts +2 -0
  90. package/dist/games/current-game.js +7 -1
  91. package/dist/games/files.js +1 -0
  92. package/dist/games/index.d.ts +5 -4
  93. package/dist/games/index.js +5 -4
  94. package/dist/games/talk.d.ts +1 -5
  95. package/dist/games/talk.js +14 -14
  96. package/dist/home/open-folder.js +1 -1
  97. package/dist/host-files/{ponder-agents.d.ts → agents.d.ts} +10 -2
  98. package/dist/host-files/agents.js +53 -0
  99. package/dist/host-files/host-files.d.ts +7 -2
  100. package/dist/host-files/host-files.js +27 -12
  101. package/dist/host-files/index.d.ts +2 -2
  102. package/dist/host-files/index.js +3 -3
  103. package/dist/index.d.ts +1 -3
  104. package/dist/index.js +1 -0
  105. package/dist/page/app/anchor.js +88 -0
  106. package/dist/page/{app.js → app/app.js} +116 -231
  107. package/dist/page/app/keys.js +94 -0
  108. package/dist/page/app/phase.js +30 -0
  109. package/dist/page/app/served.js +73 -0
  110. package/dist/page/avatars/avatars.js +19 -0
  111. package/dist/page/avatars/thought.js +10 -0
  112. package/dist/page/base.css +160 -0
  113. package/dist/page/board/board.css +959 -0
  114. package/dist/page/board/board.js +125 -0
  115. package/dist/page/{explain.js → board/explain.js} +1 -1
  116. package/dist/page/board/marks.js +160 -0
  117. package/dist/page/board/motion.js +71 -0
  118. package/dist/page/board/pointer.js +239 -0
  119. package/dist/page/board/squares.js +26 -0
  120. package/dist/page/dialogs/analyses.js +195 -0
  121. package/dist/page/{dialog.js → dialogs/dialog.js} +1 -1
  122. package/dist/page/dialogs/dialogs.css +1228 -0
  123. package/dist/page/{new-game-dialog.js → dialogs/new-game.js} +3 -3
  124. package/dist/page/{profile-dialogs.js → dialogs/profiles.js} +40 -11
  125. package/dist/page/dialogs/search.js +31 -0
  126. package/dist/page/dialogs/settings.js +42 -0
  127. package/dist/page/dialogs/studied.js +39 -0
  128. package/dist/page/dialogs/study.js +51 -0
  129. package/dist/page/{badges.js → game/badges.js} +1 -1
  130. package/dist/page/{browse.js → game/browse.js} +1 -1
  131. package/dist/page/game/card-fold.js +40 -0
  132. package/dist/page/game/column.js +55 -0
  133. package/dist/page/{ending.js → game/ending.js} +2 -2
  134. package/dist/page/game/game.css +577 -0
  135. package/dist/page/{move-list.js → game/move-list.js} +10 -6
  136. package/dist/page/{plies.js → game/plies.js} +30 -10
  137. package/dist/page/game/result-tile.js +14 -0
  138. package/dist/page/helps/helps.css +689 -0
  139. package/dist/page/helps/helps.js +220 -0
  140. package/dist/page/helps/view.js +127 -0
  141. package/dist/page/layout/fold-tab.js +11 -0
  142. package/dist/page/{folds.js → layout/folds.js} +1 -1
  143. package/dist/page/{header.js → layout/header.js} +5 -4
  144. package/dist/page/layout/layout.css +834 -0
  145. package/dist/page/lead/lead.js +82 -0
  146. package/dist/page/lead/view.js +38 -0
  147. package/dist/page/main.js +2 -2
  148. package/dist/page/pieces/CREDITS.md +12 -29
  149. package/dist/page/pieces/capablanca-small.svg +20 -0
  150. package/dist/page/pieces/capablanca.svg +22 -0
  151. package/dist/page/pieces/nitida-small.svg +3 -2
  152. package/dist/page/pieces/nitida.svg +3 -2
  153. package/dist/page/{pieces.js → pieces/pieces.js} +2 -2
  154. package/dist/page/pieces/torretta-small.svg +15 -14
  155. package/dist/page/pieces/torretta.svg +17 -15
  156. package/dist/page/{player-card.js → players/card.js} +2 -6
  157. package/dist/page/{clock-time.js → players/clock-time.js} +2 -2
  158. package/dist/page/{profile.js → profile/profile.js} +9 -5
  159. package/dist/page/{settings.js → profile/settings.js} +5 -4
  160. package/dist/page/style.css +9 -4398
  161. package/dist/page/talk/talk.css +958 -0
  162. package/dist/page/{talk.js → talk/talk.js} +39 -46
  163. package/dist/page/text.js +137 -19
  164. package/dist/page/theme.css +292 -0
  165. package/dist/profiles/index.d.ts +1 -1
  166. package/dist/profiles/profiles.d.ts +15 -4
  167. package/dist/profiles/profiles.js +24 -7
  168. package/dist/server/bell.d.ts +10 -2
  169. package/dist/server/bell.js +28 -12
  170. package/dist/server/caller.d.ts +14 -3
  171. package/dist/server/caller.js +44 -11
  172. package/dist/server/mcp.d.ts +2 -3
  173. package/dist/server/mcp.js +53 -19
  174. package/dist/server/page/analyses.d.ts +23 -0
  175. package/dist/server/page/analyses.js +65 -0
  176. package/dist/server/page/files.d.ts +5 -0
  177. package/dist/server/page/files.js +92 -0
  178. package/dist/server/page/game.d.ts +6 -0
  179. package/dist/server/page/game.js +381 -0
  180. package/dist/server/{http.d.ts → page/http.d.ts} +13 -0
  181. package/dist/server/{http.js → page/http.js} +11 -0
  182. package/dist/server/{page.d.ts → page/page.d.ts} +8 -13
  183. package/dist/server/page/page.js +229 -0
  184. package/dist/server/{profile-routes.d.ts → page/profiles.d.ts} +2 -2
  185. package/dist/server/{profile-routes.js → page/profiles.js} +47 -18
  186. package/instructions/analysis.md +4 -0
  187. package/instructions/analyst-agent.md +8 -0
  188. package/instructions/analyst.md +3 -7
  189. package/instructions/coach.md +11 -0
  190. package/instructions/opponent.md +4 -4
  191. package/package.json +1 -1
  192. package/dist/host-files/ponder-agents.js +0 -40
  193. package/dist/page/avatars.js +0 -16
  194. package/dist/page/board.js +0 -566
  195. package/dist/page/fold-tab.js +0 -9
  196. package/dist/page/game-column.js +0 -44
  197. package/dist/page/helps-view.js +0 -51
  198. package/dist/page/helps.js +0 -90
  199. package/dist/page/pieces/cburnett-small.svg +0 -20
  200. package/dist/page/pieces/cburnett.svg +0 -20
  201. package/dist/page/settings-panel.js +0 -32
  202. package/dist/page/studied.js +0 -14
  203. package/dist/page/study-dialog.js +0 -58
  204. package/dist/server/analyses.d.ts +0 -17
  205. package/dist/server/analyses.js +0 -46
  206. package/dist/server/page.js +0 -657
  207. /package/dist/page/{torretta.js → avatars/torretta.js} +0 -0
  208. /package/dist/page/{opening-book.js → game/opening-book.js} +0 -0
  209. /package/dist/page/{model-name.js → players/model-name.js} +0 -0
  210. /package/dist/page/{sounds.js → sounds/sounds.js} +0 -0
  211. /package/dist/page/{voice.js → talk/voice.js} +0 -0
@@ -1,657 +0,0 @@
1
- // The page on localhost (D-8, D-47): its HTML with the import map, its scripts, the chess core and the packages they import, the game it shows and keeps
2
- // up to date, the player's moves and words (T-65), draws and resignations (D-65), and the doorbell that wakes the chat when the player acts there.
3
- import { createHash } from "node:crypto";
4
- import { readFileSync, watch } from "node:fs";
5
- import { createServer } from "node:http";
6
- import { createRequire } from "node:module";
7
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { analyseGame } from "../commands/analyse-game.js";
10
- import { gameAt } from "../commands/clock.js";
11
- import { answerDraw, DrawRefused, offerDraw } from "../commands/draw-offer.js";
12
- import { endGame } from "../commands/end-game.js";
13
- import { ChatNotClear, newGame, startNewGame } from "../commands/new-game.js";
14
- import { bookPlies } from "../commands/opening-book.js";
15
- import { playMove, positionIn } from "../commands/play-move.js";
16
- import { say } from "../commands/say.js";
17
- import { gameToStudy, namedGame } from "../commands/study.js";
18
- import { clockStarted, writePgn } from "../core/index.js";
19
- import { aiModelOf, drawOfferOf, endOf, gameInAnalysis, gameStamp, heardLines, loadGame, loadTableTalk, playerPgn, saveGame, startAnalysis, talkLimits, wakesTheChat, WordsRefused } from "../games/index.js";
20
- import { gameFolder } from "../profiles/index.js";
21
- import { analysesOf, isReviewed } from "./analyses.js";
22
- import { Bell, bellProbe, pagePorts } from "./bell.js";
23
- import { bodyOf, bytesOf, isJson, json, jsonOf, send, textOf } from "./http.js";
24
- import { answerProfiles, profilesShown } from "./profile-routes.js";
25
- /**
26
- * Serves the page of the context's folder on 127.0.0.1, on a port of its own when free, else on any free one, where the doorbell cannot find it. The page
27
- * names the opponent as the host does, once it has connected; the doorbell knows from the context what the chat has read of the player's words.
28
- */
29
- export async function startPage(context, opponent = () => undefined) {
30
- const { folder } = context;
31
- const watchers = new Set();
32
- const served = { folder, context, opponent, bell: new Bell(() => hasNews(context), () => tellWatchers(served)), watchers, port: 0 };
33
- const server = createServer((request, response) => answer(request, response, served));
34
- for (const port of [...pagePorts(folder), 0]) {
35
- if (await listened(server, port)) {
36
- server.unref();
37
- served.port = portOf(server);
38
- const stopWatching = watchGame(served);
39
- return {
40
- url: `http://127.0.0.1:${served.port}/`,
41
- heard: () => tellWatchers(served),
42
- chatCalls: () => chatCalls(served),
43
- open: () => watchers.size > 0,
44
- askNewGame: () => askNewGame(served),
45
- close: () => closed(server, stopWatching),
46
- };
47
- }
48
- }
49
- throw new Error("Chesspal could not open a port for the page on 127.0.0.1: check that a firewall does not block local servers, then try again.");
50
- }
51
- // The built page and core: dist/, from src/server and from dist/server alike.
52
- const built = fileURLToPath(new URL("../../dist/", import.meta.url));
53
- // The modules the page imports from packages, by the names the import map gives them: a package, and its file.
54
- const vendorModules = {
55
- "preact.js": ["preact", "dist/preact.module.js"],
56
- "hooks.js": ["preact", "hooks/dist/hooks.module.js"],
57
- "jsx-runtime.js": ["preact", "jsx-runtime/dist/jsxRuntime.module.js"],
58
- "chess.js": ["chess.js", "dist/esm/chess.js"],
59
- };
60
- const importMap = JSON.stringify({
61
- imports: { "preact": "/vendor/preact.js", "preact/hooks": "/vendor/hooks.js", "preact/jsx-runtime": "/vendor/jsx-runtime.js", "chess.js": "/vendor/chess.js" },
62
- });
63
- // The page runs only its own files and the import map written in it, known by its hash.
64
- const policy = `default-src 'self'; script-src 'self' 'sha256-${createHash("sha256").update(importMap).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; `
65
- + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
66
- const html = [
67
- "<!doctype html>",
68
- '<html lang="en">',
69
- '<meta charset="utf-8">',
70
- '<meta name="viewport" content="width=device-width, initial-scale=1">',
71
- '<meta name="color-scheme" content="light dark">',
72
- "<title>Chesspal</title>",
73
- '<link rel="stylesheet" href="/page/style.css">',
74
- `<script type="importmap">${importMap}</script>`,
75
- '<script type="module" src="/page/main.js"></script>',
76
- '<div id="app"></div>',
77
- "",
78
- ].join("\n");
79
- const types = { ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".webp": "image/webp", ".json": "application/json; charset=utf-8", ".ogg": "audio/ogg" };
80
- function answer(request, response, served) {
81
- const url = new URL(request.url ?? "/", "http://127.0.0.1");
82
- const path = url.pathname;
83
- // Only this machine's pages: another site the browser shows can neither read the game nor move through the player's browser. A link from another site,
84
- // such as the chat's, still opens the page.
85
- if (!fromHere(request, served.port, path.startsWith("/api/"))) {
86
- send(response, 403, "text/plain; charset=utf-8", "Chesspal's page answers only at its own address on this machine.");
87
- return;
88
- }
89
- if (answerProfiles(request, response, url, served)) {
90
- return;
91
- }
92
- if (request.method === "POST" && path === "/api/move") {
93
- void moveFromPage(request, response, served);
94
- return;
95
- }
96
- if (request.method === "POST" && path === "/api/message") {
97
- void messageFromPage(request, response, served);
98
- return;
99
- }
100
- if (request.method === "POST" && path === "/api/new") {
101
- void newGameFromPage(request, response, served);
102
- return;
103
- }
104
- if (request.method === "POST" && path === "/api/draw") {
105
- void drawFromPage(request, response, served);
106
- return;
107
- }
108
- if (request.method === "POST" && path === "/api/resign") {
109
- resignFromPage(request, response, served);
110
- return;
111
- }
112
- if (request.method === "POST" && path === "/api/start") {
113
- startFromPage(request, response, served);
114
- return;
115
- }
116
- if (request.method === "POST" && path === "/api/study") {
117
- void studyFromPage(request, response, served);
118
- return;
119
- }
120
- // The API answers GET alone: a HEAD at the doorbell would wait there as a hook does.
121
- if (request.method !== "GET" && (request.method !== "HEAD" || path.startsWith("/api/"))) {
122
- send(response, 405, "text/plain; charset=utf-8", "The page only reads, moves with POST /api/move, writes with POST /api/message, starts a game with POST /api/new and its clocks with POST /api/start, "
123
- + "offers or answers a draw with POST /api/draw, resigns with POST /api/resign, brings a game to analyse or opens one again with POST /api/study, and keeps the profiles with POST /api/profile/… and /api/settings.");
124
- return;
125
- }
126
- if (path === "/") {
127
- send(response, 200, "text/html; charset=utf-8", html, { "Content-Security-Policy": policy });
128
- }
129
- else if (path === "/api/game") {
130
- sendGame(response, served);
131
- }
132
- else if (path === "/api/analyses") {
133
- send(response, 200, json, JSON.stringify(analysesOf(served.context.home())));
134
- }
135
- else if (path === "/api/events") {
136
- keepPosted(request, response, served);
137
- }
138
- else if (path === "/api/bell") {
139
- if (url.searchParams.has("probe")) {
140
- send(response, 200, json, JSON.stringify(bellProbe(served.folder, served.watchers.size > 0)));
141
- }
142
- else {
143
- served.bell.listen(request, response);
144
- }
145
- }
146
- else {
147
- sendFile(response, path);
148
- }
149
- }
150
- /**
151
- * The player's move from the page, with their words when they wrote some: played as player_move plays it (D-47) and saved with the words, refused in the
152
- * player's words, and the doorbell rung for the chat.
153
- */
154
- async function moveFromPage(request, response, served) {
155
- // A form of another site can post text, never JSON without asking first: the type keeps it out, with the origin.
156
- if (!isJson(request)) {
157
- send(response, 415, json, JSON.stringify({ error: 'Send the move as JSON: {"move": "e2e4"}.' }));
158
- return;
159
- }
160
- const body = jsonOf(await bodyOf(request));
161
- const move = textOf(body?.move);
162
- if (move === undefined || (body?.message !== undefined && typeof body.message !== "string")) {
163
- send(response, 400, json, JSON.stringify({ error: 'Send the move as JSON, in SAN or as squares, with your words if you like: {"move": "e2e4", "message": "…"}.' }));
164
- return;
165
- }
166
- const refused = refusalOf(served);
167
- if (refused !== undefined) {
168
- send(response, 409, json, JSON.stringify(refused));
169
- return;
170
- }
171
- const message = textOf(body?.message);
172
- let illegal = false;
173
- try {
174
- const now = served.context.now();
175
- const refuse = (problem) => {
176
- illegal = true;
177
- return problem;
178
- };
179
- playMove(gameFolder(served.context.home()), now, "player", move, refuse, message === undefined ? {} : { said: { text: message, at: now } });
180
- }
181
- catch (error) {
182
- // The game may have changed since the page read it: the move refused, the AI's turn, a flag fallen.
183
- send(response, 409, json, JSON.stringify(refusalFrom(error, illegal ? "illegal" : refusalOf(served)?.refusal)));
184
- return;
185
- }
186
- // The pages hear of the move from the watcher, as of every other.
187
- served.bell.ring();
188
- send(response, 200, json, JSON.stringify({ played: true }));
189
- }
190
- /** The player's words from the page, without a move: saved with the game as player_say saves them, refused in the player's words, the doorbell rung for the first. */
191
- async function messageFromPage(request, response, served) {
192
- if (!isJson(request)) {
193
- send(response, 415, json, JSON.stringify({ error: 'Send the message as JSON: {"text": "…"}.' }));
194
- return;
195
- }
196
- const text = textOf(jsonOf(await bodyOf(request))?.text);
197
- if (text === undefined) {
198
- send(response, 400, json, JSON.stringify({ error: `Send the message as JSON, ${talkLimits.player} characters at most: {"text": "…"}.` }));
199
- return;
200
- }
201
- let current;
202
- try {
203
- current = say(gameFolder(served.context.home()), "player", text, served.context.now());
204
- }
205
- catch (error) {
206
- const noGame = refusalOf(served)?.refusal === "no-game";
207
- send(response, 409, json, JSON.stringify(refusalFrom(error, noGame ? "no-game" : undefined)));
208
- return;
209
- }
210
- // The first words of a move wake the chat, and between games the first since the AI spoke; the AI finds the rest at its next call (D-54, D-74).
211
- if (wakesTheChat(current)) {
212
- served.bell.ring();
213
- }
214
- send(response, 200, json, JSON.stringify({ said: true }));
215
- }
216
- /**
217
- * A new game from the page, with the sides, the level and the clock the player chose (T-64, T-58): the sides and the level they leave out are as in the game
218
- * before, and no clock means a game without one. One going on goes to the archive unfinished, and the doorbell tells the chat.
219
- */
220
- async function newGameFromPage(request, response, served) {
221
- const asked = 'Send the new game as JSON: {"ai_colour": "black", "level": "club", "clock": "10+5"}, each one optional; no clock for a game without one.';
222
- const body = isJson(request) ? jsonOf(await bodyOf(request)) : undefined;
223
- if (body === undefined) {
224
- send(response, isJson(request) ? 400 : 415, json, JSON.stringify({ error: asked }));
225
- return;
226
- }
227
- try {
228
- const before = loadGame(gameFolder(served.context.home()));
229
- const input = newGame.input.safeParse({ ai_colour: body.ai_colour ?? before?.ai ?? "black", level: body.level, clock: body.clock, replace: true });
230
- if (!input.success) {
231
- send(response, 400, json, JSON.stringify({ error: asked }));
232
- return;
233
- }
234
- // The game starts from the page, open: its clocks wait for the player's Start there.
235
- startNewGame(input.data, { ...served.context, pageOpen: () => true }, "page");
236
- }
237
- catch (error) {
238
- send(response, 409, json, JSON.stringify(refusalFrom(error)));
239
- return;
240
- }
241
- served.bell.ring();
242
- send(response, 200, json, JSON.stringify({ started: true }));
243
- }
244
- /** The player's draw from the page (D-65): an offer on their turn, which rings the doorbell, or their answer to the AI's, which rings it when accepted. */
245
- async function drawFromPage(request, response, served) {
246
- const asked = 'Send the draw as JSON: {"action": "offer"}, "accept" or "decline".';
247
- if (!isJson(request)) {
248
- send(response, 415, json, JSON.stringify({ error: asked }));
249
- return;
250
- }
251
- const action = jsonOf(await bodyOf(request))?.action;
252
- if (action !== "offer" && action !== "accept" && action !== "decline") {
253
- send(response, 400, json, JSON.stringify({ error: asked }));
254
- return;
255
- }
256
- try {
257
- if (action === "offer") {
258
- offerDraw(served.context);
259
- }
260
- else {
261
- answerDraw(served.context, action === "accept");
262
- }
263
- }
264
- catch (error) {
265
- send(response, 409, json, JSON.stringify(refusalFrom(error, refusalOf(served)?.refusal)));
266
- return;
267
- }
268
- if (action !== "decline") {
269
- served.bell.ring();
270
- }
271
- send(response, 200, json, JSON.stringify({ done: true }));
272
- }
273
- /** The player starts the clocks from the page, which wait from the game's start; the doorbell wakes the chat when its move is first, waiting for this. */
274
- function startFromPage(request, response, served) {
275
- if (!isJson(request)) {
276
- send(response, 415, json, JSON.stringify({ error: "Send the request as JSON: {}." }));
277
- return;
278
- }
279
- const folder = gameFolder(served.context.home());
280
- const current = loadGame(folder);
281
- if (current?.clock?.waiting !== true || endOf(current.game) !== undefined) {
282
- send(response, 409, json, JSON.stringify({ error: "No clock waits to start: start a game with a clock first." }));
283
- return;
284
- }
285
- saveGame(folder, { ...current, clock: clockStarted(current.clock, served.context.now()) });
286
- if (current.game.position.turn === current.ai) {
287
- served.bell.ring();
288
- }
289
- send(response, 200, json, JSON.stringify({ started: true }));
290
- }
291
- /** The player resigns from the page (D-65), whoever is to move: the game ends and goes to the archive, and the doorbell tells the chat. */
292
- function resignFromPage(request, response, served) {
293
- if (!isJson(request)) {
294
- send(response, 415, json, JSON.stringify({ error: "Send the request as JSON: {}." }));
295
- return;
296
- }
297
- try {
298
- endGame.run({ result: "player_resigns" }, served.context);
299
- }
300
- catch (error) {
301
- send(response, 409, json, JSON.stringify(refusalFrom(error, refusalOf(served)?.refusal === "no-game" ? "no-game" : undefined)));
302
- return;
303
- }
304
- served.bell.ring();
305
- send(response, 200, json, JSON.stringify({ resigned: true }));
306
- }
307
- /**
308
- * A game the player brings from elsewhere to analyse (T-92): kept to study and put in analysis as analyse_game does with a PGN, the page showing it at once,
309
- * and the doorbell rung for the chat to go over it. Never while a game goes on.
310
- */
311
- async function studyFromPage(request, response, served) {
312
- const asked = 'Send the game as JSON: {"pgn": "[White \\"…\\"] … 1. e4 e5 …"}, one to open again by its name: {"game": "study/2026-11-04-153012.pgn"}, '
313
- + 'or the game just over: {"last": true}.';
314
- if (!isJson(request)) {
315
- send(response, 415, json, JSON.stringify({ error: asked }));
316
- return;
317
- }
318
- const body = jsonOf((await bytesOf(request, pgnLimit))?.toString("utf8"));
319
- const pgn = textOf(body?.pgn);
320
- const game = textOf(body?.game);
321
- const last = body?.last === true;
322
- if (pgn === undefined && game === undefined && !last) {
323
- send(response, 400, json, JSON.stringify({ error: asked, refusal: "pgn-unreadable" }));
324
- return;
325
- }
326
- if (refusalOf(served)?.refusal !== "no-game") {
327
- send(response, 409, json, JSON.stringify({ error: "A game is going on: finish it before you bring another to analyse.", refusal: "game-going" }));
328
- return;
329
- }
330
- let reviewed;
331
- try {
332
- // Without what the chat was told: the analyst's instructions stay for the AI's own analyse_game.
333
- const opened = last ? lastPlayed(served.context) : game === undefined ? kept(served, pgn) : namedGame(served.context, game);
334
- reviewed = isReviewed(opened.record);
335
- // A game not yet reviewed is marked as brought, for status to ask the AI to go over it (T-92); one reviewed is ready, and the chat sleeps on (T-106).
336
- startAnalysis(gameFolder(served.context.home()), opened.name, served.context.now(), !reviewed);
337
- }
338
- catch (error) {
339
- send(response, 400, json, JSON.stringify({ error: error.message, refusal: "pgn-unreadable" }));
340
- return;
341
- }
342
- if (!reviewed) {
343
- served.bell.ring();
344
- }
345
- send(response, 200, json, JSON.stringify({ kept: true, reviewed }));
346
- }
347
- /** The game just over, or else the last one played, as analyse_game takes it without a name: always one of the archive. */
348
- function lastPlayed(context) {
349
- const { name, record } = gameToStudy(context);
350
- if (name === undefined) {
351
- throw new Error("The game just over is not in the archive yet: try again in a moment.");
352
- }
353
- return { name, record };
354
- }
355
- /** A game brought as a PGN, kept to study as analyse_game keeps it: the one already kept when it is the same game, with its review. */
356
- function kept(served, pgn) {
357
- analyseGame.run({ pgn }, { ...served.context, told: undefined });
358
- return namedGame(served.context, gameInAnalysis(gameFolder(served.context.home())).game);
359
- }
360
- /** The longest PGN the page brings: a long game with its clocks, and the comments a site adds, which go. */
361
- const pgnLimit = 256 * 1024;
362
- /** Why the player cannot move now; undefined when they can. */
363
- function refusalOf(served) {
364
- try {
365
- const current = gameNow(served);
366
- if (current === undefined || endOf(current.game) !== undefined) {
367
- return { error: "There is no game going on: start one in the chat.", refusal: "no-game" };
368
- }
369
- return current.game.position.turn === current.ai ? { error: "Wait for the AI's move.", refusal: "ai-to-move" } : undefined;
370
- }
371
- catch (error) {
372
- return { error: error.message };
373
- }
374
- }
375
- /** The refusal of an error thrown by a command, with its code when the error or the caller knows it. */
376
- function refusalFrom(error, refusal) {
377
- const message = error.message;
378
- if (error instanceof ChatNotClear) {
379
- return { error: message, refusal: "clear-chat" };
380
- }
381
- if (error instanceof WordsRefused) {
382
- return { error: message, refusal: `words-${error.reason}`, most: error.most };
383
- }
384
- if (error instanceof DrawRefused) {
385
- return { error: message, refusal: `draw-${error.reason}` };
386
- }
387
- return refusal === undefined ? { error: message } : { error: message, refusal };
388
- }
389
- /** The game as the page reads it now: a flag that has fallen ends it (D-13), and the doorbell tells the chat, which hears how from status (T-37). */
390
- function gameNow(served) {
391
- const folder = gameFolder(served.context.home());
392
- const before = loadGame(folder);
393
- // The AI's clock is held while nobody hears the player: its flag cannot fall before the chat is there to move.
394
- if (before !== undefined && aiHeld(served, before)) {
395
- return before;
396
- }
397
- const current = gameAt(folder, served.context.now());
398
- if (before !== undefined && endOf(before.game) === undefined && current !== undefined && endOf(current.game) !== undefined) {
399
- served.bell.ring();
400
- }
401
- return current;
402
- }
403
- /** Whether the AI's clock is held: a game with a clock, going on, the AI to move, and nobody hearing the player. */
404
- function aiHeld(served, current) {
405
- return !served.bell.listening && current.clock !== undefined && endOf(current.game) === undefined && current.game.position.turn === current.ai;
406
- }
407
- /** The chat calls a tool: when nobody heard the player before, the AI's clock, held since, starts now, with the time it was held given back. */
408
- function chatCalls(served) {
409
- const folder = gameFolder(served.context.home());
410
- const current = loadGame(folder);
411
- const held = current !== undefined && aiHeld(served, current);
412
- if (served.bell.chatCalls() && held) {
413
- saveGame(folder, { ...current, clock: { ...current.clock, since: served.context.now().toISOString() } });
414
- }
415
- }
416
- /**
417
- * The chat has read the game as it is now, at the end of a tool's call: the doorbell wakes it only for what changes it after; and, when the tool `showed` it
418
- * the game, it moves on that position and no other until a tool shows it another (D-71). A game that cannot be read shows none.
419
- */
420
- export function chatHasRead(context, showed) {
421
- context.seen = gameSeen(context);
422
- if (!showed) {
423
- return;
424
- }
425
- try {
426
- context.positionSeen = positionIn(gameFolder(context.home()));
427
- }
428
- catch {
429
- context.positionSeen = null;
430
- }
431
- }
432
- /**
433
- * Whether the chat has news from the page: a game changed since the chat's last tool, by the player's move, words, draw or resignation, a new game or a
434
- * flag that fell, or another profile in use, which wakes the chat at the next ring (D-66). The player's words the chat was given change nothing it has not read.
435
- */
436
- function hasNews(context) {
437
- return gameSeen(context) !== context.seen;
438
- }
439
- // The game the chat sees: the profile's folder, and the stamp of its game.json; none while the data folder cannot be made.
440
- function gameSeen(context) {
441
- try {
442
- const folder = gameFolder(context.home());
443
- return `${folder}
444
- ${gameStamp(folder) ?? ""}`;
445
- }
446
- catch {
447
- return "";
448
- }
449
- }
450
- /** A page that waits to hear the game has changed: a stream of events, one "changed" for each change, after which the page reads the game again. */
451
- function keepPosted(request, response, served) {
452
- response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
453
- response.write(": posted\n\n");
454
- served.watchers.add(response);
455
- request.on("close", () => served.watchers.delete(response));
456
- }
457
- /** The pages open now open their New game window; whether there was one. */
458
- function askNewGame(served) {
459
- for (const watcher of served.watchers) {
460
- watcher.write("data: new-game\n\n");
461
- }
462
- return served.watchers.size > 0;
463
- }
464
- function tellWatchers(served) {
465
- for (const watcher of served.watchers) {
466
- watcher.write("data: changed\n\n");
467
- }
468
- }
469
- /**
470
- * Watches the data folder, its profiles and the profile in use for changes to the game and the profiles, whoever makes them (the AI's tools, another chat,
471
- * the CLI, a hand edit): the pages hear of them. Each folder is watched on its own, never its whole tree, and watched again after each change, since the
472
- * profile in use may be another. It returns how to stop watching.
473
- */
474
- function watchGame(served) {
475
- let waiting;
476
- let watching = [];
477
- const watchAll = () => {
478
- watching.forEach((watcher) => watcher.close());
479
- watching = watchedFolders(served).flatMap((folder) => watchFolder(folder, heard));
480
- };
481
- // A save writes a new file and renames it: the events of one save come together, and the pages hear of it once.
482
- const heard = (name) => {
483
- if ((name === null || watched.test(name)) && waiting === undefined) {
484
- waiting = setTimeout(() => {
485
- waiting = undefined;
486
- watchAll();
487
- tellWatchers(served);
488
- }, 30);
489
- }
490
- };
491
- watchAll();
492
- return () => {
493
- clearTimeout(waiting);
494
- watching.forEach((watcher) => watcher.close());
495
- };
496
- }
497
- // None while the data folder cannot be made: each tool call tells the chat why, and the page reads the game as it loads.
498
- function watchedFolders(served) {
499
- try {
500
- const home = served.context.home();
501
- return [home, join(home, "profiles"), gameFolder(home)];
502
- }
503
- catch {
504
- return [];
505
- }
506
- }
507
- // A folder watched, without its subfolders; none when it cannot be, or is not there yet: the pages see its files only as they load them.
508
- function watchFolder(folder, heard) {
509
- try {
510
- const watcher = watch(folder, (_event, name) => heard(name));
511
- watcher.on("error", () => watcher.close());
512
- watcher.unref();
513
- return [watcher];
514
- }
515
- catch {
516
- return [];
517
- }
518
- }
519
- // The current game, the talk before the first, the game in analysis, the profile in use, and what a profile keeps (who plays, their picture, their settings); a folder's name, for a
520
- // profile made or gone.
521
- const watched = /^(?:game\.json|talk\.json|analysis\.json|active\.json|profile\.md|settings\.json|avatar\.\w+|[a-z0-9-]+)$/;
522
- /**
523
- * The game in progress or just ended, as the player may see it: its PGN without the AI's plans while it goes on, the AI's side, when it started, and the
524
- * talk on the page, each line of the player's with how far it got (D-53), and the draw offer that stands (D-65); the AI's name and model (D-82); and the
525
- * profiles.
526
- */
527
- function sendGame(response, served) {
528
- try {
529
- const current = gameNow(served);
530
- // With no game the page still shows the talk before the first (D-77).
531
- const spoken = current ?? loadTableTalk(gameFolder(served.context.home()));
532
- const heard = heardLines(spoken, served.context.given);
533
- const talk = spoken.talk.map(({ who, text, ply }, index) => heard[index] === undefined ? { who, text, ply } : { who, text, ply, heard: heard[index] });
534
- const offer = current === undefined ? undefined : drawOfferOf(current);
535
- const game = current === undefined ? null : {
536
- pgn: playerPgn(current),
537
- ai: current.ai,
538
- started: current.started,
539
- talk,
540
- // How many of its first moves are the book's: the page marks them while the game goes on, as the AI's review does after.
541
- book: bookPlies(current.game.record),
542
- ...(current.clock === undefined ? {} : { clock: current.clock }),
543
- ...(offer === undefined ? {} : { drawOffer: offer }),
544
- };
545
- const analysis = current === undefined || endOf(current.game) !== undefined ? analysisShown(served) : undefined;
546
- const aiModel = aiModelOf(gameFolder(served.context.home()));
547
- const body = {
548
- game,
549
- ...(game === null && talk.length > 0 ? { talk } : {}),
550
- ...(analysis === undefined ? {} : { analysis }),
551
- opponent: served.opponent(),
552
- ...(aiModel === undefined ? {} : { aiModel }),
553
- listening: served.bell.listening,
554
- ...profilesShown(served.context.home()),
555
- };
556
- send(response, 200, json, JSON.stringify(body));
557
- }
558
- catch (error) {
559
- send(response, 500, json, JSON.stringify({ error: error.message }));
560
- }
561
- }
562
- /** The game in analysis, once no game goes on (D-69): its name and its PGN with the AI's review; none without one, or when it cannot be read. */
563
- function analysisShown(served) {
564
- const analysis = gameInAnalysis(gameFolder(served.context.home()));
565
- if (analysis === undefined) {
566
- return undefined;
567
- }
568
- try {
569
- const { name, record } = namedGame(served.context, analysis.game);
570
- // How far the book goes, which the page cannot tell without the book's moves: its card names the opening on those moves.
571
- return { name, pgn: writePgn(record), book: bookPlies(record) };
572
- }
573
- catch {
574
- // A game gone from the archive is no longer shown.
575
- return undefined;
576
- }
577
- }
578
- /** A script, style, picture or sound of the page, a module of the core, or a package's module: only from their folders, and only of the types the page uses. */
579
- function sendFile(response, path) {
580
- const file = path.startsWith("/vendor/") ? vendorModule(path.slice("/vendor/".length)) : builtFile(path);
581
- const type = file === undefined ? undefined : types[extname(file)];
582
- let bytes;
583
- try {
584
- bytes = file === undefined || type === undefined ? undefined : readFileSync(file);
585
- }
586
- catch {
587
- bytes = undefined;
588
- }
589
- if (bytes === undefined || type === undefined) {
590
- send(response, 404, "text/plain; charset=utf-8", `Nothing at ${path}.`);
591
- return;
592
- }
593
- // A pieces' set loaded again for each piece that moves blanks the pieces drawn from it, at times until the next move (T-88): the browser keeps it an hour,
594
- // and the drawn avatars too, all at once in the profile's dialog.
595
- const kept = type === types[".svg"] || type === types[".webp"];
596
- send(response, 200, type, bytes, kept ? { "Cache-Control": "max-age=3600" } : {});
597
- }
598
- function builtFile(path) {
599
- const area = /^\/(page|core)\//.exec(path)?.[1];
600
- if (area === undefined) {
601
- return undefined;
602
- }
603
- try {
604
- const root = join(built, area);
605
- const file = resolve(built, `.${decodeURIComponent(path)}`);
606
- const inside = relative(root, file);
607
- return inside === "" || inside.startsWith("..") || isAbsolute(inside) ? undefined : file;
608
- }
609
- catch {
610
- // An address that does not decode.
611
- return undefined;
612
- }
613
- }
614
- function vendorModule(name) {
615
- const module = vendorModules[name];
616
- if (module === undefined) {
617
- return undefined;
618
- }
619
- try {
620
- // The package's folder, from where its main file is: node_modules/<package>.
621
- const main = createRequire(import.meta.url).resolve(module[0]);
622
- const marker = join("node_modules", module[0]);
623
- return join(main.slice(0, main.lastIndexOf(marker) + marker.length), module[1]);
624
- }
625
- catch {
626
- return undefined;
627
- }
628
- }
629
- function fromHere(request, port, api) {
630
- const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
631
- const origin = request.headers.origin;
632
- // An image or a script of another site sends no origin, but the browser says where it comes from: such a request could take the doorbell's place.
633
- const site = request.headers["sec-fetch-site"];
634
- return hosts.includes(request.headers.host ?? "") && (origin === undefined || hosts.some((host) => origin === `http://${host}`))
635
- && (!api || site === undefined || site === "same-origin" || site === "none");
636
- }
637
- function listened(server, port) {
638
- return new Promise((done) => {
639
- const failed = () => done(false);
640
- server.once("error", failed);
641
- server.listen(port, "127.0.0.1", () => {
642
- server.off("error", failed);
643
- done(true);
644
- });
645
- });
646
- }
647
- function portOf(server) {
648
- const address = server.address();
649
- return typeof address === "object" && address !== null ? address.port : 0;
650
- }
651
- function closed(server, stopWatching) {
652
- stopWatching();
653
- return new Promise((done) => {
654
- server.close(() => done());
655
- server.closeAllConnections();
656
- });
657
- }
File without changes
File without changes
File without changes