chesspal 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/cli/cli.js +7 -2
  2. package/dist/cli/command-line.js +5 -3
  3. package/dist/cli/prompt-hook.d.ts +17 -0
  4. package/dist/cli/prompt-hook.js +100 -0
  5. package/dist/commands/analysis/analyse.js +8 -14
  6. package/dist/commands/analysis/annotate.d.ts +8 -0
  7. package/dist/commands/analysis/annotate.js +19 -3
  8. package/dist/commands/answers/agents.d.ts +7 -5
  9. package/dist/commands/answers/agents.js +16 -9
  10. package/dist/commands/answers/clock.js +2 -1
  11. package/dist/commands/answers/drawings.js +6 -2
  12. package/dist/commands/answers/index.d.ts +4 -3
  13. package/dist/commands/answers/index.js +4 -3
  14. package/dist/commands/answers/lines.d.ts +5 -3
  15. package/dist/commands/answers/lines.js +14 -7
  16. package/dist/commands/answers/opening-book.d.ts +13 -7
  17. package/dist/commands/answers/opening-book.js +34 -38
  18. package/dist/commands/answers/opening-tags.d.ts +4 -0
  19. package/dist/commands/answers/opening-tags.js +11 -0
  20. package/dist/commands/answers/play-move.d.ts +3 -1
  21. package/dist/commands/answers/play-move.js +9 -6
  22. package/dist/commands/answers/say.js +2 -1
  23. package/dist/commands/answers/summary.d.ts +5 -2
  24. package/dist/commands/answers/summary.js +16 -5
  25. package/dist/commands/game/ai-move.js +4 -4
  26. package/dist/commands/game/coach.js +7 -6
  27. package/dist/commands/game/end.js +2 -2
  28. package/dist/commands/game/export.d.ts +1 -1
  29. package/dist/commands/game/index.d.ts +1 -1
  30. package/dist/commands/game/index.js +1 -1
  31. package/dist/commands/game/new.js +41 -11
  32. package/dist/commands/game/player-move.d.ts +16 -0
  33. package/dist/commands/game/player-move.js +69 -19
  34. package/dist/commands/game/ponder.js +4 -1
  35. package/dist/commands/game/status.d.ts +1 -1
  36. package/dist/commands/game/status.js +6 -6
  37. package/dist/commands/index.d.ts +3 -3
  38. package/dist/commands/index.js +3 -3
  39. package/dist/commands/profiles/past-games.d.ts +1 -1
  40. package/dist/commands/profiles/past-games.js +21 -10
  41. package/dist/commands/registry/context.d.ts +2 -0
  42. package/dist/commands/registry/context.js +5 -0
  43. package/dist/commands/registry/index.d.ts +1 -1
  44. package/dist/commands/registry/index.js +1 -1
  45. package/dist/commands/talk/ai-say.d.ts +1 -1
  46. package/dist/commands/talk/ai-say.js +6 -5
  47. package/dist/core/game.d.ts +13 -0
  48. package/dist/core/game.js +29 -3
  49. package/dist/core/index.d.ts +4 -4
  50. package/dist/core/index.js +3 -3
  51. package/dist/core/openings.d.ts +20 -6
  52. package/dist/core/openings.js +45 -22
  53. package/dist/core/pgn.d.ts +4 -0
  54. package/dist/core/pgn.js +35 -6
  55. package/dist/games/archive.d.ts +25 -5
  56. package/dist/games/archive.js +52 -19
  57. package/dist/games/chat-start.d.ts +2 -2
  58. package/dist/games/chat-start.js +6 -1
  59. package/dist/games/current-game.d.ts +2 -2
  60. package/dist/games/current-game.js +3 -2
  61. package/dist/games/index.d.ts +2 -1
  62. package/dist/games/index.js +2 -1
  63. package/dist/games/position-shown.d.ts +24 -0
  64. package/dist/games/position-shown.js +51 -0
  65. package/dist/home/index.d.ts +1 -1
  66. package/dist/home/index.js +1 -1
  67. package/dist/home/same-path.d.ts +6 -1
  68. package/dist/home/same-path.js +22 -3
  69. package/dist/host-files/host-files.d.ts +5 -0
  70. package/dist/host-files/host-files.js +13 -4
  71. package/dist/host-files/index.d.ts +1 -1
  72. package/dist/host-files/index.js +1 -1
  73. package/dist/page/app/app.js +30 -7
  74. package/dist/page/app/served.js +9 -4
  75. package/dist/page/avatars/torretta.css +4 -4
  76. package/dist/page/base.css +1 -1
  77. package/dist/page/board/board.css +4 -2
  78. package/dist/page/dialogs/dialogs.css +3 -1
  79. package/dist/page/game/column.js +10 -8
  80. package/dist/page/game/game.css +86 -6
  81. package/dist/page/game/plies.js +4 -0
  82. package/dist/page/game/review-progress.js +11 -0
  83. package/dist/page/guide/drawings/drawings.css +0 -1
  84. package/dist/page/guide/en.md +2 -2
  85. package/dist/page/guide/it.md +2 -2
  86. package/dist/page/layout/icon.ico +0 -0
  87. package/dist/page/layout/layout.css +32 -15
  88. package/dist/page/layout/window.js +41 -2
  89. package/dist/page/legend/legend.css +4 -16
  90. package/dist/page/legend/legend.js +28 -15
  91. package/dist/page/profile/settings.js +1 -0
  92. package/dist/page/table/action.js +42 -0
  93. package/dist/page/table/table.css +57 -39
  94. package/dist/page/table/window.js +67 -67
  95. package/dist/page/talk/talk.css +52 -9
  96. package/dist/page/talk/talk.js +65 -20
  97. package/dist/page/talk/voice.js +92 -21
  98. package/dist/page/text.js +24 -8
  99. package/dist/server/bell/bell.d.ts +24 -27
  100. package/dist/server/bell/bell.js +79 -143
  101. package/dist/server/bell/index.d.ts +1 -1
  102. package/dist/server/bell/index.js +2 -2
  103. package/dist/server/mcp/mcp.js +49 -7
  104. package/dist/server/page/analyses.js +15 -4
  105. package/dist/server/page/files.js +3 -1
  106. package/dist/server/page/game.d.ts +5 -0
  107. package/dist/server/page/game.js +78 -13
  108. package/dist/server/page/http.d.ts +1 -1
  109. package/dist/server/page/page.d.ts +14 -9
  110. package/dist/server/page/page.js +111 -26
  111. package/dist/server/page/table.d.ts +38 -4
  112. package/dist/server/page/table.js +61 -23
  113. package/dist/server/page/windows.d.ts +22 -0
  114. package/dist/server/page/windows.js +45 -0
  115. package/dist/server/ports/index.d.ts +1 -0
  116. package/dist/server/ports/index.js +2 -0
  117. package/dist/server/ports/ports.d.ts +78 -0
  118. package/dist/server/ports/ports.js +222 -0
  119. package/dist/server/table/browser.d.ts +22 -6
  120. package/dist/server/table/browser.js +98 -20
  121. package/dist/server/table/hosts/hooks.d.ts +6 -0
  122. package/dist/server/table/hosts/hooks.js +38 -0
  123. package/dist/server/table/hosts/hosts.d.ts +13 -5
  124. package/dist/server/table/hosts/hosts.js +16 -10
  125. package/dist/server/table/hosts/index.d.ts +1 -0
  126. package/dist/server/table/hosts/index.js +1 -0
  127. package/dist/server/table/hosts/play.d.ts +6 -4
  128. package/dist/server/table/hosts/play.js +58 -16
  129. package/dist/server/table/index.d.ts +1 -1
  130. package/dist/server/table/launch.d.ts +5 -4
  131. package/dist/server/table/launch.js +18 -5
  132. package/dist/server/table/link.js +1 -0
  133. package/dist/server/table/table.d.ts +7 -0
  134. package/dist/server/table/table.js +39 -11
  135. package/instructions/opponent.md +1 -1
  136. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { bookOpening, deleteGame, deleteReview, siteGames, SiteGamesRefused } from "../../commands/index.js";
