chesspal 0.2.4 → 0.3.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 (184) hide show
  1. package/README.md +11 -12
  2. package/dist/cli/cli.js +83 -3
  3. package/dist/cli/command-line.js +26 -9
  4. package/dist/cli/icon.d.ts +25 -0
  5. package/dist/cli/icon.js +80 -0
  6. package/dist/commands/agents.d.ts +19 -2
  7. package/dist/commands/agents.js +39 -13
  8. package/dist/commands/ai-say.js +3 -4
  9. package/dist/commands/analyse-game.d.ts +4 -0
  10. package/dist/commands/analyse-game.js +46 -13
  11. package/dist/commands/annotate-game.d.ts +2 -0
  12. package/dist/commands/annotate-game.js +40 -18
  13. package/dist/commands/answer-lines.d.ts +2 -0
  14. package/dist/commands/answer-lines.js +13 -3
  15. package/dist/commands/coach.js +4 -2
  16. package/dist/commands/context.d.ts +11 -1
  17. package/dist/commands/context.js +5 -0
  18. package/dist/commands/delete-review.js +9 -5
  19. package/dist/commands/game-summary.d.ts +2 -0
  20. package/dist/commands/game-summary.js +4 -0
  21. package/dist/commands/index.d.ts +3 -2
  22. package/dist/commands/index.js +7 -4
  23. package/dist/commands/init.js +2 -4
  24. package/dist/commands/instructions.d.ts +9 -0
  25. package/dist/commands/instructions.js +30 -0
  26. package/dist/commands/lead-input.d.ts +1 -1
  27. package/dist/commands/lead-input.js +7 -4
  28. package/dist/commands/page-link.js +6 -5
  29. package/dist/commands/past-games.d.ts +1 -1
  30. package/dist/commands/past-games.js +12 -5
  31. package/dist/commands/player-move.js +2 -3
  32. package/dist/commands/profiles.d.ts +23 -9
  33. package/dist/commands/profiles.js +76 -35
  34. package/dist/commands/ratings.d.ts +8 -0
  35. package/dist/commands/ratings.js +45 -0
  36. package/dist/commands/registry.d.ts +1 -1
  37. package/dist/commands/registry.js +3 -2
  38. package/dist/commands/say.d.ts +1 -1
  39. package/dist/commands/say.js +1 -1
  40. package/dist/commands/status.d.ts +3 -1
  41. package/dist/commands/status.js +46 -27
  42. package/dist/commands/study.js +8 -5
  43. package/dist/core/index.d.ts +1 -1
  44. package/dist/core/index.js +1 -1
  45. package/dist/core/review.d.ts +2 -0
  46. package/dist/core/review.js +5 -1
  47. package/dist/games/ai-model.d.ts +4 -2
  48. package/dist/games/ai-model.js +10 -1
  49. package/dist/games/analysis.d.ts +26 -3
  50. package/dist/games/analysis.js +41 -6
  51. package/dist/games/archive.d.ts +6 -3
  52. package/dist/games/archive.js +13 -5
  53. package/dist/games/current-game.d.ts +10 -1
  54. package/dist/games/current-game.js +49 -15
  55. package/dist/games/files.d.ts +5 -0
  56. package/dist/games/files.js +42 -1
  57. package/dist/games/index.d.ts +3 -3
  58. package/dist/games/index.js +3 -3
  59. package/dist/home/data-folder.d.ts +2 -0
  60. package/dist/home/data-folder.js +5 -1
  61. package/dist/home/index.d.ts +1 -1
  62. package/dist/home/index.js +1 -1
  63. package/dist/host-files/agents.d.ts +2 -2
  64. package/dist/host-files/agents.js +12 -6
  65. package/dist/host-files/host-files.d.ts +5 -3
  66. package/dist/host-files/host-files.js +15 -8
  67. package/dist/host-files/index.d.ts +2 -2
  68. package/dist/host-files/index.js +2 -2
  69. package/dist/page/app/app.js +49 -20
  70. package/dist/page/app/title.js +17 -0
  71. package/dist/page/avatars/torretta.js +4 -4
  72. package/dist/page/dialogs/analyses.js +4 -4
  73. package/dist/page/dialogs/dialog.js +4 -1
  74. package/dist/page/dialogs/dialogs.css +84 -10
  75. package/dist/page/dialogs/profiles.js +96 -43
  76. package/dist/page/game/column.js +5 -1
  77. package/dist/page/game/plies.js +7 -4
  78. package/dist/page/guide/blocks.js +67 -0
  79. package/dist/page/guide/en.md +66 -0
  80. package/dist/page/guide/foot.js +18 -0
  81. package/dist/page/guide/guide.css +200 -0
  82. package/dist/page/guide/guide.js +89 -0
  83. package/dist/page/guide/it.md +66 -0
  84. package/dist/page/guide/markdown.js +67 -0
  85. package/dist/page/helps/helps.css +7 -10
  86. package/dist/page/layout/favicon.svg +13 -0
  87. package/dist/page/layout/header.js +3 -3
  88. package/dist/page/layout/icon.icns +0 -0
  89. package/dist/page/layout/icon.ico +0 -0
  90. package/dist/page/layout/icon.png +0 -0
  91. package/dist/page/layout/layout.css +85 -4
  92. package/dist/page/layout/window.js +45 -0
  93. package/dist/page/main.js +4 -1
  94. package/dist/page/players/card.js +1 -1
  95. package/dist/page/profile/profile.js +14 -9
  96. package/dist/page/profile/settings.js +9 -0
  97. package/dist/page/style.css +2 -0
  98. package/dist/page/table/table.css +123 -0
  99. package/dist/page/table/window.js +130 -0
  100. package/dist/page/talk/chat.css +23 -5
  101. package/dist/page/talk/talk.css +38 -26
  102. package/dist/page/talk/talk.js +6 -4
  103. package/dist/page/talk/voice.js +35 -9
  104. package/dist/page/text.js +131 -17
  105. package/dist/page/theme.css +7 -0
  106. package/dist/profiles/index.d.ts +2 -1
  107. package/dist/profiles/index.js +2 -1
  108. package/dist/profiles/profiles.d.ts +10 -15
  109. package/dist/profiles/profiles.js +30 -15
  110. package/dist/profiles/ratings.d.ts +35 -0
  111. package/dist/profiles/ratings.js +61 -0
  112. package/dist/server/bell.d.ts +24 -3
  113. package/dist/server/bell.js +84 -10
  114. package/dist/server/caller.d.ts +2 -1
  115. package/dist/server/caller.js +3 -2
  116. package/dist/server/mcp.d.ts +7 -3
  117. package/dist/server/mcp.js +100 -52
  118. package/dist/server/page/analyses.d.ts +2 -0
  119. package/dist/server/page/analyses.js +20 -5
  120. package/dist/server/page/attach.d.ts +4 -0
  121. package/dist/server/page/attach.js +112 -0
  122. package/dist/server/page/chat.d.ts +65 -0
  123. package/dist/server/page/chat.js +87 -0
  124. package/dist/server/page/events.d.ts +7 -0
  125. package/dist/server/page/events.js +20 -0
  126. package/dist/server/page/files.js +10 -1
  127. package/dist/server/page/game.js +43 -23
  128. package/dist/server/page/page.d.ts +51 -7
  129. package/dist/server/page/page.js +74 -48
  130. package/dist/server/page/profiles.d.ts +5 -0
  131. package/dist/server/page/profiles.js +26 -10
  132. package/dist/server/page/table.d.ts +58 -0
  133. package/dist/server/page/table.js +210 -0
  134. package/dist/server/table/browser.d.ts +6 -0
  135. package/dist/server/table/browser.js +48 -0
  136. package/dist/server/table/hosts/chats.d.ts +7 -0
  137. package/dist/server/table/hosts/chats.js +34 -0
  138. package/dist/server/table/hosts/find.d.ts +2 -0
  139. package/dist/server/table/hosts/find.js +20 -0
  140. package/dist/server/table/hosts/hosts.d.ts +34 -0
  141. package/dist/server/table/hosts/hosts.js +49 -0
  142. package/dist/server/table/hosts/index.d.ts +3 -0
  143. package/dist/server/table/hosts/index.js +4 -0
  144. package/dist/server/table/hosts/play.d.ts +29 -0
  145. package/dist/server/table/hosts/play.js +180 -0
  146. package/dist/server/table/hosts/seat.d.ts +33 -0
  147. package/dist/server/table/hosts/seat.js +80 -0
  148. package/dist/server/table/hosts/terminal.d.ts +6 -0
  149. package/dist/server/table/hosts/terminal.js +63 -0
  150. package/dist/server/table/icon/index.d.ts +32 -0
  151. package/dist/server/table/icon/index.js +83 -0
  152. package/dist/server/table/icon/linux.d.ts +5 -0
  153. package/dist/server/table/icon/linux.js +47 -0
  154. package/dist/server/table/icon/macos.d.ts +3 -0
  155. package/dist/server/table/icon/macos.js +38 -0
  156. package/dist/server/table/icon/windows.d.ts +3 -0
  157. package/dist/server/table/icon/windows.js +48 -0
  158. package/dist/server/table/index.d.ts +6 -0
  159. package/dist/server/table/index.js +7 -0
  160. package/dist/server/table/launch.d.ts +19 -0
  161. package/dist/server/table/launch.js +78 -0
  162. package/dist/server/table/link.d.ts +10 -0
  163. package/dist/server/table/link.js +231 -0
  164. package/dist/server/table/table.d.ts +35 -0
  165. package/dist/server/table/table.js +94 -0
  166. package/dist/sites/chesscom.d.ts +4 -0
  167. package/dist/sites/chesscom.js +25 -0
  168. package/dist/sites/choice.d.ts +17 -0
  169. package/dist/sites/choice.js +13 -0
  170. package/dist/sites/index.d.ts +2 -0
  171. package/dist/sites/index.js +4 -0
  172. package/dist/sites/lichess.d.ts +4 -0
  173. package/dist/sites/lichess.js +32 -0
  174. package/dist/sites/request.d.ts +11 -0
  175. package/dist/sites/request.js +15 -0
  176. package/dist/sites/sites.d.ts +6 -0
  177. package/dist/sites/sites.js +33 -0
  178. package/instructions/analysis.md +1 -1
  179. package/instructions/analyst-agent.md +1 -1
  180. package/instructions/coach.md +2 -1
  181. package/instructions/opponent.md +6 -6
  182. package/instructions/ponder.md +1 -0
  183. package/instructions/start.md +1 -0
  184. package/package.json +1 -1
