chesspal 0.0.1 → 0.2.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 (273) hide show
  1. package/README.md +99 -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 +7 -0
  7. package/dist/cli/command-line.js +177 -0
  8. package/dist/commands/agents.d.ts +26 -0
  9. package/dist/commands/agents.js +72 -0
  10. package/dist/commands/ai-move.d.ts +28 -0
  11. package/dist/commands/ai-move.js +85 -0
  12. package/dist/commands/ai-say.d.ts +22 -0
  13. package/dist/commands/ai-say.js +58 -0
  14. package/dist/commands/analyse-game.d.ts +65 -0
  15. package/dist/commands/analyse-game.js +265 -0
  16. package/dist/commands/annotate-game.d.ts +39 -0
  17. package/dist/commands/annotate-game.js +131 -0
  18. package/dist/commands/answer-lines.d.ts +28 -0
  19. package/dist/commands/answer-lines.js +144 -0
  20. package/dist/commands/clock.d.ts +15 -0
  21. package/dist/commands/clock.js +45 -0
  22. package/dist/commands/coach.d.ts +45 -0
  23. package/dist/commands/coach.js +344 -0
  24. package/dist/commands/context.d.ts +67 -0
  25. package/dist/commands/context.js +37 -0
  26. package/dist/commands/delete-game.d.ts +7 -0
  27. package/dist/commands/delete-game.js +30 -0
  28. package/dist/commands/delete-review.d.ts +8 -0
  29. package/dist/commands/delete-review.js +34 -0
  30. package/dist/commands/draw-offer.d.ts +10 -0
  31. package/dist/commands/draw-offer.js +43 -0
  32. package/dist/commands/drawings.d.ts +13 -0
  33. package/dist/commands/drawings.js +25 -0
  34. package/dist/commands/end-game.d.ts +23 -0
  35. package/dist/commands/end-game.js +67 -0
  36. package/dist/commands/export-game.d.ts +14 -0
  37. package/dist/commands/export-game.js +27 -0
  38. package/dist/commands/game-summary.d.ts +78 -0
  39. package/dist/commands/game-summary.js +102 -0
  40. package/dist/commands/index.d.ts +18 -0
  41. package/dist/commands/index.js +64 -0
  42. package/dist/commands/init.d.ts +15 -0
  43. package/dist/commands/init.js +73 -0
  44. package/dist/commands/lead-input.d.ts +8 -0
  45. package/dist/commands/lead-input.js +21 -0
  46. package/dist/commands/levels.d.ts +9 -0
  47. package/dist/commands/levels.js +25 -0
  48. package/dist/commands/look.d.ts +31 -0
  49. package/dist/commands/look.js +64 -0
  50. package/dist/commands/new-game.d.ts +68 -0
  51. package/dist/commands/new-game.js +218 -0
  52. package/dist/commands/opening-book.d.ts +13 -0
  53. package/dist/commands/opening-book.js +84 -0
  54. package/dist/commands/page-link.d.ts +11 -0
  55. package/dist/commands/page-link.js +28 -0
  56. package/dist/commands/past-games.d.ts +11 -0
  57. package/dist/commands/past-games.js +47 -0
  58. package/dist/commands/play-move.d.ts +37 -0
  59. package/dist/commands/play-move.js +94 -0
  60. package/dist/commands/player-move.d.ts +13 -0
  61. package/dist/commands/player-move.js +51 -0
  62. package/dist/commands/player-say.d.ts +5 -0
  63. package/dist/commands/player-say.js +16 -0
  64. package/dist/commands/ponder.d.ts +52 -0
  65. package/dist/commands/ponder.js +179 -0
  66. package/dist/commands/position.d.ts +14 -0
  67. package/dist/commands/position.js +130 -0
  68. package/dist/commands/profiles.d.ts +69 -0
  69. package/dist/commands/profiles.js +163 -0
  70. package/dist/commands/registry.d.ts +59 -0
  71. package/dist/commands/registry.js +62 -0
  72. package/dist/commands/say.d.ts +16 -0
  73. package/dist/commands/say.js +84 -0
  74. package/dist/commands/speech.d.ts +8 -0
  75. package/dist/commands/speech.js +20 -0
  76. package/dist/commands/status.d.ts +35 -0
  77. package/dist/commands/status.js +128 -0
  78. package/dist/commands/study-games.d.ts +5 -0
  79. package/dist/commands/study-games.js +40 -0
  80. package/dist/commands/study.d.ts +38 -0
  81. package/dist/commands/study.js +106 -0
  82. package/dist/commands/view.d.ts +14 -0
  83. package/dist/commands/view.js +125 -0
  84. package/dist/core/board-text.d.ts +7 -0
  85. package/dist/core/board-text.js +18 -0
  86. package/dist/core/book.json +1 -0
  87. package/dist/core/clock.d.ts +45 -0
  88. package/dist/core/clock.js +109 -0
  89. package/dist/core/facts.d.ts +55 -0
  90. package/dist/core/facts.js +175 -0
  91. package/dist/core/game.d.ts +95 -0
  92. package/dist/core/game.js +352 -0
  93. package/dist/core/illegal.d.ts +19 -0
  94. package/dist/core/illegal.js +141 -0
  95. package/dist/core/index.d.ts +20 -0
  96. package/dist/core/index.js +12 -0
  97. package/dist/core/lead.d.ts +15 -0
  98. package/dist/core/lead.js +40 -0
  99. package/dist/core/levels.d.ts +20 -0
  100. package/dist/core/levels.js +33 -0
  101. package/dist/core/material.d.ts +9 -0
  102. package/dist/core/material.js +12 -0
  103. package/dist/core/moods.d.ts +21 -0
  104. package/dist/core/moods.js +45 -0
  105. package/dist/core/openings.d.ts +35 -0
  106. package/dist/core/openings.js +98 -0
  107. package/dist/core/openings.json +1 -0
  108. package/dist/core/pgn.d.ts +10 -0
  109. package/dist/core/pgn.js +213 -0
  110. package/dist/core/profile.d.ts +117 -0
  111. package/dist/core/profile.js +232 -0
  112. package/dist/core/record.d.ts +21 -0
  113. package/dist/core/record.js +25 -0
  114. package/dist/core/review.d.ts +80 -0
  115. package/dist/core/review.js +146 -0
  116. package/dist/games/ai-model.d.ts +5 -0
  117. package/dist/games/ai-model.js +15 -0
  118. package/dist/games/analysis.d.ts +12 -0
  119. package/dist/games/analysis.js +26 -0
  120. package/dist/games/archive.d.ts +63 -0
  121. package/dist/games/archive.js +242 -0
  122. package/dist/games/chat-start.d.ts +16 -0
  123. package/dist/games/chat-start.js +26 -0
  124. package/dist/games/coach.d.ts +57 -0
  125. package/dist/games/coach.js +144 -0
  126. package/dist/games/current-game.d.ts +56 -0
  127. package/dist/games/current-game.js +134 -0
  128. package/dist/games/files.d.ts +9 -0
  129. package/dist/games/files.js +53 -0
  130. package/dist/games/index.d.ts +11 -0
  131. package/dist/games/index.js +12 -0
  132. package/dist/games/plans.d.ts +9 -0
  133. package/dist/games/plans.js +23 -0
  134. package/dist/games/table-talk.d.ts +12 -0
  135. package/dist/games/table-talk.js +25 -0
  136. package/dist/games/talk.d.ts +36 -0
  137. package/dist/games/talk.js +131 -0
  138. package/dist/games/thoughts.d.ts +15 -0
  139. package/dist/games/thoughts.js +60 -0
  140. package/dist/home/data-folder.d.ts +12 -0
  141. package/dist/home/data-folder.js +55 -0
  142. package/dist/home/index.d.ts +2 -0
  143. package/dist/home/index.js +3 -0
  144. package/dist/home/open-folder.d.ts +2 -0
  145. package/dist/home/open-folder.js +14 -0
  146. package/dist/host-files/agents.d.ts +20 -0
  147. package/dist/host-files/agents.js +53 -0
  148. package/dist/host-files/host-files.d.ts +15 -0
  149. package/dist/host-files/host-files.js +182 -0
  150. package/dist/host-files/index.d.ts +2 -0
  151. package/dist/host-files/index.js +3 -0
  152. package/dist/index.d.ts +2 -0
  153. package/dist/index.js +3 -0
  154. package/dist/page/app/anchor.js +88 -0
  155. package/dist/page/app/app.js +458 -0
  156. package/dist/page/app/keys.js +94 -0
  157. package/dist/page/app/phase.js +30 -0
  158. package/dist/page/app/served.js +73 -0
  159. package/dist/page/avatars/avatars.js +19 -0
  160. package/dist/page/avatars/beanie.webp +0 -0
  161. package/dist/page/avatars/beard.webp +0 -0
  162. package/dist/page/avatars/bun.webp +0 -0
  163. package/dist/page/avatars/cat.webp +0 -0
  164. package/dist/page/avatars/curls.webp +0 -0
  165. package/dist/page/avatars/glasses.webp +0 -0
  166. package/dist/page/avatars/moustache.webp +0 -0
  167. package/dist/page/avatars/owl.webp +0 -0
  168. package/dist/page/avatars/short-hair.webp +0 -0
  169. package/dist/page/avatars/silver-bob.webp +0 -0
  170. package/dist/page/avatars/thought.js +10 -0
  171. package/dist/page/avatars/torretta.js +38 -0
  172. package/dist/page/base.css +160 -0
  173. package/dist/page/board/board.css +959 -0
  174. package/dist/page/board/board.js +125 -0
  175. package/dist/page/board/explain.js +58 -0
  176. package/dist/page/board/marks.js +160 -0
  177. package/dist/page/board/motion.js +71 -0
  178. package/dist/page/board/pointer.js +239 -0
  179. package/dist/page/board/squares.js +26 -0
  180. package/dist/page/dialogs/analyses.js +195 -0
  181. package/dist/page/dialogs/dialog.js +26 -0
  182. package/dist/page/dialogs/dialogs.css +1228 -0
  183. package/dist/page/dialogs/new-game.js +42 -0
  184. package/dist/page/dialogs/profiles.js +152 -0
  185. package/dist/page/dialogs/search.js +31 -0
  186. package/dist/page/dialogs/settings.js +42 -0
  187. package/dist/page/dialogs/studied.js +39 -0
  188. package/dist/page/dialogs/study.js +51 -0
  189. package/dist/page/game/badges.js +50 -0
  190. package/dist/page/game/browse.js +17 -0
  191. package/dist/page/game/card-fold.js +40 -0
  192. package/dist/page/game/column.js +51 -0
  193. package/dist/page/game/ending.js +73 -0
  194. package/dist/page/game/game.css +577 -0
  195. package/dist/page/game/move-list.js +72 -0
  196. package/dist/page/game/opening-book.js +14 -0
  197. package/dist/page/game/plies.js +82 -0
  198. package/dist/page/game/result-tile.js +14 -0
  199. package/dist/page/helps/helps.css +689 -0
  200. package/dist/page/helps/helps.js +220 -0
  201. package/dist/page/helps/view.js +127 -0
  202. package/dist/page/layout/fold-tab.js +11 -0
  203. package/dist/page/layout/folds.js +43 -0
  204. package/dist/page/layout/header.js +28 -0
  205. package/dist/page/layout/layout.css +834 -0
  206. package/dist/page/lead/lead.js +82 -0
  207. package/dist/page/lead/view.js +38 -0
  208. package/dist/page/main.js +8 -0
  209. package/dist/page/pieces/CREDITS.md +19 -0
  210. package/dist/page/pieces/capablanca-small.svg +20 -0
  211. package/dist/page/pieces/capablanca.svg +22 -0
  212. package/dist/page/pieces/nitida-small.svg +20 -0
  213. package/dist/page/pieces/nitida.svg +21 -0
  214. package/dist/page/pieces/pieces.js +28 -0
  215. package/dist/page/pieces/torretta-small.svg +20 -0
  216. package/dist/page/pieces/torretta.svg +21 -0
  217. package/dist/page/players/card.js +56 -0
  218. package/dist/page/players/clock-time.js +12 -0
  219. package/dist/page/players/model-name.js +20 -0
  220. package/dist/page/profile/profile.js +99 -0
  221. package/dist/page/profile/settings.js +77 -0
  222. package/dist/page/sounds/capture.ogg +0 -0
  223. package/dist/page/sounds/check.ogg +0 -0
  224. package/dist/page/sounds/draw.ogg +0 -0
  225. package/dist/page/sounds/flag.ogg +0 -0
  226. package/dist/page/sounds/mate.ogg +0 -0
  227. package/dist/page/sounds/move.ogg +0 -0
  228. package/dist/page/sounds/offer.ogg +0 -0
  229. package/dist/page/sounds/resign.ogg +0 -0
  230. package/dist/page/sounds/sounds.js +105 -0
  231. package/dist/page/style.css +9 -0
  232. package/dist/page/talk/talk.css +958 -0
  233. package/dist/page/talk/talk.js +165 -0
  234. package/dist/page/talk/voice.js +38 -0
  235. package/dist/page/text.js +711 -0
  236. package/dist/page/theme.css +292 -0
  237. package/dist/profiles/avatar.d.ts +23 -0
  238. package/dist/profiles/avatar.js +84 -0
  239. package/dist/profiles/front-matter.d.ts +9 -0
  240. package/dist/profiles/front-matter.js +33 -0
  241. package/dist/profiles/index.d.ts +3 -0
  242. package/dist/profiles/index.js +5 -0
  243. package/dist/profiles/profiles.d.ts +69 -0
  244. package/dist/profiles/profiles.js +254 -0
  245. package/dist/profiles/settings.d.ts +12 -0
  246. package/dist/profiles/settings.js +46 -0
  247. package/dist/server/bell.d.ts +68 -0
  248. package/dist/server/bell.js +225 -0
  249. package/dist/server/caller.d.ts +21 -0
  250. package/dist/server/caller.js +132 -0
  251. package/dist/server/mcp.d.ts +13 -0
  252. package/dist/server/mcp.js +207 -0
  253. package/dist/server/page/analyses.d.ts +23 -0
  254. package/dist/server/page/analyses.js +65 -0
  255. package/dist/server/page/files.d.ts +5 -0
  256. package/dist/server/page/files.js +92 -0
  257. package/dist/server/page/game.d.ts +6 -0
  258. package/dist/server/page/game.js +381 -0
  259. package/dist/server/page/http.d.ts +29 -0
  260. package/dist/server/page/http.js +68 -0
  261. package/dist/server/page/page.d.ts +41 -0
  262. package/dist/server/page/page.js +229 -0
  263. package/dist/server/page/profiles.d.ts +19 -0
  264. package/dist/server/page/profiles.js +215 -0
  265. package/dist/version.d.ts +2 -0
  266. package/dist/version.js +3 -0
  267. package/instructions/analysis.md +4 -0
  268. package/instructions/analyst-agent.md +8 -0
  269. package/instructions/analyst.md +4 -0
  270. package/instructions/coach.md +11 -0
  271. package/instructions/opponent.md +15 -0
  272. package/instructions/ponder.md +9 -0
  273. package/package.json +66 -5
