chesspal 0.0.1 → 0.1.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 (225) hide show
  1. package/README.md +136 -2
  2. package/dist/cli/chat-hook.d.ts +2 -0
  3. package/dist/cli/chat-hook.js +33 -0
  4. package/dist/cli/cli.d.ts +2 -0
  5. package/dist/cli/cli.js +28 -0
  6. package/dist/cli/command-line.d.ts +8 -0
  7. package/dist/cli/command-line.js +178 -0
  8. package/dist/commands/ai-move.d.ts +37 -0
  9. package/dist/commands/ai-move.js +92 -0
  10. package/dist/commands/ai-say.d.ts +20 -0
  11. package/dist/commands/ai-say.js +40 -0
  12. package/dist/commands/analyse-game.d.ts +61 -0
  13. package/dist/commands/analyse-game.js +238 -0
  14. package/dist/commands/annotate-game.d.ts +35 -0
  15. package/dist/commands/annotate-game.js +78 -0
  16. package/dist/commands/clock.d.ts +15 -0
  17. package/dist/commands/clock.js +45 -0
  18. package/dist/commands/context.d.ts +56 -0
  19. package/dist/commands/context.js +36 -0
  20. package/dist/commands/draw-offer.d.ts +10 -0
  21. package/dist/commands/draw-offer.js +43 -0
  22. package/dist/commands/drawings.d.ts +15 -0
  23. package/dist/commands/drawings.js +23 -0
  24. package/dist/commands/end-game.d.ts +23 -0
  25. package/dist/commands/end-game.js +64 -0
  26. package/dist/commands/export-game.d.ts +14 -0
  27. package/dist/commands/export-game.js +26 -0
  28. package/dist/commands/game-summary.d.ts +95 -0
  29. package/dist/commands/game-summary.js +239 -0
  30. package/dist/commands/index.d.ts +3 -0
  31. package/dist/commands/index.js +19 -0
  32. package/dist/commands/init.d.ts +15 -0
  33. package/dist/commands/init.js +73 -0
  34. package/dist/commands/levels.d.ts +9 -0
  35. package/dist/commands/levels.js +25 -0
  36. package/dist/commands/look.d.ts +31 -0
  37. package/dist/commands/look.js +61 -0
  38. package/dist/commands/new-game.d.ts +69 -0
  39. package/dist/commands/new-game.js +215 -0
  40. package/dist/commands/opening-book.d.ts +13 -0
  41. package/dist/commands/opening-book.js +84 -0
  42. package/dist/commands/page-link.d.ts +11 -0
  43. package/dist/commands/page-link.js +28 -0
  44. package/dist/commands/past-games.d.ts +11 -0
  45. package/dist/commands/past-games.js +47 -0
  46. package/dist/commands/play-move.d.ts +44 -0
  47. package/dist/commands/play-move.js +102 -0
  48. package/dist/commands/player-move.d.ts +17 -0
  49. package/dist/commands/player-move.js +44 -0
  50. package/dist/commands/player-say.d.ts +5 -0
  51. package/dist/commands/player-say.js +16 -0
  52. package/dist/commands/ponder.d.ts +42 -0
  53. package/dist/commands/ponder.js +167 -0
  54. package/dist/commands/position.d.ts +14 -0
  55. package/dist/commands/position.js +128 -0
  56. package/dist/commands/profiles.d.ts +60 -0
  57. package/dist/commands/profiles.js +129 -0
  58. package/dist/commands/registry.d.ts +54 -0
  59. package/dist/commands/registry.js +61 -0
  60. package/dist/commands/say.d.ts +16 -0
  61. package/dist/commands/say.js +84 -0
  62. package/dist/commands/status.d.ts +29 -0
  63. package/dist/commands/status.js +103 -0
  64. package/dist/commands/study-games.d.ts +5 -0
  65. package/dist/commands/study-games.js +40 -0
  66. package/dist/commands/study.d.ts +38 -0
  67. package/dist/commands/study.js +122 -0
  68. package/dist/commands/view.d.ts +14 -0
  69. package/dist/commands/view.js +125 -0
  70. package/dist/core/board-text.d.ts +7 -0
  71. package/dist/core/board-text.js +18 -0
  72. package/dist/core/book.json +1 -0
  73. package/dist/core/clock.d.ts +45 -0
  74. package/dist/core/clock.js +109 -0
  75. package/dist/core/facts.d.ts +55 -0
  76. package/dist/core/facts.js +175 -0
  77. package/dist/core/game.d.ts +93 -0
  78. package/dist/core/game.js +348 -0
  79. package/dist/core/illegal.d.ts +19 -0
  80. package/dist/core/illegal.js +141 -0
  81. package/dist/core/index.d.ts +19 -0
  82. package/dist/core/index.js +11 -0
  83. package/dist/core/levels.d.ts +20 -0
  84. package/dist/core/levels.js +33 -0
  85. package/dist/core/material.d.ts +9 -0
  86. package/dist/core/material.js +12 -0
  87. package/dist/core/moods.d.ts +23 -0
  88. package/dist/core/moods.js +45 -0
  89. package/dist/core/openings.d.ts +37 -0
  90. package/dist/core/openings.js +98 -0
  91. package/dist/core/openings.json +1 -0
  92. package/dist/core/pgn.d.ts +10 -0
  93. package/dist/core/pgn.js +213 -0
  94. package/dist/core/profile.d.ts +117 -0
  95. package/dist/core/profile.js +232 -0
  96. package/dist/core/record.d.ts +21 -0
  97. package/dist/core/record.js +25 -0
  98. package/dist/core/review.d.ts +74 -0
  99. package/dist/core/review.js +126 -0
  100. package/dist/games/ai-model.d.ts +5 -0
  101. package/dist/games/ai-model.js +15 -0
  102. package/dist/games/analysis.d.ts +12 -0
  103. package/dist/games/analysis.js +26 -0
  104. package/dist/games/archive.d.ts +42 -0
  105. package/dist/games/archive.js +168 -0
  106. package/dist/games/chat-start.d.ts +16 -0
  107. package/dist/games/chat-start.js +26 -0
  108. package/dist/games/current-game.d.ts +54 -0
  109. package/dist/games/current-game.js +128 -0
  110. package/dist/games/files.d.ts +9 -0
  111. package/dist/games/files.js +52 -0
  112. package/dist/games/index.d.ts +10 -0
  113. package/dist/games/index.js +11 -0
  114. package/dist/games/plans.d.ts +9 -0
  115. package/dist/games/plans.js +23 -0
  116. package/dist/games/table-talk.d.ts +12 -0
  117. package/dist/games/table-talk.js +25 -0
  118. package/dist/games/talk.d.ts +40 -0
  119. package/dist/games/talk.js +131 -0
  120. package/dist/games/thoughts.d.ts +15 -0
  121. package/dist/games/thoughts.js +60 -0
  122. package/dist/home/data-folder.d.ts +12 -0
  123. package/dist/home/data-folder.js +55 -0
  124. package/dist/home/index.d.ts +2 -0
  125. package/dist/home/index.js +3 -0
  126. package/dist/home/open-folder.d.ts +2 -0
  127. package/dist/home/open-folder.js +14 -0
  128. package/dist/host-files/host-files.d.ts +10 -0
  129. package/dist/host-files/host-files.js +167 -0
  130. package/dist/host-files/index.d.ts +2 -0
  131. package/dist/host-files/index.js +3 -0
  132. package/dist/host-files/ponder-agents.d.ts +12 -0
  133. package/dist/host-files/ponder-agents.js +40 -0
  134. package/dist/index.d.ts +4 -0
  135. package/dist/index.js +2 -0
  136. package/dist/page/app.js +576 -0
  137. package/dist/page/avatars/beanie.webp +0 -0
  138. package/dist/page/avatars/beard.webp +0 -0
  139. package/dist/page/avatars/bun.webp +0 -0
  140. package/dist/page/avatars/cat.webp +0 -0
  141. package/dist/page/avatars/curls.webp +0 -0
  142. package/dist/page/avatars/glasses.webp +0 -0
  143. package/dist/page/avatars/moustache.webp +0 -0
  144. package/dist/page/avatars/owl.webp +0 -0
  145. package/dist/page/avatars/short-hair.webp +0 -0
  146. package/dist/page/avatars/silver-bob.webp +0 -0
  147. package/dist/page/avatars.js +16 -0
  148. package/dist/page/badges.js +50 -0
  149. package/dist/page/board.js +566 -0
  150. package/dist/page/browse.js +17 -0
  151. package/dist/page/clock-time.js +12 -0
  152. package/dist/page/dialog.js +26 -0
  153. package/dist/page/ending.js +73 -0
  154. package/dist/page/explain.js +58 -0
  155. package/dist/page/fold-tab.js +9 -0
  156. package/dist/page/folds.js +43 -0
  157. package/dist/page/game-column.js +44 -0
  158. package/dist/page/header.js +27 -0
  159. package/dist/page/helps-view.js +51 -0
  160. package/dist/page/helps.js +90 -0
  161. package/dist/page/main.js +8 -0
  162. package/dist/page/model-name.js +20 -0
  163. package/dist/page/move-list.js +68 -0
  164. package/dist/page/new-game-dialog.js +42 -0
  165. package/dist/page/opening-book.js +14 -0
  166. package/dist/page/pieces/CREDITS.md +36 -0
  167. package/dist/page/pieces/cburnett-small.svg +20 -0
  168. package/dist/page/pieces/cburnett.svg +20 -0
  169. package/dist/page/pieces/nitida-small.svg +19 -0
  170. package/dist/page/pieces/nitida.svg +20 -0
  171. package/dist/page/pieces/torretta-small.svg +19 -0
  172. package/dist/page/pieces/torretta.svg +19 -0
  173. package/dist/page/pieces.js +28 -0
  174. package/dist/page/player-card.js +60 -0
  175. package/dist/page/plies.js +62 -0
  176. package/dist/page/profile-dialogs.js +123 -0
  177. package/dist/page/profile.js +95 -0
  178. package/dist/page/settings-panel.js +32 -0
  179. package/dist/page/settings.js +76 -0
  180. package/dist/page/sounds/capture.ogg +0 -0
  181. package/dist/page/sounds/check.ogg +0 -0
  182. package/dist/page/sounds/draw.ogg +0 -0
  183. package/dist/page/sounds/flag.ogg +0 -0
  184. package/dist/page/sounds/mate.ogg +0 -0
  185. package/dist/page/sounds/move.ogg +0 -0
  186. package/dist/page/sounds/offer.ogg +0 -0
  187. package/dist/page/sounds/resign.ogg +0 -0
  188. package/dist/page/sounds.js +105 -0
  189. package/dist/page/studied.js +14 -0
  190. package/dist/page/study-dialog.js +58 -0
  191. package/dist/page/style.css +4398 -0
  192. package/dist/page/talk.js +172 -0
  193. package/dist/page/text.js +593 -0
  194. package/dist/page/torretta.js +38 -0
  195. package/dist/page/voice.js +38 -0
  196. package/dist/profiles/avatar.d.ts +23 -0
  197. package/dist/profiles/avatar.js +84 -0
  198. package/dist/profiles/front-matter.d.ts +9 -0
  199. package/dist/profiles/front-matter.js +33 -0
  200. package/dist/profiles/index.d.ts +3 -0
  201. package/dist/profiles/index.js +5 -0
  202. package/dist/profiles/profiles.d.ts +58 -0
  203. package/dist/profiles/profiles.js +237 -0
  204. package/dist/profiles/settings.d.ts +12 -0
  205. package/dist/profiles/settings.js +46 -0
  206. package/dist/server/analyses.d.ts +17 -0
  207. package/dist/server/analyses.js +46 -0
  208. package/dist/server/bell.d.ts +60 -0
  209. package/dist/server/bell.js +209 -0
  210. package/dist/server/caller.d.ts +10 -0
  211. package/dist/server/caller.js +99 -0
  212. package/dist/server/http.d.ts +16 -0
  213. package/dist/server/http.js +57 -0
  214. package/dist/server/mcp.d.ts +14 -0
  215. package/dist/server/mcp.js +179 -0
  216. package/dist/server/page.d.ts +46 -0
  217. package/dist/server/page.js +657 -0
  218. package/dist/server/profile-routes.d.ts +19 -0
  219. package/dist/server/profile-routes.js +186 -0
  220. package/dist/version.d.ts +2 -0
  221. package/dist/version.js +3 -0
  222. package/instructions/analyst.md +8 -0
  223. package/instructions/opponent.md +15 -0
  224. package/instructions/ponder.md +9 -0
  225. package/package.json +66 -5