@@ -4,40 +4,67 @@ import { watch } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import { join } from "node:path";
6
6
  import { positionIn, samePosition } from "../../commands/index.js";
7
+ import { aiNameOf } from "../../core/index.js";
7
8
  import { gameStamp, tableTalkStamp } from "../../games/index.js";
8
9
  import { outdatedHostFiles } from "../../host-files/index.js";
9
10
  import { gameFolder } from "../../profiles/index.js";
10
11
  import { Bell, bellProbe, pagePorts } from "../bell.js";
11
12
  import { analysesOf, answerAnalyses } from "./analyses.js";
13
+ import { answerChat } from "./attach.js";
14
+ import { attachedSeen, chatSeenOf, pageContext } from "./chat.js";
15
+ import { askNewGame, keepPosted, tellWatchers } from "./events.js";
12
16
  import { sendFile, sendHtml } from "./files.js";
13
17
  import { answerGame, chatCalls } from "./game.js";
14
18
  import { json, send } from "./http.js";
15
- import { answerProfiles } from "./profiles.js";
19
+ import { answerProfiles, readRatingsOf } from "./profiles.js";
20
+ import { answerTable, restartWhenAtRest } from "./table.js";
16
21
  /**
17
- * 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
18
- * 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.
22
+ * 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
23
+ * 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
24
+ * 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.
19
25
  */
