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.
- package/README.md +136 -2
- package/dist/cli/chat-hook.d.ts +2 -0
- package/dist/cli/chat-hook.js +33 -0
- package/dist/cli/cli.d.ts +2 -0
- package/dist/cli/cli.js +28 -0
- package/dist/cli/command-line.d.ts +8 -0
- package/dist/cli/command-line.js +178 -0
- package/dist/commands/ai-move.d.ts +37 -0
- package/dist/commands/ai-move.js +92 -0
- package/dist/commands/ai-say.d.ts +20 -0
- package/dist/commands/ai-say.js +40 -0
- package/dist/commands/analyse-game.d.ts +61 -0
- package/dist/commands/analyse-game.js +238 -0
- package/dist/commands/annotate-game.d.ts +35 -0
- package/dist/commands/annotate-game.js +78 -0
- package/dist/commands/clock.d.ts +15 -0
- package/dist/commands/clock.js +45 -0
- package/dist/commands/context.d.ts +56 -0
- package/dist/commands/context.js +36 -0
- package/dist/commands/draw-offer.d.ts +10 -0
- package/dist/commands/draw-offer.js +43 -0
- package/dist/commands/drawings.d.ts +15 -0
- package/dist/commands/drawings.js +23 -0
- package/dist/commands/end-game.d.ts +23 -0
- package/dist/commands/end-game.js +64 -0
- package/dist/commands/export-game.d.ts +14 -0
- package/dist/commands/export-game.js +26 -0
- package/dist/commands/game-summary.d.ts +95 -0
- package/dist/commands/game-summary.js +239 -0
- package/dist/commands/index.d.ts +3 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +73 -0
- package/dist/commands/levels.d.ts +9 -0
- package/dist/commands/levels.js +25 -0
- package/dist/commands/look.d.ts +31 -0
- package/dist/commands/look.js +61 -0
- package/dist/commands/new-game.d.ts +69 -0
- package/dist/commands/new-game.js +215 -0
- package/dist/commands/opening-book.d.ts +13 -0
- package/dist/commands/opening-book.js +84 -0
- package/dist/commands/page-link.d.ts +11 -0
- package/dist/commands/page-link.js +28 -0
- package/dist/commands/past-games.d.ts +11 -0
- package/dist/commands/past-games.js +47 -0
- package/dist/commands/play-move.d.ts +44 -0
- package/dist/commands/play-move.js +102 -0
- package/dist/commands/player-move.d.ts +17 -0
- package/dist/commands/player-move.js +44 -0
- package/dist/commands/player-say.d.ts +5 -0
- package/dist/commands/player-say.js +16 -0
- package/dist/commands/ponder.d.ts +42 -0
- package/dist/commands/ponder.js +167 -0
- package/dist/commands/position.d.ts +14 -0
- package/dist/commands/position.js +128 -0
- package/dist/commands/profiles.d.ts +60 -0
- package/dist/commands/profiles.js +129 -0
- package/dist/commands/registry.d.ts +54 -0
- package/dist/commands/registry.js +61 -0
- package/dist/commands/say.d.ts +16 -0
- package/dist/commands/say.js +84 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +103 -0
- package/dist/commands/study-games.d.ts +5 -0
- package/dist/commands/study-games.js +40 -0
- package/dist/commands/study.d.ts +38 -0
- package/dist/commands/study.js +122 -0
- package/dist/commands/view.d.ts +14 -0
- package/dist/commands/view.js +125 -0
- package/dist/core/board-text.d.ts +7 -0
- package/dist/core/board-text.js +18 -0
- package/dist/core/book.json +1 -0
- package/dist/core/clock.d.ts +45 -0
- package/dist/core/clock.js +109 -0
- package/dist/core/facts.d.ts +55 -0
- package/dist/core/facts.js +175 -0
- package/dist/core/game.d.ts +93 -0
- package/dist/core/game.js +348 -0
- package/dist/core/illegal.d.ts +19 -0
- package/dist/core/illegal.js +141 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.js +11 -0
- package/dist/core/levels.d.ts +20 -0
- package/dist/core/levels.js +33 -0
- package/dist/core/material.d.ts +9 -0
- package/dist/core/material.js +12 -0
- package/dist/core/moods.d.ts +23 -0
- package/dist/core/moods.js +45 -0
- package/dist/core/openings.d.ts +37 -0
- package/dist/core/openings.js +98 -0
- package/dist/core/openings.json +1 -0
- package/dist/core/pgn.d.ts +10 -0
- package/dist/core/pgn.js +213 -0
- package/dist/core/profile.d.ts +117 -0
- package/dist/core/profile.js +232 -0
- package/dist/core/record.d.ts +21 -0
- package/dist/core/record.js +25 -0
- package/dist/core/review.d.ts +74 -0
- package/dist/core/review.js +126 -0
- package/dist/games/ai-model.d.ts +5 -0
- package/dist/games/ai-model.js +15 -0
- package/dist/games/analysis.d.ts +12 -0
- package/dist/games/analysis.js +26 -0
- package/dist/games/archive.d.ts +42 -0
- package/dist/games/archive.js +168 -0
- package/dist/games/chat-start.d.ts +16 -0
- package/dist/games/chat-start.js +26 -0
- package/dist/games/current-game.d.ts +54 -0
- package/dist/games/current-game.js +128 -0
- package/dist/games/files.d.ts +9 -0
- package/dist/games/files.js +52 -0
- package/dist/games/index.d.ts +10 -0
- package/dist/games/index.js +11 -0
- package/dist/games/plans.d.ts +9 -0
- package/dist/games/plans.js +23 -0
- package/dist/games/table-talk.d.ts +12 -0
- package/dist/games/table-talk.js +25 -0
- package/dist/games/talk.d.ts +40 -0
- package/dist/games/talk.js +131 -0
- package/dist/games/thoughts.d.ts +15 -0
- package/dist/games/thoughts.js +60 -0
- package/dist/home/data-folder.d.ts +12 -0
- package/dist/home/data-folder.js +55 -0
- package/dist/home/index.d.ts +2 -0
- package/dist/home/index.js +3 -0
- package/dist/home/open-folder.d.ts +2 -0
- package/dist/home/open-folder.js +14 -0
- package/dist/host-files/host-files.d.ts +10 -0
- package/dist/host-files/host-files.js +167 -0
- package/dist/host-files/index.d.ts +2 -0
- package/dist/host-files/index.js +3 -0
- package/dist/host-files/ponder-agents.d.ts +12 -0
- package/dist/host-files/ponder-agents.js +40 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/page/app.js +576 -0
- package/dist/page/avatars/beanie.webp +0 -0
- package/dist/page/avatars/beard.webp +0 -0
- package/dist/page/avatars/bun.webp +0 -0
- package/dist/page/avatars/cat.webp +0 -0
- package/dist/page/avatars/curls.webp +0 -0
- package/dist/page/avatars/glasses.webp +0 -0
- package/dist/page/avatars/moustache.webp +0 -0
- package/dist/page/avatars/owl.webp +0 -0
- package/dist/page/avatars/short-hair.webp +0 -0
- package/dist/page/avatars/silver-bob.webp +0 -0
- package/dist/page/avatars.js +16 -0
- package/dist/page/badges.js +50 -0
- package/dist/page/board.js +566 -0
- package/dist/page/browse.js +17 -0
- package/dist/page/clock-time.js +12 -0
- package/dist/page/dialog.js +26 -0
- package/dist/page/ending.js +73 -0
- package/dist/page/explain.js +58 -0
- package/dist/page/fold-tab.js +9 -0
- package/dist/page/folds.js +43 -0
- package/dist/page/game-column.js +44 -0
- package/dist/page/header.js +27 -0
- package/dist/page/helps-view.js +51 -0
- package/dist/page/helps.js +90 -0
- package/dist/page/main.js +8 -0
- package/dist/page/model-name.js +20 -0
- package/dist/page/move-list.js +68 -0
- package/dist/page/new-game-dialog.js +42 -0
- package/dist/page/opening-book.js +14 -0
- package/dist/page/pieces/CREDITS.md +36 -0
- package/dist/page/pieces/cburnett-small.svg +20 -0
- package/dist/page/pieces/cburnett.svg +20 -0
- package/dist/page/pieces/nitida-small.svg +19 -0
- package/dist/page/pieces/nitida.svg +20 -0
- package/dist/page/pieces/torretta-small.svg +19 -0
- package/dist/page/pieces/torretta.svg +19 -0
- package/dist/page/pieces.js +28 -0
- package/dist/page/player-card.js +60 -0
- package/dist/page/plies.js +62 -0
- package/dist/page/profile-dialogs.js +123 -0
- package/dist/page/profile.js +95 -0
- package/dist/page/settings-panel.js +32 -0
- package/dist/page/settings.js +76 -0
- package/dist/page/sounds/capture.ogg +0 -0
- package/dist/page/sounds/check.ogg +0 -0
- package/dist/page/sounds/draw.ogg +0 -0
- package/dist/page/sounds/flag.ogg +0 -0
- package/dist/page/sounds/mate.ogg +0 -0
- package/dist/page/sounds/move.ogg +0 -0
- package/dist/page/sounds/offer.ogg +0 -0
- package/dist/page/sounds/resign.ogg +0 -0
- package/dist/page/sounds.js +105 -0
- package/dist/page/studied.js +14 -0
- package/dist/page/study-dialog.js +58 -0
- package/dist/page/style.css +4398 -0
- package/dist/page/talk.js +172 -0
- package/dist/page/text.js +593 -0
- package/dist/page/torretta.js +38 -0
- package/dist/page/voice.js +38 -0
- package/dist/profiles/avatar.d.ts +23 -0
- package/dist/profiles/avatar.js +84 -0
- package/dist/profiles/front-matter.d.ts +9 -0
- package/dist/profiles/front-matter.js +33 -0
- package/dist/profiles/index.d.ts +3 -0
- package/dist/profiles/index.js +5 -0
- package/dist/profiles/profiles.d.ts +58 -0
- package/dist/profiles/profiles.js +237 -0
- package/dist/profiles/settings.d.ts +12 -0
- package/dist/profiles/settings.js +46 -0
- package/dist/server/analyses.d.ts +17 -0
- package/dist/server/analyses.js +46 -0
- package/dist/server/bell.d.ts +60 -0
- package/dist/server/bell.js +209 -0
- package/dist/server/caller.d.ts +10 -0
- package/dist/server/caller.js +99 -0
- package/dist/server/http.d.ts +16 -0
- package/dist/server/http.js +57 -0
- package/dist/server/mcp.d.ts +14 -0
- package/dist/server/mcp.js +179 -0
- package/dist/server/page.d.ts +46 -0
- package/dist/server/page.js +657 -0
- package/dist/server/profile-routes.d.ts +19 -0
- package/dist/server/profile-routes.js +186 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/instructions/analyst.md +8 -0
- package/instructions/opponent.md +15 -0
- package/instructions/ponder.md +9 -0
- package/package.json +66 -5
package/dist/page/app.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
3
|
+
import { aiModelIn, annotatorOf, closingOf, Game, gameReviewOf, materialOf, moodAt, movesOnly, openingOf, readPgn, suggestedLevel, timeControlText, writePgn, } from "../core/index.js";
|
|
4
|
+
import { PlayerPicture } from "./avatars.js";
|
|
5
|
+
import { BadgeCounts } from "./badges.js";
|
|
6
|
+
import { Board, piecesOf } from "./board.js";
|
|
7
|
+
import { flipKeys, stepKeys, stepped } from "./browse.js";
|
|
8
|
+
import { howEnded, useDeclined, useJustEnded, useSawEnd } from "./ending.js";
|
|
9
|
+
import { explain } from "./explain.js";
|
|
10
|
+
import { FoldTab } from "./fold-tab.js";
|
|
11
|
+
import { foldKeys, gameUnderBoard, nextFold, oneColumn, rememberFold, startFolds } from "./folds.js";
|
|
12
|
+
import { GameColumn } from "./game-column.js";
|
|
13
|
+
import { Header } from "./header.js";
|
|
14
|
+
import { helpsOf } from "./helps-view.js";
|
|
15
|
+
import { HelpsCard, MapButton } from "./helps.js";
|
|
16
|
+
import { signature } from "./model-name.js";
|
|
17
|
+
import { Steps } from "./move-list.js";
|
|
18
|
+
import { NewGameDialog } from "./new-game-dialog.js";
|
|
19
|
+
import { useOpeningBook } from "./opening-book.js";
|
|
20
|
+
import { ChosenSet } from "./pieces.js";
|
|
21
|
+
import { ClockFace, PlayerCard } from "./player-card.js";
|
|
22
|
+
import { aiSpoke, aiWords, judgmentOf, playerSpent, playerWords, pliesOf, withBook, withoutReviews } from "./plies.js";
|
|
23
|
+
import { NameButton, PictureButton, PictureDialog, ProfilesDialog, Wizard } from "./profile-dialogs.js";
|
|
24
|
+
import { keepSettings } from "./profile.js";
|
|
25
|
+
import { SettingsPanel } from "./settings-panel.js";
|
|
26
|
+
import { languagesOf, saveSettings, settingsFrom, showSettings } from "./settings.js";
|
|
27
|
+
import { endSoundOf, playSound, soundOf, wakeSounds } from "./sounds.js";
|
|
28
|
+
import { studiedSides } from "./studied.js";
|
|
29
|
+
import { StudyDialog } from "./study-dialog.js";
|
|
30
|
+
import { AiNote, AiWords, Compose, PointSquare, TalkFaces } from "./talk.js";
|
|
31
|
+
import { textFor } from "./text.js";
|
|
32
|
+
import { Torretta } from "./torretta.js";
|
|
33
|
+
/**
|
|
34
|
+
* The page: the board of the game in the player's folder, where the player moves on their turn, the talk with the AI beside it, and the game's column; in the
|
|
35
|
+
* settings the player chose, `start` as this browser remembered them (D-56).
|
|
36
|
+
*/
|
|
37
|
+
export function App({ start }) {
|
|
38
|
+
const [settings, setSettings] = useState(start);
|
|
39
|
+
const [setting, setSetting] = useState(false);
|
|
40
|
+
const text = useMemo(() => textFor(languagesOf(settings)), [settings.language]);
|
|
41
|
+
const sound = settings.sound;
|
|
42
|
+
useEffect(() => void (document.documentElement.lang = text.language), [text]);
|
|
43
|
+
const apply = (next) => {
|
|
44
|
+
setSettings(next);
|
|
45
|
+
saveSettings(next);
|
|
46
|
+
showSettings(next);
|
|
47
|
+
};
|
|
48
|
+
// A choice of the player's is kept in the profile in use too, so that it follows them (D-56).
|
|
49
|
+
const change = (next) => {
|
|
50
|
+
apply(next);
|
|
51
|
+
if (served !== undefined) {
|
|
52
|
+
keepSettings(served.profile.id, next);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const [served, setServed] = useState(undefined);
|
|
56
|
+
const [problem, setProblem] = useState(undefined);
|
|
57
|
+
const [offline, setOffline] = useState(false);
|
|
58
|
+
const [selected, setSelected] = useState(undefined);
|
|
59
|
+
const [refusal, setRefusal] = useState(undefined);
|
|
60
|
+
// A pawn taken to the last rank waits for the player to choose what it becomes (T-11).
|
|
61
|
+
const [promoting, setPromoting] = useState(undefined);
|
|
62
|
+
const [flipped, setFlipped] = useState(false);
|
|
63
|
+
const [folds, setFolds] = useState(startFolds);
|
|
64
|
+
const [draft, setDraft] = useState("");
|
|
65
|
+
const [unsent, setUnsent] = useState(undefined);
|
|
66
|
+
const [dialog, setDialog] = useState(undefined);
|
|
67
|
+
// Reads the game again: when the server says it changed, and when a clock runs out on the page (T-37).
|
|
68
|
+
const reload = useRef(() => { });
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let latest = 0;
|
|
71
|
+
const load = () => {
|
|
72
|
+
// Two reads in flight may answer out of order: only the last one asked counts.
|
|
73
|
+
const asked = ++latest;
|
|
74
|
+
fetch("/api/game")
|
|
75
|
+
.then((response) => response.json())
|
|
76
|
+
.then((body) => {
|
|
77
|
+
if (asked !== latest) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setProblem(body.error);
|
|
81
|
+
if (body.error === undefined) {
|
|
82
|
+
setServed(body);
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
.catch((error) => setProblem(String(error)));
|
|
86
|
+
};
|
|
87
|
+
// 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.
|
|
88
|
+
// 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.
|
|
89
|
+
reload.current = load;
|
|
90
|
+
const events = new EventSource("/api/events");
|
|
91
|
+
events.onopen = () => {
|
|
92
|
+
setOffline(false);
|
|
93
|
+
load();
|
|
94
|
+
};
|
|
95
|
+
// The chat asks for a new game: the player chooses it in the New game window, which opens here.
|
|
96
|
+
events.onmessage = (event) => event.data === "new-game" ? setDialog("new-game") : load();
|
|
97
|
+
events.onerror = () => setOffline(events.readyState !== EventSource.OPEN);
|
|
98
|
+
return () => events.close();
|
|
99
|
+
}, []);
|
|
100
|
+
// The profile in use brings its settings, as it comes and each time another comes in its place; one without any takes this browser's.
|
|
101
|
+
const profileId = served?.profile.id;
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (served === undefined || profileId === undefined) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (served.settings === undefined) {
|
|
107
|
+
keepSettings(profileId, settings);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
apply(settingsFrom(served.settings));
|
|
111
|
+
}
|
|
112
|
+
}, [profileId]);
|
|
113
|
+
// The AI as the host names it (Claude, Codex), also where the page sends the player to write; the last known while the page is offline.
|
|
114
|
+
const name = served?.opponent ?? text.ai;
|
|
115
|
+
// Under it, the model and effort the AI played its last move with (D-82).
|
|
116
|
+
const signed = served?.aiModel === undefined ? undefined : signature(served.aiModel.model, served.aiModel.effort);
|
|
117
|
+
// The game is replayed from its PGN once for each one the server sends, not at every click. Once no game goes on, the game in analysis shows in its place
|
|
118
|
+
// (D-69), with the AI's badges and notes: replayed again only when its review changed.
|
|
119
|
+
const game = served?.game;
|
|
120
|
+
const studied = served?.analysis;
|
|
121
|
+
const replayed = useMemo(() => studied !== undefined ? new Game(readPgn(studied.pgn)[0]) : game === undefined || game === null ? new Game() : new Game(readPgn(game.pgn)[0]), [studied === undefined ? game : studied.pgn]);
|
|
122
|
+
// Between games the board shows the table set for the next (D-77): before the first, and on a game over the page did not see end; one it saw end keeps
|
|
123
|
+
// its final position until the next starts. The talk keeps the game's words all the same.
|
|
124
|
+
const over = isOver(resultOf(replayed));
|
|
125
|
+
const going = studied === undefined && game !== undefined && game !== null && !over;
|
|
126
|
+
const between = studied === undefined && game !== undefined && !going;
|
|
127
|
+
const sawEnd = useSawEnd(studied !== undefined || game === undefined || game === null ? undefined : over ? "over" : "going");
|
|
128
|
+
const idle = between && !sawEnd;
|
|
129
|
+
const tableSet = useMemo(() => new Game(), []);
|
|
130
|
+
const live = idle ? tableSet : replayed;
|
|
131
|
+
const material = useMemo(() => materialOf(live.position.fen), [live]);
|
|
132
|
+
const pieces = useMemo(() => piecesOf(live.position.fen), [live]);
|
|
133
|
+
// While the game goes on, the book's first moves carry its badge, when the player wants it, as the AI's review gives it after (D-69).
|
|
134
|
+
const inBook = studied === undefined && settings.book ? game?.book ?? 0 : 0;
|
|
135
|
+
// The AI's judgments of the player's moves, and its drawings, show during a game only with the helps on, always once it is over (D-78).
|
|
136
|
+
const judged = !going || settings.helps;
|
|
137
|
+
const plies = useMemo(() => withBook(judged ? pliesOf(replayed.record) : withoutReviews(pliesOf(replayed.record)), inBook), [replayed, inBook, judged]);
|
|
138
|
+
const book = useOpeningBook(live.record.moves.length > 0);
|
|
139
|
+
const opening = useMemo(() => book === undefined ? undefined : openingOf(live.record, book), [live, book]);
|
|
140
|
+
// The player's move shows at once, before the server has it: the game it sends next takes its place (T-70).
|
|
141
|
+
const [pending, setPending] = useState(undefined);
|
|
142
|
+
// The player may look back through the moves played (T-88): `viewing` how many are shown, undefined for the game as it is; a new move brings it back.
|
|
143
|
+
const [viewing, setViewing] = useState(undefined);
|
|
144
|
+
// The squares the player points at in the AI's words or the helps' card, lit on the board (T-111); the control map while it is held (D-78).
|
|
145
|
+
const [pointed, setPointed] = useState(noSquares);
|
|
146
|
+
const pointSquare = useMemo(() => (square) => setPointed(square === undefined ? noSquares : [square]), []);
|
|
147
|
+
const [mapping, setMapping] = useState(false);
|
|
148
|
+
const past = useMemo(() => viewing === undefined ? undefined : new Game({ tags: live.record.tags, moves: live.record.moves.slice(0, viewing) }), [live, viewing]);
|
|
149
|
+
const next = useMemo(() => viewing === undefined ? undefined : new Game({ tags: live.record.tags, moves: live.record.moves.slice(0, viewing + 1) }).lastMove, [live, viewing]);
|
|
150
|
+
const shown = useMemo(() => past ?? (pending === undefined ? live : withMove(live, pending)), [live, pending, past]);
|
|
151
|
+
// A square lit from the AI's words goes off with the position: a touch keeps it lit until then, a mouse until it leaves the word (T-111).
|
|
152
|
+
useEffect(() => setPointed(noSquares), [shown]);
|
|
153
|
+
// With the helps on, the player sees all of Chesspal's facts, whatever the AI's level (D-79).
|
|
154
|
+
const helps = useMemo(() => going && settings.helps ? helpsOf(shown) : undefined, [shown, going, settings.helps]);
|
|
155
|
+
useMapKey(helps !== undefined, setMapping);
|
|
156
|
+
// A game read again ends what the player was doing on the one before: the move sent, the look back, the choice of a promotion. A review written on the
|
|
157
|
+
// game in analysis leaves the player where they are in it.
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
setPending(undefined);
|
|
160
|
+
setViewing(undefined);
|
|
161
|
+
setPromoting(undefined);
|
|
162
|
+
}, [studied === undefined ? game : studied.name]);
|
|
163
|
+
const played = studied === undefined ? game : null;
|
|
164
|
+
useGameSounds(played, replayed, sound);
|
|
165
|
+
// Before the first game the AI speaks in the talk alone (D-77).
|
|
166
|
+
const spoke = played === undefined ? undefined : played === null ? served?.talk?.filter((line) => line.who === "ai").length ?? 0 : aiSpoke(plies, played.ai, played.talk);
|
|
167
|
+
const unread = useUnread(spoke, folds.talk !== "open");
|
|
168
|
+
const fold = (column) => {
|
|
169
|
+
const next = nextFold(column, folds[column]);
|
|
170
|
+
// The focus in what folds away goes to the column's tab.
|
|
171
|
+
if (next !== "open" && document.getElementById(column)?.contains(document.activeElement) === true) {
|
|
172
|
+
document.getElementById(`fold-${column}`)?.focus();
|
|
173
|
+
}
|
|
174
|
+
setFolds({ ...folds, [column]: next });
|
|
175
|
+
rememberFold(column, next);
|
|
176
|
+
};
|
|
177
|
+
useFoldKeys(fold);
|
|
178
|
+
const total = live.record.moves.length;
|
|
179
|
+
const at = viewing ?? total;
|
|
180
|
+
// In analysis, the opening the move shown has reached, while the book knows the moves (D-69).
|
|
181
|
+
const bookAt = studied === undefined || book === undefined || at === 0 || at > studied.book ? undefined : { tags: live.record.tags, moves: live.record.moves.slice(0, at) };
|
|
182
|
+
const shownOpening = useMemo(() => bookAt === undefined || book === undefined ? undefined : openingOf(bookAt, book), [live, book, bookAt === undefined ? 0 : at]);
|
|
183
|
+
// A jump of more than one move shows the position at once: only a step slides its pieces.
|
|
184
|
+
const shownBefore = useRef(at);
|
|
185
|
+
const jumped = Math.abs(shownBefore.current - at) > 1;
|
|
186
|
+
shownBefore.current = at;
|
|
187
|
+
const goTo = (count) => {
|
|
188
|
+
setSelected(undefined);
|
|
189
|
+
setViewing(count >= total ? undefined : Math.max(0, count));
|
|
190
|
+
};
|
|
191
|
+
const step = (to) => goTo(stepped(to, at, total));
|
|
192
|
+
useBrowseKeys(step, () => setFlipped(!flipped));
|
|
193
|
+
const wheel = useWheelSteps(step);
|
|
194
|
+
// The header's theme switch picks the other of the two the page shows, leaving the system's.
|
|
195
|
+
const switchTheme = () => change({ ...settings, theme: document.documentElement.dataset.theme === "dark" ? "light" : "dark" });
|
|
196
|
+
// An action of the player's on the game: the game the server sends next shows it; a refusal shows on the board.
|
|
197
|
+
const act = (path, body) => {
|
|
198
|
+
setSelected(undefined);
|
|
199
|
+
void post(path, body, text, name).then((refused) => setRefusal(refused === undefined ? undefined : { text: refused.text }));
|
|
200
|
+
};
|
|
201
|
+
const result = resultOf(live);
|
|
202
|
+
const ended = isOver(result);
|
|
203
|
+
const how = ended ? howEnded(live.outcome, live.record.tags.Termination) : undefined;
|
|
204
|
+
const winner = result === "1-0" ? "white" : result === "0-1" ? "black" : undefined;
|
|
205
|
+
const justEnded = useJustEnded(played === undefined || played === null ? undefined : over ? "over" : "going");
|
|
206
|
+
const offer = played?.drawOffer;
|
|
207
|
+
const declined = useDeclined(offer, total, ended);
|
|
208
|
+
// In a game going on the player offers a draw on their turn once each side has moved, answers the AI's, or resigns, saying so first (D-65).
|
|
209
|
+
const actions = !going || played === undefined || played === null ? undefined : {
|
|
210
|
+
...(offer === undefined ? {} : { offer }),
|
|
211
|
+
canOffer: offer === undefined && pending === undefined && live.position.turn !== played.ai && total >= 2,
|
|
212
|
+
onOffer: () => act("/api/draw", { action: "offer" }),
|
|
213
|
+
onAnswer: (accept) => act("/api/draw", { action: accept ? "accept" : "decline" }),
|
|
214
|
+
onResign: () => {
|
|
215
|
+
if (confirm(text.resignGame)) {
|
|
216
|
+
act("/api/resign", {});
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
const header = (_jsx(Header, { text: text, kind: studied !== undefined ? "analysis" : idle ? "idle" : ended ? "over" : offer !== undefined || declined ? "offer" : undefined, verdict: studied !== undefined
|
|
221
|
+
? text.analysing
|
|
222
|
+
: idle
|
|
223
|
+
? text.noGameNow
|
|
224
|
+
: ended
|
|
225
|
+
? text.verdict(how, winner)
|
|
226
|
+
: offer === "ai"
|
|
227
|
+
? text.offers
|
|
228
|
+
: offer === "player"
|
|
229
|
+
? text.drawOffered
|
|
230
|
+
: declined
|
|
231
|
+
? text.declines
|
|
232
|
+
: undefined, ...(!ended && (offer === "ai" || declined) ? { who: name } : {}), actions: actions, onNewGame: served === undefined ? undefined : () => setDialog("new-game"),
|
|
233
|
+
// Between games the new one stands out in the player's card (D-77): in the header only when the talk, and the card with it, is folded away.
|
|
234
|
+
standOut: studied !== undefined || (between && folds.talk !== "open"), onStudy: served === undefined || going ? undefined : () => setDialog("study"), flipped: flipped, onFlip: () => setFlipped(!flipped), sound: sound, onSound: () => change({ ...settings, sound: !sound }), onTheme: switchTheme, onSettings: () => setSetting(true) }));
|
|
235
|
+
const panel = _jsx(SettingsPanel, { open: setting, settings: settings, onChange: change, onClose: () => setSetting(false), text: text });
|
|
236
|
+
if (problem !== undefined) {
|
|
237
|
+
return _jsx("p", { class: "problem", children: problem });
|
|
238
|
+
}
|
|
239
|
+
if (served === undefined || game === undefined) {
|
|
240
|
+
return (_jsxs(_Fragment, { children: [header, panel] }));
|
|
241
|
+
}
|
|
242
|
+
// At the first start the page asks who plays (D-23), to complete the profile Chesspal made (D-71): the game comes after.
|
|
243
|
+
if (served.profile.new === true) {
|
|
244
|
+
return _jsx(Wizard, { text: text, hasGame: served.game !== null, account: served.profile.name, dataFolder: served.dataFolder, onMade: () => reload.current() });
|
|
245
|
+
}
|
|
246
|
+
const profile = served.profile;
|
|
247
|
+
const picture = _jsx(PlayerPicture, { profile: profile });
|
|
248
|
+
// In analysis the cards name the players of the game, the player's side at the bottom when they played it (D-69).
|
|
249
|
+
const sides = studied === undefined ? undefined : studiedSides(live.record.tags, profile.name, name, text.sides);
|
|
250
|
+
const player = sides?.bottom ?? (game === null || game.ai === "black" ? "white" : "black");
|
|
251
|
+
const ai = player === "white" ? "black" : "white";
|
|
252
|
+
// The Torretta's face on the move shown: the mood the AI gave, while it lasts (D-70); in analysis, of the side the AI played.
|
|
253
|
+
const mood = moodAt(live.record, sides?.white.who === "ai" ? "white" : sides?.black.who === "ai" ? "black" : ai, at).mood;
|
|
254
|
+
const playerToMove = going && live.position.turn === player;
|
|
255
|
+
const ahead = (side) => side === "white" ? material.balance : -material.balance;
|
|
256
|
+
const reviewed = judged || at <= inBook ? plies[at - 1] : undefined;
|
|
257
|
+
// With the helps on, the AI's judgment of the player's last move, once it has answered it (D-78).
|
|
258
|
+
const judgment = going && settings.helps ? judgmentOf(plies, player) : undefined;
|
|
259
|
+
const review = reviewed?.review;
|
|
260
|
+
const summary = studied === undefined ? undefined : gameReviewOf(live.record);
|
|
261
|
+
const closing = studied === undefined ? undefined : closingOf(live.record);
|
|
262
|
+
const annotator = studied === undefined ? undefined : annotatorOf(live.record);
|
|
263
|
+
// In analysis the AI's card says who played that game, as its PGN keeps it (D-82), not who plays now.
|
|
264
|
+
const playedBy = studied === undefined ? undefined : aiModelIn(live.record);
|
|
265
|
+
const talk = game?.talk ?? served.talk ?? [];
|
|
266
|
+
const said = playerWords(talk);
|
|
267
|
+
// The clocks only in a game going on: between games the player's card holds the next one in their place (D-77).
|
|
268
|
+
const clock = going ? game?.clock : undefined;
|
|
269
|
+
// No chat hears the player: the page says where to wake it, and the AI's clock waits, as the server holds it.
|
|
270
|
+
const asleep = served.listening === false && !offline;
|
|
271
|
+
// The clocks wait for the player from the game's start: when the AI moves first, a button gives it the start; else the player's first move does (Stefano).
|
|
272
|
+
const clockWaits = clock?.waiting === true && !ended && studied === undefined && viewing === undefined && live.position.turn === ai;
|
|
273
|
+
const clockOf = (side) => clock === undefined ? undefined : (_jsx(ClockFace, { clock: clock, side: side, toMove: !ended && live.position.turn === side, held: pending !== undefined || (asleep && side === ai), onFlag: () => reload.current(), text: text }));
|
|
274
|
+
// Where to wake the chat, under the AI's card (over the board when the talk is folded); the button that starts the clocks, under the player's (Zoe).
|
|
275
|
+
const asleepNote = asleep ? _jsx("p", { class: "asleep", role: "status", children: text.asleep(name) }) : null;
|
|
276
|
+
const startClocks = clockWaits
|
|
277
|
+
? (_jsxs("button", { type: "button", class: "command primary start-clocks", onClick: () => act("/api/start", {}), children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M8 5.5v13l10.5-6.5z" }) }), text.startClocks] }))
|
|
278
|
+
: null;
|
|
279
|
+
// Between games the next starts from the player's card, where the clock and the button that starts it stand in a game (D-77).
|
|
280
|
+
const nextGame = between
|
|
281
|
+
? (_jsxs("button", { type: "button", class: "command primary next-game", onClick: () => setDialog("new-game"), children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M12 5V19M5 12H19" }) }), text.newGame] }))
|
|
282
|
+
: null;
|
|
283
|
+
// Words sent leave the box; what the player wrote on while they went stays.
|
|
284
|
+
const clearSent = (refused, words) => {
|
|
285
|
+
if (refused === undefined) {
|
|
286
|
+
setDraft((now) => now.startsWith(words) ? now.slice(words.length).trimStart() : now);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const select = (square) => {
|
|
290
|
+
setRefusal(undefined);
|
|
291
|
+
setSelected(square);
|
|
292
|
+
};
|
|
293
|
+
const play = (from, to, piece) => {
|
|
294
|
+
setSelected(undefined);
|
|
295
|
+
setRefusal(undefined);
|
|
296
|
+
if (piece === undefined && promotes(pieces.get(from), to)) {
|
|
297
|
+
setPromoting({ from, to });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// The words written and not sent go with the move (D-53): the box holds no more than the move has left (D-54). A command for Claude's app stays here.
|
|
301
|
+
const command = isCommand(draft);
|
|
302
|
+
const words = command ? "" : draft;
|
|
303
|
+
const move = `${from}${to}${piece ?? ""}`;
|
|
304
|
+
setPromoting(undefined);
|
|
305
|
+
setPending(move);
|
|
306
|
+
void sendMove(move, words, text, name).then((refused) => {
|
|
307
|
+
setRefusal(refused === undefined || refused.words ? undefined : { text: refused.text, square: from });
|
|
308
|
+
setUnsent(refused?.words === true ? refused.text : command ? text.commandHere(name) : undefined);
|
|
309
|
+
clearSent(refused?.text, words);
|
|
310
|
+
if (refused !== undefined) {
|
|
311
|
+
setPending(undefined);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
// A move the board refuses says why, without the AI (D-18), unless the player turned it off: the piece goes back, the reason over it.
|
|
316
|
+
const refuse = (from, to) => {
|
|
317
|
+
const illegal = settings.explain ? live.whyNot(from, to) : undefined;
|
|
318
|
+
if (illegal !== undefined) {
|
|
319
|
+
const letterOn = (square) => pieces.get(square).toUpperCase();
|
|
320
|
+
const sideOn = (square) => pieces.get(square) === letterOn(square) ? "white" : "black";
|
|
321
|
+
const by = illegal.by === undefined ? undefined : { letter: letterOn(illegal.by), side: sideOn(illegal.by), square: illegal.by };
|
|
322
|
+
const words = text.illegal({ reason: illegal.reason, piece: letterOn(from), to, takes: pieces.has(to), by, ...(illegal.at === undefined ? {} : { at: illegal.at }) });
|
|
323
|
+
const king = [...pieces].find(([, letter]) => letter === (sideOn(from) === "white" ? "K" : "k"))?.[0];
|
|
324
|
+
setRefusal({ text: words, square: from, piece: pieces.get(from), explanation: explain(illegal, to, king) });
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const sendWords = () => {
|
|
328
|
+
const words = draft;
|
|
329
|
+
// /clear and the other commands are for Claude's app: written here they would reach the AI as words (Sebastiano's playtest).
|
|
330
|
+
if (isCommand(words)) {
|
|
331
|
+
setUnsent(text.commandHere(name));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
setUnsent(undefined);
|
|
335
|
+
void sendMessage(words, text, name).then((refused) => {
|
|
336
|
+
setUnsent(refused?.text);
|
|
337
|
+
clearSent(refused?.text, words);
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
// The new game starts from the choices of the game before; the first, from the level that suits the player (T-58).
|
|
341
|
+
const level = suggestedLevel(played === null ? undefined : replayed.record, profile.level);
|
|
342
|
+
const lastControl = game?.clock?.control;
|
|
343
|
+
const lastClock = lastControl === undefined ? undefined : timeControlText(lastControl);
|
|
344
|
+
const choice = { colour: player, level, ...(lastClock === undefined ? {} : { clock: lastClock }) };
|
|
345
|
+
const startNewGame = ({ colour, level: chosen, clock: control }) => {
|
|
346
|
+
setDialog(undefined);
|
|
347
|
+
act("/api/new", { ai_colour: colour === "random" ? "random" : colour === "white" ? "black" : "white", level: chosen, ...(control === undefined ? {} : { clock: control }) });
|
|
348
|
+
};
|
|
349
|
+
// In analysis the AI's note stays under the card on top and its word on the game over the one below, however the board is turned (D-69).
|
|
350
|
+
const top = flipped ? player : ai;
|
|
351
|
+
const bottom = flipped ? ai : player;
|
|
352
|
+
const studiedCard = (side, place) => sides === undefined ? null : (_jsx(PlayerCard, { place: place, picture: pictureOf(sides[side], profile, 48, mood), name: sides[side].name, signed: sides[side].who === "ai" && playedBy !== undefined ? signature(playedBy.model, playedBy.effort) : undefined, side: side, taken: material.taken[side], ahead: ahead(side), badges: _jsx(BadgeCounts, { plies: plies, side: side, names: text.badges }), text: text }));
|
|
353
|
+
// A game brought to analyse (T-92): the page shows it once the server has it in analysis; a refusal stays in the dialog.
|
|
354
|
+
const studyGame = async (studied) => {
|
|
355
|
+
const refused = await post("/api/study", studied, text, name);
|
|
356
|
+
if (refused === undefined) {
|
|
357
|
+
setDialog(undefined);
|
|
358
|
+
}
|
|
359
|
+
return refused?.text;
|
|
360
|
+
};
|
|
361
|
+
return (_jsx(ChosenSet.Provider, { value: settings.set, children: _jsxs(PointSquare.Provider, { value: pointSquare, children: [panel, _jsx(PictureDialog, { open: dialog === "picture", profile: profile, onClose: () => setDialog(undefined), text: text }), _jsx(ProfilesDialog, { open: dialog === "profiles", profiles: served.profiles, active: profile, playing: going, dataFolder: served.dataFolder, onClose: () => setDialog(undefined), text: text }), _jsx(NewGameDialog, { open: dialog === "new-game", choice: choice, going: going, onStart: startNewGame, onClose: () => setDialog(undefined), text: text }), _jsx(StudyDialog, { open: dialog === "study", onStudy: studyGame, onClose: () => setDialog(undefined), you: profile.name, ai: name, text: text }), offline ? _jsx("p", { class: "offline", role: "alert", children: text.offline(name) }) : null, _jsxs("main", { class: `app${flipped ? " flipped" : ""}${folds.talk === "open" ? "" : ` talk-${folds.talk}`}${folds.game === "folded" ? " folded" : ""}`, inert: offline, children: [header, sides === undefined
|
|
362
|
+
? (_jsx(TalkFaces, { name: name, aiPicture: _jsx(Torretta, { size: 40, mood: mood }), you: profile.name, picture: picture, ...(idle ? {} : { ai, player }), aiMoves: going && !playerToMove, playerMoves: playerToMove, unread: unread || asleep, text: text }))
|
|
363
|
+
: (_jsx(TalkFaces, { name: sides[ai].name, aiPicture: pictureOf(sides[ai], profile, 40, mood), you: sides[player].name, picture: pictureOf(sides[player], profile, 40, mood), ai: ai, player: player, aiMoves: false, playerMoves: false, unread: asleep, text: text })), _jsx(FoldTab, { column: "talk", folded: folds.talk === "folded", label: text.talk, unread: (unread || asleep) && folds.talk === "folded", onFold: fold }), _jsx(FoldTab, { column: "game", folded: folds.game === "folded", label: text.game, onFold: fold }), _jsx("span", { class: "visually-hidden", "aria-live": "polite", children: unread ? text.wrote(name) : "" }), _jsx("div", { class: sides === undefined ? "talk" : "talk studied", id: "talk", children: sides !== undefined
|
|
364
|
+
? (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": sides[top].name, children: [studiedCard(top, "ai"), asleepNote, _jsx(AiNote, { ...(reviewed === undefined ? {} : { ply: reviewed }), ...(shownOpening === undefined ? {} : { opening: shownOpening }), ...(at === total && closing !== undefined ? { closing } : {}), ...(at === 0 && summary !== undefined ? { summary } : {}), name: name, by: annotator, mood: mood, ...(settings.voice ? { voice: text.language } : {}), text: text })] }), _jsx("section", { class: "you-block", "aria-label": sides[bottom].name, children: studiedCard(bottom, "you") })] }))
|
|
365
|
+
: (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": name, children: [_jsx(PlayerCard, { place: "ai", picture: _jsx(Torretta, { size: 48, mood: mood }), name: name, signed: signed, side: idle ? undefined : ai, ...(idle ? {} : { level: text.start.levels[level] }), taken: material.taken[ai], ahead: ahead(ai), clock: clockOf(ai), text: text }), asleepNote, _jsx(AiWords, { words: aiWords(plies, ai, talk), newestFirst: !flipped, ...(settings.voice ? { voice: text.language } : {}), ...(judgment === undefined ? {} : { judged: judgment }), text: text })] }), _jsxs("section", { class: "you-block", "aria-label": profile.name, children: [_jsx(Compose, { lines: said, newestFirst: flipped,
|
|
366
|
+
// Between games, and before the first, the player writes freely, a line at a time, as the AI does (D-74, D-77).
|
|
367
|
+
spent: going ? playerSpent(plies, ai, talk) : 0, draft: draft, onDraft: (words) => {
|
|
368
|
+
setDraft(words);
|
|
369
|
+
setUnsent(undefined);
|
|
370
|
+
}, onSend: sendWords, canMove: playerToMove, name: name, problem: unsent, text: text }), _jsx(PlayerCard, { place: "you", picture: _jsx(PictureButton, { profile: profile, onOpen: () => setDialog("picture"), text: text }), name: _jsx(NameButton, { profile: profile, onOpen: () => setDialog("profiles"), text: text }), side: idle ? undefined : player, taken: material.taken[player], ahead: ahead(player), turn: playerToMove, clock: _jsxs(_Fragment, { children: [clockOf(player), startClocks, nextGame] }), text: text })] })] })) }), _jsxs("div", {
|
|
371
|
+
// 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.
|
|
372
|
+
class: between && !justEnded ? "board-wrap off" : "board-wrap", onWheel: wheel,
|
|
373
|
+
// A press on the board while the player looks back brings the game as it is: the next one plays.
|
|
374
|
+
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 } : {}), ...(review?.badge === undefined ? {} : { badge: { badge: review.badge, name: text.badges[review.badge] } }), ...(review === undefined ? {} : { drawn: review }), nextMove: next, jumped: jumped, check: shown.position.check, end: ended && viewing === undefined && pending === undefined ? { how, winner, player, fresh: justEnded } : undefined, player: playerToMove && pending === undefined && viewing === undefined ? player : undefined, selected: selected, targets: selected === undefined || viewing !== undefined ? [] : live.legalMoves(selected), dots: settings.dots, 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" && (asleep || clockWaits)
|
|
375
|
+
? (_jsxs("div", { class: "over-board", children: [asleep ? _jsx("p", { class: "asleep", role: "status", children: text.asleep(name) }) : null, startClocks] }))
|
|
376
|
+
: null] }), studied === undefined ? null : (_jsx("div", { class: "board-steps", children: _jsx(Steps, { shown: at, total: total, onStep: step, text: text }) })), helps === undefined ? null : (_jsx("div", { class: "board-map", children: _jsx(MapButton, { mapping: mapping, onMap: setMapping, held: false, 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, onPoint: setPointed, onMap: setMapping, mapping: mapping, text: text })), ...(studied === undefined ? {} : { analysis: summary === undefined ? { by: name } : { by: name, summary } }), shownPlies: at, onShow: goTo, onStep: step, ...(studied === undefined ? {} : { onDownload: () => download(studied.pgn, pgnName(live.record.tags, "analysis")) }), ...(total === 0 ? {} : { onSaveMoves: () => download(writePgn(movesOnly(live.record)), pgnName(live.record.tags)) }), ...(between && game !== null ? { onAnalyse: () => void studyGame({ last: true }) } : {}), text: text })] })] }) }));
|
|
377
|
+
}
|
|
378
|
+
/** The picture of a player of the game in analysis: the player's own, the AI's Torretta with its mood, or the plain face of someone else. */
|
|
379
|
+
function pictureOf({ who }, profile, size, mood) {
|
|
380
|
+
return who === "ai" ? _jsx(Torretta, { size: size, mood: mood }) : _jsx(PlayerPicture, { profile: who === "player" ? profile : undefined, size: size });
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Plays the sound of each move the game gains while the page watches it, the AI's and the player's, of its end without a move and of a draw offered:
|
|
384
|
+
* none for a game loaded or begun again (T-71, T-100).
|
|
385
|
+
*/
|
|
386
|
+
function useGameSounds(game, live, on) {
|
|
387
|
+
const seen = useRef(undefined);
|
|
388
|
+
useEffect(wakeSounds, []);
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (game === undefined || game === null) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const moves = live.record.moves.length;
|
|
394
|
+
const over = isOver(resultOf(live));
|
|
395
|
+
const offered = game.drawOffer !== undefined;
|
|
396
|
+
const before = seen.current;
|
|
397
|
+
seen.current = { started: game.started, moves, over, offered };
|
|
398
|
+
if (!on || before?.started !== game.started) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// A move sounds as the board stands after it, and two read together as the last, so a mate that comes with the player's move sounds too; a game
|
|
402
|
+
// ended without a move, by how it ended; a draw offered, with its move or on its own.
|
|
403
|
+
const sound = moves > before.moves ? soundOf(live) : over && !before.over ? endSoundOf(howEnded(live.outcome, live.record.tags.Termination)) : undefined;
|
|
404
|
+
if (sound !== undefined) {
|
|
405
|
+
playSound(sound);
|
|
406
|
+
}
|
|
407
|
+
if (offered && !before.offered) {
|
|
408
|
+
playSound("offer");
|
|
409
|
+
}
|
|
410
|
+
}, [live]);
|
|
411
|
+
}
|
|
412
|
+
/** The game's result: the board's, else the record's, which a resignation, a draw agreed or a flag write. */
|
|
413
|
+
function resultOf(live) {
|
|
414
|
+
return live.outcome.over ? live.outcome.result : live.record.tags.Result;
|
|
415
|
+
}
|
|
416
|
+
function isOver(result) {
|
|
417
|
+
return result !== undefined && result !== "*";
|
|
418
|
+
}
|
|
419
|
+
/** Whether the AI spoke while the talk was folded, by how many times it has spoken (`spoke`, undefined before a game): until the player opens the talk. */
|
|
420
|
+
function useUnread(spoke, folded) {
|
|
421
|
+
const [unread, setUnread] = useState(false);
|
|
422
|
+
const heard = useRef(spoke);
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (!folded) {
|
|
425
|
+
setUnread(false);
|
|
426
|
+
}
|
|
427
|
+
else if (spoke !== undefined && heard.current !== undefined && spoke > heard.current) {
|
|
428
|
+
setUnread(true);
|
|
429
|
+
}
|
|
430
|
+
heard.current = spoke;
|
|
431
|
+
}, [spoke, folded]);
|
|
432
|
+
return unread;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* `[` and `]` fold the talk and the game, as in editors with two side bars; never while the player types or drags a piece, nor in one column, nor the game
|
|
436
|
+
* under the board.
|
|
437
|
+
*/
|
|
438
|
+
function useFoldKeys(fold) {
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
const press = (event) => {
|
|
441
|
+
if (!forThePage(event) || oneColumn()) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const column = Object.keys(foldKeys).find((key) => foldKeys[key] === event.key);
|
|
445
|
+
if (column !== undefined && !(column === "game" && gameUnderBoard())) {
|
|
446
|
+
event.preventDefault();
|
|
447
|
+
fold(column);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
window.addEventListener("keydown", press);
|
|
451
|
+
return () => window.removeEventListener("keydown", press);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/** M held shows the control map while the helps are on (D-78); it goes off when M is let go or the page loses the focus. */
|
|
455
|
+
function useMapKey(helping, map) {
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
if (!helping) {
|
|
458
|
+
map(false);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const down = (event) => {
|
|
462
|
+
if (forThePage(event) && event.key.toLowerCase() === "m") {
|
|
463
|
+
event.preventDefault();
|
|
464
|
+
map(true);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const up = (event) => {
|
|
468
|
+
if (event.key.toLowerCase() === "m") {
|
|
469
|
+
map(false);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
const away = () => map(false);
|
|
473
|
+
window.addEventListener("keydown", down);
|
|
474
|
+
window.addEventListener("keyup", up);
|
|
475
|
+
window.addEventListener("blur", away);
|
|
476
|
+
return () => {
|
|
477
|
+
window.removeEventListener("keydown", down);
|
|
478
|
+
window.removeEventListener("keyup", up);
|
|
479
|
+
window.removeEventListener("blur", away);
|
|
480
|
+
};
|
|
481
|
+
}, [helping]);
|
|
482
|
+
}
|
|
483
|
+
/** The keys of Lichess and chess.com: the arrows, Home and End step through the moves (T-88), f and x turn the board round. */
|
|
484
|
+
function useBrowseKeys(step, flip) {
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
const press = (event) => {
|
|
487
|
+
const to = stepKeys[event.key];
|
|
488
|
+
const flips = flipKeys.includes(event.key.toLowerCase());
|
|
489
|
+
if (!forThePage(event) || (to === undefined && !flips)) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
event.preventDefault();
|
|
493
|
+
if (to === undefined) {
|
|
494
|
+
flip();
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
step(to);
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
window.addEventListener("keydown", press);
|
|
501
|
+
return () => window.removeEventListener("keydown", press);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/** The mouse wheel over the board steps through the moves, as on Lichess: down forward, a step a notch, however finely a touchpad turns it. */
|
|
505
|
+
function useWheelSteps(step) {
|
|
506
|
+
const turned = useRef(0);
|
|
507
|
+
return (event) => {
|
|
508
|
+
event.preventDefault();
|
|
509
|
+
turned.current += event.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? event.deltaY : event.deltaY * notch;
|
|
510
|
+
while (Math.abs(turned.current) >= notch) {
|
|
511
|
+
step(turned.current > 0 ? "forward" : "back");
|
|
512
|
+
turned.current -= Math.sign(turned.current) * notch;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
// How far a wheel turns in one notch, in pixels.
|
|
517
|
+
const notch = 100;
|
|
518
|
+
const noSquares = [];
|
|
519
|
+
/** Whether a key is the page's: not one the player types into a box or the settings, nor with a modifier, nor while a piece is in the air. */
|
|
520
|
+
function forThePage(event) {
|
|
521
|
+
const typing = event.target.closest("input, textarea, select, [contenteditable], dialog") !== null;
|
|
522
|
+
return !(event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey || typing || document.documentElement.classList.contains("dragging"));
|
|
523
|
+
}
|
|
524
|
+
/** Sends the player's move to the server, which plays it as the chat would, with the words written for the AI: the reason it was refused, or undefined. */
|
|
525
|
+
function sendMove(move, words, text, name) {
|
|
526
|
+
const message = words.trim();
|
|
527
|
+
return post("/api/move", message === "" ? { move } : { move, message }, text, name);
|
|
528
|
+
}
|
|
529
|
+
/** Sends the player's words alone: the reason they were refused, or undefined. */
|
|
530
|
+
function sendMessage(words, text, name) {
|
|
531
|
+
return post("/api/message", { text: words.trim() }, text, name);
|
|
532
|
+
}
|
|
533
|
+
/** Whether the words are a command for Claude's app, such as /clear, rather than words for the AI. */
|
|
534
|
+
function isCommand(words) {
|
|
535
|
+
return /^\s*\/[a-z]/i.test(words);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Sends a request to the server: the reason it was refused, in the player's language when the page knows the refusal (T-11, D-57), with the host's name where
|
|
539
|
+
* it sends the player, or undefined.
|
|
540
|
+
*/
|
|
541
|
+
async function post(path, body, text, name) {
|
|
542
|
+
try {
|
|
543
|
+
const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
544
|
+
if (response.ok) {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
const refused = (await response.json());
|
|
548
|
+
const known = refused.refusal === undefined ? undefined : text.refused[refused.refusal];
|
|
549
|
+
return { text: known?.(refused.most ?? 0, name) ?? refused.error ?? response.statusText, words: refused.refusal?.startsWith("words-") === true };
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
return { text: String(error), words: false };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** Saves a PGN file in the player's downloads: a game in analysis with its review, which the board page loads back ready (T-106), or the moves alone (T-59). */
|
|
556
|
+
function download(pgn, name) {
|
|
557
|
+
const link = document.createElement("a");
|
|
558
|
+
link.href = URL.createObjectURL(new Blob([pgn], { type: "application/x-chess-pgn" }));
|
|
559
|
+
link.download = name;
|
|
560
|
+
link.click();
|
|
561
|
+
URL.revokeObjectURL(link.href);
|
|
562
|
+
}
|
|
563
|
+
/** The file's name from the game's tags and what it holds: Ann-Bob-2026.11.02-analysis.pgn, without characters a file's name cannot hold. */
|
|
564
|
+
function pgnName(tags, kind) {
|
|
565
|
+
const parts = [tags.White, tags.Black, tags.Date?.includes("?") === true ? undefined : tags.Date, kind].filter((part) => part !== undefined && part !== "");
|
|
566
|
+
return `${(parts.length === 0 ? ["game"] : parts).join("-").replace(/[\\/:*?"<>|\s]+/g, "_")}.pgn`;
|
|
567
|
+
}
|
|
568
|
+
/** The game with the player's move played on a copy, for the board to show before the server answers; the game itself when the move is not legal. */
|
|
569
|
+
function withMove(game, move) {
|
|
570
|
+
const copy = new Game(game.record);
|
|
571
|
+
return copy.play(move).played ? copy : game;
|
|
572
|
+
}
|
|
573
|
+
/** Whether a pawn's move takes it to the last rank, where the player chooses what it becomes (T-11). */
|
|
574
|
+
function promotes(piece, to) {
|
|
575
|
+
return (piece === "P" && to.endsWith("8")) || (piece === "p" && to.endsWith("1"));
|
|
576
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "preact/jsx-runtime";
|
|
2
|
+
// The player's picture (D-45): their own image when the profile has one, else one of the drawn avatars, else the plain face of a page without profiles.
|
|
3
|
+
import { Avatar } from "./player-card.js";
|
|
4
|
+
import { imageAddress } from "./profile.js";
|
|
5
|
+
/** The player's picture, `size` pixels wide: their image, their drawn avatar, or the plain face. */
|
|
6
|
+
export function PlayerPicture({ profile, size = 48 }) {
|
|
7
|
+
const image = profile === undefined ? undefined : imageAddress(profile);
|
|
8
|
+
if (image !== undefined) {
|
|
9
|
+
return _jsx("img", { class: "avatar photo", src: image, width: size, height: size, alt: "" });
|
|
10
|
+
}
|
|
11
|
+
return profile?.avatar === undefined ? _jsx(Avatar, {}) : _jsx(DrawnAvatar, { id: profile.avatar, size: size });
|
|
12
|
+
}
|
|
13
|
+
/** One of the drawn avatars, its colour behind it in the picture (T-102); named for a screen reader when `label` is given. */
|
|
14
|
+
export function DrawnAvatar({ id, size, label }) {
|
|
15
|
+
return _jsx("img", { class: "avatar drawn", src: `/page/avatars/${id}.webp`, width: size, height: size, alt: label ?? "", decoding: "async" });
|
|
16
|
+
}
|