2
2
  import { aiModelIn, annotatorOf, isReviewed, leadOf, reviewOf } from "../../core/index.js";
3
- import { archivedGame, archivedGames, gameInAnalysis, gameStemOf, keptGame, keptGames } from "../../games/index.js";
3
+ import { archivedGame, archivedGames, archivedGameUnplayed, gameInAnalysis, gameStemOf, hasListTags, keptGame, keptGames } from "../../games/index.js";
4
4
  import { gameFolder } from "../../profiles/index.js";
5
5
  import { bodyOf, isJson, jsonOf, runRoute, sendJson, textOf } from "./http.js";
6
6
  /** The games to analyse in the profile in use, the latest first, all of them for the page's search: those kept to study and those played, reviewed or not. */
@@ -10,7 +10,7 @@ export function analysesOf(home) {
10
10
  const stamp = (name) => gameStemOf(name) ?? "";
11
11
  const names = [...keptGames(folder), ...archivedGames(folder)].sort((one, other) => stamp(other).localeCompare(stamp(one)));
12
12
  const taken = gameInAnalysis(folder)?.analyst?.game;
13
- return names.flatMap((name) => itemOf(name, () => name.startsWith("study/") ? keptGame(folder, name) : archivedGame(folder, name), taken === name));
13
+ return names.flatMap((name) => itemOf(name, () => name.startsWith("study/") ? withBookOpening(keptGame(folder, name)) : archivedListed(folder, name), taken === name));
14
14
  }
15
15
  /** Answers the page's deletions, of a game or of its review alone, done as the command line does them: true when the request was one. */
16
16
  export function answerAnalyses(request, response, url, served) {
@@ -51,10 +51,22 @@ export function sendSiteGames(response, served) {
51
51
  });
52
52
  }
53
53
  const deletions = { "/api/delete-game": deleteGame, "/api/delete-review": deleteReview };