20
- export async function startPage(context, opponent = () => undefined) {
26
+ export async function startPage(context, options = {}) {
21
27
  const { folder } = context;
28
+ const { table } = options;
22
29
  const watchers = new Set();
23
- const outdated = outdatedHostFiles(folder, context.platform).length > 0;
24
- const served = { folder, context, opponent, bell: new Bell(() => hasNews(context), () => tellWatchers(served)), watchers, port: 0, outdated };
30
+ const chat = table === undefined ? chatSeenOf(context) : attachedSeen(() => served.attached);
31
+ const served = {
32
+ folder,
33
+ context: pageContext(context),
34
+ chat,
35
+ opponent: options.opponent ?? (() => aiNameOf(chat.host())),
36
+ // A start again asked while the chat was at work comes once it has ended its answer (D-107).
37
+ bell: new Bell(() => hasNews(context, chat), () => {
38
+ restartWhenAtRest(served);
39
+ tellWatchers(watchers);
40
+ }),
41
+ watchers,
42
+ port: 0,
43
+ outdated: outdatedHostFiles(folder, context.platform).length > 0,
44
+ ...(table === undefined ? {} : { table }),
45
+ };
25
46
  const server = createServer((request, response) => answer(request, response, served));
26
- for (const port of [...pagePorts(folder), 0]) {
47
+ const ports = table === undefined ? [...pagePorts(folder), 0] : pagePorts(folder);
48
+ for (const port of ports) {
27
49
  if (await listened(server, port)) {
28
50
  server.unref();
29
51
  served.port = portOf(server);
30
52
  const stopWatching = watchGame(served);
31
53
  return {
32
54
  url: `http://127.0.0.1:${served.port}/`,
33
- heard: () => tellWatchers(served),
55
+ heard: () => tellWatchers(watchers),
34
56
  chatCalls: () => chatCalls(served),
57
+ toolEnded: () => undefined,
35
58
  open: () => watchers.size > 0,
36
- askNewGame: () => askNewGame(served),
59
+ inUse: () => watchers.size > 0 || served.attached !== undefined,
60
+ askNewGame: () => askNewGame(watchers),
37
61
  close: () => closed(server, stopWatching),
38
62
  };
39
63
  }
40
64
  }
65
+ if (table !== undefined) {
66
+ throw new Error(`The table's ports ${ports.join(", ")} on 127.0.0.1 are taken by other programs: close them, or play from a chat, which serves its own page.`);
67
+ }
41
68
  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.");
42
69
  }
43
70
  /**
@@ -46,20 +73,22 @@ export async function startPage(context, opponent = () => undefined) {
46
73
  */
47
74
  export function chatHasRead(context, showed) {
48
75
  context.seen = gameSeen(context);
49
- if (!showed) {
50
- return;
51
- }
52
- const before = context.positionSeen;
53
- try {
54
- context.positionSeen = positionIn(gameFolder(context.home()));
55
- }
56
- catch {
57
- context.positionSeen = null;
58
- }
59
- // The chat on another position may now be thinking on its move: the page hears it, which no file tells when no bell woke the chat.
60
- if (!sameSeen(before, context.positionSeen)) {
61
- context.heard?.();
76
+ if (showed) {
77
+ const before = context.positionSeen;
78
+ try {
79
+ context.positionSeen = positionIn(gameFolder(context.home()));
80
+ }
81
+ catch {
82
+ context.positionSeen = null;
83
+ }
84
+ context.positionFirstSeen ??= context.positionSeen ?? undefined;
85
+ // The chat on another position may now be thinking on its move: the page hears it, which no file tells when no bell woke the chat.
86
+ if (!sameSeen(before, context.positionSeen)) {
87
+ context.heard?.();
88
+ }
62
89
  }
90
+ // The table the chat is attached to reads it from a copy (D-104).
91
+ context.toolEnded?.();
63
92
  }
64
93
  function sameSeen(one, other) {
65
94
  return one === other || (one !== undefined && one !== null && other !== undefined && other !== null && samePosition(one, other));
@@ -74,7 +103,13 @@ function answer(request, response, served) {
74
103
  send(response, 403, "text/plain; charset=utf-8", "Chesspal's page answers only at its own address on this machine.");
75
104
  return;
76
105
  }
77
- if (answerProfiles(request, response, url, served) || answerGame(request, response, url, served) || answerAnalyses(request, response, url, served)) {
106
+ // A probe is no use of the table: a chat on its own page looks for it after each tool, and the hooks look for theirs.
107
+ if (!(path === "/api/bell" && url.searchParams.has("probe"))) {
108
+ served.table?.used();
109
+ }
110
+ const answered = answerProfiles(request, response, url, served) || answerGame(request, response, url, served) || answerAnalyses(request, response, url, served)
111
+ || answerChat(request, response, url, served) || answerTable(request, response, url, served);
112
+ if (answered) {
78
113
  return;
79
114
  }
80
115
  // The API answers GET alone: a HEAD at the doorbell would wait there as a hook does.
@@ -90,11 +125,13 @@ function answer(request, response, served) {
90
125
  send(response, 200, json, JSON.stringify(analysesOf(served.context.home())));
91
126
  }
92
127
  else if (path === "/api/events") {
93
- keepPosted(request, response, served);
128
+ keepPosted(request, response, served.watchers);
129
+ // A page that opens reads the player's ratings again after a day (D-106).
130
+ readRatingsOf(served);
94
131
  }
95
132
  else if (path === "/api/bell") {
96
133
  if (url.searchParams.has("probe")) {
97
- send(response, 200, json, JSON.stringify(bellProbe(served.folder, served.watchers.size > 0)));
134
+ send(response, 200, json, JSON.stringify(bellProbe(served.folder, served.watchers.size > 0, tableChat(served))));
98
135
  }
99
136
  else {
100
137
  served.bell.listen(request, response);
@@ -104,12 +141,20 @@ function answer(request, response, served) {
104
141
  sendFile(response, path);
105
142
  }
106
143
  }
144
+ /** The chat at the table, as its probe says it: none on a page a chat serves itself. */
145
+ function tableChat(served) {
146
+ if (served.table === undefined) {
147
+ return undefined;
148
+ }
149
+ const chat = served.attached?.chat;
150
+ return chat === undefined ? { session: null } : { session: chat.session, chat: chat.folder };
151
+ }
107
152
  /**
108
153
  * 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
109
154
  * 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.
110
155
  */
111
- function hasNews(context) {
112
- return gameSeen(context) !== context.seen;
156
+ function hasNews(context, chat) {
157
+ return gameSeen(context) !== chat.seen;
113
158
  }
114
159
  // 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
115
160
  // before any game was played here (Priya); none while the data folder cannot be made.
@@ -124,25 +169,6 @@ ${tableTalkStamp(folder) ?? ""}`;
124
169
  return "";
125
170
  }
126
171
  }
127
- /** 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. */
128
- function keepPosted(request, response, served) {
129
- response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
130
- response.write(": posted\n\n");
131
- served.watchers.add(response);
132
- request.on("close", () => served.watchers.delete(response));
133
- }
134
- /** The pages open now open their New game window; whether there was one. */
135
- function askNewGame(served) {
136
- for (const watcher of served.watchers) {
137
- watcher.write("data: new-game\n\n");
138
- }
139
- return served.watchers.size > 0;
140
- }
141
- function tellWatchers(served) {
142
- for (const watcher of served.watchers) {
143
- watcher.write("data: changed\n\n");
144
- }
145
- }
146
172
  /**
147
173
  * 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,
148
174
  * 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
@@ -161,7 +187,7 @@ function watchGame(served) {
161
187
  waiting = setTimeout(() => {
162
188
  waiting = undefined;
163
189
  watchAll();
164
- tellWatchers(served);
190
+ tellWatchers(served.watchers);
165
191
  }, 30);
166
192
  }
167
193
  };
@@ -195,7 +221,7 @@ function watchFolder(folder, heard) {
195
221
  }
196
222
  // The current game, what the coach wrote on it, whether the AI thinks on the player's time, the talk before the first, the game in analysis, the profile
197
223
  // in use, and what a profile keeps (who plays, their picture, their settings); a folder's name, for a profile made or gone.
198
- const watched = /^(?:game\.json|coach\.json|thoughts\.json|talk\.json|analysis\.json|active\.json|profile\.md|settings\.json|avatar\.\w+|[a-z0-9-]+)$/;
224
+ const watched = /^(?:game\.json|coach\.json|thoughts\.json|talk\.json|analysis\.json|active\.json|profile\.md|settings\.json|ratings\.json|avatar\.\w+|[a-z0-9-]+)$/;
199
225
  function fromHere(request, port, api) {
200
226
  const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
201
227
  const origin = request.headers.origin;
@@ -17,3 +17,8 @@ export interface ProfilesShown {
17
17
  export declare function profilesShown(root: string, userFolder?: string): ProfilesShown;
18
18
  /** Answers the page's requests about profiles: true when the request was one. A file that cannot be written is told to the page, and the server goes on. */
19
19
  export declare function answerProfiles(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;
20
+ /**
21
+ * Reads the ratings of a profile, the one in use unless named, again a day after the last reading, while the page goes on: the pages hear of them from the
22
+ * watcher (D-106).
23
+ */
24
+ export declare function readRatingsOf(served: Served, id?: string): void;
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { isAbsolute, relative, sep } from "node:path";
3
- import { chooseDrawnAvatar, GameGoingOn, inputProblems, newProfile, setAccounts, useProfile } from "../../commands/index.js";
3
+ import { chooseDrawnAvatar, editProfile, GameGoingOn, inputProblems, newProfile, ratingsAge, refreshRatings, useProfile } from "../../commands/index.js";
4
4
  import { activeProfile, drawnAvatars, imageTypes, listProfiles, ownImage, ownImageStamp, profileFolder, profileSettings, saveOwnImage, saveProfileSettings, sentImageLimit, settingsOf, } from "../../profiles/index.js";
5
5
  import { bodyOf, bytesOf, isJson, jsonOf, runRoute, send, sendJson } from "./http.js";
6
6
  /** The profiles of the data folder, as the page shows them. */
@@ -25,7 +25,7 @@ const routes = {
25
25
  "POST /api/profile/new": newProfileFromPage,
26
26
  "POST /api/profile/use": useProfileFromPage,
27
27
  "POST /api/profile/avatar": drawnAvatarFromPage,
28
- "POST /api/profile/accounts": accountsFromPage,
28
+ "POST /api/profile/edit": editFromPage,
29
29
  "POST /api/profile/image": imageFromPage,
30
30
  "GET /api/profile/image": sendImage,
31
31
  "POST /api/settings": settingsFromPage,
@@ -48,7 +48,7 @@ async function newProfileFromPage(request, response, served) {
48
48
  sendJson(response, 409, refusalOf(error));
49
49
  }
50
50
  }
51
- /** Another profile put in use from the menu, once the game going on has ended: the chat is not woken for it (D-66). */
51
+ /** Another profile put in use from the menu, once the game going on has ended: the chat is not woken for it (D-66); its ratings are read again after a day (D-106). */
52
52
  async function useProfileFromPage(request, response, served) {
53
53
  const profile = isJson(request) ? jsonOf(await bodyOf(request))?.profile : undefined;
54
54
  if (typeof profile !== "string") {
@@ -63,6 +63,7 @@ async function useProfileFromPage(request, response, served) {
63
63
  return;
64
64
  }
65
65
  sendJson(response, 200, { profile });
66
+ readRatingsOf(served, profile);
66
67
  }
67
68
  /** A drawn avatar for the profile the page shows, in place of the player's own image. */
68
69
  async function drawnAvatarFromPage(request, response, served, url) {
@@ -79,21 +80,24 @@ async function drawnAvatarFromPage(request, response, served, url) {
79
80
  chooseDrawnAvatar(served.context, profile.id, drawn);
80
81
  sendJson(response, 200, { avatar: drawn });
81
82
  }
82
- /** The player's names on Lichess and chess.com for the profile the page shows (D-93), in a game or in analysis too: they change only which side is theirs. */
83
- async function accountsFromPage(request, response, served, url) {
83
+ /**
84
+ * What the player changes in the profile window for the profile the page shows, in a game or in analysis too: their name, how they play, their names on
85
+ * Lichess and chess.com (D-93), each saved once changed; the profile keeps its folder (D-106).
86
+ */
87
+ async function editFromPage(request, response, served, url) {
84
88
  const body = isJson(request) ? jsonOf(await bodyOf(request)) : undefined;
85
- const input = setAccounts.input.safeParse(body ?? {});
89
+ const input = editProfile.input.safeParse(body ?? {});
86
90
  const profile = profileInUse(served, url, response);
87
91
  if (profile === undefined) {
88
92
  return;
89
93
  }
90
94
  if (body === undefined || !input.success) {
91
- const problems = input.success ? "" : ` ${inputProblems("set_accounts", input.error.issues, body ?? {}, (name) => name)}`;
92
- sendJson(response, 400, { error: `Send the names as JSON, {"lichess": "…", "chesscom": "…"}.${problems}` });
95
+ const problems = input.success ? "" : ` ${inputProblems("edit_profile", input.error.issues, body ?? {}, (name) => name)}`;
96
+ sendJson(response, 400, { error: `Send the changes as JSON, {"name": "…", "level": "regular", "lichess": "…", "chesscom": "…", "fide": 1650}.${problems}` });
93
97
  return;
94
98
  }
95
- const { accounts } = await setAccounts.run(input.data, served.context);
96
- sendJson(response, 200, { accounts: accounts ?? {} });
99
+ const edited = await editProfile.run(input.data, served.context);
100
+ sendJson(response, 200, { profile: shown(served.context.home(), edited) });
97
101
  }
98
102
  /**
99
103
  * The player's own image for a profile, as the page made it from their photo: a PNG, JPEG or WebP of 1 MB at most. The one in use, or one just added while a
@@ -168,6 +172,18 @@ async function openDataFolder(request, response, served) {
168
172
  }
169
173
  sendJson(response, 200, { opened: true });
170
174
  }
175
+ /**
176
+ * Reads the ratings of a profile, the one in use unless named, again a day after the last reading, while the page goes on: the pages hear of them from the
177
+ * watcher (D-106).
178
+ */
179
+ export function readRatingsOf(served, id) {
180
+ try {
181
+ void refreshRatings(served.context, id ?? activeProfile(served.context.home()).id, ratingsAge).catch(() => false);
182
+ }
183
+ catch {
184
+ // No data folder yet: the page says why as it loads.
185
+ }
186
+ }
171
187
  /**
172
188
  * The profile in use, when it is the one the page shows (?profile=): a page that has not yet heard of another put in use elsewhere is refused, so that its
173
189
  * choices never go to the wrong player.
@@ -0,0 +1,58 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Served } from "./page.ts";
3
+ /** A host the table offers a chat in: whether it is installed, the models and efforts the player may choose, and the command to open one by hand. */
4
+ export interface ChatHost {
5
+ readonly name: string;
6
+ readonly installed: boolean;
7
+ readonly models: readonly string[];
8
+ readonly efforts: readonly string[];
9
+ readonly command: string;
10
+ }
11
+ /**
12
+ * How the table opens a chat: the hosts it offers, and a terminal with one of them, whether one opened; the chat `chesspal play` holds in a folder, with
13
+ * the model and effort it started with, and how it is asked to start it again.
14
+ */
15
+ export interface TableChats {
16
+ hosts(): readonly ChatHost[];
17
+ open(host: string): Promise<boolean>;
18
+ held(folder: string): HeldChat | undefined;
19
+ relaunch(folder: string, host?: string): boolean;
20
+ }
21
+ /** The chat `chesspal play` holds: the model and effort its host started with, none for the host's own. */
22
+ export interface HeldChat {
23
+ readonly model?: string | undefined;
24
+ readonly effort?: string | undefined;
25
+ }
26
+ /** A chat opening at the table: one the page asked for, the one started again for a new game, or with another model or host (D-107). */
27
+ export type Opening = "chat" | "again" | "restart";
28
+ /** What the table's page shows of its chat (D-104, D-107). */
29
+ export interface TableShown {
30
+ /** Whether a chat is at the table, and its host among those offered, when it says one. */
31
+ readonly chat: boolean;
32
+ readonly host?: string;
33
+ /** Whether `chesspal play` holds the chat, which the page can then start again; one opened by hand it cannot. With the model and effort it started with. */
34
+ readonly held: boolean;
35
+ readonly started?: HeldChat;
36
+ readonly hosts: readonly ChatHost[];
37
+ readonly opening?: Opening;
38
+ /** A start again asked while the chat was at work, waiting for it to end its answer, or to be started at once or let go from the page. */
39
+ readonly waiting?: true;
40
+ }
41
+ /** What the table's page shows of its chat: whether one is at the table, with which host, the hosts it can open one in, and one opening. */
42
+ export declare function tableShown(served: Served): {
43
+ table?: TableShown;
44
+ };
45
+ /** Answers the table's own routes, under /api/table/; false for any other, and for every request to a page a chat serves itself. */
46
+ export declare function answerTable(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;
47
+ /**
48
+ * Whether a new game starts the chat at the table again, clean (D-57): `chesspal play` holds it, it waits for the bell, no analyst of it is at work, and it
49
+ * has seen a game move since it was first shown one; one that only greeted the player stays, as one opened from the chat's window before a new game.
50
+ */
51
+ export declare function chatToRelaunch(served: Served): boolean;
52
+ /**
53
+ * Asks `chesspal play` to start the chat at the table again: once the new game has started, or with another model or host in its place (D-107). Whether
54
+ * it was asked; the pages then show it opening.
55
+ */
56
+ export declare function relaunchChat(served: Served, how?: "again" | "restart", host?: string): boolean;
57
+ /** Starts the chat again as the page asked while it was at work, now that it has ended its answer; a chat gone meanwhile takes the request with it. */
58
+ export declare function restartWhenAtRest(served: Served): void;
@@ -0,0 +1,210 @@
1
+ import { samePosition } from "../../commands/index.js";
2
+ import { gameInAnalysis } from "../../games/index.js";
3
+ import { gameFolder } from "../../profiles/index.js";
4
+ import { tellWatchers } from "./events.js";
5
+ import { bodyOf, isJson, jsonOf, runRoute, sendJson } from "./http.js";
6
+ /** What the table's page shows of its chat: whether one is at the table, with which host, the hosts it can open one in, and one opening. */
7
+ export function tableShown(served) {
8
+ const { table, attached } = served;
9
+ if (table === undefined) {
10
+ return {};
11
+ }
12
+ const host = hostAtTable(served);
13
+ const held = heldChat(served);
14
+ const now = opening(served);
15
+ return {
16
+ table: {
17
+ chat: attached !== undefined,
18
+ ...(host === undefined ? {} : { host }),
19
+ held: held !== undefined,
20
+ ...(held === undefined ? {} : { started: held }),
21
+ hosts: table.chats?.hosts() ?? [],
22
+ ...(now === undefined ? {} : { opening: now }),
23
+ ...(served.restartWanted === undefined ? {} : { waiting: true }),
24
+ },
25
+ };
26
+ }
27
+ /** Answers the table's own routes, under /api/table/; false for any other, and for every request to a page a chat serves itself. */
28
+ export function answerTable(request, response, url, served) {
29
+ const path = url.pathname;
30
+ if (served.table === undefined || request.method !== "POST" || !path.startsWith("/api/table/") || !isJson(request)) {
31
+ return false;
32
+ }
33
+ if (path === "/api/table/quit") {
34
+ // Another Chesspal version asks the table to quit, to start its own in its place.
35
+ sendJson(response, 200, { quitting: true });
36
+ served.table.quit();
37
+ }
38
+ else if (path === "/api/table/chat") {
39
+ runRoute(response, () => openChat(request, response, served));
40
+ }
41
+ else if (path === "/api/table/restart") {
42
+ runRoute(response, () => restartChat(request, response, served));
43
+ }
44
+ else if (path === "/api/table/waiting") {
45
+ runRoute(response, () => endWaiting(request, response, served));
46
+ }
47
+ else {
48
+ sendJson(response, 404, { error: `${path} is no route of the table's: it answers POST /api/table/chat, /api/table/restart, /api/table/waiting and /api/table/quit.` });
49
+ }
50
+ return true;
51
+ }
52
+ /**
53
+ * Whether a new game starts the chat at the table again, clean (D-57): `chesspal play` holds it, it waits for the bell, no analyst of it is at work, and it
54
+ * has seen a game move since it was first shown one; one that only greeted the player stays, as one opened from the chat's window before a new game.
55
+ */
56
+ export function chatToRelaunch(served) {
57
+ const { positionSeen, positionFirstSeen } = served.chat;
58
+ const played = positionSeen?.started !== undefined && (positionFirstSeen === undefined || !samePosition(positionSeen, positionFirstSeen));
59
+ return played && heldChat(served) !== undefined && chatAtRest(served);
60
+ }
61
+ /**
62
+ * Asks `chesspal play` to start the chat at the table again: once the new game has started, or with another model or host in its place (D-107). Whether
63
+ * it was asked; the pages then show it opening.
64
+ */
65
+ export function relaunchChat(served, how = "again", host) {
66
+ const folder = served.attached?.chat.folder;
67
+ // Another host may first ask whether it trusts the table's folder: the pages say so, as for a chat opened.
68
+ const switching = how === "restart" && host !== undefined && host !== hostAtTable(served);
69
+ if (folder === undefined || served.table?.chats?.relaunch(folder, host) !== true) {
70
+ return false;
71
+ }
72
+ noteOpening(served, switching ? "chat" : how);
73
+ return true;
74
+ }
75
+ /** Starts the chat again as the page asked while it was at work, now that it has ended its answer; a chat gone meanwhile takes the request with it. */
76
+ export function restartWhenAtRest(served) {
77
+ const wanted = served.restartWanted;
78
+ if (wanted === undefined) {
79
+ return;
80
+ }
81
+ if (heldChat(served) === undefined) {
82
+ served.restartWanted = undefined;
83
+ }
84
+ else if (chatAtRest(served)) {
85
+ served.restartWanted = undefined;
86
+ relaunchChat(served, "restart", wanted.host);
87
+ }
88
+ }
89
+ // A terminal takes a few seconds to open and its chat to attach: a second click meanwhile would open a second chat, which serves a page of its own.
90
+ const openingFor = 30_000;
91
+ /**
92
+ * Opens a chat in a terminal, asked by the table's own page alone, which a browser marks with its origin: never while a chat is at the table, or one is
93
+ * opening. Whether it opened; else the page shows the command to type.
94
+ */
95
+ async function openChat(request, response, served) {
96
+ const offered = hostOffered(served, jsonOf(await bodyOf(request))?.host);
97
+ if (!fromTablePage(request, served)) {
98
+ sendJson(response, 403, { error: "Only the table's own page opens a chat." });
99
+ }
100
+ else if (offered === undefined || served.table?.chats === undefined) {
101
+ sendJson(response, 400, { error: hostsOffered(served) });
102
+ }
103
+ else if (served.attached !== undefined || opening(served) !== undefined) {
104
+ sendJson(response, 409, { error: "A chat is at the table, or opening: play there." });
105
+ }
106
+ else {
107
+ const opened = noteOpening(served, "chat");
108
+ const started = await served.table.chats.open(offered.name);
109
+ if (!started && served.chatOpened === opened) {
110
+ served.chatOpened = undefined;
111
+ }
112
+ sendJson(response, 200, { opened: started });
113
+ tellWatchers(served.watchers);
114
+ }
115
+ }
116
+ /**
117
+ * Starts the chat `chesspal play` holds again, clean, with the host the page names and the model and effort it keeps for it (D-107): at once when it is at
118
+ * rest, else once it has ended its answer. Asked by the table's own page alone; a chat opened by hand changes its model with /model.
119
+ */
120
+ async function restartChat(request, response, served) {
121
+ const offered = hostOffered(served, jsonOf(await bodyOf(request))?.host);
122
+ if (!fromTablePage(request, served)) {
123
+ sendJson(response, 403, { error: "Only the table's own page starts the chat again." });
124
+ }
125
+ else if (offered === undefined) {
126
+ sendJson(response, 400, { error: hostsOffered(served) });
127
+ }
128
+ else if (heldChat(served) === undefined) {
129
+ sendJson(response, 409, { error: "No chat the table opened is at it: open one from the page, or change the model with /model in a chat opened by hand." });
130
+ }
131
+ else if (chatAtRest(served)) {
132
+ sendJson(response, 200, { restarting: relaunchChat(served, "restart", offered.name) });
133
+ }
134
+ else {
135
+ served.restartWanted = { host: offered.name };
136
+ tellWatchers(served.watchers);
137
+ sendJson(response, 200, { waiting: true });
138
+ }
139
+ }
140
+ /**
141
+ * Ends a start again the page asked while the chat was at work (D-107): `now` starts it at once, cutting the answer it gives, as after an Esc, when it would
142
+ * never end; else the request is let go. Asked by the table's own page alone.
143
+ */
144
+ async function endWaiting(request, response, served) {
145
+ const now = jsonOf(await bodyOf(request))?.now;
146
+ const wanted = served.restartWanted;
147
+ if (!fromTablePage(request, served)) {
148
+ sendJson(response, 403, { error: "Only the table's own page ends a start again that waits." });
149
+ }
150
+ else if (typeof now !== "boolean") {
151
+ sendJson(response, 400, { error: "Say now: true to start the chat again at once, cutting its answer, or now: false to let the request go." });
152
+ }
153
+ else if (wanted === undefined) {
154
+ sendJson(response, 409, { error: "No start again waits: ask for one from the chat's window." });
155
+ }
156
+ else {
157
+ served.restartWanted = undefined;
158
+ const restarting = now && heldChat(served) !== undefined && relaunchChat(served, "restart", wanted.host);
159
+ tellWatchers(served.watchers);
160
+ sendJson(response, 200, now ? { restarting } : { waiting: false });
161
+ }
162
+ }
163
+ // The browser marks a request of the table's own page with its origin.
164
+ function fromTablePage(request, served) {
165
+ return request.headers.origin === `http://127.0.0.1:${served.port}` || request.headers.origin === `http://localhost:${served.port}`;
166
+ }
167
+ function hostOffered(served, host) {
168
+ return served.table?.chats?.hosts().find((one) => one.name === host && one.installed);
169
+ }
170
+ function hostsOffered(served) {
171
+ return `Name a host the table offers and this computer has: ${served.table?.chats?.hosts().filter((one) => one.installed).map((one) => one.name).join(", ") || "none"}.`;
172
+ }
173
+ // The host of the chat at the table, among those offered: it says its name as its MCP client does, «claude-code» or «codex-mcp-client».
174
+ function hostAtTable(served) {
175
+ const said = served.chat.host()?.toLowerCase() ?? "";
176
+ return served.attached === undefined ? undefined : served.table?.chats?.hosts().find((one) => said.includes(one.name))?.name;
177
+ }
178
+ // The chat at the table when `chesspal play` holds it, which the table can start again: the model and effort it started with.
179
+ function heldChat(served) {
180
+ const { attached } = served;
181
+ return attached === undefined ? undefined : served.table?.chats?.held(attached.chat.folder);
182
+ }
183
+ // The chat waits for the doorbell, out of a turn, and no analyst of it is at work: starting it again loses nothing.
184
+ function chatAtRest(served) {
185
+ const { bell } = served;
186
+ return bell.listening && !bell.inTurn && gameInAnalysis(gameFolder(served.context.home()))?.analyst === undefined;
187
+ }
188
+ // The pages show a chat opening from now until it attaches; one that has not by then lets them open another. The opening noted.
189
+ function noteOpening(served, how) {
190
+ const opened = { at: served.context.now().getTime(), how };
191
+ served.chatOpened = opened;
192
+ tellWatchers(served.watchers);
193
+ setTimeout(() => {
194
+ if (served.chatOpened === opened) {
195
+ tellWatchers(served.watchers);
196
+ }
197
+ }, openingFor).unref();
198
+ return opened;
199
+ }
200
+ // A chat opening, with none at the table: one noted lately, or one chesspal play holds the seat for, as at a trust prompt longer than the wait.
201
+ function opening(served) {
202
+ const { attached, chatOpened, context } = served;
203
+ if (attached !== undefined) {
204
+ return undefined;
205
+ }
206
+ if (chatOpened !== undefined && context.now().getTime() - chatOpened.at < openingFor) {
207
+ return chatOpened.how;
208
+ }
209
+ return served.table?.chats?.held(context.folder) === undefined ? undefined : "chat";
210
+ }
@@ -0,0 +1,6 @@
1
+ /** Opens an address in a window of its own, else in a tab of the default browser, without waiting for it: whether a browser started. */
2
+ export declare function openInBrowser(url: string, platform: string): Promise<boolean>;
3
+ /** The address of the window the table opens, marked so that the page sizes it the first time (page/layout/window.ts). */
4
+ export declare function openedAddress(url: string): string;
5
+ /** Chrome or Edge, where each system installs them: the first there is; none when neither is. */
6
+ export declare function appBrowser(platform: string, env: NodeJS.ProcessEnv, exists?: (path: string) => boolean): string | undefined;
@@ -0,0 +1,48 @@
1
+ // The table's page opened in the player's browser (D-104): in a window of its own, as an application, when Chrome or Edge is installed, which open one with
2
+ // --app; else in a tab of the system's default browser.
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { posix, win32 } from "node:path";
6
+ import { openFolder } from "../../home/index.js";
7
+ /** Opens an address in a window of its own, else in a tab of the default browser, without waiting for it: whether a browser started. */
8
+ export async function openInBrowser(url, platform) {
9
+ const browser = appBrowser(platform, process.env);
10
+ // Explorer, open and xdg-open take an address as they take a folder, and hand it to the default browser.
11
+ return (browser !== undefined && await started(browser, [`--app=${openedAddress(url)}`])) || openFolder(url, platform);
12
+ }
13
+ /** The address of the window the table opens, marked so that the page sizes it the first time (page/layout/window.ts). */
14
+ export function openedAddress(url) {
15
+ const address = new URL(url);
16
+ address.searchParams.set("opened", "");
17
+ return address.href;
18
+ }
19
+ /** Chrome or Edge, where each system installs them: the first there is; none when neither is. */
20
+ export function appBrowser(platform, env, exists = existsSync) {
21
+ return browserPaths(platform, env).find(exists);
22
+ }
23
+ // Chrome before Edge: Chrome is the one tried, and Edge may be missing even on Windows.
24
+ function browserPaths(platform, env) {
25
+ if (platform === "win32") {
26
+ const roots = [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA].filter((root) => root !== undefined && root !== "");
27
+ const programs = [["Google", "Chrome", "Application", "chrome.exe"], ["Microsoft", "Edge", "Application", "msedge.exe"]];
28
+ return programs.flatMap((program) => roots.map((root) => win32.join(root, ...program)));
29
+ }
30
+ if (platform === "darwin") {
31
+ const roots = ["/Applications", ...(env.HOME === undefined ? [] : [posix.join(env.HOME, "Applications")])];
32
+ return ["Google Chrome", "Microsoft Edge"].flatMap((name) => roots.map((root) => posix.join(root, `${name}.app`, "Contents", "MacOS", name)));
33
+ }
34
+ const folders = (env.PATH ?? "").split(":").filter((folder) => folder !== "");
35
+ const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable"];
36
+ return names.flatMap((name) => folders.map((folder) => posix.join(folder, name)));
37
+ }
38
+ // A browser started apart from this process, which it outlives: whether it started.
39
+ function started(browser, args) {
40
+ return new Promise((done) => {
41
+ const opened = spawn(browser, args, { detached: true, stdio: "ignore" });
42
+ opened.once("spawn", () => {
43
+ opened.unref();
44
+ done(true);
45
+ });
46
+ opened.once("error", () => done(false));
47
+ });
48
+ }
@@ -0,0 +1,7 @@
1
+ import type { Context } from "../../../commands/index.ts";
2
+ import type { TableChats } from "../../page/table.ts";
3
+ /**
4
+ * The chats of a real table: the hosts installed, looked for again once a while has passed, since the player may install one with the page open; a
5
+ * terminal opened with `chesspal play`, run by `chesspal`, the same Node and script as the table's.
6
+ */
7
+ export declare function terminalChats(context: Context, chesspal?: readonly string[], env?: NodeJS.ProcessEnv): TableChats;