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,128 @@
1
+ // The player's current game on disk (D-27, D-41): game.json, its PGN and the talk on the board page inside, written whole at every change, and kept in
2
+ // memory while nobody else changes it.
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { Game, readPgn, writePgn } from "../core/index.js";
7
+ import { writeAtomically } from "./files.js";
8
+ /** The current game of a folder, as it is on disk, or undefined when there is none. A game.json Chesspal cannot read throws: nothing overwrites it. */
9
+ export function loadGame(folder) {
10
+ const path = join(folder, fileName);
11
+ const stamp = stampOf(path);
12
+ if (stamp === undefined) {
13
+ kept.delete(path);
14
+ return undefined;
15
+ }
16
+ const known = kept.get(path);
17
+ if (known?.stamp === stamp) {
18
+ return known.current;
19
+ }
20
+ const current = parsed(readFileSync(path, "utf8"), path);
21
+ kept.set(path, { stamp, current });
22
+ return current;
23
+ }
24
+ /** Writes the current game to game.json, whole or not at all; from then on it is the one kept in memory. */
25
+ export function saveGame(folder, current) {
26
+ const path = join(folder, fileName);
27
+ const file = {
28
+ format: 1,
29
+ ai: current.ai,
30
+ started: current.started,
31
+ pgn: writePgn(current.game.record),
32
+ ...(current.talk.length === 0 ? {} : { talk: current.talk }),
33
+ ...(current.clock === undefined ? {} : { clock: savedClockOf(current.clock) }),
34
+ ...(drawOfferOf(current) === undefined ? {} : { drawOffer: current.drawOffer }),
35
+ };
36
+ try {
37
+ writeAtomically(path, `${JSON.stringify(file, null, 2)}\n`);
38
+ }
39
+ catch (error) {
40
+ // The live game may hold a move the file does not: the next call reads the file again.
41
+ kept.delete(path);
42
+ throw new Error(`Chesspal could not save the game: ${error.message} Nothing changed.`, { cause: error });
43
+ }
44
+ kept.set(path, { stamp: stampOf(path) ?? "", current });
45
+ }
46
+ /** How the game ended, or undefined while it goes on: by the board, or by what the record says, such as a resignation. */
47
+ export function endOf(game) {
48
+ const { tags } = game.record;
49
+ const { outcome } = game;
50
+ if (tags.Result === undefined || tags.Result === "*") {
51
+ return undefined;
52
+ }
53
+ const termination = tags.Termination ?? (outcome.over ? outcome.ending : undefined);
54
+ return termination === undefined ? { result: tags.Result } : { result: tags.Result, termination };
55
+ }
56
+ /** The draw offer that stands: none once the game is over (D-65). */
57
+ export function drawOfferOf(current) {
58
+ return endOf(current.game) === undefined ? current.drawOffer : undefined;
59
+ }
60
+ /** Whether each side has made a move in the game, before which no draw is agreed (FIDE 5.2.3, D-65). */
61
+ export function bothSidesMoved(game) {
62
+ return game.record.moves.length >= 2;
63
+ }
64
+ /** A stamp of the folder's game.json that changes whenever it is written, whoever writes it; undefined when there is none. */
65
+ export function gameStamp(folder) {
66
+ return stampOf(join(folder, fileName));
67
+ }
68
+ const fileName = "game.json";
69
+ /** A line of the talk as game.json and talk.json keep it. */
70
+ export const savedLine = z.object({ who: z.enum(["player", "ai"]), text: z.string(), ply: z.int().min(0), at: z.iso.datetime(), agent: z.literal(true).optional() });
71
+ const savedClock = z.object({
72
+ base: z.int().positive(),
73
+ increment: z.int().min(0),
74
+ white: z.number().min(0),
75
+ black: z.number().min(0),
76
+ since: z.iso.datetime(),
77
+ waiting: z.literal(true).optional(),
78
+ });
79
+ const gameFile = z.object({
80
+ format: z.literal(1),
81
+ ai: z.enum(["white", "black"]),
82
+ started: z.iso.datetime(),
83
+ pgn: z.string(),
84
+ talk: z.array(savedLine).optional(),
85
+ clock: savedClock.optional(),
86
+ drawOffer: z.enum(["player", "ai"]).optional(),
87
+ });
88
+ // The games read or written in this process, by their file, with the file's stamp then: a call reads a file again only when another process changed it.
89
+ const kept = new Map();
90
+ function stampOf(path) {
91
+ // The file's id changes at every rename: a write within the same tick of the clock, and of the same size, is seen too.
92
+ const stats = statSync(path, { bigint: true, throwIfNoEntry: false });
93
+ return stats === undefined ? undefined : `${stats.ino}:${stats.mtimeNs}:${stats.size}`;
94
+ }
95
+ function parsed(text, path) {
96
+ try {
97
+ const file = gameFile.parse(JSON.parse(text));
98
+ const records = readPgn(file.pgn);
99
+ const [record] = records;
100
+ if (record === undefined || records.length > 1) {
101
+ throw new Error(`its PGN holds ${records.length} games`);
102
+ }
103
+ return {
104
+ game: new Game(record),
105
+ ai: file.ai,
106
+ started: file.started,
107
+ talk: file.talk ?? [],
108
+ ...(file.clock === undefined ? {} : { clock: clockOf(file.clock) }),
109
+ ...(file.drawOffer === undefined ? {} : { drawOffer: file.drawOffer }),
110
+ };
111
+ }
112
+ catch (error) {
113
+ throw new Error(`${path} is not a game Chesspal can read (${problemOf(error)}): ask the player to fix it or move it away, since Chesspal never overwrites it.`);
114
+ }
115
+ }
116
+ /** The clock as game.json keeps it, flat, in milliseconds. */
117
+ function savedClockOf({ control, left, since, waiting }) {
118
+ return { base: control.base, increment: control.increment, white: left.white, black: left.black, since, ...(waiting === true ? { waiting } : {}) };
119
+ }
120
+ function clockOf({ base, increment, white, black, since, waiting }) {
121
+ return { control: { base, increment }, left: { white, black }, since, ...(waiting === true ? { waiting } : {}) };
122
+ }
123
+ function problemOf(error) {
124
+ if (error instanceof z.ZodError) {
125
+ return error.issues.map((issue) => `${issue.path.join(".") || "the file"}: ${issue.message}`).join("; ");
126
+ }
127
+ return error.message;
128
+ }
@@ -0,0 +1,9 @@
1
+ /** Writes a file whole or not at all: a temporary file beside it, then renamed over it (D-27). A failure says what to do about it. */
2
+ export declare function writeAtomically(path: string, text: string | Uint8Array): void;
3
+ /** Makes a folder, and those above it, when missing. A failure says what to do about it. */
4
+ export declare function makeFolder(path: string): void;
5
+ /**
6
+ * Renames a file or a folder. Windows refuses while another program holds a file in it open (an editor, an antivirus, a sync client): Chesspal waits a
7
+ * little and tries again, half a second at most.
8
+ */
9
+ export declare function renameOver(from: string, to: string): void;
@@ -0,0 +1,52 @@
1
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ /** Writes a file whole or not at all: a temporary file beside it, then renamed over it (D-27). A failure says what to do about it. */
3
+ export function writeAtomically(path, text) {
4
+ const temporary = `${path}.${process.pid}.tmp`;
5
+ try {
6
+ writeFileSync(temporary, text);
7
+ renameOver(temporary, path);
8
+ }
9
+ catch (error) {
10
+ throw new Error(problemOf(error, path), { cause: error });
11
+ }
12
+ finally {
13
+ rmSync(temporary, { force: true });
14
+ }
15
+ }
16
+ /** Makes a folder, and those above it, when missing. A failure says what to do about it. */
17
+ export function makeFolder(path) {
18
+ try {
19
+ mkdirSync(path, { recursive: true });
20
+ }
21
+ catch (error) {
22
+ throw new Error(`${path} could not be made (${error.message}): check that it is a folder, and can be written.`, { cause: error });
23
+ }
24
+ }
25
+ const attempts = 10;
26
+ /**
27
+ * Renames a file or a folder. Windows refuses while another program holds a file in it open (an editor, an antivirus, a sync client): Chesspal waits a
28
+ * little and tries again, half a second at most.
29
+ */
30
+ export function renameOver(from, to) {
31
+ for (let attempt = 1;; attempt++) {
32
+ try {
33
+ renameSync(from, to);
34
+ return;
35
+ }
36
+ catch (error) {
37
+ if (attempt === attempts || !isHeld(error)) {
38
+ throw error;
39
+ }
40
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * attempt);
41
+ }
42
+ }
43
+ }
44
+ function isHeld(error) {
45
+ return error.syscall === "rename" && (error.code === "EPERM" || error.code === "EACCES" || error.code === "EBUSY");
46
+ }
47
+ function problemOf(error, path) {
48
+ if (isHeld(error)) {
49
+ return `${path} is held open by another program, or is read-only (${error.code}, ${attempts} tries): close that program, or make the file writable.`;
50
+ }
51
+ return `${path} could not be written (${error.message}): check that its folder can be written.`;
52
+ }
@@ -0,0 +1,10 @@
1
+ export { aiModelOf, noteAiModel } from "./ai-model.ts";
2
+ export { type Analysis, endAnalysis, gameInAnalysis, startAnalysis } from "./analysis.ts";
3
+ export { archivedGame, archivedGames, archivedName, archivedWhen, archiveEnded, archiveGame, keepToStudy, keptGame, keptGames, movesPgn, playerPgn, rewriteGame, startOfNewGame } from "./archive.ts";
4
+ export { type ChatStart, cleanSince, lastChatStart, saveChatStart } from "./chat-start.ts";
5
+ export { bothSidesMoved, type CurrentGame, drawOfferOf, endOf, type GameEnd, gameStamp, loadGame, saveGame, type Speaker, type TalkLine } from "./current-game.ts";
6
+ export { makeFolder, renameOver, writeAtomically } from "./files.ts";
7
+ export { commentWithPlan, currentPlan, planOf, withoutPlans } from "./plans.ts";
8
+ export { endTableTalk, loadTableTalk, saveTableTalk, type TableTalk } from "./table-talk.ts";
9
+ export { type Heard, heardLines, playerLinesThisMove, type Sayer, type Talk, talkLimits, talkLine, unheardWords, wakesTheChat, type Words, WordsRefused } from "./talk.ts";
10
+ export { keepThought, type Thought, thoughtForLastMove, thoughtKey, waitingSince } from "./thoughts.ts";
@@ -0,0 +1,11 @@
1
+ // The player's games on disk: the current one in game.json, the finished ones in games/ (D-27, D-41), the games kept to study in study/ (D-68). Below the commands, above the core.
2
+ export { aiModelOf, noteAiModel } from "./ai-model.js";
3
+ export { endAnalysis, gameInAnalysis, startAnalysis } from "./analysis.js";
4
+ export { archivedGame, archivedGames, archivedName, archivedWhen, archiveEnded, archiveGame, keepToStudy, keptGame, keptGames, movesPgn, playerPgn, rewriteGame, startOfNewGame } from "./archive.js";
5
+ export { cleanSince, lastChatStart, saveChatStart } from "./chat-start.js";
6
+ export { bothSidesMoved, drawOfferOf, endOf, gameStamp, loadGame, saveGame } from "./current-game.js";
7
+ export { makeFolder, renameOver, writeAtomically } from "./files.js";
8
+ export { commentWithPlan, currentPlan, planOf, withoutPlans } from "./plans.js";
9
+ export { endTableTalk, loadTableTalk, saveTableTalk } from "./table-talk.js";
10
+ export { heardLines, playerLinesThisMove, talkLimits, talkLine, unheardWords, wakesTheChat, WordsRefused } from "./talk.js";
11
+ export { keepThought, thoughtForLastMove, thoughtKey, waitingSince } from "./thoughts.js";
@@ -0,0 +1,9 @@
1
+ import type { GameRecord } from "../core/index.ts";
2
+ /** The comment of the AI's move, when it has one, with its plan. */
3
+ export declare function commentWithPlan(comment: string | undefined, plan: string): string;
4
+ /** The plan a move's comment carries, if it carries one. */
5
+ export declare function planOf(comment: string | undefined): string | undefined;
6
+ /** The plan the AI wrote last, if it wrote one. */
7
+ export declare function currentPlan(record: GameRecord): string | undefined;
8
+ /** The record without the AI's plans: what the player may see while the game goes on. */
9
+ export declare function withoutPlans(record: GameRecord): GameRecord;
@@ -0,0 +1,23 @@
1
+ /** The comment of the AI's move, when it has one, with its plan. */
2
+ export function commentWithPlan(comment, plan) {
3
+ return comment === undefined ? `[%plan ${plan}]` : `${comment} [%plan ${plan}]`;
4
+ }
5
+ /** The plan a move's comment carries, if it carries one. */
6
+ export function planOf(comment) {
7
+ return planPattern.exec(comment ?? "")?.[1];
8
+ }
9
+ /** The plan the AI wrote last, if it wrote one. */
10
+ export function currentPlan(record) {
11
+ for (let index = record.moves.length - 1; index >= 0; index--) {
12
+ const plan = planOf(record.moves[index]?.comment);
13
+ if (plan !== undefined) {
14
+ return plan;
15
+ }
16
+ }
17
+ return undefined;
18
+ }
19
+ /** The record without the AI's plans: what the player may see while the game goes on. */
20
+ export function withoutPlans(record) {
21
+ return { ...record, moves: record.moves.map((move) => ({ ...move, comment: move.comment?.replace(planPattern, "") })) };
22
+ }
23
+ const planPattern = /\s*\[%plan ([^\]]*)\]/;
@@ -0,0 +1,12 @@
1
+ import { type TalkLine } from "./current-game.ts";
2
+ /** What the player and the AI said with no game on the page, and since when: it keys the player's lines apart from any game's. */
3
+ export interface TableTalk {
4
+ readonly started: string;
5
+ readonly talk: readonly TalkLine[];
6
+ }
7
+ /** The talk with no game: empty when there is none, or talk.json cannot be read, since it is only words. */
8
+ export declare function loadTableTalk(folder: string): TableTalk;
9
+ /** Writes the talk with no game, whole or not at all. */
10
+ export declare function saveTableTalk(folder: string, table: TableTalk): void;
11
+ /** Forgets the talk with no game, when a game starts. */
12
+ export declare function endTableTalk(folder: string): void;
@@ -0,0 +1,25 @@
1
+ // The talk on the board page before the first game (D-77): talk.json, in the folder of the profile's games, until a game starts and its talk goes in game.json.
2
+ import { readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { savedLine } from "./current-game.js";
6
+ import { writeAtomically } from "./files.js";
7
+ /** The talk with no game: empty when there is none, or talk.json cannot be read, since it is only words. */
8
+ export function loadTableTalk(folder) {
9
+ try {
10
+ return tableFile.parse(JSON.parse(readFileSync(join(folder, fileName), "utf8")));
11
+ }
12
+ catch {
13
+ return { started: "", talk: [] };
14
+ }
15
+ }
16
+ /** Writes the talk with no game, whole or not at all. */
17
+ export function saveTableTalk(folder, table) {
18
+ writeAtomically(join(folder, fileName), `${JSON.stringify(table, null, 2)}\n`);
19
+ }
20
+ /** Forgets the talk with no game, when a game starts. */
21
+ export function endTableTalk(folder) {
22
+ rmSync(join(folder, fileName), { force: true });
23
+ }
24
+ const fileName = "talk.json";
25
+ const tableFile = z.object({ started: z.iso.datetime(), talk: z.array(savedLine) });
@@ -0,0 +1,40 @@
1
+ import { type CurrentGame, type Speaker, type TalkLine } from "./current-game.ts";
2
+ import type { TableTalk } from "./table-talk.ts";
3
+ /** The most the player may write in a move, in all their lines, and the most a line of the AI's may hold (D-54); with the helps on, as an analyst's note (D-78). */
4
+ export declare const talkLimits: {
5
+ readonly player: 200;
6
+ readonly ai: 300;
7
+ readonly aiHelps: 700;
8
+ };
9
+ /** Who says a line: the player, the AI in the chat, or the AI's agent thinking on the player's time (D-72), whose lines are the AI's on the page. */
10
+ export type Sayer = Speaker | "agent";
11
+ /** A talk: a game's, going on or over, or the one with no game, where both talk freely as between games (D-74, D-77). */
12
+ export type Talk = CurrentGame | TableTalk;
13
+ /**
14
+ * A new line of the talk, said when the game has `ply` moves: refused when it is empty or too long. The player writes 200 characters a move in all, the
15
+ * words sent with the move included; the AI speaks once a move of theirs, on their time, and its agent's word leaves it an answer to what they write after (D-54, D-72).
16
+ */
17
+ export declare function talkLine(current: Talk, sayer: Sayer, text: string, at: Date, ply?: number, aiMost?: number): TalkLine;
18
+ /** Words refused, and why, for the page to say it in the player's language (T-11): none, the move's characters spent, more than a line or what is left holds. */
19
+ export declare class WordsRefused extends Error {
20
+ readonly reason: "empty" | "spent" | "long" | "longer-than-left";
21
+ readonly most: number;
22
+ constructor(message: string, reason: WordsRefused["reason"], most?: number);
23
+ }
24
+ /** The lines the player wrote since the AI's last move: those it answers with its next move, and the 200 characters of this move are counted on them. */
25
+ export declare function playerLinesThisMove(current: CurrentGame): TalkLine[];
26
+ /** How many characters the player may still write for this move; between games a whole line each time, with no count (D-74). */
27
+ export declare function wordsLeft(current: Talk): number;
28
+ /** Whether the player's last line wakes the chat: the first of a move, and between games the first since the AI last spoke (D-54, D-74). */
29
+ export declare function wakesTheChat(current: Talk): boolean;
30
+ /** Words of the player's, with the key that tells them apart across games. */
31
+ export interface Words {
32
+ readonly key: string;
33
+ readonly text: string;
34
+ }
35
+ /** What the player said that the AI has not answered, but for the words whose keys are in `given`. */
36
+ export declare function unheardWords(current: Talk, given: ReadonlySet<string>): Words[];
37
+ /** How far a line of the player's got: written, read by the AI in this chat, or answered by its words or its move. */
38
+ export type Heard = "sent" | "read" | "answered";
39
+ /** How far each line of the talk got: the player's by `Heard`, the AI's undefined (D-53). */
40
+ export declare function heardLines(current: Talk, given: ReadonlySet<string>): (Heard | undefined)[];
@@ -0,0 +1,131 @@
1
+ // The talk on the board page (D-51, T-65): the player's words and the AI's answers, kept in game.json beside the PGN, or in talk.json with no game (D-77).
2
+ import { endOf } from "./current-game.js";
3
+ /** The most the player may write in a move, in all their lines, and the most a line of the AI's may hold (D-54); with the helps on, as an analyst's note (D-78). */
4
+ export const talkLimits = { player: 200, ai: 300, aiHelps: 700 };
5
+ /**
6
+ * A new line of the talk, said when the game has `ply` moves: refused when it is empty or too long. The player writes 200 characters a move in all, the
7
+ * words sent with the move included; the AI speaks once a move of theirs, on their time, and its agent's word leaves it an answer to what they write after (D-54, D-72).
8
+ */
9
+ export function talkLine(current, sayer, text, at, ply = isGame(current) ? current.game.record.moves.length : 0, aiMost = talkLimits.ai) {
10
+ const who = sayer === "agent" ? "ai" : sayer;
11
+ const words = text.trim();
12
+ const most = who === "player" ? wordsLeft(current) : aiMost;
13
+ if (words === "") {
14
+ throw new WordsRefused("There is nothing to say: write the words first.", "empty");
15
+ }
16
+ if (who === "player" && most === 0) {
17
+ throw new WordsRefused(`You have written your ${talkLimits.player} characters until the AI's next move: write again after it.`, "spent");
18
+ }
19
+ if (words.length > most) {
20
+ const more = who === "player" && most < talkLimits.player;
21
+ throw new WordsRefused(`The words are ${words.length} characters long: write at most ${most}${more ? " more until the AI's next move" : ""}.`, more ? "longer-than-left" : "long", most);
22
+ }
23
+ if (who === "ai" && isGame(current)) {
24
+ refuseAiWords(current, sayer === "agent");
25
+ }
26
+ return { who, text: words, ply, at: at.toISOString(), ...(sayer === "agent" ? { agent: true } : {}) };
27
+ }
28
+ /** Words refused, and why, for the page to say it in the player's language (T-11): none, the move's characters spent, more than a line or what is left holds. */
29
+ export class WordsRefused extends Error {
30
+ reason;
31
+ most;
32
+ constructor(message, reason, most = 0) {
33
+ super(message);
34
+ this.reason = reason;
35
+ this.most = most;
36
+ }
37
+ }
38
+ /** The lines the player wrote since the AI's last move: those it answers with its next move, and the 200 characters of this move are counted on them. */
39
+ export function playerLinesThisMove(current) {
40
+ const since = sinceAiMoved(current);
41
+ return current.talk.filter((line) => line.who === "player" && line.ply >= since);
42
+ }
43
+ /** How many characters the player may still write for this move; between games a whole line each time, with no count (D-74). */
44
+ export function wordsLeft(current) {
45
+ if (!going(current)) {
46
+ return talkLimits.player;
47
+ }
48
+ return Math.max(talkLimits.player - playerLinesThisMove(current).reduce((sum, line) => sum + line.text.length, 0), 0);
49
+ }
50
+ /** Whether the player's last line wakes the chat: the first of a move, and between games the first since the AI last spoke (D-54, D-74). */
51
+ export function wakesTheChat(current) {
52
+ return going(current) ? playerLinesThisMove(current).length === 1 : unanswered(current).length === 1;
53
+ }
54
+ /** What the player said that the AI has not answered, but for the words whose keys are in `given`. */
55
+ export function unheardWords(current, given) {
56
+ return unanswered(current).filter(({ key }) => !given.has(key)).map(({ key, line }) => ({ key, text: line.text }));
57
+ }
58
+ /** How far each line of the talk got: the player's by `Heard`, the AI's undefined (D-53). */
59
+ export function heardLines(current, given) {
60
+ const waiting = new Set(unanswered(current).map(({ key }) => key));
61
+ return current.talk.map((line, index) => {
62
+ const key = keyOf(current, index);
63
+ return line.who === "ai" ? undefined : !waiting.has(key) ? "answered" : given.has(key) ? "read" : "sent";
64
+ });
65
+ }
66
+ /**
67
+ * The AI speaks apart from its move once a move of the player's, on their time, with something to say or to answer (D-72), and never while its move waits:
68
+ * it speaks in the move's comment then. Its agent speaks only first, when the player has written nothing to answer, and its word leaves the chat one answer
69
+ * to what they write after. Between games it talks freely, about the game just over or to start another (D-74).
70
+ */
71
+ function refuseAiWords(current, byAgent) {
72
+ const { game, ai } = current;
73
+ const since = sinceAiMoved(current);
74
+ if (endOf(game) !== undefined) {
75
+ return;
76
+ }
77
+ if (game.position.turn === ai) {
78
+ throw new Error("It is your move: speak in ai_move's comment. Nothing changed.");
79
+ }
80
+ const spoken = current.talk.filter((line) => line.who === "ai" && line.ply >= since);
81
+ if (byAgent) {
82
+ refuseAgentWords(current, spoken);
83
+ return;
84
+ }
85
+ if (spoken.some((line) => line.agent !== true)) {
86
+ throw new Error("You have spoken once for this move: say the rest in ai_move's comment. Nothing changed.");
87
+ }
88
+ if (spoken.length > 0 && unanswered(current).length === 0) {
89
+ throw new Error("You have said a word on the player's time already: speak again only to answer them, else in ai_move's comment. Nothing changed.");
90
+ }
91
+ }
92
+ /** The agent's word on the player's time: one, and none once they have written, since the chat answers them. */
93
+ function refuseAgentWords(current, spoken) {
94
+ if (spoken.length > 0) {
95
+ throw new Error("You have spoken for this move already: say nothing more. Nothing changed.");
96
+ }
97
+ if (unanswered(current).length > 0) {
98
+ throw new Error("The player has written to you, and the AI in the chat answers them: say nothing. Nothing changed.");
99
+ }
100
+ }
101
+ /** How many moves the game had just after the AI's last move, 0 before it: what is said since then waits for its next move. */
102
+ function sinceAiMoved(current) {
103
+ const moves = current.game.record.moves.length;
104
+ // The last move is the AI's when the AI is not to move; else the AI's is the one before it.
105
+ const lastIsAi = current.game.position.turn !== current.ai;
106
+ return lastIsAi ? moves : moves >= 2 ? moves - 1 : 0;
107
+ }
108
+ /** The player's lines after the AI's last words and its last move, each with a key of its own across games. */
109
+ function unanswered(current) {
110
+ const { talk } = current;
111
+ const lastSaid = talk.findLastIndex((line) => line.who === "ai");
112
+ return talk.flatMap((line, index) => index > lastSaid && line.who === "player" && !aiMovedSince(current, line.ply) ? [{ key: keyOf(current, index), line }] : []);
113
+ }
114
+ /** Whether the AI has moved since the game had `ply` moves: the last move is the AI's when the AI is not to move, and two moves on, one of them is. */
115
+ function aiMovedSince(current, ply) {
116
+ if (!isGame(current)) {
117
+ return false;
118
+ }
119
+ const moves = current.game.record.moves.length;
120
+ return moves > ply + 1 || (moves === ply + 1 && current.game.position.turn !== current.ai);
121
+ }
122
+ function isGame(current) {
123
+ return "game" in current;
124
+ }
125
+ /** Whether a game goes on, with its limits to the talk; none with no game or once it is over. */
126
+ function going(current) {
127
+ return isGame(current) && endOf(current.game) === undefined;
128
+ }
129
+ function keyOf(current, index) {
130
+ return `${current.started}#${index}`;
131
+ }
@@ -0,0 +1,15 @@
1
+ import type { CurrentGame } from "./current-game.ts";
2
+ /** The AI's answer to one reply of the player's: the reply and its move in SAN, and why in one sentence. */
3
+ export interface Thought {
4
+ readonly reply: string;
5
+ readonly move: string;
6
+ readonly why: string;
7
+ }
8
+ /** The key of the position the player is to move in: the game, by its start, and how many moves it has. */
9
+ export declare function thoughtKey(current: CurrentGame, ply?: number): string;
10
+ /** Keeps a thought for a position, in place of one for the same reply; thoughts of another position go. The thoughts kept now. */
11
+ export declare function keepThought(folder: string, key: string, thought: Thought): Thought[];
12
+ /** The thought for the player's last move, when the AI is to move and thought of it on their time. */
13
+ export declare function thoughtForLastMove(folder: string, current: CurrentGame): Thought | undefined;
14
+ /** Since when the AI has waited for the player's move in a position: the first time it asked, `now` then, kept with its thoughts. */
15
+ export declare function waitingSince(folder: string, key: string, now: Date): Date;
@@ -0,0 +1,60 @@
1
+ // What the AI thought on the player's time (D-72): its answers to the player's likely replies, in thoughts.json beside game.json, hidden from the player
2
+ // as its plan is (D-29), and kept only for the position they were thought on: the next position's replace them.
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { writeAtomically } from "./files.js";
7
+ /** The key of the position the player is to move in: the game, by its start, and how many moves it has. */
8
+ export function thoughtKey(current, ply = current.game.record.moves.length) {
9
+ return `${current.started}#${ply}`;
10
+ }
11
+ /** Keeps a thought for a position, in place of one for the same reply; thoughts of another position go. The thoughts kept now. */
12
+ export function keepThought(folder, key, thought) {
13
+ const kept = keptAt(folder, key);
14
+ const thoughts = [...kept.thoughts.filter((one) => one.reply !== thought.reply), thought];
15
+ if (thoughts.length > mostThoughts) {
16
+ throw new Error(`You have ${mostThoughts} thoughts for this position already: they are enough. Nothing changed.`);
17
+ }
18
+ write(folder, { ...kept, thoughts });
19
+ return thoughts;
20
+ }
21
+ /** The thought for the player's last move, when the AI is to move and thought of it on their time. */
22
+ export function thoughtForLastMove(folder, current) {
23
+ const { moves } = current.game.record;
24
+ const last = moves.at(-1);
25
+ return last === undefined ? undefined : keptAt(folder, thoughtKey(current, moves.length - 1)).thoughts.find((thought) => thought.reply === last.san);
26
+ }
27
+ /** Since when the AI has waited for the player's move in a position: the first time it asked, `now` then, kept with its thoughts. */
28
+ export function waitingSince(folder, key, now) {
29
+ const kept = keptAt(folder, key);
30
+ if (kept.since !== undefined) {
31
+ return new Date(kept.since);
32
+ }
33
+ write(folder, { ...kept, since: now.toISOString() });
34
+ return now;
35
+ }
36
+ /** The most thoughts kept for a position: a few likely replies, not every legal one. */
37
+ const mostThoughts = 8;
38
+ const fileName = "thoughts.json";
39
+ const thoughtsFile = z.object({
40
+ key: z.string(),
41
+ thoughts: z.array(z.object({ reply: z.string(), move: z.string(), why: z.string() })),
42
+ since: z.iso.datetime().optional(),
43
+ });
44
+ /** What is kept for a position: nothing yet when the file is for another, or missing. */
45
+ function keptAt(folder, key) {
46
+ const kept = read(folder);
47
+ return kept?.key === key ? kept : { key, thoughts: [] };
48
+ }
49
+ function read(folder) {
50
+ try {
51
+ return thoughtsFile.parse(JSON.parse(readFileSync(join(folder, fileName), "utf8")));
52
+ }
53
+ catch {
54
+ // None yet, or one Chesspal cannot read: thoughts are only a help, and the next one written replaces it.
55
+ return undefined;
56
+ }
57
+ }
58
+ function write(folder, kept) {
59
+ writeAtomically(join(folder, fileName), `${JSON.stringify(kept, null, 2)}\n`);
60
+ }
@@ -0,0 +1,12 @@
1
+ /** What the data folder is found from: the environment, the system, the user's own folder, and where the system says Documents is. */
2
+ export interface System {
3
+ readonly env: Readonly<Record<string, string | undefined>>;
4
+ readonly platform: string;
5
+ readonly home: string;
6
+ /** What `reg query` prints of Documents' place, or undefined when it cannot run. */
7
+ readonly registry: () => string | undefined;
8
+ /** A file's text, or undefined when it cannot be read: the XDG user dirs on Linux. */
9
+ readonly readText: (path: string) => string | undefined;
10
+ }
11
+ /** The player's data folder: CHESSPAL_HOME when set, else the system's Documents with Chesspal's folder in it. */
12
+ export declare function dataFolder(system?: System): string;
@@ -0,0 +1,55 @@
1
+ // The player's data folder (D-71): Documents\My Games\Chesspal on Windows, wherever the system keeps Documents, OneDrive too; Documents/Chesspal
2
+ // elsewhere, where Linux's XDG user dirs put Documents; CHESSPAL_HOME in its place, for the tests and the playtests.
3
+ import { spawnSync } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { posix, win32 } from "node:path";
7
+ /** The player's data folder: CHESSPAL_HOME when set, else the system's Documents with Chesspal's folder in it. */
8
+ export function dataFolder(system = thisSystem()) {
9
+ const chosen = system.env.CHESSPAL_HOME?.trim();
10
+ const path = system.platform === "win32" ? win32 : posix;
11
+ if (chosen !== undefined && chosen !== "") {
12
+ return path.resolve(chosen);
13
+ }
14
+ if (system.platform === "win32") {
15
+ return win32.join(windowsDocuments(system), "My Games", "Chesspal");
16
+ }
17
+ return posix.join(system.platform === "darwin" ? posix.join(system.home, "Documents") : xdgDocuments(system), "Chesspal");
18
+ }
19
+ // Where Windows keeps Documents for this user, as Explorer's known folder does: the registry names it, with variables such as %USERPROFILE%.
20
+ const shellFolders = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
21
+ function thisSystem() {
22
+ return { env: process.env, platform: process.platform, home: homedir(), registry: readRegistry, readText };
23
+ }
24
+ function readText(path) {
25
+ try {
26
+ return readFileSync(path, "utf8");
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ /** Documents as the XDG user dirs name it, in the system's language, such as ~/Documenti, as GLib reads them; ~/Documents when they say nothing usable. */
33
+ function xdgDocuments({ env, home, readText }) {
34
+ const config = env.XDG_CONFIG_HOME?.startsWith("/") === true ? env.XDG_CONFIG_HOME : posix.join(home, ".config");
35
+ const named = /^\s*XDG_DOCUMENTS_DIR\s*=\s*"([^"]*)"/m.exec(readText(posix.join(config, "user-dirs.dirs")) ?? "")?.[1];
36
+ const filled = named?.replace(/^\$HOME(?=\/|$)/, home);
37
+ return filled !== undefined && posix.isAbsolute(filled) ? posix.normalize(filled) : posix.join(home, "Documents");
38
+ }
39
+ // About 40 ms, once a process: PowerShell would say the same in 270. reg writes in the console's code page, which breaks a folder with accents: UTF-8 first.
40
+ function readRegistry() {
41
+ const command = `"chcp 65001 >nul & reg query "${shellFolders}" /v Personal"`;
42
+ const answer = spawnSync("cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", windowsHide: true, windowsVerbatimArguments: true, timeout: 5_000 });
43
+ return answer.status === 0 ? answer.stdout : undefined;
44
+ }
45
+ /** Documents as the registry names it, with its variables filled in; the user's own Documents when the registry says nothing usable. */
46
+ function windowsDocuments({ env, home, registry }) {
47
+ const named = /^\s*Personal\s+REG_(?:EXPAND_)?SZ\s+(.+?)\s*$/m.exec(registry() ?? "")?.[1];
48
+ const filled = named?.replace(/%([^%]+)%/g, (whole, name) => valueOf(env, name) ?? whole);
49
+ return filled !== undefined && win32.isAbsolute(filled) && !/[%�]/.test(filled) ? filled : win32.join(valueOf(env, "USERPROFILE") ?? home, "Documents");
50
+ }
51
+ // Windows's variables, whatever their case.
52
+ function valueOf(env, name) {
53
+ const key = Object.keys(env).find((one) => one.toLowerCase() === name.toLowerCase());
54
+ return key === undefined ? undefined : env[key];
55
+ }
@@ -0,0 +1,2 @@
1
+ export { dataFolder, type System } from "./data-folder.ts";
2
+ export { openFolder } from "./open-folder.ts";
@@ -0,0 +1,3 @@
1
+ // Where the player's data is (D-71): one folder for every chat, apart from the host's folder. Below the commands, beside the games and the profiles.
2
+ export { dataFolder } from "./data-folder.js";
3
+ export { openFolder } from "./open-folder.js";
@@ -0,0 +1,2 @@
1
+ /** Opens a folder in the system's file manager, without waiting for it to close: whether the manager started, for the page to say so when it did not. */
2
+ export declare function openFolder(folder: string, platform: string): Promise<boolean>;
@@ -0,0 +1,14 @@
1
+ // The data folder shown to the player in the system's own file manager (T-119): Explorer, Finder, or the Linux desktop's.
2
+ import { spawn } from "node:child_process";
3
+ /** Opens a folder in the system's file manager, without waiting for it to close: whether the manager started, for the page to say so when it did not. */
4
+ export function openFolder(folder, platform) {
5
+ const manager = platform === "win32" ? "explorer.exe" : platform === "darwin" ? "open" : "xdg-open";
6
+ return new Promise((done) => {
7
+ const opened = spawn(manager, [folder], { detached: true, stdio: "ignore" });
8
+ opened.once("spawn", () => {
9
+ opened.unref();
10
+ done(true);
11
+ });
12
+ opened.once("error", () => done(false));
13
+ });
14
+ }