chesspal 0.2.4 → 0.3.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 (183) 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 +9 -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 +29 -16
  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 +95 -42
  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 +111 -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/opener.css +65 -0
  99. package/dist/page/table/opener.js +59 -0
  100. package/dist/page/talk/chat.css +10 -3
  101. package/dist/page/talk/talk.css +38 -26
  102. package/dist/page/talk/talk.js +4 -2
  103. package/dist/page/text.js +83 -17
  104. package/dist/page/theme.css +7 -0
  105. package/dist/profiles/index.d.ts +2 -1
  106. package/dist/profiles/index.js +2 -1
  107. package/dist/profiles/profiles.d.ts +10 -15
  108. package/dist/profiles/profiles.js +30 -15
  109. package/dist/profiles/ratings.d.ts +35 -0
  110. package/dist/profiles/ratings.js +61 -0
  111. package/dist/server/bell.d.ts +24 -3
  112. package/dist/server/bell.js +84 -10
  113. package/dist/server/caller.d.ts +2 -1
  114. package/dist/server/caller.js +3 -2
  115. package/dist/server/mcp.d.ts +7 -3
  116. package/dist/server/mcp.js +100 -52
  117. package/dist/server/page/analyses.d.ts +2 -0
  118. package/dist/server/page/analyses.js +20 -5
  119. package/dist/server/page/attach.d.ts +4 -0
  120. package/dist/server/page/attach.js +112 -0
  121. package/dist/server/page/chat.d.ts +60 -0
  122. package/dist/server/page/chat.js +79 -0
  123. package/dist/server/page/events.d.ts +7 -0
  124. package/dist/server/page/events.js +20 -0
  125. package/dist/server/page/files.js +10 -1
  126. package/dist/server/page/game.js +43 -23
  127. package/dist/server/page/page.d.ts +47 -7
  128. package/dist/server/page/page.js +69 -48
  129. package/dist/server/page/profiles.d.ts +5 -0
  130. package/dist/server/page/profiles.js +26 -10
  131. package/dist/server/page/table.d.ts +39 -0
  132. package/dist/server/page/table.js +102 -0
  133. package/dist/server/table/browser.d.ts +6 -0
  134. package/dist/server/table/browser.js +48 -0
  135. package/dist/server/table/hosts/chats.d.ts +7 -0
  136. package/dist/server/table/hosts/chats.js +31 -0
  137. package/dist/server/table/hosts/find.d.ts +2 -0
  138. package/dist/server/table/hosts/find.js +20 -0
  139. package/dist/server/table/hosts/hosts.d.ts +34 -0
  140. package/dist/server/table/hosts/hosts.js +49 -0
  141. package/dist/server/table/hosts/index.d.ts +3 -0
  142. package/dist/server/table/hosts/index.js +4 -0
  143. package/dist/server/table/hosts/play.d.ts +25 -0
  144. package/dist/server/table/hosts/play.js +152 -0
  145. package/dist/server/table/hosts/seat.d.ts +25 -0
  146. package/dist/server/table/hosts/seat.js +74 -0
  147. package/dist/server/table/hosts/terminal.d.ts +6 -0
  148. package/dist/server/table/hosts/terminal.js +63 -0
  149. package/dist/server/table/icon/index.d.ts +32 -0
  150. package/dist/server/table/icon/index.js +83 -0
  151. package/dist/server/table/icon/linux.d.ts +5 -0
  152. package/dist/server/table/icon/linux.js +47 -0
  153. package/dist/server/table/icon/macos.d.ts +3 -0
  154. package/dist/server/table/icon/macos.js +38 -0
  155. package/dist/server/table/icon/windows.d.ts +3 -0
  156. package/dist/server/table/icon/windows.js +48 -0
  157. package/dist/server/table/index.d.ts +6 -0
  158. package/dist/server/table/index.js +7 -0
  159. package/dist/server/table/launch.d.ts +19 -0
  160. package/dist/server/table/launch.js +78 -0
  161. package/dist/server/table/link.d.ts +10 -0
  162. package/dist/server/table/link.js +230 -0
  163. package/dist/server/table/table.d.ts +35 -0
  164. package/dist/server/table/table.js +94 -0
  165. package/dist/sites/chesscom.d.ts +4 -0
  166. package/dist/sites/chesscom.js +25 -0
  167. package/dist/sites/choice.d.ts +17 -0
  168. package/dist/sites/choice.js +13 -0
  169. package/dist/sites/index.d.ts +2 -0
  170. package/dist/sites/index.js +4 -0
  171. package/dist/sites/lichess.d.ts +4 -0
  172. package/dist/sites/lichess.js +32 -0
  173. package/dist/sites/request.d.ts +11 -0
  174. package/dist/sites/request.js +15 -0
  175. package/dist/sites/sites.d.ts +6 -0
  176. package/dist/sites/sites.js +33 -0
  177. package/instructions/analysis.md +1 -1
  178. package/instructions/analyst-agent.md +1 -1
  179. package/instructions/coach.md +2 -1
  180. package/instructions/opponent.md +6 -6
  181. package/instructions/ponder.md +1 -0
  182. package/instructions/start.md +1 -0
  183. package/package.json +1 -1