54
+ /**
55
+ * A game of the archive for the list: its moves taken as written, not played, when the archive wrote its PlyCount, with its opening's tags, none for a game
56
+ * that has none; a game an older Chesspal archived is read whole, its opening looked up.
57
+ */
58
+ function archivedListed(folder, name) {
59
+ const unplayed = archivedGameUnplayed(folder, name);
60
+ return hasListTags(unplayed.tags) ? { record: unplayed, opening: unplayed.tags.Opening } : withBookOpening(archivedGame(folder, name));
61
+ }
62
+ function withBookOpening(record) {
63
+ return { record, opening: bookOpening(record)?.name };
64
+ }
54
65
  function itemOf(name, read, taken) {
55
66
  let record;
67
+ let opening;
56
68
  try {
57
- record = read();
69
+ ({ record, opening } = read());
58
70
  }
59
71
  catch {
60
72
  // A game that cannot be read stays out of the list.
@@ -66,7 +78,6 @@ function itemOf(name, read, taken) {
66
78
  }
67
79
  const { tags } = record;
68
80
  const date = tags.Date === undefined || tags.Date.includes("?") ? undefined : tags.Date;
69
- const opening = bookOpening(record)?.name;
70
81
  const aiModel = aiModelIn(record);
71
82
  const site = tags.Site === undefined || /^[?\-\s]*$/.test(tags.Site) ? undefined : tags.Site;
72
83
  const signed = annotatorOf(record);
@@ -47,8 +47,10 @@ const policy = `default-src 'self'; script-src 'self' 'sha256-${createHash("sha2
47
47
  + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
48
48
  const html = [
49
49
  "<!doctype html>",
50
- '<html lang="en">',
50
+ '<html lang="en" translate="no">',
51
51
  '<meta charset="utf-8">',
52
+ // The page speaks the player's language, set once the app starts, and moves are never translated: no browser offers to.
53
+ '<meta name="google" content="notranslate">',
52
54
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
53
55
  '<meta name="color-scheme" content="light dark">',
54
56
  "<title>Chesspal</title>",
@@ -4,3 +4,8 @@ import type { Served } from "./page.ts";
4
4
  export declare function answerGame(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;
5
5
  /** 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. */
6
6
  export declare function chatCalls(served: Served): void;
7
+ /**
8
+ * Whether the AI's sign on the page is not the one the pages were last given: the ponder agent's every thought rewrites thoughts.json, and only the sign
9
+ * that comes or goes is news for them. True when the game cannot be read: they read it again.
10
+ */
11
+ export declare function thinkingChanged(served: Served): boolean;
@@ -1,13 +1,13 @@
1
- import { analyseGame, analystOf, answerDraw, bookPlies, ChatNotClear, DrawRefused, endGame, gameAt, gameToStudy, namedGame, newGame, offerDraw, playMove, samePosition, say, shownPosition, startNewGame, } from "../../commands/index.js";
1
+ import { analyseGame, answerDraw, bookPlies, ChatNotClear, DrawRefused, endGame, gameAt, gameToStudy, namedGame, newGame, offerDraw, playMove, samePosition, say, shownPosition, startNewGame, } from "../../commands/index.js";
2
2
  import { clockStarted, isReviewed, writePgn } from "../../core/index.js";
3
- import { aiModelOf, archiveName, coachShown, coachWords, drawOfferOf, endOf, gameInAnalysis, heardLines, loadGame, loadTableTalk, playerPgn, ponderingUntil, rerunIfChanged, saveGame, startAnalysis, talkLimits, thoughtKey, wakesTheChat, WordsRefused, } from "../../games/index.js";
3
+ import { aiModelOf, archiveName, coachShown, coachWords, drawOfferOf, endOf, gameInAnalysis, GameMissing, heardLines, loadGame, loadTableTalk, playerPgn, ponderingUntil, rerunIfChanged, saveGame, startAnalysis, talkLimits, thoughtKey, wakesTheChat, WordsRefused, } from "../../games/index.js";
4
4
  import { gameFolder, helpsIn } from "../../profiles/index.js";
5
5
  import { version } from "../../version.js";
6
6
  import { asTheChatSees } from "./chat.js";
7
7
  import { tellWatchers } from "./events.js";
8
8
  import { bodyOf, bytesOf, isJson, json, jsonOf, runRoute, send, textOf } from "./http.js";
9
9
  import { profilesShown } from "./profiles.js";
10
- import { chatToRelaunch, relaunchChat, tableShown } from "./table.js";
10
+ import { analystAtWork, chatToRelaunch, chatToWaitFor, refuseWaited, relaunchChat, tableShown } from "./table.js";
11
11
  import { updateShown } from "./update.js";
12
12
  /** Answers the page's requests about the game: true when the request was one. */
13
13
  export function answerGame(request, response, url, served) {
@@ -27,6 +27,18 @@ export function chatCalls(served) {
27
27
  rerunIfChanged(() => giveHeldTime(served, folder));
28
28
  }
29
29
  }
30
+ /**
31
+ * Whether the AI's sign on the page is not the one the pages were last given: the ponder agent's every thought rewrites thoughts.json, and only the sign
32
+ * that comes or goes is news for them. True when the game cannot be read: they read it again.
33
+ */
34
+ export function thinkingChanged(served) {
35
+ try {
36
+ return thinkingShown(served, loadGame(gameFolder(served.context.home()))).thinking !== thinkingSent.get(served);
37
+ }
38
+ catch {
39
+ return true;
40
+ }
41
+ }
30
42
  /** The AI's clock, held while nobody heard the player, starts now: the time it was held is not the AI's. */
31
43
  function giveHeldTime(served, folder) {
32
44
  const current = loadGame(folder);
@@ -112,19 +124,21 @@ async function messageFromPage(request, response, served) {
112
124
  // 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). In
113
125
  // analysis every line does: a chat that answered in its own window would not hear the next (Priya).
114
126
  if (shown !== undefined || wakesTheChat(current)) {
115
- served.bell.ring();
127
+ // Words may come while the chat thinks on the AI's move, which they do not end.
128
+ served.bell.ring("game" in current && endOf(current.game) === undefined && current.game.position.turn === current.ai);
116
129
  }
117
130
  send(response, 200, json, JSON.stringify({ said: true }));
118
131
  }
119
132
  /**
120
133
  * A new game from the page, with the sides, the level and the clock the player chose: the sides and the level they leave out are as in the game
121
134
  * before, and no clock means a game without one. One going on goes to the archive unfinished, and the doorbell tells the chat, unless the table starts
122
- * it again.
135
+ * it again. A chat the table holds that must be clear for it, and is at work, has the game wait with the choices kept until it has ended its answer (D-107).
123
136
  */
124
137
  async function newGameFromPage(request, response, served) {
125
138
  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.';
126
139
  const body = isJson(request) ? jsonOf(await bodyOf(request)) : undefined;
127
140
  let relaunching = false;
141
+ let chosen;
128
142
  if (body === undefined) {
129
143
  send(response, isJson(request) ? 400 : 415, json, JSON.stringify({ error: asked }));
130
144
  return;
@@ -139,10 +153,17 @@ async function newGameFromPage(request, response, served) {
139
153
  // The game starts from the page, open: its clocks wait for the player's Start there. A chat the table starts again is clean: no /clear is asked.
140
154
  relaunching = chatToRelaunch(served);
141
155
  const sight = relaunching ? served.context : asTheChatSees(served.context, served.chat);
156
+ chosen = input.data;
142
157
  rerunIfChanged(() => startNewGame(input.data, { ...sight, pageOpen: () => true }, "page"));
143
158
  }
144
159
  catch (error) {
145
- send(response, 409, json, JSON.stringify(refusalFrom(error)));
160
+ if (error instanceof ChatNotClear && chosen !== undefined && chatToWaitFor(served)) {
161
+ waitForChat(served, chosen);
162
+ send(response, 200, json, JSON.stringify({ waiting: true }));
163
+ }
164
+ else {
165
+ send(response, 409, json, JSON.stringify(refusalFrom(error)));
166
+ }
146
167
  return;
147
168
  }
148
169
  // A seat lost meanwhile leaves the chat as it is: the doorbell tells it.
@@ -151,6 +172,34 @@ async function newGameFromPage(request, response, served) {
151
172
  }
152
173
  send(response, 200, json, JSON.stringify({ started: true }));
153
174
  }
175
+ /**
176
+ * The new game waits, with the player's choices, for the chat at the table to end its answer; then it starts and the chat starts again, clean, for it, or
177
+ * one gone meanwhile hears the doorbell when another comes. It starts in the profile it was asked in: with another in use by then it is dropped. A game
178
+ * that cannot start leaves everything as it was, and the pages say why.
179
+ */
180
+ function waitForChat(served, chosen) {
181
+ const askedIn = gameFolder(served.context.home());
182
+ const start = () => {
183
+ try {
184
+ if (gameFolder(served.context.home()) !== askedIn) {
185
+ refuseWaited(served, { error: "Another profile is in use since the new game was asked: ask for it again in this one.", refusal: "new-game-profile" });
186
+ return false;
187
+ }
188
+ rerunIfChanged(() => startNewGame(chosen, { ...served.context, pageOpen: () => true }, "page"));
189
+ }
190
+ catch (error) {
191
+ refuseWaited(served, refusalFrom(error));
192
+ return false;
193
+ }
194
+ const relaunching = relaunchChat(served);
195
+ if (!relaunching) {
196
+ served.bell.ring();
197
+ }
198
+ return relaunching;
199
+ };
200
+ served.chatWait = { for: "new-game", start };
201
+ tellWatchers(served.watchers);
202
+ }
154
203
  /** 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. */
155
204
  async function drawFromPage(request, response, served) {
156
205
  const asked = 'Send the draw as JSON: {"action": "offer"}, "accept" or "decline".';
@@ -255,7 +304,7 @@ async function studyFromPage(request, response, served) {
255
304
  startAnalysis(gameFolder(served.context.home()), opened.name, served.context.now(), { brought, redo });
256
305
  }
257
306
  catch (error) {
258
- send(response, 400, json, JSON.stringify({ error: error.message, refusal: "pgn-unreadable" }));
307
+ send(response, 400, json, JSON.stringify({ error: error.message, refusal: refusalOfStudy(error) }));
259
308
  return;
260
309
  }
261
310
  if (brought) {
@@ -292,13 +341,15 @@ function sendGame(response, served) {
292
341
  name: archiveName(current),
293
342
  talk,
294
343
  // 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.
295
- book: bookPlies(current.game.record),
344
+ book: bookPlies(current.game),
296
345
  ...(current.clock === undefined ? {} : { clock: current.clock }),
297
346
  ...(offer === undefined ? {} : { drawOffer: offer }),
298
347
  ...(coach === undefined ? {} : { coach }),
299
348
  };
300
349
  const analysis = current === undefined || endOf(current.game) !== undefined ? analysisShown(served) : undefined;
301
350
  const aiModel = aiModelOf(folder);
351
+ const thinking = thinkingShown(served, current);
352
+ thinkingSent.set(served, thinking.thinking);
302
353
  const body = {
303
354
  // The page loaded from another version loads again, its files and all (Federico).
304
355
  version,
@@ -308,7 +359,7 @@ function sendGame(response, served) {
308
359
  opponent: served.opponent(),
309
360
  ...(aiModel === undefined ? {} : { aiModel }),
310
361
  listening: served.bell.listening,
311
- ...thinkingShown(served, current),
362
+ ...thinking,
312
363
  ...(served.outdated ? { outdated: true } : {}),
313
364
  ...updateShown(served),
314
365
  ...tableShown(served),
@@ -338,6 +389,8 @@ function thinkingShown(served, current) {
338
389
  signGoesIn(served, left);
339
390
  return { thinking: "ponder" };
340
391
  }
392
+ // The AI's sign the pages were last given, one a table.
393
+ const thinkingSent = new WeakMap();
341
394
  // The pages hear when the sign of an agent that stopped pondering without a word goes, which no file tells: one timer a table, the latest.
342
395
  const signTimers = new WeakMap();
343
396
  function signGoesIn(served, wait) {
@@ -368,11 +421,10 @@ function analysisShown(served) {
368
421
  try {
369
422
  const { name, record } = namedGame(served.context, analysis.game);
370
423
  // Without a review, an AI is at it only while the chat is in a turn, or hears the player while its analyst holds the game (D-89): one the chat has
371
- // started again since is gone (Priya). On the table the chat's start is in the chat's own folder.
424
+ // started again since is gone (Priya).
372
425
  const { bell } = served;
373
- const chat = { ...served.context, folder: served.attached?.chat.folder ?? served.folder };
374
426
  const reviewed = isReviewed(record);
375
- const reviewing = !reviewed && (bell.inTurn || (bell.listening && analystOf(chat, analysis.game) !== undefined));
427
+ const reviewing = !reviewed && (bell.inTurn || (bell.listening && analystAtWork(served, analysis.game)));
376
428
  // Without a review and with nobody at it, the analysis is incomplete: the page offers to bring it back to the AI (D-109).
377
429
  const state = reviewed ? {} : reviewing ? { reviewing: true } : { incomplete: true };
378
430
  // How far the book goes, which the page cannot tell without the book's moves: its card names the opening on those moves.
@@ -387,10 +439,23 @@ function analysisShown(served) {
387
439
  function lastPlayed(context) {
388
440
  const { name, record } = gameToStudy(context);
389
441
  if (name === undefined) {
390
- throw new Error("The game just over is not in the archive yet: try again in a moment.");
442
+ throw new GameNotArchived();
391
443
  }
392
444
  return { name, record };
393
445
  }
446
+ /** The refusal of the game just over asked to analyse before the archive keeps it. */
447
+ class GameNotArchived extends Error {
448
+ constructor() {
449
+ super("The game just over is not in the archive yet: try again in a moment.");
450
+ }
451
+ }
452
+ /** Why a game to study was refused, for the page to say it: a name no game has, the game just over not kept yet, or a game that cannot be read. */
453
+ function refusalOfStudy(error) {
454
+ if (error instanceof GameMissing) {
455
+ return "game-missing";
456
+ }
457
+ return error instanceof GameNotArchived ? "game-not-archived" : "pgn-unreadable";
458
+ }
394
459
  /** 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. */
395
460
  async function kept(served, pgn) {
396
461
  await analyseGame.run({ pgn }, served.context);
@@ -8,7 +8,7 @@ export declare const json = "application/json; charset=utf-8";
8
8
  */
9
9
  export interface Refused {
10
10
  readonly error: string;
11
- readonly refusal?: "no-game" | "ai-to-move" | "illegal" | "clear-chat" | `words-${WordsRefused["reason"]}` | `draw-${DrawRefused["reason"]}` | "profile-game" | "game-going" | "pgn-unreadable" | "update-game" | "update-chat";
11
+ readonly refusal?: "no-game" | "ai-to-move" | "illegal" | "clear-chat" | "new-game-profile" | `words-${WordsRefused["reason"]}` | `draw-${DrawRefused["reason"]}` | "profile-game" | "game-going" | "pgn-unreadable" | "game-missing" | "game-not-archived" | "update-game" | "update-chat";
12
12
  readonly most?: number;
13
13
  }
14
14
  /** Answers a request, with nothing cached, sniffed or referred, and the page's policy unless the headers give another. */
@@ -2,7 +2,8 @@ import { type ServerResponse } from "node:http";
2
2
  import { type Context } from "../../commands/index.ts";
3
3
  import { Bell } from "../bell/index.ts";
4
4
  import { type Attached, type ChatSeen } from "./chat.ts";
5
- import { type Opening, type TableChats } from "./table.ts";
5
+ import { type ChatWait, type Opening, type TableChats, type WaitRefused } from "./table.ts";
6
+ import { type TableWindows } from "./windows.ts";
6
7
  /**
7
8
  * The page of a player's folder, served by the chat's process or by the table it is attached to (D-104): its address, how to tell it what the chat does
8
9
  * and was given, which change no file, and how to stop it.
@@ -15,6 +16,8 @@ export interface Page {
15
16
  chatCalls(): void | Promise<void>;
16
17
  /** A tool of the chat's has ended: the table gets a copy of what the chat saw; the chat's own page reads it from the chat. */
17
18
  toolEnded(): void;
19
+ /** Whether the doorbell finds the page: on its folder's usual ports, at the port it noted in the data folder, or at the table. */
20
+ readonly findable: boolean;
18
21
  /** Whether a browser has the page open now: one listens to the game's changes. */
19
22
  open(): boolean;
20
23
  /** Whether the page is in use: open in a browser, or, on the table, with a chat attached. */
@@ -44,8 +47,8 @@ export interface TableUpdate {
44
47
  restart(): void;
45
48
  }
46
49
  /**
47
- * 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
48
- * table's on its own ports alone, which keep two tables apart. The page reads the chat from the context in the chat's process, and on the table from
50
+ * 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, which it notes in the data folder for the
51
+ * doorbell to find it there; the table's on its own ports alone, which keep two tables apart. The page reads the chat from the context in the chat's process, and on the table from
49
52
  * what the chat attached to it tells it (D-104); it names the opponent as the chat's host does. The doorbell knows what the chat has read.
50
53
  */
51
54
  export declare function startPage(context: Context, options?: PageOptions): Promise<Page>;
@@ -75,16 +78,18 @@ export interface Served {
75
78
  readonly at: number;
76
79
  readonly how: Opening;
77
80
  } | undefined;
78
- /** The host the page asked the chat to start again with while it was at work (D-107). */
79
- restartWanted?: {
80
- readonly host: string;
81
- } | undefined;
81
+ /** What the page asked while the chat was at work, waiting for it to end its answer: a start again with another host, or a new game (D-107). */
82
+ chatWait?: ChatWait | undefined;
83
+ /** Why the last new game that waited for the chat could not start, numbered so that each page says it once. */
84
+ waitRefused?: WaitRefused | undefined;
82
85
  /** The newer Chesspal npm has (D-108); the table installing it, installed and waiting to be at rest, or starting again from it. */
83
86
  newer?: string;
84
87
  updating?: "installing" | "installed" | "restarting" | undefined;
88
+ /** The windows the table opened: the newest stays, the others close (D-104). */
89
+ windows?: TableWindows;
85
90
  }
86
91
  /**
87
92
  * 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
88
- * 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.
93
+ * 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. The data folder is the call's.
89
94
  */
90
- export declare function chatHasRead(context: Context, showed: boolean): void;
95
+ export declare function chatHasRead(context: Context, showed: boolean, home?: () => string): void;
@@ -2,26 +2,28 @@
2
2
  // kept up to date as the game changes; the doorbell wakes the chat when the player acts there.
3
3
  import { watch } from "node:fs";
4
4
  import { createServer } from "node:http";
5
- import { join } from "node:path";
6
- import { positionIn, samePosition } from "../../commands/index.js";
5
+ import { basename, join } from "node:path";
6
+ import { analystQuietAt, positionIn, samePosition } from "../../commands/index.js";
7
7
  import { aiNameOf } from "../../core/index.js";
8
8
  import { forgetAnalyst, gameStamp, tableTalkStamp } from "../../games/index.js";
9
9
  import { outdatedHostFiles } from "../../host-files/index.js";
10
10
  import { gameFolder } from "../../profiles/index.js";
11
- import { Bell, bellProbe, pagePorts } from "../bell/index.js";
11
+ import { Bell, forgetPagePort, notePagePort } from "../bell/index.js";
12
+ import { bellProbe, pagePorts } from "../ports/index.js";
12
13
  import { analysesOf, answerAnalyses, sendSiteGames } from "./analyses.js";
13
14
  import { answerChat } from "./attach.js";
14
15
  import { attachedSeen, chatSeenOf, pageContext } from "./chat.js";
15
16
  import { askNewGame, keepPosted, tellWatchers } from "./events.js";
16
17
  import { sendFile, sendHtml } from "./files.js";
17
- import { answerGame, chatCalls } from "./game.js";
18
+ import { answerGame, chatCalls, thinkingChanged } from "./game.js";
18
19
  import { json, send } from "./http.js";
19
20
  import { answerProfiles, readRatingsOf } from "./profiles.js";
20
- import { answerTable, restartWhenAtRest, updateWhenAtRest } from "./table.js";
21
+ import { answerTable, startWhenAtRest, updateWhenAtRest } from "./table.js";
21
22
  import { watchForUpdates } from "./update.js";
23
+ import { windowConnected } from "./windows.js";
22
24
  /**
23
- * 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
24
- * table's on its own ports alone, which keep two tables apart. The page reads the chat from the context in the chat's process, and on the table from
25
+ * 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, which it notes in the data folder for the
26
+ * doorbell to find it there; the table's on its own ports alone, which keep two tables apart. The page reads the chat from the context in the chat's process, and on the table from
25
27
  * what the chat attached to it tells it (D-104); it names the opponent as the chat's host does. The doorbell knows what the chat has read.
26
28
  */
27
29
  export async function startPage(context, options = {}) {
@@ -34,9 +36,9 @@ export async function startPage(context, options = {}) {
34
36
  context: pageContext(context),
35
37
  chat,
36
38
  opponent: options.opponent ?? (() => aiNameOf(chat.host())),
37
- // A start again asked while the chat was at work comes once it has ended its answer (D-107), as an update installed meanwhile (D-108).
39
+ // A start again or a new game asked while the chat was at work comes once it has ended its answer (D-107), as an update installed meanwhile (D-108).
38
40
  bell: new Bell(() => hasNews(context, chat), () => {
39
- restartWhenAtRest(served);
41
+ startWhenAtRest(served);
40
42
  updateWhenAtRest(served);
41
43
  tellWatchers(watchers);
42
44
  }),
@@ -46,19 +48,36 @@ export async function startPage(context, options = {}) {
46
48
  ...(table === undefined ? {} : { table }),
47
49
  };
48
50
  const server = createServer((request, response) => answer(request, response, served));
49
- const ports = table === undefined ? [...pagePorts(folder), 0] : pagePorts(folder);
51
+ // The folder's real path is read once: it costs a call to the disk for each part of the path.
52
+ const usual = pagePorts(folder);
53
+ const ports = table === undefined ? [...usual, 0] : usual;
50
54
  for (const port of ports) {
51
55
  if (await listened(server, port)) {
52
56
  server.unref();
53
57
  served.port = portOf(server);
58
+ // Noted before the game is watched: the notes' folder, made in the data folder, is no news for the pages.
59
+ const offPorts = table === undefined && !usual.includes(served.port);
60
+ const noted = offPorts ? notePort(served) : undefined;
54
61
  const stopWatchingGame = watchGame(served);
55
62
  const stopAskingNpm = watchForUpdates(served);
63
+ // A chat's process ends without closing its page: the note goes with it.
64
+ const forgetPort = () => {
65
+ if (noted !== undefined) {
66
+ forgetPagePort(noted, folder, served.port);
67
+ }
68
+ };
69
+ if (noted !== undefined) {
70
+ process.once("exit", forgetPort);
71
+ }
56
72
  const stopWatching = () => {
57
73
  stopWatchingGame();
58
74
  stopAskingNpm();
75
+ forgetPort();
76
+ process.off("exit", forgetPort);
59
77
  };
60
78
  return {
61
79
  url: `http://127.0.0.1:${served.port}/`,
80
+ findable: !offPorts || noted !== undefined,
62
81
  heard: () => tellWatchers(watchers),
63
82
  chatCalls: () => chatCalls(served),
64
83
  toolEnded: () => undefined,
@@ -76,14 +95,14 @@ export async function startPage(context, options = {}) {
76
95
  }
77
96
  /**
78
97
  * 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
79
- * 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.
98
+ * 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. The data folder is the call's.
80
99
  */
81
- export function chatHasRead(context, showed) {
82
- context.seen = gameSeen(context);
100
+ export function chatHasRead(context, showed, home = context.home) {
101
+ context.seen = gameSeen(home);
83
102
  if (showed) {
84
103
  const before = context.positionSeen;
85
104
  try {
86
- context.positionSeen = positionIn(gameFolder(context.home()));
105
+ context.positionSeen = positionIn(gameFolder(home()));
87
106
  }
88
107
  catch {
89
108
  context.positionSeen = null;
@@ -136,6 +155,7 @@ function answer(request, response, served) {
136
155
  }
137
156
  else if (path === "/api/events") {
138
157
  keepPosted(request, response, served.watchers);
158
+ windowConnected(served, url);
139
159
  // A page that opens reads the player's ratings again after a day (D-106).
140
160
  readRatingsOf(served);
141
161
  }
@@ -147,13 +167,29 @@ function answer(request, response, served) {
147
167
  if (url.searchParams.get("analyst") === "gone") {
148
168
  forgetGoneAnalyst(served);
149
169
  }
150
- served.bell.listen(request, response);
170
+ // Nothing changed the game since the prompt hook played the move for the chat: the chat hears of it from the player's message.
171
+ const shown = url.searchParams.get("shown");
172
+ served.bell.listen(request, response, shown !== null && shown === stampNow(served));
151
173
  }
152
174
  }
175
+ else if (path === "/api/bell/prompt") {
176
+ // The prompt hook: a message starts the chat's turn. A path of its own, which a page of an older Chesspal answers with a 404, not as a hook waiting.
177
+ served.bell.chatPrompted();
178
+ send(response, 200, "text/plain; charset=utf-8", "at work");
179
+ }
153
180
  else {
154
181
  sendFile(response, path);
155
182
  }
156
183
  }
184
+ /** The stamp of the game in the data folder now; none when there is none or the folder cannot be read. */
185
+ function stampNow(served) {
186
+ try {
187
+ return gameStamp(gameFolder(served.context.home()));
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
157
193
  /** The chat's hook says no analyst runs any more (D-109): the game it held is free, and its review, unless finished, incomplete. */
158
194
  function forgetGoneAnalyst(served) {
159
195
  try {
@@ -176,13 +212,13 @@ function tableChat(served) {
176
212
  * 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.
177
213
  */
178
214
  function hasNews(context, chat) {
179
- return gameSeen(context) !== chat.seen;
215
+ return gameSeen(context.home) !== chat.seen;
180
216
  }
181
217
  // The game the chat sees: the profile's folder, and the stamps of its game.json and of the talk with no game, where the player's words go in analysis
182
218
  // before any game was played here (Priya); none while the data folder cannot be made.
183
- function gameSeen(context) {
219
+ function gameSeen(home) {
184
220
  try {
185
- const folder = gameFolder(context.home());
221
+ const folder = gameFolder(home());
186
222
  return `${folder}
187
223
  ${gameStamp(folder) ?? ""}
188
224
  ${tableTalkStamp(folder) ?? ""}`;
@@ -198,19 +234,46 @@ ${tableTalkStamp(folder) ?? ""}`;
198
234
  */
199
235
  function watchGame(served) {
200
236
  let waiting;
237
+ let quiet;
201
238
  let watching = [];
239
+ let game;
240
+ // An analyst that writes nothing for half an hour counts as gone, which no file tells: the pages, and what waits for the chat at rest, hear it then.
241
+ const analystGoesQuiet = () => {
242
+ clearTimeout(quiet);
243
+ const left = analystQuietIn(served);
244
+ quiet = left === undefined ? undefined : setTimeout(() => {
245
+ tellWatchers(served.watchers);
246
+ startWhenAtRest(served);
247
+ updateWhenAtRest(served);
248
+ }, left + 100).unref();
249
+ };
202
250
  const watchAll = () => {
203
251
  watching.forEach((watcher) => watcher.close());
204
- watching = watchedFolders(served).flatMap((folder) => watchFolder(folder, heard));
252
+ const folders = watchedFolders(served);
253
+ game = folders.length === 0 ? undefined : basename(folders.at(-1));
254
+ watching = folders.flatMap((folder) => watchFolder(folder, heard));
255
+ analystGoesQuiet();
205
256
  };
206
- // A save writes a new file and renames it: the events of one save come together, and the pages hear of it once.
207
- const heard = (name) => {
208
- if ((name === null || watched.test(name)) && waiting === undefined) {
257
+ // A save writes a new file and renames it: the events of one save come together, and the pages hear of it once. Of the ponder agent's thoughts,
258
+ // only its sign coming or going.
259
+ let news = false;
260
+ // On Windows the profiles' folder hears every write in the game's folder, as a change of it: the game folder's own watcher hears what it was.
261
+ const heard = (name, event) => {
262
+ if (name !== null && (!watched.test(name) || (event === "change" && name === game))) {
263
+ return;
264
+ }
265
+ news ||= name !== "thoughts.json";
266
+ if (waiting === undefined) {
209
267
  waiting = setTimeout(() => {
210
268
  waiting = undefined;
269
+ const tell = news || thinkingChanged(served);
270
+ news = false;
211
271
  watchAll();
212
- tellWatchers(served.watchers);
213
- // A game that has ended lets an update installed meanwhile start the table again (D-108).
272
+ if (tell) {
273
+ tellWatchers(served.watchers);
274
+ }
275
+ // An analyst that has ended lets what waited for the chat start (D-107), and a game that has ended an update installed meanwhile (D-108).
276
+ startWhenAtRest(served);
214
277
  updateWhenAtRest(served);
215
278
  }, 30);
216
279
  }
@@ -218,10 +281,21 @@ function watchGame(served) {
218
281
  watchAll();
219
282
  return () => {
220
283
  clearTimeout(waiting);
284
+ clearTimeout(quiet);
221
285
  watching.forEach((watcher) => watcher.close());
222
286
  };
223
287
  }
224
- // None while the data folder cannot be made: each tool call tells the chat why, and the page reads the game as it loads.
288
+ // How long the analyst at work may still write nothing before it counts as gone; none without one, one gone already, or while the data folder cannot be made.
289
+ function analystQuietIn(served) {
290
+ try {
291
+ const left = (analystQuietAt(served.context)?.getTime() ?? 0) - served.context.now().getTime();
292
+ return left > 0 ? left : undefined;
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ }
298
+ // None while the data folder cannot be made: each tool call tells the chat why, and the page reads the game as it loads. The game's folder last.
225
299
  function watchedFolders(served) {
226
300
  try {
227
301
  const home = served.context.home();
@@ -234,7 +308,7 @@ function watchedFolders(served) {
234
308
  // 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.
235
309
  function watchFolder(folder, heard) {
236
310
  try {
237
- const watcher = watch(folder, (event, name) => heard(name));
311
+ const watcher = watch(folder, (event, name) => heard(name, event));
238
312
  watcher.on("error", () => watcher.close());
239
313
  watcher.unref();
240
314
  return [watcher];
@@ -254,6 +328,17 @@ function fromHere(request, port, api) {
254
328
  return hosts.includes(request.headers.host ?? "") && (origin === undefined || hosts.some((host) => origin === `http://${host}`))
255
329
  && (!api || site === undefined || site === "same-origin" || site === "none");
256
330
  }
331
+ /** Notes the port of a page off its folder's usual ports in the data folder: the data folder it noted it in, or none when it could not. */
332
+ function notePort(served) {
333
+ try {
334
+ const home = served.context.home();
335
+ void notePagePort(home, served.folder, served.port);
336
+ return home;
337
+ }
338
+ catch {
339
+ return undefined;
340
+ }
341
+ }
257
342
  function listened(server, port) {
258
343
  return new Promise((done) => {
259
344
  const failed = () => done(false);