@@ -0,0 +1,10 @@
1
+ import { type GameRecord } from "./record.ts";
2
+ /**
3
+ * A game as PGN: the seven standard tags first, then its others (SetUp before a FEN), the moves with their judgments, in lines under 80 characters.
4
+ * The judgments $1 to $6 are written as !, ?, !!, ??, !? and ?!, which players read and every tool takes.
5
+ */
6
+ export declare function writePgn(record: GameRecord): string;
7
+ /** The games of a PGN text, with their tags, moves, NAGs (! and ? too) and comments; variations are skipped. Every move is checked: an error names its line. */
8
+ export declare function readPgn(text: string): GameRecord[];
9
+ /** The day of a date, in its own time zone, as PGN's Date tag writes it: 2026.11.02. */
10
+ export declare function pgnDate(date: Date): string;
@@ -0,0 +1,213 @@
1
+ // PGN in and out, our own: chess.js drops NAGs and variations. Every move read is checked by a Game.
2
+ import { Game } from "./game.js";
3
+ import { cleanComment, recordedMove } from "./record.js";
4
+ /**
5
+ * A game as PGN: the seven standard tags first, then its others (SetUp before a FEN), the moves with their judgments, in lines under 80 characters.
6
+ * The judgments $1 to $6 are written as !, ?, !!, ??, !? and ?!, which players read and every tool takes.
7
+ */
8
+ export function writePgn(record) {
9
+ const roster = sevenTags.map(([name, unknown]) => tagLine(name, record.tags[name] ?? unknown));
10
+ // SetUp goes first, before the FEN it announces.
11
+ const { SetUp: setUp = record.tags.FEN === undefined ? undefined : "1", ...tags } = record.tags;
12
+ const others = Object.entries(setUp === undefined ? tags : { SetUp: setUp, ...tags }).filter(([name]) => !sevenTagNames.has(name)).map(([name, value]) => tagLine(name, value));
13
+ return `${[...roster, ...others].join("\n")}\n\n${wrapped(movetext(record))}\n`;
14
+ }
15
+ /** The games of a PGN text, with their tags, moves, NAGs (! and ? too) and comments; variations are skipped. Every move is checked: an error names its line. */
16
+ export function readPgn(text) {
17
+ return new PgnReader(text).read();
18
+ }
19
+ /** The day of a date, in its own time zone, as PGN's Date tag writes it: 2026.11.02. */
20
+ export function pgnDate(date) {
21
+ const two = (value) => String(value).padStart(2, "0");
22
+ return `${date.getFullYear()}.${two(date.getMonth() + 1)}.${two(date.getDate())}`;
23
+ }
24
+ // The seven tags of every PGN, in their order, with the value for an unknown one.
25
+ const sevenTags = [["Event", "?"], ["Site", "?"], ["Date", "????.??.??"], ["Round", "?"], ["White", "?"], ["Black", "?"], ["Result", "*"]];
26
+ const sevenTagNames = new Set(sevenTags.map(([name]) => name));
27
+ // The judgments written after a move, NAGs $1 to $6.
28
+ const glyphs = ["!", "?", "!!", "??", "!?", "?!"];
29
+ const nagOfGlyph = new Map(glyphs.map((glyph, index) => [glyph, index + 1]));
30
+ // The standard wants lines under 80 characters.
31
+ const lineLength = 79;
32
+ // A token: spaces, a % escape line, a ; comment, a tag, a { comment }, a NAG, a parenthesis, a move number, a result, a move.
33
+ const tokenPattern = /\s+|(?<![^\n])%[^\n]*|;[^\n]*|\[\s*(\w+)\s*"((?:[^"\\]|\\.)*)"\s*\]|\{([^}]*)\}|\$(\d+)|([()])|\d+\s*\.+|\.+|(1-0|0-1|1\/2-1\/2|\*)|([^\s{}()[\]";$]+)/y;
34
+ class PgnReader {
35
+ text;
36
+ records = [];
37
+ tags = [];
38
+ comments = [];
39
+ moves = [];
40
+ game;
41
+ result;
42
+ depth = 0;
43
+ at = 0;
44
+ constructor(text) {
45
+ this.text = text;
46
+ }
47
+ read() {
48
+ for (let index = 0; index < this.text.length; index = tokenPattern.lastIndex) {
49
+ tokenPattern.lastIndex = index;
50
+ const match = tokenPattern.exec(this.text);
51
+ if (match === null) {
52
+ const character = this.text[index];
53
+ throw this.error(index, character === "{" ? "this comment is never closed with }." : `"${character}" is not PGN: remove it.`);
54
+ }
55
+ this.at = index;
56
+ this.take(match);
57
+ }
58
+ if (this.depth > 0) {
59
+ throw this.error(this.text.length, "a variation is never closed with ).");
60
+ }
61
+ this.finish();
62
+ return this.records;
63
+ }
64
+ take([, tagName, tagValue = "", comment, nag, parenthesis, result, move]) {
65
+ if (parenthesis !== undefined) {
66
+ this.depth += parenthesis === "(" ? 1 : -1;
67
+ if (this.depth < 0) {
68
+ throw this.error(this.at, "this ) closes no variation: remove it.");
69
+ }
70
+ return;
71
+ }
72
+ // Inside a variation, everything is skipped.
73
+ if (this.depth > 0) {
74
+ return;
75
+ }
76
+ if (tagName !== undefined) {
77
+ this.tag(tagName, tagValue.replace(/\\(.)/g, "$1"));
78
+ }
79
+ else if (comment !== undefined) {
80
+ this.started();
81
+ (this.moves.at(-1)?.comments ?? this.comments).push(comment);
82
+ }
83
+ else if (nag !== undefined) {
84
+ this.nag(Number(nag));
85
+ }
86
+ else if (result !== undefined) {
87
+ this.started();
88
+ this.result = result;
89
+ this.finish();
90
+ }
91
+ else if (move !== undefined) {
92
+ this.move(move);
93
+ }
94
+ }
95
+ tag(name, value) {
96
+ // A tag after the moves starts the next game, when the last one had no result.
97
+ if (this.game !== undefined) {
98
+ this.finish();
99
+ }
100
+ this.tags.push([name, value]);
101
+ }
102
+ move(token) {
103
+ // "e.p." after an en passant capture says nothing more.
104
+ if (/^e\.?p\.?$/i.test(token)) {
105
+ return;
106
+ }
107
+ const [, san = "", glyph = ""] = /^(.*?)([!?]*)$/.exec(token) ?? [];
108
+ const game = this.started();
109
+ if (san !== "") {
110
+ const played = game.playRecorded(san);
111
+ if (!played.played) {
112
+ throw this.error(this.at, played.message, game.position.moveNumber);
113
+ }
114
+ this.moves.push({ san: played.move.san, nags: [], comments: [] });
115
+ }
116
+ if (glyph !== "") {
117
+ const nag = nagOfGlyph.get(glyph);
118
+ if (nag === undefined) {
119
+ throw this.error(this.at, `"${glyph}" is not a judgment: write !, ?, !!, ??, !? or ?!.`);
120
+ }
121
+ this.nag(nag);
122
+ }
123
+ }
124
+ nag(nag) {
125
+ if (nag > 255) {
126
+ throw this.error(this.at, `$${nag} is not a NAG: NAGs go from $0 to $255.`);
127
+ }
128
+ this.started();
129
+ // A NAG before the first move judges nothing: skipped.
130
+ this.moves.at(-1)?.nags.push(nag);
131
+ }
132
+ /** The game the moves are played on, made from the tags at the first move. */
133
+ started() {
134
+ if (this.game === undefined) {
135
+ try {
136
+ this.game = new Game({ tags: Object.fromEntries(this.tags), moves: [] });
137
+ }
138
+ catch (error) {
139
+ throw this.error(this.at, error.message);
140
+ }
141
+ }
142
+ return this.game;
143
+ }
144
+ finish() {
145
+ if (this.tags.length > 0 || this.moves.length > 0 || this.result !== undefined) {
146
+ this.started();
147
+ const tags = Object.fromEntries(this.tags);
148
+ if (tags.Result === undefined && this.result !== undefined) {
149
+ tags.Result = this.result;
150
+ }
151
+ const moves = Object.freeze(this.moves.map((move) => recordedMove(move.san, move.nags, move.comments.join(" "))));
152
+ const comment = cleanComment(this.comments.join(" ")) || undefined;
153
+ this.records.push(Object.freeze(comment === undefined ? { tags: Object.freeze(tags), moves } : { tags: Object.freeze(tags), comment, moves }));
154
+ }
155
+ this.tags = [];
156
+ this.comments = [];
157
+ this.moves = [];
158
+ this.game = undefined;
159
+ this.result = undefined;
160
+ }
161
+ error(index, message, moveNumber) {
162
+ const line = this.text.slice(0, index).split("\n").length;
163
+ return new Error(`Line ${line}${moveNumber === undefined ? "" : `, move ${moveNumber}`}: ${message}`);
164
+ }
165
+ }
166
+ function movetext(record) {
167
+ const [, turn, , , , number] = (record.tags.FEN ?? "").split(" ");
168
+ const tokens = commentTokens(record.comment);
169
+ let moveNumber = Number(number) || 1;
170
+ let white = turn !== "b";
171
+ // Black's move carries its number at the start and after a comment or a NAG of its own.
172
+ let annotated = true;
173
+ for (const move of record.moves) {
174
+ if (white) {
175
+ tokens.push(`${moveNumber}.`);
176
+ }
177
+ else if (annotated) {
178
+ tokens.push(`${moveNumber}...`);
179
+ }
180
+ const glyph = move.nags?.find((nag) => nag >= 1 && nag <= glyphs.length);
181
+ const nags = (move.nags ?? []).filter((nag) => nag !== glyph).map((nag) => `$${nag}`);
182
+ const comment = commentTokens(move.comment);
183
+ tokens.push(glyph === undefined ? move.san : `${move.san}${glyphs[glyph - 1]}`, ...nags, ...comment);
184
+ annotated = nags.length > 0 || comment.length > 0;
185
+ moveNumber += white ? 0 : 1;
186
+ white = !white;
187
+ }
188
+ tokens.push(record.tags.Result ?? "*");
189
+ return tokens;
190
+ }
191
+ /** A comment as PGN tokens, cleaned for a record written by hand; none for no comment. */
192
+ function commentTokens(comment) {
193
+ const text = cleanComment(comment ?? "");
194
+ return text === "" ? [] : `{${text}}`.split(" ");
195
+ }
196
+ function tagLine(name, value) {
197
+ return `[${name} "${value.replace(/\s*[\r\n]+\s*/g, " ").replace(/[\\"]/g, "\\$&")}"]`;
198
+ }
199
+ function wrapped(tokens) {
200
+ const lines = [];
201
+ let line = "";
202
+ for (const token of tokens) {
203
+ if (line !== "" && line.length + 1 + token.length > lineLength) {
204
+ lines.push(line);
205
+ line = token;
206
+ }
207
+ else {
208
+ line = line === "" ? token : `${line} ${token}`;
209
+ }
210
+ }
211
+ lines.push(line);
212
+ return lines.join("\n");
213
+ }
@@ -0,0 +1,117 @@
1
+ import { Chess, type Square } from "chess.js";
2
+ import { type PieceLetter } from "./facts.ts";
3
+ import type { Move, Side } from "./game.ts";
4
+ import { type Material } from "./material.ts";
5
+ /** The pawns of a side, by kind; a pawn may be of several. */
6
+ export interface PawnFacts {
7
+ /** No pawn of its side on the files beside it. */
8
+ readonly isolated: readonly Square[];
9
+ /** Another pawn of its side on its file. */
10
+ readonly doubled: readonly Square[];
11
+ /** Pawns of its side beside it, all of them further on, and the square in front of it attacked by an enemy pawn. */
12
+ readonly backward: readonly Square[];
13
+ /** No enemy pawn in front of it, on its file or the files beside it. */
14
+ readonly passed: readonly Square[];
15
+ /** Groups of neighbouring files with pawns of its side, by their files: "a-b", "d", "f-h". */
16
+ readonly islands: readonly string[];
17
+ }
18
+ /** A bishop with how many pawns of its side stand on squares of its colour. */
19
+ export interface BishopFacts {
20
+ readonly square: Square;
21
+ readonly pawnsOnColour: number;
22
+ readonly pawns: number;
23
+ }
24
+ /** A square on the side's ranks 4 to 6, defended by its pawn, that no enemy pawn can ever attack and no pawn stands on; with the piece of the side on it. */
25
+ export interface Outpost {
26
+ readonly square: Square;
27
+ readonly piece?: PieceLetter;
28
+ }
29
+ /** Around a side's king: its pawns in front of it, the files beside it without a pawn of its side, and how many enemy attacks fall on the squares next to it. */
30
+ export interface KingFacts {
31
+ readonly square: Square;
32
+ readonly shield: readonly Square[];
33
+ readonly openFiles: readonly string[];
34
+ readonly attacksNear: number;
35
+ }
36
+ /** The profile of one side. */
37
+ export interface SideProfile {
38
+ readonly pawns: PawnFacts;
39
+ /** Files with enemy pawns and none of its own. */
40
+ readonly halfOpenFiles: readonly string[];
41
+ /** Its rooks and queens on open or half-open files. */
42
+ readonly heavyOnFiles: readonly Square[];
43
+ /** Its rooks and queens on the enemy's second rank: the seventh for White, the second for Black. */
44
+ readonly onSeventh: readonly Square[];
45
+ readonly bishopPair: boolean;
46
+ readonly bishops: readonly BishopFacts[];
47
+ readonly outposts: readonly Outpost[];
48
+ /** Squares of the other half that one of its pieces attacks. */
49
+ readonly space: number;
50
+ /** Its legal moves by piece, as if it were to move; left out for the side not to move when the other is in check. */
51
+ readonly mobility?: Readonly<Partial<Record<PieceLetter, number>>>;
52
+ /** Its knights and bishops still on their first squares. */
53
+ readonly undeveloped: readonly Square[];
54
+ /** How it castled, or "no"; left out when the game began from a FEN and it has not castled since, which says nothing of before. */
55
+ readonly castled?: "O-O" | "O-O-O" | "no";
56
+ readonly castlingRights: readonly ("O-O" | "O-O-O")[];
57
+ readonly king?: KingFacts;
58
+ }
59
+ /** Who stands on a central square and how many pieces of each side attack it. */
60
+ export interface CentreSquare {
61
+ readonly square: Square;
62
+ readonly piece?: {
63
+ readonly letter: PieceLetter;
64
+ readonly side: Side;
65
+ };
66
+ readonly white: number;
67
+ readonly black: number;
68
+ }
69
+ /**
70
+ * The game's phase, by a rule, never a judgment of the position: endgame when the pieces but pawns and kings add up to 26 or less (62 at the start, N and B
71
+ * 3, R 5, Q 9); opening while they add up to 56 or more, by move 15, and a knight or bishop is still on its first square; middlegame otherwise.
72
+ */
73
+ export type Phase = "opening" | "middlegame" | "endgame";
74
+ /** A passed pawn of a pawn ending with whether the enemy king is inside its square, the side to move counted. */
75
+ export interface PawnSquare {
76
+ readonly pawn: Square;
77
+ readonly caught: boolean;
78
+ }
79
+ /** The profile of a position (D-19): data for the AI and the page, never a judgment. */
80
+ export interface Profile {
81
+ readonly material: Material;
82
+ readonly white: SideProfile;
83
+ readonly black: SideProfile;
84
+ /** Files without pawns. */
85
+ readonly openFiles: readonly string[];
86
+ /** One bishop each, on squares of different colours. */
87
+ readonly oppositeBishops: boolean;
88
+ readonly centre: readonly CentreSquare[];
89
+ /** For each square, how many pieces of each side attack it, as the facts count them (a piece pinned to its king only along the pin): [White, Black]. */
90
+ readonly control: Readonly<Partial<Record<Square, readonly [number, number]>>>;
91
+ /** How many squares only White attacks, only Black, and both. */
92
+ readonly controlled: {
93
+ readonly white: number;
94
+ readonly black: number;
95
+ readonly contested: number;
96
+ };
97
+ readonly phase: Phase;
98
+ /** How many times the position has stood with this side to move, now included; the half-moves left before the fifty-move draw; material too little to mate. */
99
+ readonly draw: {
100
+ readonly repetitions: number;
101
+ readonly fiftyMovesLeft: number;
102
+ readonly insufficient: boolean;
103
+ };
104
+ /** Only kings and pawns: the rule of the square for each passed pawn, and the side that has the opposition, when the kings stand in it. */
105
+ readonly pawnEnding?: {
106
+ readonly squares: readonly PawnSquare[];
107
+ readonly opposition?: Side;
108
+ };
109
+ }
110
+ /** What the profile needs of the game's past: how many times the position has stood, now included, how each side castled, and whether it began from a FEN. */
111
+ export interface Past {
112
+ readonly repetitions: number;
113
+ readonly castled: Readonly<Partial<Record<Side, "O-O" | "O-O-O">>>;
114
+ readonly fromFen: boolean;
115
+ }
116
+ /** The profile of a chess.js board and its game's past: computed once for each position and past, and frozen. */
117
+ export declare function profileOf(chess: Chess, past: Past, legal: readonly Move[]): Profile;
@@ -0,0 +1,232 @@
1
+ // The positional profile (D-19): pawns, files, bishops, outposts, space and centre, mobility, development, king safety, control, phase, draws and
2
+ // the geometry of pawn endings, each a separate fact; never weighed, never summed.
3
+ import { Chess } from "chess.js";
4
+ import { attacksOf, fileOf, rankOf } from "./facts.js";
5
+ import { materialOf } from "./material.js";
6
+ /** The profile of a chess.js board and its game's past: computed once for each position and past, and frozen. */
7
+ export function profileOf(chess, past, legal) {
8
+ // The FEN, not the hash: the fifty-move count and the move number are facts of the profile too.
9
+ const key = `${chess.fen()} ${past.repetitions} ${past.castled.white ?? ""} ${past.castled.black ?? ""} ${past.fromFen}`;
10
+ let profile = profileByPosition.get(key);
11
+ if (profile === undefined) {
12
+ profile = deepFrozen(computed(chess, past.repetitions, { w: castledOf(past, "white"), b: castledOf(past, "black") }, legal));
13
+ if (profileByPosition.size >= positionsKept) {
14
+ profileByPosition.delete(profileByPosition.keys().next().value);
15
+ }
16
+ profileByPosition.set(key, profile);
17
+ }
18
+ return profile;
19
+ }
20
+ const profileByPosition = new Map();
21
+ const positionsKept = 256;
22
+ const files = "abcdefgh";
23
+ const allSquares = [..."abcdefgh"].flatMap((file) => [1, 2, 3, 4, 5, 6, 7, 8].map((rank) => `${file}${rank}`));
24
+ const centreSquares = ["d4", "e4", "d5", "e5"];
25
+ const pieceValues = { p: 0, n: 3, b: 3, r: 5, q: 9, k: 0 };
26
+ const firstSquares = {
27
+ w: [["b1", "n"], ["g1", "n"], ["c1", "b"], ["f1", "b"]],
28
+ b: [["b8", "n"], ["g8", "n"], ["c8", "b"], ["f8", "b"]],
29
+ };
30
+ function computed(chess, repetitions, castled, legal) {
31
+ const placed = chess.board().flat().filter((cell) => cell !== null).map(({ square, type, color }) => ({ square, type, color }));
32
+ const attackers = attacksOf(chess);
33
+ const control = Object.fromEntries(allSquares.map((square) => [square, [attackers(square, "w").length, attackers(square, "b").length]]));
34
+ const pawnsOn = (color, file) => placed.filter((piece) => piece.type === "p" && piece.color === color && fileOf(piece.square) === file);
35
+ const openFiles = [...files].filter((_, file) => pawnsOn("w", file).length === 0 && pawnsOn("b", file).length === 0);
36
+ const bishops = placed.filter((piece) => piece.type === "b");
37
+ const onlyKingsAndPawns = placed.every((piece) => piece.type === "k" || piece.type === "p");
38
+ const mobility = mobilityOf(chess, legal);
39
+ const side = (color) => sideProfile(chess, color, placed, control, castled[color], mobility[color]);
40
+ const counts = allSquares.map((square) => control[square]);
41
+ const endingFacts = onlyKingsAndPawns ? pawnEnding(chess, placed) : undefined;
42
+ return {
43
+ material: materialOf(chess.fen()),
44
+ white: side("w"),
45
+ black: side("b"),
46
+ openFiles,
47
+ oppositeBishops: bishops.length === 2 && bishops[0].color !== bishops[1].color && squareColour(bishops[0].square) !== squareColour(bishops[1].square),
48
+ centre: centreSquares.map((square) => centreSquare(chess, square, control[square])),
49
+ control,
50
+ controlled: {
51
+ white: counts.filter(([white, black]) => white > 0 && black === 0).length,
52
+ black: counts.filter(([white, black]) => black > 0 && white === 0).length,
53
+ contested: counts.filter(([white, black]) => white > 0 && black > 0).length,
54
+ },
55
+ phase: phaseOf(chess, placed),
56
+ draw: { repetitions, fiftyMovesLeft: Math.max(0, 100 - Number(chess.fen().split(" ")[4])), insufficient: chess.isInsufficientMaterial() },
57
+ ...(endingFacts === undefined ? {} : { pawnEnding: endingFacts }),
58
+ };
59
+ }
60
+ function sideProfile(chess, color, placed, control, castled, mobility) {
61
+ const enemy = color === "w" ? "b" : "w";
62
+ const mine = placed.filter((piece) => piece.color === color);
63
+ const pawns = mine.filter((piece) => piece.type === "p");
64
+ const enemyPawns = placed.filter((piece) => piece.type === "p" && piece.color === enemy);
65
+ const hasPawn = (list, file) => list.some((pawn) => fileOf(pawn.square) === file);
66
+ const halfOpenFiles = [...files].filter((_, file) => !hasPawn(pawns, file) && hasPawn(enemyPawns, file));
67
+ const bishops = mine.filter((piece) => piece.type === "b");
68
+ const otherHalf = allSquares.filter((square) => (color === "w" ? rankOf(square) >= 5 : rankOf(square) <= 4));
69
+ const index = color === "w" ? 0 : 1;
70
+ const rights = chess.getCastlingRights(color);
71
+ const king = mine.find((piece) => piece.type === "k");
72
+ return {
73
+ pawns: pawnFacts(color, pawns, enemyPawns),
74
+ halfOpenFiles,
75
+ heavyOnFiles: mine.filter((piece) => (piece.type === "r" || piece.type === "q") && !hasPawn(pawns, fileOf(piece.square))).map((piece) => piece.square).sort(),
76
+ onSeventh: mine.filter((piece) => (piece.type === "r" || piece.type === "q") && rankOf(piece.square) === (color === "w" ? 7 : 2)).map((piece) => piece.square).sort(),
77
+ bishopPair: bishops.length >= 2 && new Set(bishops.map((bishop) => squareColour(bishop.square))).size === 2,
78
+ bishops: bishops.map((bishop) => ({
79
+ square: bishop.square,
80
+ pawnsOnColour: pawns.filter((pawn) => squareColour(pawn.square) === squareColour(bishop.square)).length,
81
+ pawns: pawns.length,
82
+ })),
83
+ outposts: outpostsOf(chess, color, pawns, enemyPawns),
84
+ space: otherHalf.filter((square) => control[square][index] > 0).length,
85
+ ...(mobility === undefined ? {} : { mobility }),
86
+ undeveloped: firstSquares[color].filter(([square, type]) => chess.get(square)?.type === type && chess.get(square)?.color === color).map(([square]) => square),
87
+ ...(castled === undefined ? {} : { castled }),
88
+ castlingRights: [...(rights.k ? ["O-O"] : []), ...(rights.q ? ["O-O-O"] : [])],
89
+ ...(king === undefined ? {} : { king: kingFacts(king.square, color, pawns, control) }),
90
+ };
91
+ }
92
+ function pawnFacts(color, pawns, enemyPawns) {
93
+ const forward = color === "w" ? 1 : -1;
94
+ const ahead = (from, other) => (rankOf(other) - rankOf(from)) * forward > 0;
95
+ const besides = (pawn) => pawns.filter((other) => Math.abs(fileOf(other.square) - fileOf(pawn.square)) === 1);
96
+ const stopAttacked = (pawn) => enemyPawns.some((enemy) => Math.abs(fileOf(enemy.square) - fileOf(pawn.square)) === 1 && rankOf(enemy.square) === rankOf(pawn.square) + 2 * forward);
97
+ const usedFiles = [...new Set(pawns.map((pawn) => fileOf(pawn.square)))].sort();
98
+ const sorted = (list) => list.map((pawn) => pawn.square).sort();
99
+ return {
100
+ isolated: sorted(pawns.filter((pawn) => besides(pawn).length === 0)),
101
+ doubled: sorted(pawns.filter((pawn) => pawns.some((other) => other !== pawn && fileOf(other.square) === fileOf(pawn.square)))),
102
+ backward: sorted(pawns.filter((pawn) => besides(pawn).length > 0 && besides(pawn).every((other) => ahead(pawn.square, other.square)) && stopAttacked(pawn))),
103
+ passed: sorted(pawns.filter((pawn) => !enemyPawns.some((enemy) => Math.abs(fileOf(enemy.square) - fileOf(pawn.square)) <= 1 && ahead(pawn.square, enemy.square)))),
104
+ islands: islandsOf(usedFiles),
105
+ };
106
+ }
107
+ /** The pawn islands, from the files with pawns in order: neighbouring files make one, named by its first and last file. */
108
+ function islandsOf(usedFiles) {
109
+ const islands = [];
110
+ for (const file of usedFiles) {
111
+ const last = islands.at(-1);
112
+ if (last !== undefined && last.at(-1) === file - 1) {
113
+ last.push(file);
114
+ }
115
+ else {
116
+ islands.push([file]);
117
+ }
118
+ }
119
+ return islands.map((island) => (island.length === 1 ? files[island[0]] : `${files[island[0]]}-${files[island.at(-1)]}`));
120
+ }
121
+ /** The outposts of a side: on ranks 4 to 6 as it sees the board, defended by its pawn, out of reach of every enemy pawn, not under one of its pawns. */
122
+ function outpostsOf(chess, color, pawns, enemyPawns) {
123
+ const forward = color === "w" ? 1 : -1;
124
+ const ranks = color === "w" ? [4, 5, 6] : [5, 4, 3];
125
+ return allSquares.filter((square) => {
126
+ const file = fileOf(square);
127
+ const rank = rankOf(square);
128
+ const defended = pawns.some((pawn) => Math.abs(fileOf(pawn.square) - file) === 1 && rankOf(pawn.square) === rank - forward);
129
+ const reachable = enemyPawns.some((enemy) => Math.abs(fileOf(enemy.square) - file) === 1 && (rankOf(enemy.square) - rank) * forward > 0);
130
+ const own = chess.get(square);
131
+ return ranks.includes(rank) && defended && !reachable && own?.type !== "p";
132
+ }).map((square) => {
133
+ const piece = chess.get(square);
134
+ return piece?.color === color ? { square, piece: piece.type.toUpperCase() } : { square };
135
+ });
136
+ }
137
+ function kingFacts(square, color, pawns, control) {
138
+ const forward = color === "w" ? 1 : -1;
139
+ const file = fileOf(square);
140
+ const near = [file - 1, file, file + 1].filter((other) => other >= 0 && other < 8);
141
+ const enemy = color === "w" ? 1 : 0;
142
+ const around = allSquares.filter((other) => other !== square && Math.abs(fileOf(other) - file) <= 1 && Math.abs(rankOf(other) - rankOf(square)) <= 1);
143
+ return {
144
+ square,
145
+ shield: pawns.filter((pawn) => near.includes(fileOf(pawn.square)) && [1, 2].includes((rankOf(pawn.square) - rankOf(square)) * forward)).map((pawn) => pawn.square).sort(),
146
+ openFiles: near.filter((other) => !pawns.some((pawn) => fileOf(pawn.square) === other && (rankOf(pawn.square) - rankOf(square)) * forward > 0))
147
+ .map((other) => files[other]),
148
+ attacksNear: around.reduce((sum, other) => sum + control[other][enemy], 0),
149
+ };
150
+ }
151
+ /** The legal moves of each side by piece: the side to move's, and the other's on the same board with the turn passed, unless the side to move is in check. */
152
+ function mobilityOf(chess, legal) {
153
+ const byPiece = (letters) => {
154
+ const counts = {};
155
+ for (const letter of letters) {
156
+ counts[letter] = (counts[letter] ?? 0) + 1;
157
+ }
158
+ return counts;
159
+ };
160
+ const turn = chess.turn();
161
+ // A promotion is one move, whatever the pawn becomes.
162
+ const mine = byPiece(legal.filter((move) => move.promotion === undefined || move.promotion === "q").map((move) => chess.get(move.from).type.toUpperCase()));
163
+ if (chess.inCheck()) {
164
+ return { [turn]: mine };
165
+ }
166
+ const fields = chess.fen().split(" ");
167
+ fields[1] = turn === "w" ? "b" : "w";
168
+ fields[3] = "-";
169
+ // A SAN names its piece first, castling is the king's, and a pawn's move starts with its file.
170
+ const letterOf = (san) => (san.startsWith("O-O") ? "K" : /^[KQRBN]/.test(san) ? san[0] : "P");
171
+ return { [turn]: mine, [fields[1]]: byPiece(new Chess(fields.join(" ")).moves().filter((san) => !/=[RBN]/.test(san)).map(letterOf)) };
172
+ }
173
+ function centreSquare(chess, square, [white, black]) {
174
+ const piece = chess.get(square);
175
+ return piece === undefined ? { square, white, black } : { square, piece: { letter: piece.type.toUpperCase(), side: piece.color === "w" ? "white" : "black" }, white, black };
176
+ }
177
+ function phaseOf(chess, placed) {
178
+ const material = placed.reduce((sum, piece) => sum + pieceValues[piece.type], 0);
179
+ if (material <= 26) {
180
+ return "endgame";
181
+ }
182
+ const undeveloped = ["w", "b"].some((color) => firstSquares[color].some(([square, type]) => chess.get(square)?.type === type && chess.get(square)?.color === color));
183
+ return material >= 56 && chess.moveNumber() <= 15 && undeveloped ? "opening" : "middlegame";
184
+ }
185
+ /** The rule of the square for each passed pawn, and the opposition of the kings: on one file, rank or diagonal, an odd number of squares between them. */
186
+ function pawnEnding(chess, placed) {
187
+ const kings = placed.filter((piece) => piece.type === "k");
188
+ const kingOf = (color) => kings.find((king) => king.color === color)?.square;
189
+ const squares = [];
190
+ for (const color of ["w", "b"]) {
191
+ const pawns = placed.filter((piece) => piece.type === "p" && piece.color === color);
192
+ const enemyPawns = placed.filter((piece) => piece.type === "p" && piece.color !== color);
193
+ const defender = kingOf(color === "w" ? "b" : "w");
194
+ for (const pawn of pawnFacts(color, pawns, enemyPawns).passed) {
195
+ const promotion = `${pawn[0]}${color === "w" ? 8 : 1}`;
196
+ // From its first square a pawn steps two: it is as far as from the next one.
197
+ const steps = Math.min(5, Math.abs((color === "w" ? 8 : 1) - rankOf(pawn)));
198
+ // The king takes the new queen on its square: one move more when it is the defender's turn.
199
+ const reach = steps + (chess.turn() === color ? 0 : 1);
200
+ if (defender !== undefined) {
201
+ squares.push({ pawn, caught: distance(defender, promotion) <= reach });
202
+ }
203
+ }
204
+ }
205
+ const white = kingOf("w");
206
+ const black = kingOf("b");
207
+ const opposition = white === undefined || black === undefined ? undefined : oppositionOf(white, black, chess.turn());
208
+ return opposition === undefined ? { squares } : { squares, opposition };
209
+ }
210
+ /** The side that has the opposition: the one not to move, when the kings stand on one line with an odd number of squares between them. */
211
+ function oppositionOf(white, black, turn) {
212
+ const files = Math.abs(fileOf(white) - fileOf(black));
213
+ const ranks = Math.abs(rankOf(white) - rankOf(black));
214
+ const between = files === 0 ? ranks - 1 : ranks === 0 ? files - 1 : files === ranks ? files - 1 : -1;
215
+ return between > 0 && between % 2 === 1 ? (turn === "w" ? "black" : "white") : undefined;
216
+ }
217
+ function castledOf(past, side) {
218
+ return past.castled[side] ?? (past.fromFen ? undefined : "no");
219
+ }
220
+ function squareColour(square) {
221
+ return (fileOf(square) + rankOf(square)) % 2;
222
+ }
223
+ function distance(from, to) {
224
+ return Math.max(Math.abs(fileOf(from) - fileOf(to)), Math.abs(rankOf(from) - rankOf(to)));
225
+ }
226
+ function deepFrozen(value) {
227
+ if (typeof value === "object" && value !== null) {
228
+ Object.values(value).forEach(deepFrozen);
229
+ Object.freeze(value);
230
+ }
231
+ return value;
232
+ }
@@ -0,0 +1,21 @@
1
+ /** A judgment on a move: NAGs ($1 good move, $2 mistake, $3 brilliant…) and a comment. */
2
+ export interface Annotation {
3
+ readonly nags?: readonly number[];
4
+ readonly comment?: string;
5
+ }
6
+ /** A move of a recorded game: its SAN and its judgment. */
7
+ export interface RecordedMove extends Annotation {
8
+ readonly san: string;
9
+ }
10
+ /** A game as plain data: its PGN tags (a FEN tag when it starts from another position), the comment before its first move, its moves. */
11
+ export interface GameRecord {
12
+ readonly tags: Readonly<Record<string, string>>;
13
+ readonly comment?: string;
14
+ readonly moves: readonly RecordedMove[];
15
+ }
16
+ /** The game with its tags and moves alone, without comments, plans or judgments: for Lichess or chess.com to load. */
17
+ export declare function movesOnly(record: GameRecord): GameRecord;
18
+ /** A recorded move, frozen, with only the annotations it has. */
19
+ export declare function recordedMove(san: string, nags?: readonly number[], comment?: string): RecordedMove;
20
+ /** A comment as PGN can hold it: on one line, without a closing brace. */
21
+ export declare function cleanComment(text: string): string;
@@ -0,0 +1,25 @@
1
+ // A game as plain data: the form it takes on disk (D-27) and in a PGN.
2
+ /** The game with its tags and moves alone, without comments, plans or judgments: for Lichess or chess.com to load. */
3
+ export function movesOnly(record) {
4
+ return { tags: record.tags, moves: record.moves.map((move) => ({ san: move.san })) };
5
+ }
6
+ /** A recorded move, frozen, with only the annotations it has. */
7
+ export function recordedMove(san, nags = [], comment = "") {
8
+ const wrong = nags.find((nag) => !Number.isInteger(nag) || nag < 0 || nag > 255);
9
+ if (wrong !== undefined) {
10
+ throw new RangeError(`${wrong} is not a NAG: NAGs are whole numbers from 0 to 255.`);
11
+ }
12
+ const text = cleanComment(comment);
13
+ const move = { san };
14
+ if (nags.length > 0) {
15
+ move.nags = Object.freeze([...nags]);
16
+ }
17
+ if (text !== "") {
18
+ move.comment = text;
19
+ }
20
+ return Object.freeze(move);
21
+ }
22
+ /** A comment as PGN can hold it: on one line, without a closing brace. */
23
+ export function cleanComment(text) {
24
+ return text.replaceAll("}", "").replace(/\s+/g, " ").trim();
25
+ }