chesspal 0.3.5 → 0.3.6

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 (102) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/cli.js +10 -2
  3. package/dist/cli/command-line.js +17 -9
  4. package/dist/cli/icon.d.ts +2 -0
  5. package/dist/cli/icon.js +11 -1
  6. package/dist/commands/analysis/analyse.d.ts +2 -2
  7. package/dist/commands/analysis/analyse.js +3 -3
  8. package/dist/commands/answers/agents.d.ts +10 -2
  9. package/dist/commands/answers/agents.js +30 -7
  10. package/dist/commands/answers/index.d.ts +1 -1
  11. package/dist/commands/answers/index.js +1 -1
  12. package/dist/commands/answers/lines.js +3 -2
  13. package/dist/commands/answers/summary.d.ts +2 -1
  14. package/dist/commands/answers/view.d.ts +1 -1
  15. package/dist/commands/answers/view.js +2 -2
  16. package/dist/commands/game/look.d.ts +7 -3
  17. package/dist/commands/game/look.js +9 -4
  18. package/dist/commands/game/new.js +1 -1
  19. package/dist/commands/game/ponder.js +3 -3
  20. package/dist/commands/game/position.d.ts +2 -1
  21. package/dist/commands/game/position.js +9 -4
  22. package/dist/commands/game/status.d.ts +3 -1
  23. package/dist/commands/game/status.js +19 -9
  24. package/dist/commands/profiles/past-games.d.ts +7 -2
  25. package/dist/commands/profiles/past-games.js +26 -10
  26. package/dist/commands/talk/ai-say.js +1 -1
  27. package/dist/games/analysis.d.ts +7 -4
  28. package/dist/games/analysis.js +5 -3
  29. package/dist/games/index.d.ts +1 -1
  30. package/dist/games/index.js +1 -1
  31. package/dist/games/thoughts.d.ts +12 -4
  32. package/dist/games/thoughts.js +27 -10
  33. package/dist/home/index.d.ts +1 -0
  34. package/dist/home/index.js +1 -0
  35. package/dist/home/same-path.d.ts +2 -0
  36. package/dist/home/same-path.js +5 -0
  37. package/dist/host-files/host-files.js +8 -3
  38. package/dist/page/app/anchor.js +15 -11
  39. package/dist/page/app/app.js +20 -8
  40. package/dist/page/app/served.js +23 -36
  41. package/dist/page/app/watch.js +97 -0
  42. package/dist/page/avatars/torretta.css +108 -0
  43. package/dist/page/board/board.css +7 -6
  44. package/dist/page/board/marks.js +2 -2
  45. package/dist/page/board/pointer.js +2 -2
  46. package/dist/page/dialogs/analyses.js +5 -3
  47. package/dist/page/dialogs/rows.js +72 -0
  48. package/dist/page/dialogs/site-games.js +3 -1
  49. package/dist/page/game/game.css +51 -10
  50. package/dist/page/game/move-list.js +7 -5
  51. package/dist/page/guide/en.md +1 -1
  52. package/dist/page/guide/it.md +1 -1
  53. package/dist/page/helps/helps.css +5 -5
  54. package/dist/page/helps/helps.js +3 -181
  55. package/dist/page/helps/rows.js +16 -0
  56. package/dist/page/helps/strategy.js +105 -0
  57. package/dist/page/helps/tactics.js +69 -0
  58. package/dist/page/layout/header.js +49 -6
  59. package/dist/page/layout/layout.css +17 -2
  60. package/dist/page/layout/window.js +4 -4
  61. package/dist/page/lead/lead.js +41 -4
  62. package/dist/page/sounds/sounds.js +1 -1
  63. package/dist/page/style.css +1 -0
  64. package/dist/page/talk/talk.css +0 -109
  65. package/dist/page/text.js +10 -0
  66. package/dist/page/theme.css +13 -0
  67. package/dist/profiles/profiles.js +5 -1
  68. package/dist/server/bell/bell.d.ts +0 -2
  69. package/dist/server/bell/bell.js +17 -7
  70. package/dist/server/bell/index.d.ts +1 -1
  71. package/dist/server/bell/index.js +1 -1
  72. package/dist/server/page/chat.d.ts +5 -1
  73. package/dist/server/page/chat.js +5 -1
  74. package/dist/server/page/game.js +18 -3
  75. package/dist/server/page/table.d.ts +2 -1
  76. package/dist/server/page/table.js +4 -2
  77. package/dist/server/page/update.d.ts +1 -1
  78. package/dist/server/page/update.js +4 -4
  79. package/dist/server/table/hosts/chats.d.ts +5 -0
  80. package/dist/server/table/hosts/chats.js +11 -4
  81. package/dist/server/table/hosts/play.d.ts +3 -1
  82. package/dist/server/table/hosts/play.js +17 -5
  83. package/dist/server/table/icon/index.d.ts +2 -0
  84. package/dist/server/table/icon/index.js +16 -2
  85. package/dist/server/table/index.d.ts +1 -1
  86. package/dist/server/table/index.js +1 -1
  87. package/dist/server/table/link.js +3 -1
  88. package/dist/server/table/table.js +2 -2
  89. package/dist/sites/index.d.ts +1 -1
  90. package/dist/sites/index.js +1 -1
  91. package/dist/sites/{chesscom.d.ts → ratings/chesscom.d.ts} +2 -2
  92. package/dist/sites/{chesscom.js → ratings/chesscom.js} +1 -1
  93. package/dist/sites/{choice.d.ts → ratings/choice.d.ts} +1 -1
  94. package/dist/sites/{choice.js → ratings/choice.js} +1 -1
  95. package/dist/sites/ratings/index.d.ts +1 -0
  96. package/dist/sites/ratings/index.js +2 -0
  97. package/dist/sites/{lichess.d.ts → ratings/lichess.d.ts} +2 -2
  98. package/dist/sites/{lichess.js → ratings/lichess.js} +1 -1
  99. package/dist/sites/{sites.d.ts → ratings/ratings.d.ts} +2 -2
  100. package/dist/sites/{sites.js → ratings/ratings.js} +1 -1
  101. package/instructions/start.md +1 -1
  102. package/package.json +1 -1