@@ -0,0 +1,38 @@
1
+ // The AI's words read aloud (Stefano's idea, IDEE.md): the browser's own voices, a local one first, so the words stay on the computer where it can.
2
+ /** Reads the words aloud in the language given, cutting short what was being read. */
3
+ export function speak(words, language) {
4
+ if (typeof speechSynthesis === "undefined") {
5
+ return;
6
+ }
7
+ const said = words.replace(/\*\*/g, "").trim();
8
+ speechSynthesis.cancel();
9
+ clearTimeout(pending);
10
+ if (said === "") {
11
+ return;
12
+ }
13
+ // Chrome drops words spoken right after a cancel, and lists its voices late the first time: the words wait a moment.
14
+ pending = setTimeout(() => {
15
+ const utterance = new SpeechSynthesisUtterance(said);
16
+ const voice = voiceFor(language);
17
+ utterance.lang = voice?.lang ?? language;
18
+ if (voice !== undefined) {
19
+ utterance.voice = voice;
20
+ }
21
+ speechSynthesis.resume();
22
+ speechSynthesis.speak(utterance);
23
+ }, 150);
24
+ }
25
+ /** Stops reading. */
26
+ export function hush() {
27
+ if (typeof speechSynthesis !== "undefined") {
28
+ clearTimeout(pending);
29
+ speechSynthesis.cancel();
30
+ }
31
+ }
32
+ let pending;
33
+ /** A voice for the language: a local one first, the browser's default among them first. */
34
+ function voiceFor(language) {
35
+ const voices = speechSynthesis.getVoices().filter((voice) => voice.lang.toLowerCase().startsWith(language.toLowerCase()));
36
+ const local = voices.filter((voice) => voice.localService);
37
+ return local.find((voice) => voice.default) ?? local[0] ?? voices[0];
38
+ }
@@ -0,0 +1,23 @@
1
+ /** The types of image a profile takes, by the file names they have there. */
2
+ export declare const imageTypes: {
3
+ readonly png: "image/png";
4
+ readonly jpg: "image/jpeg";
5
+ readonly webp: "image/webp";
6
+ };
7
+ export type ImageType = keyof typeof imageTypes;
8
+ /** The largest image the page may send: it sends a small one, made from the player's photo. */
9
+ export declare const sentImageLimit: number;
10
+ /** The player's own image: its bytes, its type as its first bytes tell it, and a stamp that changes with it; undefined when there is none, or it is no image. */
11
+ export declare function ownImage(root: string, id: string): {
12
+ bytes: Buffer;
13
+ type: ImageType;
14
+ stamp: string;
15
+ } | undefined;
16
+ /** The stamp of the player's own image, which the page adds to its address; undefined when there is none. Only its first bytes are read. */
17
+ export declare function ownImageStamp(root: string, id: string): string | undefined;
18
+ /** Saves an image the page sent as the player's own, in place of any other: a PNG, JPEG or WebP, as its first bytes tell, within the limit. */
19
+ export declare function saveOwnImage(root: string, id: string, bytes: Uint8Array): void;
20
+ /** Removes the player's own images, but the one kept: a drawn avatar chosen on the page shows in their place. */
21
+ export declare function removeOwnImage(root: string, id: string, kept?: string): void;
22
+ /** An image's type by its first bytes: never by its name, which anything may have. */
23
+ export declare function imageTypeOf(bytes: Uint8Array): ImageType | undefined;
@@ -0,0 +1,84 @@
1
+ // The player's own picture (D-45): an image in the profile's folder, put there by hand or sent from the page, which shows instead of a drawn avatar.
2
+ import { closeSync, openSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeAtomically } from "../games/index.js";
5
+ import { profileFolder } from "./profiles.js";
6
+ /** The types of image a profile takes, by the file names they have there. */
7
+ export const imageTypes = { png: "image/png", jpg: "image/jpeg", webp: "image/webp" };
8
+ /** The largest image the page may send: it sends a small one, made from the player's photo. */
9
+ export const sentImageLimit = 1024 * 1024;
10
+ /** The player's own image: its bytes, its type as its first bytes tell it, and a stamp that changes with it; undefined when there is none, or it is no image. */
11
+ export function ownImage(root, id) {
12
+ const found = ownImageFile(root, id);
13
+ try {
14
+ return found === undefined ? undefined : { bytes: readFileSync(found.path), type: found.type, stamp: found.stamp };
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ /** The stamp of the player's own image, which the page adds to its address; undefined when there is none. Only its first bytes are read. */
21
+ export function ownImageStamp(root, id) {
22
+ return ownImageFile(root, id)?.stamp;
23
+ }
24
+ /** Saves an image the page sent as the player's own, in place of any other: a PNG, JPEG or WebP, as its first bytes tell, within the limit. */
25
+ export function saveOwnImage(root, id, bytes) {
26
+ const type = imageTypeOf(bytes);
27
+ if (type === undefined) {
28
+ throw new Error("The picture must be a PNG, JPEG or WebP image.");
29
+ }
30
+ if (bytes.length > sentImageLimit) {
31
+ throw new Error(`The picture is ${Math.ceil(bytes.length / 1024)} KB: send one of ${sentImageLimit / 1024} KB at most.`);
32
+ }
33
+ const name = `avatar.${type}`;
34
+ writeAtomically(join(profileFolder(root, id), name), bytes);
35
+ removeOwnImage(root, id, name);
36
+ }
37
+ /** Removes the player's own images, but the one kept: a drawn avatar chosen on the page shows in their place. */
38
+ export function removeOwnImage(root, id, kept) {
39
+ for (const name of fileNames.filter((one) => one !== kept)) {
40
+ rmSync(join(profileFolder(root, id), name), { force: true });
41
+ }
42
+ }
43
+ /** An image's type by its first bytes: never by its name, which anything may have. */
44
+ export function imageTypeOf(bytes) {
45
+ const starts = (signature, at = 0) => signature.every((byte, index) => bytes[at + index] === byte);
46
+ if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
47
+ return "png";
48
+ }
49
+ if (starts([0xff, 0xd8, 0xff])) {
50
+ return "jpg";
51
+ }
52
+ return starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8) ? "webp" : undefined;
53
+ }
54
+ function ownImageFile(root, id) {
55
+ for (const name of fileNames) {
56
+ const path = join(profileFolder(root, id), name);
57
+ try {
58
+ const { size, mtimeMs } = statSync(path);
59
+ const type = size > keptImageLimit ? undefined : imageTypeOf(firstBytes(path));
60
+ if (type !== undefined) {
61
+ return { path, type, stamp: `${Math.trunc(mtimeMs)}-${size}` };
62
+ }
63
+ }
64
+ catch {
65
+ // No such file, or one that cannot be read: the next name.
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+ function firstBytes(path) {
71
+ const bytes = new Uint8Array(12);
72
+ const file = openSync(path, "r");
73
+ try {
74
+ readSync(file, bytes, 0, bytes.length, 0);
75
+ }
76
+ finally {
77
+ closeSync(file);
78
+ }
79
+ return bytes;
80
+ }
81
+ // The names the player's image may have in the profile's folder, the first found winning.
82
+ const fileNames = ["avatar.png", "avatar.jpg", "avatar.jpeg", "avatar.webp"];
83
+ // An image the player put in the folder by hand may be a photo as the camera took it, but not a huge file.
84
+ const keptImageLimit = 10 * 1024 * 1024;
@@ -0,0 +1,9 @@
1
+ /** The fields of a markdown file's front matter, as text, and the text below it. */
2
+ export interface FrontMatter {
3
+ readonly fields: Readonly<Record<string, string>>;
4
+ readonly body: string;
5
+ }
6
+ /** Reads a flat front matter: a value in double quotes as JSON writes it, any other as it stands; lines that are not key: value are left out. */
7
+ export declare function readFrontMatter(text: string): FrontMatter;
8
+ /** Writes a flat front matter, its text values in double quotes so that any name reads back as it was, and the text below it. */
9
+ export declare function writeFrontMatter(fields: Readonly<Record<string, string | number | undefined>>, body?: string): string;
@@ -0,0 +1,33 @@
1
+ // A flat YAML front matter (D-9): one field a line, key: value, between two lines of ---, then the text below it, which stays as it was.
2
+ /** Reads a flat front matter: a value in double quotes as JSON writes it, any other as it stands; lines that are not key: value are left out. */
3
+ export function readFrontMatter(text) {
4
+ // An editor may begin the file with a byte-order mark.
5
+ const match = /^?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
6
+ if (match === null) {
7
+ return { fields: {}, body: text };
8
+ }
9
+ const fields = {};
10
+ for (const line of match[1].split(/\r?\n/)) {
11
+ const field = /^([A-Za-z][\w-]*):\s*(.*?)\s*$/.exec(line);
12
+ if (field !== null && field[2] !== "") {
13
+ fields[field[1]] = valueOf(field[2]);
14
+ }
15
+ }
16
+ return { fields, body: text.slice(match[0].length) };
17
+ }
18
+ /** Writes a flat front matter, its text values in double quotes so that any name reads back as it was, and the text below it. */
19
+ export function writeFrontMatter(fields, body = "") {
20
+ const lines = Object.entries(fields).flatMap(([key, value]) => (value === undefined ? [] : [`${key}: ${typeof value === "number" ? value : JSON.stringify(value)}`]));
21
+ return `---\n${lines.join("\n")}\n---\n${body}`;
22
+ }
23
+ function valueOf(written) {
24
+ if (written.startsWith('"')) {
25
+ try {
26
+ return String(JSON.parse(written));
27
+ }
28
+ catch {
29
+ return written;
30
+ }
31
+ }
32
+ return written.startsWith("'") && written.endsWith("'") && written.length > 1 ? written.slice(1, -1).replaceAll("''", "'") : written;
33
+ }
@@ -0,0 +1,3 @@
1
+ export { type ImageType, imageTypeOf, imageTypes, ownImage, ownImageStamp, removeOwnImage, saveOwnImage, sentImageLimit } from "./avatar.ts";
2
+ export { activateProfile, activeProfile, createProfile, type DrawnAvatar, drawnAvatars, ensureProfile, gameFolder, gameFolders, type Level, levels, listProfiles, nameLimit, type PlayerProfile, type ProfileFields, profileFolder, type Rating, ratingLimits, type RatingSite, ratingSites, updateProfile, } from "./profiles.ts";
3
+ export { helpsIn, languageIn, type PageSettings, profileSettings, saveProfileSettings, settingsOf } from "./settings.ts";
@@ -0,0 +1,5 @@
1
+ // The player's profiles (D-23): who plays, their games, their picture and their page settings, each in profiles/<id>/ of the data folder (D-71). Below the
2
+ // commands, beside the games.
3
+ export { imageTypeOf, imageTypes, ownImage, ownImageStamp, removeOwnImage, saveOwnImage, sentImageLimit } from "./avatar.js";
4
+ export { activateProfile, activeProfile, createProfile, drawnAvatars, ensureProfile, gameFolder, gameFolders, levels, listProfiles, nameLimit, profileFolder, ratingLimits, ratingSites, updateProfile, } from "./profiles.js";
5
+ export { helpsIn, languageIn, profileSettings, saveProfileSettings, settingsOf } from "./settings.js";
@@ -0,0 +1,58 @@
1
+ /** How the player says they play, from who has just learned to who plays tournaments. */
2
+ export declare const levels: readonly ["learning", "casual", "regular", "tournament"];
3
+ export type Level = (typeof levels)[number];
4
+ /** Where a rating comes from. */
5
+ export declare const ratingSites: readonly ["lichess", "chess.com", "fide"];
6
+ export type RatingSite = (typeof ratingSites)[number];
7
+ /** The lowest and highest rating a profile takes. */
8
+ export declare const ratingLimits: {
9
+ readonly lowest: 100;
10
+ readonly highest: 3500;
11
+ };
12
+ /** The longest name a profile keeps. */
13
+ export declare const nameLimit = 40;
14
+ /** The avatars Zoe drew, for a player without an image of their own (D-45). */
15
+ export declare const drawnAvatars: readonly ["short-hair", "bun", "glasses", "beanie", "beard", "curls", "moustache", "silver-bob", "cat", "owl"];
16
+ export type DrawnAvatar = (typeof drawnAvatars)[number];
17
+ /** A rating and where it comes from. */
18
+ export interface Rating {
19
+ readonly value: number;
20
+ readonly site: RatingSite;
21
+ }
22
+ /** What the player tells of themselves: their name, how they play, their rating, the avatar they chose. */
23
+ export interface ProfileFields {
24
+ readonly name: string;
25
+ readonly level?: Level;
26
+ readonly rating?: Rating;
27
+ readonly avatar?: DrawnAvatar;
28
+ }
29
+ /** A profile: its folder's name, and what profile.md says; a folder without one is named by the folder. */
30
+ export interface PlayerProfile extends ProfileFields {
31
+ readonly id: string;
32
+ /** Made by Chesspal at the first start, until the player completes it: the board page's wizard does (D-71). */
33
+ readonly new?: true;
34
+ }
35
+ /** The profiles in the data folder, by their folders' names. */
36
+ export declare function listProfiles(root: string): PlayerProfile[];
37
+ /**
38
+ * Makes the first profile when there is none, named after the system's account and new until the player completes it (D-71). Two processes starting
39
+ * together make one: the second finds its folder taken.
40
+ */
41
+ export declare function ensureProfile(root: string, account: string): void;
42
+ /** The profile in use: the one active.json names, else the first. */
43
+ export declare function activeProfile(root: string): PlayerProfile;
44
+ /** Where the games are: the folder of the profile in use. */
45
+ export declare function gameFolder(root: string): string;
46
+ /** A profile's folder. */
47
+ export declare function profileFolder(root: string, id: string): string;
48
+ /**
49
+ * Makes a profile, its folder named from the player's name, and puts it in use; the one Chesspal made, while new, becomes it instead, with its games, and
50
+ * its folder takes the player's name too (D-71).
51
+ */
52
+ export declare function createProfile(root: string, fields: ProfileFields): PlayerProfile;
53
+ /** Every profile's folder, where games may be. */
54
+ export declare function gameFolders(root: string): string[];
55
+ /** Puts a profile in use. */
56
+ export declare function activateProfile(root: string, id: string): PlayerProfile;
57
+ /** Changes what a profile says of the player, keeping the rest of profile.md as it was written: its other fields and the text below them. */
58
+ export declare function updateProfile(root: string, id: string, changes: Partial<ProfileFields>): PlayerProfile;
@@ -0,0 +1,237 @@
1
+ // The player's profiles (D-23): profiles/<id>/ in the data folder (D-71), each with its profile.md, its games and its page settings, and the one in use;
2
+ // there is always one, the first made by Chesspal.
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { makeFolder, renameOver, writeAtomically } from "../games/index.js";
6
+ import { readFrontMatter, writeFrontMatter } from "./front-matter.js";
7
+ /** How the player says they play, from who has just learned to who plays tournaments. */
8
+ export const levels = ["learning", "casual", "regular", "tournament"];
9
+ /** Where a rating comes from. */
10
+ export const ratingSites = ["lichess", "chess.com", "fide"];
11
+ /** The lowest and highest rating a profile takes. */
12
+ export const ratingLimits = { lowest: 100, highest: 3500 };
13
+ /** The longest name a profile keeps. */
14
+ export const nameLimit = 40;
15
+ /** The avatars Zoe drew, for a player without an image of their own (D-45). */
16
+ export const drawnAvatars = ["short-hair", "bun", "glasses", "beanie", "beard", "curls", "moustache", "silver-bob", "cat", "owl"];
17
+ /** The profiles in the data folder, by their folders' names. */
18
+ export function listProfiles(root) {
19
+ return profileIds(root).map((id) => readProfile(root, id));
20
+ }
21
+ /**
22
+ * Makes the first profile when there is none, named after the system's account and new until the player completes it (D-71). Two processes starting
23
+ * together make one: the second finds its folder taken.
24
+ */
25
+ export function ensureProfile(root, account) {
26
+ const id = freeId(root, slugOf(account));
27
+ if (profileIds(root).length === 0 && claimed(root, id)) {
28
+ fillProfile(root, id, { name: account }, true);
29
+ }
30
+ }
31
+ /** The profile in use: the one active.json names, else the first. */
32
+ export function activeProfile(root) {
33
+ return readProfile(root, activeIdOf(root));
34
+ }
35
+ /** Where the games are: the folder of the profile in use. */
36
+ export function gameFolder(root) {
37
+ return profileFolder(root, activeIdOf(root));
38
+ }
39
+ /** A profile's folder. */
40
+ export function profileFolder(root, id) {
41
+ return join(root, folderName, id);
42
+ }
43
+ /**
44
+ * Makes a profile, its folder named from the player's name, and puts it in use; the one Chesspal made, while new, becomes it instead, with its games, and
45
+ * its folder takes the player's name too (D-71).
46
+ */
47
+ export function createProfile(root, fields) {
48
+ const id = activeId(root);
49
+ if (id !== undefined && readProfile(root, id).new === true) {
50
+ const { level, rating, avatar } = fields;
51
+ const named = renamed(root, id, fields.name);
52
+ writeProfile(root, named, { name: fields.name, level, rating, avatar, new: false });
53
+ return readProfile(root, named);
54
+ }
55
+ let made = freeId(root, slugOf(fields.name));
56
+ // Another process may take the same id between the look and the folder: the next free one, then.
57
+ while (!claimed(root, made)) {
58
+ made = freeId(root, slugOf(fields.name));
59
+ }
60
+ return fillProfile(root, made, fields, false);
61
+ }
62
+ /** Every profile's folder, where games may be. */
63
+ export function gameFolders(root) {
64
+ return profileIds(root).map((id) => profileFolder(root, id));
65
+ }
66
+ /** Puts a profile in use. */
67
+ export function activateProfile(root, id) {
68
+ const ids = profileIds(root);
69
+ if (!ids.includes(id)) {
70
+ throw new Error(ids.length === 0 ? "There is no profile yet: make one first." : `There is no profile ${id}: the profiles are ${ids.join(", ")}.`);
71
+ }
72
+ writeActive(root, id);
73
+ return readProfile(root, id);
74
+ }
75
+ /** Changes what a profile says of the player, keeping the rest of profile.md as it was written: its other fields and the text below them. */
76
+ export function updateProfile(root, id, changes) {
77
+ writeProfile(root, id, changes);
78
+ return readProfile(root, id);
79
+ }
80
+ const folderName = "profiles";
81
+ const profileFile = "profile.md";
82
+ const activeFile = "active.json";
83
+ // Names Windows keeps for devices: no folder may take them.
84
+ const reserved = /^(con|prn|aux|nul|com\d|lpt\d)$/;
85
+ function profileIds(root) {
86
+ try {
87
+ return readdirSync(join(root, folderName), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ function activeId(root) {
94
+ const named = activeNamed(root);
95
+ const ids = profileIds(root);
96
+ return named !== undefined && ids.includes(named) ? named : ids[0];
97
+ }
98
+ function activeIdOf(root) {
99
+ const id = activeId(root);
100
+ if (id === undefined) {
101
+ throw new Error(`There is no profile in ${join(root, folderName)}: run any Chesspal command, and it makes one.`);
102
+ }
103
+ return id;
104
+ }
105
+ /** Takes a profile's folder for this call alone: false when another process has made it already. */
106
+ function claimed(root, id) {
107
+ makeFolder(join(root, folderName));
108
+ const folder = profileFolder(root, id);
109
+ try {
110
+ mkdirSync(folder);
111
+ return true;
112
+ }
113
+ catch (error) {
114
+ if (error.code === "EEXIST") {
115
+ return false;
116
+ }
117
+ throw new Error(`${folder} could not be made (${error.message}): check that it is a folder, and can be written.`, { cause: error });
118
+ }
119
+ }
120
+ /** Writes a profile in the folder this call claimed and puts it in use; on a failure the folder goes, which holds nothing of another's. */
121
+ function fillProfile(root, id, fields, made) {
122
+ try {
123
+ writeProfile(root, id, made ? { ...fields, new: true } : fields);
124
+ writeActive(root, id);
125
+ }
126
+ catch (error) {
127
+ rmSync(profileFolder(root, id), { recursive: true, force: true });
128
+ throw error;
129
+ }
130
+ return readProfile(root, id);
131
+ }
132
+ /**
133
+ * The folder of the profile Chesspal made, named after the account, moved to the player's name and put in use; it keeps the account's for a name without
134
+ * letters or digits, and where it cannot move, as with a file held open in it.
135
+ */
136
+ function renamed(root, id, name) {
137
+ const plain = plainName(name);
138
+ if (plain === "" || id === plain || (id.startsWith(`${plain}-`) && /^\d+$/.test(id.slice(plain.length + 1)))) {
139
+ return id;
140
+ }
141
+ const target = freeId(root, plain);
142
+ // On Linux and macOS a folder another process made between the look and the move would be replaced: two profiles made in the same instant.
143
+ try {
144
+ renameOver(profileFolder(root, id), profileFolder(root, target));
145
+ }
146
+ catch {
147
+ return id;
148
+ }
149
+ try {
150
+ writeActive(root, target);
151
+ }
152
+ catch (error) {
153
+ // Moved back, or the first folder would be put in use in its place.
154
+ renameOver(profileFolder(root, target), profileFolder(root, id));
155
+ throw error;
156
+ }
157
+ return target;
158
+ }
159
+ function activeNamed(root) {
160
+ try {
161
+ const { profile } = JSON.parse(readFileSync(join(root, folderName, activeFile), "utf8"));
162
+ return typeof profile === "string" && /^[^/\\.][^/\\]*$/.test(profile) ? profile : undefined;
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ function writeActive(root, id) {
169
+ writeAtomically(join(root, folderName, activeFile), `${JSON.stringify({ profile: id }, null, 2)}\n`);
170
+ }
171
+ function readProfile(root, id) {
172
+ const { fields } = readFrontMatter(profileText(root, id));
173
+ const value = Number(fields.rating);
174
+ const site = oneOf(ratingSites, fields.site);
175
+ const level = oneOf(levels, fields.level);
176
+ const avatar = oneOf(drawnAvatars, fields.avatar);
177
+ const rated = Number.isInteger(value) && value >= ratingLimits.lowest && value <= ratingLimits.highest && site !== undefined;
178
+ // A name written by hand is cut as the page and the CLI cut it: the AI reads it.
179
+ return {
180
+ id,
181
+ name: fields.name?.trim().slice(0, nameLimit).trim() || id,
182
+ ...(level === undefined ? {} : { level }),
183
+ ...(rated ? { rating: { value, site } } : {}),
184
+ ...(avatar === undefined ? {} : { avatar }),
185
+ ...(fields.new === "true" ? { new: true } : {}),
186
+ };
187
+ }
188
+ // Writes the fields that change, and keeps the others as the file has them, even those this version does not read.
189
+ function writeProfile(root, id, changes) {
190
+ const { fields, body } = readFrontMatter(profileText(root, id));
191
+ const written = { ...fields };
192
+ if (changes.name !== undefined) {
193
+ written.name = changes.name;
194
+ }
195
+ if ("level" in changes) {
196
+ written.level = changes.level;
197
+ }
198
+ if ("rating" in changes) {
199
+ written.rating = changes.rating?.value;
200
+ written.site = changes.rating?.site;
201
+ }
202
+ if ("avatar" in changes) {
203
+ written.avatar = changes.avatar;
204
+ }
205
+ if ("new" in changes) {
206
+ written.new = changes.new === true ? "true" : undefined;
207
+ }
208
+ writeAtomically(join(profileFolder(root, id), profileFile), writeFrontMatter(written, body));
209
+ }
210
+ function profileText(root, id) {
211
+ try {
212
+ return readFileSync(join(profileFolder(root, id), profileFile), "utf8");
213
+ }
214
+ catch {
215
+ return "";
216
+ }
217
+ }
218
+ /** A folder's name from the player's name: its plain letters and digits, or player for a name without any. */
219
+ function slugOf(name) {
220
+ return plainName(name) || "player";
221
+ }
222
+ /** A name in plain letters and digits, lower case, words joined by hyphens; empty for a name without any. */
223
+ function plainName(name) {
224
+ return name.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").slice(0, 32).replace(/-+$/, "");
225
+ }
226
+ function freeId(root, slug) {
227
+ const taken = (id) => reserved.test(id) || existsSync(profileFolder(root, id));
228
+ let id = slug;
229
+ for (let number = 2; taken(id); number++) {
230
+ id = `${slug}-${number}`;
231
+ }
232
+ return id;
233
+ }
234
+ // A value of a list, as a hand may write it: in capitals too.
235
+ function oneOf(values, value) {
236
+ return values.find((one) => one === value?.toLowerCase());
237
+ }
@@ -0,0 +1,12 @@
1
+ /** The page's settings, by name: text, numbers and flags. */
2
+ export type PageSettings = Readonly<Record<string, string | number | boolean>>;
3
+ /** A profile's settings, or undefined when it has none yet, or they cannot be read. */
4
+ export declare function profileSettings(root: string, id: string): PageSettings | undefined;
5
+ /** Whether the player has turned the helps on (D-78), in the settings of the profile whose games are in `folder`: off without them. */
6
+ export declare function helpsIn(folder: string): boolean;
7
+ /** The language of the player of the profile whose games are in `folder`: the page's, or the system's when the page follows the browser. */
8
+ export declare function languageIn(folder: string, systemLanguage: string | undefined): string | undefined;
9
+ /** Saves a profile's settings, over the ones before. */
10
+ export declare function saveProfileSettings(root: string, id: string, settings: PageSettings): void;
11
+ /** Settings from what JSON gave: an object of text, numbers and flags, at most a few dozen; undefined for anything else. */
12
+ export declare function settingsOf(value: unknown): PageSettings | undefined;
@@ -0,0 +1,46 @@
1
+ // The page's settings of a profile (D-56): settings.json in its folder, kept as the page sends them; the page checks each one as it reads them.
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeAtomically } from "../games/index.js";
5
+ import { profileFolder } from "./profiles.js";
6
+ /** A profile's settings, or undefined when it has none yet, or they cannot be read. */
7
+ export function profileSettings(root, id) {
8
+ try {
9
+ return settingsOf(JSON.parse(readFileSync(join(profileFolder(root, id), fileName), "utf8")));
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ /** Whether the player has turned the helps on (D-78), in the settings of the profile whose games are in `folder`: off without them. */
16
+ export function helpsIn(folder) {
17
+ return settingsIn(folder)?.helps === true;
18
+ }
19
+ /** The language of the player of the profile whose games are in `folder`: the page's, or the system's when the page follows the browser. */
20
+ export function languageIn(folder, systemLanguage) {
21
+ const language = settingsIn(folder)?.language;
22
+ return typeof language === "string" && language !== "auto" ? language : systemLanguage;
23
+ }
24
+ /** Saves a profile's settings, over the ones before. */
25
+ export function saveProfileSettings(root, id, settings) {
26
+ writeAtomically(join(profileFolder(root, id), fileName), `${JSON.stringify(settings, null, 2)}\n`);
27
+ }
28
+ /** Settings from what JSON gave: an object of text, numbers and flags, at most a few dozen; undefined for anything else. */
29
+ export function settingsOf(value) {
30
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
31
+ return undefined;
32
+ }
33
+ const entries = Object.entries(value);
34
+ const plain = entries.every(([, one]) => ["string", "number", "boolean"].includes(typeof one) && (typeof one !== "string" || one.length <= 64));
35
+ return plain && entries.length <= 40 ? Object.fromEntries(entries) : undefined;
36
+ }
37
+ const fileName = "settings.json";
38
+ /** The settings of the profile whose games are in `folder`; undefined without them. */
39
+ function settingsIn(folder) {
40
+ try {
41
+ return settingsOf(JSON.parse(readFileSync(join(folder, fileName), "utf8")));
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
@@ -0,0 +1,17 @@
1
+ import { type AiModel, type GameRecord } from "../core/index.ts";
2
+ /** An analysis as the page lists it: who played, with the AI's model (D-82), when, how it ended, its opening, how long, and whether the AI has reviewed it. */
3
+ export interface AnalysisItem {
4
+ readonly name: string;
5
+ readonly white: string;
6
+ readonly black: string;
7
+ readonly aiModel?: AiModel;
8
+ readonly date?: string;
9
+ readonly result: string;
10
+ readonly opening?: string;
11
+ readonly moves: number;
12
+ readonly reviewed: boolean;
13
+ }
14
+ /** The games to analyse in the profile in use, the latest 20: those kept to study and those played, reviewed or not. */
15
+ export declare function analysesOf(home: string): AnalysisItem[];
16
+ /** Whether the analyst has reviewed a game: its word on the whole game or on how it ended. The opponent's badges, given during the game with the helps on, are not a review (D-78). */
17
+ export declare function isReviewed(record: GameRecord): boolean;
@@ -0,0 +1,46 @@
1
+ // The analyses the page lists (T-106): the games brought to study and those played here, to analyse or open again on the board page.
2
+ import { bookOpening } from "../commands/opening-book.js";
3
+ import { aiModelIn, closingOf, gameReviewOf } from "../core/index.js";
4
+ import { archivedGame, archivedGames, keptGame, keptGames } from "../games/index.js";
5
+ import { gameFolder } from "../profiles/index.js";
6
+ /** The games to analyse in the profile in use, the latest 20: those kept to study and those played, reviewed or not. */
7
+ export function analysesOf(home) {
8
+ const folder = gameFolder(home);
9
+ const names = [...keptGames(folder), ...archivedGames(folder)].sort((one, other) => stampOf(other).localeCompare(stampOf(one))).slice(0, shown);
10
+ return names.flatMap((name) => itemOf(name, () => name.startsWith("study/") ? keptGame(folder, name) : archivedGame(folder, name)));
11
+ }
12
+ // Enough for now; a longer list would load more as it scrolls.
13
+ const shown = 20;
14
+ /** Whether the analyst has reviewed a game: its word on the whole game or on how it ended. The opponent's badges, given during the game with the helps on, are not a review (D-78). */
15
+ export function isReviewed(record) {
16
+ return gameReviewOf(record) !== undefined || closingOf(record) !== undefined;
17
+ }
18
+ function itemOf(name, read) {
19
+ let record;
20
+ try {
21
+ record = read();
22
+ }
23
+ catch {
24
+ // A game that cannot be read stays out of the list.
25
+ return [];
26
+ }
27
+ const { tags } = record;
28
+ const date = tags.Date === undefined || tags.Date.includes("?") ? undefined : tags.Date;
29
+ const opening = bookOpening(record)?.name;
30
+ const aiModel = aiModelIn(record);
31
+ return [{
32
+ name,
33
+ white: tags.White ?? "?",
34
+ black: tags.Black ?? "?",
35
+ ...(aiModel === undefined ? {} : { aiModel }),
36
+ ...(date === undefined ? {} : { date }),
37
+ result: tags.Result ?? "*",
38
+ ...(opening === undefined ? {} : { opening }),
39
+ moves: Math.ceil(record.moves.length / 2),
40
+ reviewed: isReviewed(record),
41
+ }];
42
+ }
43
+ /** The time in a game's name, which orders kept and played games together: 2026-11-04-153012. */
44
+ function stampOf(name) {
45
+ return /(\d{4}-\d{2}-\d{2}-\d{6})/.exec(name)?.[1] ?? "";
46
+ }