@@ -4,40 +4,63 @@ 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 } 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
+ bell: new Bell(() => hasNews(context, chat), () => tellWatchers(watchers)),
37
+ watchers,
38
+ port: 0,
39
+ outdated: outdatedHostFiles(folder, context.platform).length > 0,
40
+ ...(table === undefined ? {} : { table }),
41
+ };
25
42
  const server = createServer((request, response) => answer(request, response, served));
26
- for (const port of [...pagePorts(folder), 0]) {
43
+ const ports = table === undefined ? [...pagePorts(folder), 0] : pagePorts(folder);
44
+ for (const port of ports) {
27
45
  if (await listened(server, port)) {
28
46
  server.unref();
29
47
  served.port = portOf(server);
30
48
  const stopWatching = watchGame(served);
31
49
  return {
32
50
  url: `http://127.0.0.1:${served.port}/`,
33
- heard: () => tellWatchers(served),
51
+ heard: () => tellWatchers(watchers),
34
52
  chatCalls: () => chatCalls(served),
53
+ toolEnded: () => undefined,
35
54
  open: () => watchers.size > 0,
36
- askNewGame: () => askNewGame(served),
55
+ inUse: () => watchers.size > 0 || served.attached !== undefined,
56
+ askNewGame: () => askNewGame(watchers),
37
57
  close: () => closed(server, stopWatching),
38
58
  };
39
59
  }
40
60
  }
61
+ if (table !== undefined) {
62
+ 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.`);
63
+ }
41
64
  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
65
  }
43
66
  /**
@@ -46,20 +69,21 @@ export async function startPage(context, opponent = () => undefined) {
46
69
  */
47
70
  export function chatHasRead(context, showed) {
48
71
  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?.();
72
+ if (showed) {
73
+ const before = context.positionSeen;
74
+ try {
75
+ context.positionSeen = positionIn(gameFolder(context.home()));
76
+ }
77
+ catch {
78
+ context.positionSeen = null;
79
+ }
80
+ // 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.
81
+ if (!sameSeen(before, context.positionSeen)) {
82
+ context.heard?.();
83
+ }
62
84
  }
85
+ // The table the chat is attached to reads it from a copy (D-104).
86
+ context.toolEnded?.();
63
87
  }
64
88
  function sameSeen(one, other) {
65
89
  return one === other || (one !== undefined && one !== null && other !== undefined && other !== null && samePosition(one, other));
@@ -74,7 +98,13 @@ function answer(request, response, served) {
74
98
  send(response, 403, "text/plain; charset=utf-8", "Chesspal's page answers only at its own address on this machine.");
75
99
  return;
76
100
  }
77
- if (answerProfiles(request, response, url, served) || answerGame(request, response, url, served) || answerAnalyses(request, response, url, served)) {
101
+ // 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.
102
+ if (!(path === "/api/bell" && url.searchParams.has("probe"))) {
103
+ served.table?.used();
104
+ }
105
+ const answered = answerProfiles(request, response, url, served) || answerGame(request, response, url, served) || answerAnalyses(request, response, url, served)
106
+ || answerChat(request, response, url, served) || answerTable(request, response, url, served);
107
+ if (answered) {
78
108
  return;
79
109
  }
80
110
  // The API answers GET alone: a HEAD at the doorbell would wait there as a hook does.
@@ -90,11 +120,13 @@ function answer(request, response, served) {
90
120
  send(response, 200, json, JSON.stringify(analysesOf(served.context.home())));
91
121
  }
92
122
  else if (path === "/api/events") {
93
- keepPosted(request, response, served);
123
+ keepPosted(request, response, served.watchers);
124
+ // A page that opens reads the player's ratings again after a day (D-106).
125
+ readRatingsOf(served);
94
126
  }
95
127
  else if (path === "/api/bell") {
96
128
  if (url.searchParams.has("probe")) {
97
- send(response, 200, json, JSON.stringify(bellProbe(served.folder, served.watchers.size > 0)));
129
+ send(response, 200, json, JSON.stringify(bellProbe(served.folder, served.watchers.size > 0, tableChat(served))));
98
130
  }
99
131
  else {
100
132
  served.bell.listen(request, response);
@@ -104,12 +136,20 @@ function answer(request, response, served) {
104
136
  sendFile(response, path);
105
137
  }
106
138
  }
139
+ /** The chat at the table, as its probe says it: none on a page a chat serves itself. */
140
+ function tableChat(served) {
141
+ if (served.table === undefined) {
142
+ return undefined;
143
+ }
144
+ const chat = served.attached?.chat;
145
+ return chat === undefined ? { session: null } : { session: chat.session, chat: chat.folder };
146
+ }
107
147
  /**
108
148
  * 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
149
  * 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
150
  */
111
- function hasNews(context) {
112
- return gameSeen(context) !== context.seen;
151
+ function hasNews(context, chat) {
152
+ return gameSeen(context) !== chat.seen;
113
153
  }
114
154
  // 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
155
  // before any game was played here (Priya); none while the data folder cannot be made.
@@ -124,25 +164,6 @@ ${tableTalkStamp(folder) ?? ""}`;
124
164
  return "";
125
165
  }
126
166
  }
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
167
  /**
147
168
  * 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
169
  * 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 +182,7 @@ function watchGame(served) {
161
182
  waiting = setTimeout(() => {
162
183
  waiting = undefined;
163
184
  watchAll();
164
- tellWatchers(served);
185
+ tellWatchers(served.watchers);
165
186
  }, 30);
166
187
  }
167
188
  };
@@ -195,7 +216,7 @@ function watchFolder(folder, heard) {
195
216
  }
196
217
  // 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
218
  // 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-]+)$/;
219
+ 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
220
  function fromHere(request, port, api) {
200
221
  const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
201
222
  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,39 @@
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; whether `chesspal play` holds the chat of a folder,
13
+ * 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
+ holds(folder: string): boolean;
19
+ relaunch(folder: string): boolean;
20
+ }
21
+ /** A chat opening at the table: one the page asked for, or the one started again for a new game. */
22
+ export type Opening = "chat" | "again";
23
+ /** What the table's page shows of its chat: whether one is at the table, the hosts it can open one in, and one opening. */
24
+ export declare function tableShown(served: Served): {
25
+ table?: {
26
+ chat: boolean;
27
+ hosts: readonly ChatHost[];
28
+ opening?: Opening;
29
+ };
30
+ };
31
+ /** Answers the table's own routes, under /api/table/; false for any other, and for every request to a page a chat serves itself. */
32
+ export declare function answerTable(request: IncomingMessage, response: ServerResponse, url: URL, served: Served): boolean;
33
+ /**
34
+ * 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
35
+ * has seen a game; one that only greeted the player stays.
36
+ */
37
+ export declare function chatToRelaunch(served: Served): boolean;
38
+ /** Asks `chesspal play` to start the chat at the table again, once the new game has started: whether it was asked; the pages then show it opening. */
39
+ export declare function relaunchChat(served: Served): boolean;
@@ -0,0 +1,102 @@
1
+ import { gameInAnalysis } from "../../games/index.js";
2
+ import { gameFolder } from "../../profiles/index.js";
3
+ import { tellWatchers } from "./events.js";
4
+ import { bodyOf, isJson, jsonOf, runRoute, sendJson } from "./http.js";
5
+ /** What the table's page shows of its chat: whether one is at the table, the hosts it can open one in, and one opening. */
6
+ export function tableShown(served) {
7
+ const { table } = served;
8
+ if (table === undefined) {
9
+ return {};
10
+ }
11
+ const now = opening(served);
12
+ return { table: { chat: served.attached !== undefined, hosts: table.chats?.hosts() ?? [], ...(now === undefined ? {} : { opening: now }) } };
13
+ }
14
+ /** Answers the table's own routes, under /api/table/; false for any other, and for every request to a page a chat serves itself. */
15
+ export function answerTable(request, response, url, served) {
16
+ const path = url.pathname;
17
+ if (served.table === undefined || request.method !== "POST" || !path.startsWith("/api/table/") || !isJson(request)) {
18
+ return false;
19
+ }
20
+ if (path === "/api/table/quit") {
21
+ // Another Chesspal version asks the table to quit, to start its own in its place.
22
+ sendJson(response, 200, { quitting: true });
23
+ served.table.quit();
24
+ }
25
+ else if (path === "/api/table/chat") {
26
+ runRoute(response, () => openChat(request, response, served));
27
+ }
28
+ else {
29
+ sendJson(response, 404, { error: `${path} is no route of the table's: it answers POST /api/table/chat and /api/table/quit.` });
30
+ }
31
+ return true;
32
+ }
33
+ /**
34
+ * 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
35
+ * has seen a game; one that only greeted the player stays.
36
+ */
37
+ export function chatToRelaunch(served) {
38
+ const { attached, bell } = served;
39
+ const stopped = bell.listening && !bell.inTurn;
40
+ const analysing = gameInAnalysis(gameFolder(served.context.home()))?.analyst !== undefined;
41
+ return attached !== undefined && stopped && !analysing && served.chat.positionSeen?.started !== undefined && served.table?.chats?.holds(attached.chat.folder) === true;
42
+ }
43
+ /** Asks `chesspal play` to start the chat at the table again, once the new game has started: whether it was asked; the pages then show it opening. */
44
+ export function relaunchChat(served) {
45
+ const folder = served.attached?.chat.folder;
46
+ if (folder === undefined || served.table?.chats?.relaunch(folder) !== true) {
47
+ return false;
48
+ }
49
+ noteOpening(served, "again");
50
+ return true;
51
+ }
52
+ // 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.
53
+ const openingFor = 30_000;
54
+ /**
55
+ * 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
56
+ * opening. Whether it opened; else the page shows the command to type.
57
+ */
58
+ async function openChat(request, response, served) {
59
+ const host = jsonOf(await bodyOf(request))?.host;
60
+ const offered = served.table?.chats?.hosts().find((one) => one.name === host && one.installed);
61
+ if (request.headers.origin !== `http://127.0.0.1:${served.port}` && request.headers.origin !== `http://localhost:${served.port}`) {
62
+ sendJson(response, 403, { error: "Only the table's own page opens a chat." });
63
+ }
64
+ else if (offered === undefined || served.table?.chats === undefined) {
65
+ sendJson(response, 400, { error: `Name a host the table offers and this computer has: ${served.table?.chats?.hosts().filter((one) => one.installed).map((one) => one.name).join(", ") || "none"}.` });
66
+ }
67
+ else if (served.attached !== undefined || opening(served) !== undefined) {
68
+ sendJson(response, 409, { error: "A chat is at the table, or opening: play there." });
69
+ }
70
+ else {
71
+ const opened = noteOpening(served, "chat");
72
+ const started = await served.table.chats.open(offered.name);
73
+ if (!started && served.chatOpened === opened) {
74
+ served.chatOpened = undefined;
75
+ }
76
+ sendJson(response, 200, { opened: started });
77
+ tellWatchers(served.watchers);
78
+ }
79
+ }
80
+ // The pages show a chat opening from now until it attaches; one that has not by then lets them open another. The opening noted.
81
+ function noteOpening(served, how) {
82
+ const opened = { at: served.context.now().getTime(), how };
83
+ served.chatOpened = opened;
84
+ tellWatchers(served.watchers);
85
+ setTimeout(() => {
86
+ if (served.chatOpened === opened) {
87
+ tellWatchers(served.watchers);
88
+ }
89
+ }, openingFor).unref();
90
+ return opened;
91
+ }
92
+ // 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.
93
+ function opening(served) {
94
+ const { attached, chatOpened, context } = served;
95
+ if (attached !== undefined) {
96
+ return undefined;
97
+ }
98
+ if (chatOpened !== undefined && context.now().getTime() - chatOpened.at < openingFor) {
99
+ return chatOpened.how;
100
+ }
101
+ return served.table?.chats?.holds(context.folder) === true ? "chat" : undefined;
102
+ }
@@ -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;
@@ -0,0 +1,31 @@
1
+ import { tableFolder } from "../../../home/index.js";
2
+ import { samePath } from "../../bell.js";
3
+ import { programPath } from "./find.js";
4
+ import { hostChoices, hostNames, isHostName } from "./hosts.js";
5
+ import { askAgain, seatTaken } from "./seat.js";
6
+ import { openTerminal } from "./terminal.js";
7
+ /**
8
+ * 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
9
+ * terminal opened with `chesspal play`, run by `chesspal`, the same Node and script as the table's.
10
+ */
11
+ export function terminalChats(context, chesspal = [process.execPath, ...process.execArgv, process.argv[1] ?? ""], env = process.env) {
12
+ let found;
13
+ return {
14
+ hosts: () => {
15
+ const now = context.now().getTime();
16
+ if (found === undefined || now - found.at >= lookAgain) {
17
+ found = { hosts: hostNames.map((name) => chatHost(name, context.platform, env)), at: now };
18
+ }
19
+ return found.hosts;
20
+ },
21
+ open: async (host) => isHostName(host) && await openTerminal(tableFolder(context.home()), [...chesspal, "play", host], context.platform, env),
22
+ // Only a chat in the table's folder can be the one chesspal play holds.
23
+ holds: (folder) => samePath(folder, tableFolder(context.home())) && seatTaken(folder, context.now()),
24
+ relaunch: (folder) => samePath(folder, tableFolder(context.home())) && askAgain(folder, context.now()),
25
+ };
26
+ }
27
+ // Looking on the PATH takes a few milliseconds, and the page asks at every change of the game.
28
+ const lookAgain = 10_000;
29
+ function chatHost(name, platform, env) {
30
+ return { name, installed: programPath(name, platform, env) !== undefined, ...hostChoices[name], command: `chesspal play ${name}` };
31
+ }
@@ -0,0 +1,2 @@
1
+ /** The program's path on the PATH of `env`, the first found; none when it is not installed there. */
2
+ export declare function programPath(name: string, platform: string, env: NodeJS.ProcessEnv, exists?: (path: string) => boolean): string | undefined;
@@ -0,0 +1,20 @@
1
+ // A program found on the PATH, as a shell would find it: on Windows with the extensions of PATHEXT, which npm's shims (.cmd) and installers (.exe) use.
2
+ import { existsSync, lstatSync } from "node:fs";
3
+ import { posix, win32 } from "node:path";
4
+ /** The program's path on the PATH of `env`, the first found; none when it is not installed there. */
5
+ export function programPath(name, platform, env, exists = present) {
6
+ const windows = platform === "win32";
7
+ const path = windows ? win32 : posix;
8
+ const folders = (env.PATH ?? env.Path ?? "").split(windows ? ";" : ":").filter((folder) => folder.trim() !== "");
9
+ const extensions = windows ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => extension !== "").map((extension) => extension.toLowerCase()) : [""];
10
+ return folders.flatMap((folder) => extensions.map((extension) => path.join(folder.trim(), `${name}${extension}`))).find(exists);
11
+ }
12
+ // Windows' app execution aliases, such as wt.exe in WindowsApps, are links that stat cannot follow: existsSync calls them missing, lstat finds them.
13
+ function present(path) {
14
+ try {
15
+ return existsSync(path) || lstatSync(path, { throwIfNoEntry: false }) !== undefined;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
@@ -0,0 +1,34 @@
1
+ /** The hosts the page offers, by their command's name. */
2
+ export declare const hostNames: readonly ["claude", "codex"];
3
+ export type HostName = typeof hostNames[number];
4
+ /** The models and efforts the player may choose for a host, as its command line names them: aliases, which follow the host's newest models. */
5
+ export interface HostChoices {
6
+ readonly models: readonly string[];
7
+ readonly efforts: readonly string[];
8
+ }
9
+ /** What the player chose for a host on the page: none of either keeps the host's own. */
10
+ export interface HostChoice {
11
+ readonly model?: string | undefined;
12
+ readonly effort?: string | undefined;
13
+ }
14
+ /**
15
+ * Each host's choices, as tried in each (Priya): a value outside them would fail only at the chat's first turn. Claude Code's are its aliases; Codex's, the
16
+ * models a ChatGPT account lists, which change with its releases.
17
+ */
18
+ export declare const hostChoices: Readonly<Record<HostName, HostChoices>>;
19
+ /** Whether a word names a host the page offers. */
20
+ export declare function isHostName(name: unknown): name is HostName;
21
+ /** The message a chat opened by the table starts with, in the player's language: the AI answers it by calling status, as every chat does. */
22
+ export declare function startMessage(language: string | undefined): string;
23
+ /**
24
+ * The start message and the choices the host knows: first for Claude Code, whose options may take the word after; last for Codex, where "resume" is no
25
+ * subcommand, kept from its update screen, which an Enter would accept.
26
+ */
27
+ export declare function hostArgs(host: HostName, message: string, choice?: HostChoice): string[];
28
+ /** The settings' keys that keep a host's choice in the profile: claudeModel, claudeEffort, codexModel, codexEffort. */
29
+ export declare function choiceKeys(host: HostName): {
30
+ model: string;
31
+ effort: string;
32
+ };
33
+ /** A host's choice as the profile's settings keep it: the values the host offers alone, the rest is the host's own. */
34
+ export declare function choiceIn(settings: Readonly<Record<string, unknown>> | undefined, host: HostName): HostChoice;