@@ -8,19 +8,32 @@ import { defineCommand } from "../registry/index.js";
8
8
  export const pastGames = defineCommand({
9
9
  name: "past_games",
10
10
  description: "Lists the games played with this player, the latest first: when, your colour, the result, the opening, how long, and its name in the "
11
- + "archive. With game, that game's PGN with your comments and plans; to comment a game with the player, analyse_game. new_game already gives the "
12
- + "last three. Example: past_games {}.",
13
- input: z.object({ game: z.string().optional().describe("a game's name, as the list writes it: its PGN") }),
11
+ + "archive. With search, only the games whose line holds every word of it; the colour and the result are yours, the AI's. With game, that game's "
12
+ + "PGN with your comments and plans; to comment a game with the player, analyse_game. new_game already gives the last three. Example, the "
13
+ + 'Sicilians the player lost: past_games {search: "Sicilian won"}.',
14
+ input: z.object({
15
+ search: z.string().optional().describe("words the game's line must all hold: a date, your colour, won, lost, drew or unfinished as you did, an opening"),
16
+ game: z.string().optional().describe("a game's name, as the list writes it: its PGN"),
17
+ }),
14
18
  readOnly: true,
15
19
  idempotent: true,
16
- run: ({ game }, context) => {
20
+ run: ({ search, game }, context) => {
17
21
  const folder = gameFolder(context.home());
18
- return game === undefined ? { games: pastGameLines(folder, listed) } : { pgn: writePgn(archivedGame(folder, game)) };
22
+ return game === undefined ? { games: pastGameLines(folder, listed, search), searched: search?.trim() ? true : undefined } : { pgn: writePgn(archivedGame(folder, game)) };
23
+ },
24
+ format: (past) => {
25
+ if ("pgn" in past) {
26
+ return past.pgn;
27
+ }
28
+ return past.games.length > 0 ? past.games.join("\n") : past.searched ? "No game played holds all those words." : "No game played yet.";
19
29
  },
20
- format: (past) => ("pgn" in past ? past.pgn : past.games.length === 0 ? "No game played yet." : past.games.join("\n")),
21
30
  });
22
- /** The latest games of the archive, one line each as the AI reads them: none when there is no archive. A game closed before any move is no game played. */
23
- export function pastGameLines(folder, count) {
31
+ /**
32
+ * The latest games of the archive, one line each as the AI reads them, those holding every word searched: none when there is no archive. A game closed
33
+ * before any move is no game played.
34
+ */
35
+ export function pastGameLines(folder, count, search = "") {
36
+ const words = search.toLowerCase().split(/\s+/).filter((word) => word !== "");
24
37
  const lines = [];
25
38
  for (const name of archivedGames(folder)) {
26
39
  if (lines.length === count) {
@@ -28,8 +41,11 @@ export function pastGameLines(folder, count) {
28
41
  }
29
42
  try {
30
43
  const record = archivedGame(folder, name);
31
- if (record.moves.length > 0) {
32
- lines.push(pastGameLine(name, record));
44
+ const line = record.moves.length > 0 ? pastGameLine(name, record) : undefined;
45
+ // The ending names the player's colour too, as "(Black resigns)": a colour searched is the AI's alone.
46
+ const searched = line?.replace(/ \((?:White|Black) [^)]*\)/, "").toLowerCase();
47
+ if (line !== undefined && words.every((word) => searched.includes(word))) {
48
+ lines.push(line);
33
49
  }
34
50
  }
35
51
  catch {
@@ -43,7 +43,7 @@ function afterWords(game, words) {
43
43
  return `Next: ${reviewedLine(game.end.archive)} When they want another game, ${newGameStep}`;
44
44
  }
45
45
  return game.end.analyst === "offer"
46
- ? `Next: when the player wants this game gone over, ${analystLine(game.end.archive)} When they want another game, ${newGameStep}`
46
+ ? `Next: when the player wants this game gone over, ${analystLine(game.end.archive, game.end.codex === true)} When they want another game, ${newGameStep}`
47
47
  : `Next: when the player wants another game, ${newGameStep}`;
48
48
  }
49
49
  if (words.length === 0 && game?.drawOffer === "player" && game.turn !== game.ai) {
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * The game in analysis: its name as the lists write it, when the analysis last changed it, whether the player brought it on the page and the AI has not
3
- * gone over it yet, the moves whose note the chat corrected with the player, which the analyst leaves (D-101), and the run of the analyst that took it.
3
+ * gone over it yet, and whether they asked there for a new review of it; the moves whose note the chat corrected with the player, which the analyst leaves
4
+ * (D-101), and the run of the analyst that took it.
4
5
  */
5
6
  export interface Analysis {
6
7
  readonly game: string;
7
8
  readonly at: string;
8
9
  readonly brought?: true;
10
+ readonly redo?: true;
9
11
  readonly corrected?: readonly string[];
10
12
  readonly analyst?: AnalystRun;
11
13
  }
@@ -16,11 +18,12 @@ export interface AnalystRun {
16
18
  readonly since: string;
17
19
  }
18
20
  /**
19
- * How a game is named in analysis: brought to the page by the player, with the moves the chat just corrected, taken by a run of the analyst, or afresh,
20
- * without the corrections and the analyst it had.
21
+ * How a game is named in analysis: brought to the page by the player, for a new review too, with the moves the chat just corrected, taken by a run of the
22
+ * analyst, or afresh, without the corrections and the analyst it had.
21
23
  */
22
24
  export interface AnalysisStart {
23
25
  readonly brought?: boolean;
26
+ readonly redo?: boolean;
24
27
  readonly corrected?: readonly string[];
25
28
  readonly analyst?: AnalystRun;
26
29
  readonly afresh?: boolean;
@@ -30,7 +33,7 @@ export interface AnalysisStart {
30
33
  * unless it starts afresh, in one write, so that the page never sees it leave the analysis; the analyst stays with the game it took, whichever the page
31
34
  * opens meanwhile (Federico), and goes when that game starts afresh.
32
35
  */
33
- export declare function startAnalysis(folder: string, game: string, now: Date, { brought, corrected, analyst, afresh }?: AnalysisStart): void;
36
+ export declare function startAnalysis(folder: string, game: string, now: Date, { brought, redo, corrected, analyst, afresh }?: AnalysisStart): void;
34
37
  /** Lets the analyst go from the game it took, when that game's review is deleted while the page shows another. */
35
38
  export declare function forgetAnalyst(folder: string): void;
36
39
  /** The game in analysis, or none when there is none, or analysis.json cannot be read. */
@@ -8,7 +8,7 @@ import { writeAtomically } from "./files.js";
8
8
  * unless it starts afresh, in one write, so that the page never sees it leave the analysis; the analyst stays with the game it took, whichever the page
9
9
  * opens meanwhile (Federico), and goes when that game starts afresh.
10
10
  */
11
- export function startAnalysis(folder, game, now, { brought = false, corrected = [], analyst, afresh = false } = {}) {
11
+ export function startAnalysis(folder, game, now, { brought = false, redo = false, corrected = [], analyst, afresh = false } = {}) {
12
12
  const before = gameInAnalysis(folder);
13
13
  const same = !afresh && before?.game === game;
14
14
  const kept = [...new Set([...(same ? before.corrected ?? [] : []), ...corrected])];
@@ -17,6 +17,7 @@ export function startAnalysis(folder, game, now, { brought = false, corrected =
17
17
  game,
18
18
  at: now.toISOString(),
19
19
  ...(brought ? { brought: true } : {}),
20
+ ...(brought && redo ? { redo: true } : {}),
20
21
  ...(kept.length === 0 ? {} : { corrected: kept }),
21
22
  ...withAnalyst(analyst ?? held),
22
23
  });
@@ -25,8 +26,8 @@ export function startAnalysis(folder, game, now, { brought = false, corrected =
25
26
  export function forgetAnalyst(folder) {
26
27
  const analysis = gameInAnalysis(folder);
27
28
  if (analysis?.analyst !== undefined) {
28
- const { game, at, brought, corrected } = analysis;
29
- writeAnalysis(folder, { game, at, ...(brought === undefined ? {} : { brought }), ...(corrected === undefined ? {} : { corrected }) });
29
+ const { game, at, brought, redo, corrected } = analysis;
30
+ writeAnalysis(folder, { game, at, ...(brought === undefined ? {} : { brought }), ...(redo === undefined ? {} : { redo }), ...(corrected === undefined ? {} : { corrected }) });
30
31
  }
31
32
  }
32
33
  /** The game in analysis, or none when there is none, or analysis.json cannot be read. */
@@ -39,6 +40,7 @@ export function gameInAnalysis(folder) {
39
40
  game: analysis.game,
40
41
  at: analysis.at,
41
42
  ...(analysis.brought === true ? { brought: true } : {}),
43
+ ...(analysis.brought === true && analysis.redo === true ? { redo: true } : {}),
42
44
  ...(corrected.length === 0 ? {} : { corrected }),
43
45
  ...withAnalyst(isAnalystRun(analysis.analyst) ? analysis.analyst : undefined),
44
46
  }
@@ -8,4 +8,4 @@ export { makeFolder, renameOver, writeAtomically } from "./files.ts";
8
8
  export { commentWithPlan, currentPlan, planOf, withoutPlans } from "./plans.ts";
9
9
  export { endTableTalk, loadTableTalk, saveTableTalk, type TableTalk, tableTalkStamp } from "./table-talk.ts";
10
10
  export { type Heard, heardLines, type Sayer, type Talk, talkLimits, talkLine, unheardWords, wakesTheChat, type Words, WordsRefused } from "./talk.ts";
11
- export { keepThought, notePondering, ponderingOn, type Thought, thoughtForLastMove, thoughtKey, waitingSince } from "./thoughts.ts";
11
+ export { keepThought, notePondering, ponderingOn, ponderingUntil, type Thought, thoughtForLastMove, thoughtKey, waitingSince } from "./thoughts.ts";
@@ -9,4 +9,4 @@ export { makeFolder, renameOver, writeAtomically } from "./files.js";
9
9
  export { commentWithPlan, currentPlan, planOf, withoutPlans } from "./plans.js";
10
10
  export { endTableTalk, loadTableTalk, saveTableTalk, tableTalkStamp } from "./table-talk.js";
11
11
  export { heardLines, talkLimits, talkLine, unheardWords, wakesTheChat, WordsRefused } from "./talk.js";
12
- export { keepThought, notePondering, ponderingOn, thoughtForLastMove, thoughtKey, waitingSince } from "./thoughts.js";
12
+ export { keepThought, notePondering, ponderingOn, ponderingUntil, thoughtForLastMove, thoughtKey, waitingSince } from "./thoughts.js";
@@ -7,8 +7,11 @@ export interface Thought {
7
7
  }
8
8
  /** The key of the position the player is to move in: the game, by its start, and how many moves it has. */
9
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[];
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. A thought kept while the AI
12
+ * ponders shows it still at work.
13
+ */
14
+ export declare function keepThought(folder: string, key: string, thought: Thought, now: Date): Thought[];
12
15
  /** The thought for the player's last move, when the AI is to move and thought of it on their time. */
13
16
  export declare function thoughtForLastMove(folder: string, current: CurrentGame): Thought | undefined;
14
17
  /** 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. */
@@ -17,6 +20,11 @@ export declare function waitingSince(folder: string, key: string, now: Date): Da
17
20
  * Notes whether the AI thinks on the player's time in a position now, from its first look until it waits for their move, for the page's sign: in the file,
18
21
  * since in Codex the agent that thinks has a process of its own (D-98).
19
22
  */
20
- export declare function notePondering(folder: string, key: string, pondering: boolean): void;
23
+ export declare function notePondering(folder: string, key: string, pondering: boolean, now: Date): void;
24
+ /**
25
+ * Until when the AI is seen thinking on the player's time in a position, as noted: a while after its last look or thought, so that an agent stopped before
26
+ * it waits (Esc, its host closed) does not leave the sign on until the player moves.
27
+ */
28
+ export declare function ponderingUntil(folder: string, key: string): Date | undefined;
21
29
  /** Whether the AI thinks on the player's time in a position now, as noted. */
22
- export declare function ponderingOn(folder: string, key: string): boolean;
30
+ export declare function ponderingOn(folder: string, key: string, now: Date): boolean;
@@ -8,14 +8,17 @@ import { writeAtomically } from "./files.js";
8
8
  export function thoughtKey(current, ply = current.game.record.moves.length) {
9
9
  return `${current.started}#${ply}`;
10
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) {
11
+ /**
12
+ * Keeps a thought for a position, in place of one for the same reply; thoughts of another position go. The thoughts kept now. A thought kept while the AI
13
+ * ponders shows it still at work.
14
+ */
15
+ export function keepThought(folder, key, thought, now) {
13
16
  const kept = keptAt(folder, key);
14
17
  const thoughts = [...kept.thoughts.filter((one) => one.reply !== thought.reply), thought];
15
18
  if (thoughts.length > mostThoughts) {
16
19
  throw new Error(`You have ${mostThoughts} thoughts for this position already: they are enough. Nothing changed.`);
17
20
  }
18
- write(folder, { ...kept, thoughts });
21
+ write(folder, { ...kept, thoughts, ...(kept.pondering === undefined ? {} : { pondering: now.toISOString() }) });
19
22
  return thoughts;
20
23
  }
21
24
  /** The thought for the player's last move, when the AI is to move and thought of it on their time. */
@@ -37,17 +40,30 @@ export function waitingSince(folder, key, now) {
37
40
  * Notes whether the AI thinks on the player's time in a position now, from its first look until it waits for their move, for the page's sign: in the file,
38
41
  * since in Codex the agent that thinks has a process of its own (D-98).
39
42
  */
40
- export function notePondering(folder, key, pondering) {
43
+ export function notePondering(folder, key, pondering, now) {
41
44
  const { pondering: before, ...kept } = keptAt(folder, key);
42
- if ((before === true) !== pondering) {
43
- write(folder, pondering ? { ...kept, pondering: true } : kept);
45
+ if (pondering) {
46
+ write(folder, { ...kept, pondering: now.toISOString() });
47
+ }
48
+ else if (before !== undefined) {
49
+ write(folder, kept);
44
50
  }
45
51
  }
46
- /** Whether the AI thinks on the player's time in a position now, as noted. */
47
- export function ponderingOn(folder, key) {
52
+ /**
53
+ * Until when the AI is seen thinking on the player's time in a position, as noted: a while after its last look or thought, so that an agent stopped before
54
+ * it waits (Esc, its host closed) does not leave the sign on until the player moves.
55
+ */
56
+ export function ponderingUntil(folder, key) {
48
57
  const kept = read(folder);
49
- return kept?.key === key && kept.pondering === true;
58
+ return kept?.key === key && typeof kept.pondering === "string" ? new Date(new Date(kept.pondering).getTime() + ponderSignFor) : undefined;
59
+ }
60
+ /** Whether the AI thinks on the player's time in a position now, as noted. */
61
+ export function ponderingOn(folder, key, now) {
62
+ const until = ponderingUntil(folder, key);
63
+ return until !== undefined && now < until;
50
64
  }
65
+ // An agent thinks on one reply in well under this; one silent for longer has stopped.
66
+ const ponderSignFor = 5 * 60_000;
51
67
  /** The most thoughts kept for a position: a few likely replies, not every legal one. */
52
68
  const mostThoughts = 8;
53
69
  const fileName = "thoughts.json";
@@ -55,7 +71,8 @@ const thoughtsFile = z.object({
55
71
  key: z.string(),
56
72
  thoughts: z.array(z.object({ reply: z.string(), move: z.string(), why: z.string() })),
57
73
  since: z.iso.datetime().optional(),
58
- pondering: z.boolean().optional(),
74
+ // The time of the AI's last look or thought while it ponders; true, from an older Chesspal, is a sign without its time, taken as gone.
75
+ pondering: z.union([z.iso.datetime(), z.boolean()]).optional(),
59
76
  });
60
77
  /** What is kept for a position: nothing yet when the file is for another, or missing. */
61
78
  function keptAt(folder, key) {
@@ -1,2 +1,3 @@
1
1
  export { dataFolder, type System, tableFolder } from "./data-folder.ts";
2
2
  export { openFolder } from "./open-folder.ts";
3
+ export { samePath } from "./same-path.ts";
@@ -1,3 +1,4 @@
1
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
2
  export { dataFolder, tableFolder } from "./data-folder.js";
3
3
  export { openFolder } from "./open-folder.js";
4
+ export { samePath } from "./same-path.js";
@@ -0,0 +1,2 @@
1
+ /** Whether two paths name the same folder: without regard to case on Windows. */
2
+ export declare function samePath(one: string, other: string): boolean;
@@ -0,0 +1,5 @@
1
+ import { resolve } from "node:path";
2
+ /** Whether two paths name the same folder: without regard to case on Windows. */
3
+ export function samePath(one, other) {
4
+ return process.platform === "win32" ? resolve(one).toLowerCase() === resolve(other).toLowerCase() : resolve(one) === resolve(other);
5
+ }
@@ -120,7 +120,8 @@ const bellHook = { type: "command", command: "chesspal listen", asyncRewake: tru
120
120
  const chatHook = { type: "command", command: "chesspal chat" };
121
121
  /**
122
122
  * Codex's settings, read once the player trusts the folder: the chesspal server with its tools approved, a table of Chesspal's rewritten but for the
123
- * environment the player gave it, as CHESSPAL_HOME in a playtest; the rest stays.
123
+ * environment the player gave it; CHESSPAL_HOME and CHESSPAL_SEAT always passed on to the server, which Codex otherwise starts without them, so that a
124
+ * playtest's data folder is the one the server reads, and the chat `chesspal play` holds knows it. The rest stays.
124
125
  */
125
126
  function codexConfigFile(folder, platform) {
126
127
  const path = join(folder, ".codex", "config.toml");
@@ -128,8 +129,12 @@ function codexConfigFile(folder, platform) {
128
129
  const { command, args } = chesspalServer(platform);
129
130
  // The table runs up to the next one, or to the end; a header with a comment after it is the same table.
130
131
  const ours = /^\s*\[mcp_servers\.chesspal\]\s*(#.*)?\r?\n[\s\S]*?(?=^\s*\[|(?![\s\S]))/m;
131
- const environment = (ours.exec(before)?.[0].match(/^\s*env(?:_vars)?\s*=.*$/gm) ?? []).map((line) => `${line.trim()}\n`).join("");
132
- const table = `[mcp_servers.chesspal]\ncommand = ${JSON.stringify(command)}\nargs = [${args.map((arg) => JSON.stringify(arg)).join(", ")}]\ndefault_tools_approval_mode = "approve"\n${environment}`;
132
+ const given = ours.exec(before)?.[0].match(/^\s*env(?:_vars)?\s*=.*$/gm) ?? [];
133
+ const environment = given.filter((line) => !/^\s*env_vars\s*=/.test(line)).map((line) => `${line.trim()}\n`).join("");
134
+ const passed = given.filter((line) => /^\s*env_vars\s*=/.test(line)).flatMap((line) => [...line.matchAll(/["']([^"']+)["']/g)].map((name) => name[1] ?? ""));
135
+ const names = [...new Set(["CHESSPAL_HOME", "CHESSPAL_SEAT", ...passed])].map((name) => JSON.stringify(name)).join(", ");
136
+ const table = `[mcp_servers.chesspal]\ncommand = ${JSON.stringify(command)}\nargs = [${args.map((arg) => JSON.stringify(arg)).join(", ")}]\n`
137
+ + `default_tools_approval_mode = "approve"\nenv_vars = [${names}]\n${environment}`;
133
138
  const rest = before.replace(ours, "").trim();
134
139
  const text = rest === "" ? table : `${rest}\n\n${table}`;
135
140
  return text === before ? undefined : { path, text };
@@ -17,16 +17,16 @@ export function anchorName(name) {
17
17
  // A game's name without its folder and .pgn, the time it started, as games/archive.ts writes it: the page cannot import that module.
18
18
  const stem = /\d{4}-\d{2}-\d{2}-\d{6}/.source;
19
19
  /**
20
- * What the page does with the place the address names: goes to its move on the game shown; drops it while a game goes on; waits while its game opens in
21
- * analysis, `asked` already; else opens its game in analysis, but the game just played, which opens on the player's yes alone (D-89): the link waits for
22
- * it while the table is set (D-77), and goes once another game shows (Zoe).
20
+ * What the page does with the place the address names: goes to its move on the game shown; refuses it while a game goes on; waits while its game opens
21
+ * in analysis, `asked` already; else opens its game in analysis, but the game just played, which opens on the player's yes alone (D-89): the link waits
22
+ * for it while the table is set (D-77), and goes once another game shows (Zoe).
23
23
  */
24
24
  export function anchorAction(wanted, games, asked) {
25
25
  if (wanted.game === games.shown) {
26
26
  return "view";
27
27
  }
28
28
  if (games.going) {
29
- return "drop";
29
+ return "refuse";
30
30
  }
31
31
  if (wanted.game === games.played) {
32
32
  return games.shown === undefined ? "wait" : "drop";
@@ -39,16 +39,16 @@ export function shownHash(games, viewing) {
39
39
  }
40
40
  /**
41
41
  * Keeps the move shown in the address, and goes to the place the address names as the page opens or as the player writes it (anchorAction); `open` opens a
42
- * game in analysis and says whether it did. A link never shows its move on another game.
42
+ * game in analysis and says whether it did, and `ignored` says why a link was not followed. A link never shows its move on another game.
43
43
  */
44
- export function useAnchor(games, viewing, view, open) {
44
+ export function useAnchor(games, viewing, view, open, ignored) {
45
45
  const { ready, shown, played, going } = games;
46
- const [wanted, setWanted] = useState(() => anchorOf(location.hash));
46
+ const [wanted, setWanted] = useState(() => anchorOf(window.location.hash));
47
47
  const asked = useRef(undefined);
48
48
  useEffect(() => {
49
49
  const heard = () => {
50
50
  asked.current = undefined;
51
- setWanted(anchorOf(location.hash));
51
+ setWanted(anchorOf(window.location.hash));
52
52
  };
53
53
  window.addEventListener("hashchange", heard);
54
54
  return () => window.removeEventListener("hashchange", heard);
@@ -61,7 +61,10 @@ export function useAnchor(games, viewing, view, open) {
61
61
  if (action === "view") {
62
62
  view(wanted.plies);
63
63
  }
64
- if (action === "view" || action === "drop") {
64
+ if (action === "refuse") {
65
+ ignored("going");
66
+ }
67
+ if (action === "view" || action === "refuse" || action === "drop") {
65
68
  setWanted(undefined);
66
69
  }
67
70
  if (action !== "open") {
@@ -72,6 +75,7 @@ export function useAnchor(games, viewing, view, open) {
72
75
  void open(game).then((opened) => {
73
76
  // A place written since stays wanted (Federico).
74
77
  if (!opened && asked.current === game) {
78
+ ignored("gone");
75
79
  setWanted(undefined);
76
80
  }
77
81
  });
@@ -81,8 +85,8 @@ export function useAnchor(games, viewing, view, open) {
81
85
  return;
82
86
  }
83
87
  const hash = shownHash(games, viewing);
84
- if (hash !== location.hash) {
85
- history.replaceState(history.state, "", hash === "" ? location.pathname + location.search : hash);
88
+ if (hash !== window.location.hash) {
89
+ window.history.replaceState(window.history.state, "", hash === "" ? window.location.pathname + window.location.search : hash);
86
90
  }
87
91
  }, [ready, shown, going, viewing, wanted]);
88
92
  }
@@ -27,6 +27,7 @@ import { FoldTab } from "../layout/fold-tab.js";
27
27
  import { nextFold, rememberFold, startFolds } from "../layout/folds.js";
28
28
  import { PageFoot } from "../layout/footer.js";
29
29
  import { Header } from "../layout/header.js";
30
+ import { LeadChart } from "../lead/lead.js";
30
31
  import { standingsOf } from "../lead/view.js";
31
32
  import { MarksLegend } from "../legend/legend.js";
32
33
  import { legendOf } from "../legend/view.js";
@@ -94,6 +95,20 @@ export function App({ start }) {
94
95
  };
95
96
  const [selected, setSelected] = useState(undefined);
96
97
  const [refusal, setRefusal] = useState(undefined);
98
+ // A link the page did not follow says why on the board, in the language of the settings the page has by then, until the player's next click or key.
99
+ const [linkIgnored, setLinkIgnored] = useState(undefined);
100
+ useEffect(() => {
101
+ if (linkIgnored === undefined) {
102
+ return;
103
+ }
104
+ const dismiss = () => setLinkIgnored(undefined);
105
+ window.addEventListener("pointerdown", dismiss, { once: true });
106
+ window.addEventListener("keydown", dismiss, { once: true });
107
+ return () => {
108
+ window.removeEventListener("pointerdown", dismiss);
109
+ window.removeEventListener("keydown", dismiss);
110
+ };
111
+ }, [linkIgnored]);
97
112
  // A pawn taken to the last rank waits for the player to choose what it becomes.
98
113
  const [promoting, setPromoting] = useState(undefined);
99
114
  const [flipped, setFlipped] = useState(false);
@@ -229,7 +244,7 @@ export function App({ start }) {
229
244
  // The move shown stays in the page's address, for a reload and a link to it; after the reset on a new game, which would undo the move it goes to. A
230
245
  // game a link opens is there to look at: the analyst starts on the player's yes alone (D-89; Zoe and Federico).
231
246
  const playedName = game === undefined || game === null ? undefined : anchorName(game.name);
232
- useAnchor({ ready: served !== undefined, shown: studied !== undefined ? anchorName(studied.name) : idle ? undefined : playedName, played: playedName, going }, viewing, (plies) => goTo(plies ?? total), async (wanted) => await post("/api/study", { game: wanted, look: true }, text, name) === undefined);
247
+ useAnchor({ ready: served !== undefined, shown: studied !== undefined ? anchorName(studied.name) : idle ? undefined : playedName, played: playedName, going }, viewing, (plies) => goTo(plies ?? total), async (wanted) => await post("/api/study", { game: wanted, look: true }, text, name) === undefined, setLinkIgnored);
233
248
  // The tab names the game and the move shown, as the cards name its players; between games, Chesspal alone.
234
249
  const titled = idle || served === undefined ? undefined : studiedSides(live.record.tags, served.profile.name, playedAs, text.sides, served.profile.accounts, othersOf(served.profiles, served.profile.id));
235
250
  useTabTitle(titled === undefined ? undefined : tabTitle(titled.white.name, titled.black.name, at === 0 ? undefined : plies[at - 1]));
@@ -255,11 +270,8 @@ export function App({ start }) {
255
270
  canOffer: offer === undefined && pending === undefined && live.position.turn !== played.ai && total >= 2,
256
271
  onOffer: () => act("/api/draw", { action: "offer" }),
257
272
  onAnswer: (accept) => act("/api/draw", { action: accept ? "accept" : "decline" }),
258
- onResign: () => {
259
- if (confirm(text.resignGame)) {
260
- act("/api/resign", {});
261
- }
262
- },
273
+ // The header asks first, in its own words (D-65).
274
+ onResign: () => act("/api/resign", {}),
263
275
  };
264
276
  const { kind, verdict } = aborted && phase === "over" ? { kind: "over", verdict: text.aborted } : verdictOf(phase, how, winner, offer, declined, text);
265
277
  const header = (_jsx(Header, { text: text, kind: kind, verdict: verdict, ...(!ended && (offer === "ai" || declined) ? { who: name } : {}), actions: actions, onNewGame: served === undefined ? undefined : askNewGame,
@@ -449,9 +461,9 @@ export function App({ start }) {
449
461
  // Between games the board goes off (D-77): a game the page saw end once its effects are over, its final position less off than the table set.
450
462
  class: between && !justEnded ? "board-wrap off" : "board-wrap", onWheel: wheel,
451
463
  // A press on the board while the player looks back brings the game as it is: the next one plays.
452
- onPointerDownCapture: viewing === undefined || studied !== undefined ? undefined : (event) => event.button === 0 && goTo(total), children: [_jsx(Board, { fen: shown.position.fen, bottom: flipped ? ai : player, lastMove: shown.lastMove, pointed: pointed, ...(helps === undefined ? {} : { helps }), ...(mapping && helps !== undefined ? { control: helps.strategy.control } : {}), bare: bare && helps !== undefined, ...(review?.badge === undefined ? {} : { badge: { badge: review.badge, name: text.badges[review.badge] } }), ...(drawn === undefined ? {} : { drawn }), nextMove: next, jumped: jumped, check: shown.position.check, end: boardEnd, player: playerToMove && pending === undefined && viewing === undefined ? player : undefined, selected: selected, targets: selected === undefined || viewing !== undefined ? [] : live.legalMoves(selected), dots: settings.dots, names: settings.squareNames, notice: viewing === undefined ? refusal : undefined, promotion: promoting === undefined || viewing !== undefined || !playerToMove ? undefined : { ...promoting, side: player }, onPromote: (piece) => piece === undefined ? setPromoting(undefined) : play(promoting.from, promoting.to, piece), onSelect: select, onMove: play, onRefuse: refuse, text: text }), folds.talk !== "open" && (asleepNote !== null || clockWaits)
464
+ onPointerDownCapture: viewing === undefined || studied !== undefined ? undefined : (event) => event.button === 0 && goTo(total), children: [_jsx(Board, { fen: shown.position.fen, bottom: flipped ? ai : player, lastMove: shown.lastMove, pointed: pointed, ...(helps === undefined ? {} : { helps }), ...(mapping && helps !== undefined ? { control: helps.strategy.control } : {}), bare: bare && helps !== undefined, ...(review?.badge === undefined ? {} : { badge: { badge: review.badge, name: text.badges[review.badge] } }), ...(drawn === undefined ? {} : { drawn }), nextMove: next, jumped: jumped, check: shown.position.check, end: boardEnd, player: playerToMove && pending === undefined && viewing === undefined ? player : undefined, selected: selected, targets: selected === undefined || viewing !== undefined ? [] : live.legalMoves(selected), dots: settings.dots, names: settings.squareNames, notice: viewing !== undefined ? undefined : refusal ?? (linkIgnored === undefined ? undefined : { text: text.link[linkIgnored] }), promotion: promoting === undefined || viewing !== undefined || !playerToMove ? undefined : { ...promoting, side: player }, onPromote: (piece) => piece === undefined ? setPromoting(undefined) : play(promoting.from, promoting.to, piece), onSelect: select, onMove: play, onRefuse: refuse, text: text }), folds.talk !== "open" && (asleepNote !== null || clockWaits)
453
465
  ? (_jsxs("div", { class: "over-board", children: [asleepNote, startClocks] }))
454
- : null] }), studied === undefined ? null : (_jsx("div", { class: "board-steps", children: _jsx(Steps, { shown: at, total: total, onStep: step, text: text }) })), helps === undefined ? null : (_jsxs("div", { class: "board-map", children: [legendAt(true), _jsx(BareButton, { bare: bare, onBare: setBare, text: text }), _jsx(MapButton, { mapping: mapping, onMap: setMapping, text: text }), hint === undefined || viewing !== undefined ? null : _jsx(HintLine, { hint: hint, open: hintOpen, onOpen: openTheHint, text: text })] })), _jsx(GameColumn, { plies: idle ? [] : plies, result: ended ? result : undefined, how: how, lit: justEnded, started: idle ? undefined : played?.started, move: live.position.moveNumber, toMove: live.position.turn, opening: opening, helps: helps === undefined ? undefined : (_jsx(HelpsCard, { helps: helps, judged: judgments, hint: viewing === undefined ? hint : undefined, hintOpen: hintOpen, onHint: openTheHint, onPoint: setPointed, onMap: setMapping, mapping: mapping, onBare: setBare, bare: bare, legend: legend, text: text })), ...(helps === undefined && legendRows.length > 0 ? { legend } : {}), ...(standings === undefined ? {} : { standings }), ...(studied === undefined ? {} : {
466
+ : null] }), studied === undefined ? null : (_jsxs("div", { class: "board-steps", children: [_jsx(Steps, { shown: at, total: total, onStep: step, text: text }), standings === undefined ? null : _jsx(LeadChart, { plies: plies, standings: standings, shown: at, onShow: goTo, text: text })] })), helps === undefined ? null : (_jsxs("div", { class: "board-map", children: [legendAt(true), _jsx(BareButton, { bare: bare, onBare: setBare, text: text }), _jsx(MapButton, { mapping: mapping, onMap: setMapping, text: text }), hint === undefined || viewing !== undefined ? null : _jsx(HintLine, { hint: hint, open: hintOpen, onOpen: openTheHint, text: text })] })), _jsx(GameColumn, { plies: idle ? [] : plies, result: ended ? result : undefined, how: how, lit: justEnded, started: idle ? undefined : played?.started, move: live.position.moveNumber, toMove: live.position.turn, opening: opening, helps: helps === undefined ? undefined : (_jsx(HelpsCard, { helps: helps, judged: judgments, hint: viewing === undefined ? hint : undefined, hintOpen: hintOpen, onHint: openTheHint, onPoint: setPointed, onMap: setMapping, mapping: mapping, onBare: setBare, bare: bare, legend: legend, text: text })), ...(helps === undefined && legendRows.length > 0 ? { legend } : {}), ...(standings === undefined ? {} : { standings }), ...(studied === undefined ? {} : {
455
467
  analysis: {
456
468
  by: analyst,
457
469
  ...(summary === undefined ? {} : { summary }),
@@ -1,6 +1,7 @@
1
1
  // What the page asks the server (D-47): the game of the player's folder, read again each time it changes, and the player's moves, words and choices sent
2
2
  // there, refused in the player's language.
3
3
  import { useEffect, useRef, useState } from "preact/hooks";
4
+ import { watchServed } from "./watch.js";
4
5
  /**
5
6
  * The game of the player's folder, read from the server, and read again each time the server says it changed, whoever changed it; `onNewGame` when the chat
6
7
  * asks for a new game, which the player chooses on the page.
@@ -11,43 +12,29 @@ export function useServed(onNewGame) {
11
12
  const [offline, setOffline] = useState(false);
12
13
  // Reads the game again: when the server says it changed, and when a clock runs out on the page.
13
14
  const reload = useRef(() => { });
15
+ // The server says when the game changes, whoever changed it: the page reads it again, and as it connects, the first time or to a new chat's server.
16
+ // The server lives in the chat's MCP server, or the table: when it is gone the page waits for the next one and says so.
14
17
  useEffect(() => {
15
- let latest = 0;
16
- // The Chesspal version of the first answer: a server of another, after an update, sends data this page may not read.
17
- let pageVersion;
18
- const load = () => {
19
- // Two reads in flight may answer out of order: only the last one asked counts.
20
- const asked = ++latest;
21
- fetch("/api/game")
22
- .then((response) => response.json())
23
- .then((body) => {
24
- if (asked !== latest) {
25
- return;
26
- }
27
- pageVersion ??= body.version;
28
- if (body.version !== undefined && body.version !== pageVersion) {
29
- window.location.reload();
30
- return;
31
- }
32
- setProblem(body.error);
33
- if (body.error === undefined) {
34
- setData(body);
35
- }
36
- })
37
- .catch((error) => setProblem(String(error)));
38
- };
39
- // The server says when the game changes, whoever changed it: the page reads it again, and as it connects, the first time or to a new chat's server.
40
- // The server lives in the chat's MCP server: when the chat ends, after /clear in Claude's app too, the page waits for the next one and says so.
41
- reload.current = load;
42
- const events = new EventSource("/api/events");
43
- events.onopen = () => {
44
- setOffline(false);
45
- load();
46
- };
47
- // The chat asks for a new game: the player chooses it in the New game window, which opens here.
48
- events.onmessage = (event) => event.data === "new-game" ? onNewGame() : load();
49
- events.onerror = () => setOffline(events.readyState !== EventSource.OPEN);
50
- return () => events.close();
18
+ const watch = watchServed({
19
+ read: () => fetch("/api/game").then((response) => response.json()),
20
+ connect: (handlers) => {
21
+ const events = new EventSource("/api/events");
22
+ events.onopen = handlers.open;
23
+ events.onmessage = (event) => handlers.message(event.data);
24
+ events.onerror = handlers.error;
25
+ return events;
26
+ },
27
+ later: (run, wait) => window.setTimeout(run, wait),
28
+ cancel: (timer) => window.clearTimeout(timer),
29
+ }, {
30
+ read: setData,
31
+ problem: setProblem,
32
+ offline: setOffline,
33
+ newGame: onNewGame,
34
+ otherVersion: () => window.location.reload(),
35
+ });
36
+ reload.current = watch.reload;
37
+ return watch.stop;
51
38
  }, []);
52
39
  return { data, problem, offline, reload: () => reload.current() };
53
40
  }
@@ -0,0 +1,97 @@
1
+ // How the page follows the server's game: it reads it when the server says it changed, tries a read lost on the way again, and connects again when the
2
+ // browser gives the server up for good, as when an old table hands the page to the new one. Without Preact, so that a test drives it with fake timers.
3
+ /** How many times, and how far apart in milliseconds, a read lost on the way is tried again; how long before a stream given up is opened again. */
4
+ export const readTries = 5;
5
+ export const readTryWait = 1000;
6
+ export const reconnectWait = 3000;
7
+ /** Follows the server's game: the first read when the events connect, another at each change, until `stop`. */
8
+ export function watchServed(tools, listener) {
9
+ let latest = 0;
10
+ // The Chesspal version of the first answer: a server of another, after an update, sends data this page may not read.
11
+ let pageVersion;
12
+ // The reads of the game asked last that failed, and the next try.
13
+ let failures = 0;
14
+ let retry;
15
+ let reconnect;
16
+ let stopped = false;
17
+ // Whether the page shows a game: then a read that fails leaves it shown, offline, rather than replacing it with the error.
18
+ let shown = false;
19
+ let events = open();
20
+ function load(again = false) {
21
+ tools.cancel(retry);
22
+ failures = again ? failures : 0;
23
+ // Two reads in flight may answer out of order: only the last one asked counts.
24
+ const asked = ++latest;
25
+ tools.read()
26
+ .then((body) => {
27
+ if (asked !== latest || stopped) {
28
+ return;
29
+ }
30
+ pageVersion ??= body.version;
31
+ if (body.version !== undefined && body.version !== pageVersion) {
32
+ listener.otherVersion();
33
+ return;
34
+ }
35
+ listener.problem(body.error);
36
+ if (body.error === undefined) {
37
+ shown = true;
38
+ listener.offline(false);
39
+ listener.read(body);
40
+ }
41
+ })
42
+ .catch((error) => {
43
+ if (asked !== latest || stopped) {
44
+ return;
45
+ }
46
+ // With the server gone the page waits for it and says so: connecting again reads the game.
47
+ if (events.readyState !== streamOpen) {
48
+ listener.offline(true);
49
+ return;
50
+ }
51
+ // A read lost on the way while the server is there is tried again shortly.
52
+ if (++failures <= readTries) {
53
+ retry = tools.later(() => load(true), readTryWait);
54
+ return;
55
+ }
56
+ if (shown) {
57
+ listener.offline(true);
58
+ }
59
+ else {
60
+ listener.problem(String(error));
61
+ }
62
+ });
63
+ }
64
+ function open() {
65
+ const stream = tools.connect({
66
+ open: () => {
67
+ listener.offline(false);
68
+ load();
69
+ },
70
+ // The chat asks for a new game: the player chooses it in the New game window.
71
+ message: (data) => data === "new-game" ? listener.newGame() : load(),
72
+ // The browser connects again by itself, but not after a server that answered with an error: then the page does, a little later.
73
+ error: () => {
74
+ listener.offline(stream.readyState !== streamOpen);
75
+ if (stream.readyState === streamClosed && !stopped) {
76
+ tools.cancel(reconnect);
77
+ reconnect = tools.later(() => {
78
+ events = open();
79
+ }, reconnectWait);
80
+ }
81
+ },
82
+ });
83
+ return stream;
84
+ }
85
+ return {
86
+ reload: () => load(),
87
+ stop: () => {
88
+ stopped = true;
89
+ tools.cancel(retry);
90
+ tools.cancel(reconnect);
91
+ events.close();
92
+ },
93
+ };
94
+ }
95
+ // EventSource's states, which a test's fake stream has no class for.
96
+ const streamOpen = 1;
97
+ const streamClosed = 2;