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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The moves of a game as the page shows them: each with its side and number, the comment the player may read and the AI's review; and the words said on
|
|
2
|
+
// the page.
|
|
3
|
+
import { drawingsIn, reviewOf } from "../core/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* The moves of a record, each with its side and number: a game may start from a position where Black moves first. A comment keeps only its words: the
|
|
6
|
+
* commands in it, such as the AI's plan that an ended game shows, and the (=) of a draw offer (D-65), are not for the page.
|
|
7
|
+
*/
|
|
8
|
+
export function pliesOf(record) {
|
|
9
|
+
const [, turn, , , , number] = (record.tags.FEN ?? "").split(" ");
|
|
10
|
+
const first = Number(number) || 1;
|
|
11
|
+
const offset = turn === "b" ? 1 : 0;
|
|
12
|
+
return record.moves.map((move, index) => {
|
|
13
|
+
const comment = move.comment?.replace(/\s*\[%[^\]]*\]/g, "").replace(/^\s*\(=\)/, "").trim();
|
|
14
|
+
// The AI's drawings with its own move, with the helps on, come without a badge or a note (D-78).
|
|
15
|
+
const drawings = drawingsIn(move.comment);
|
|
16
|
+
const review = reviewOf(move.comment) ?? (Object.keys(drawings).length === 0 ? undefined : drawings);
|
|
17
|
+
const ply = {
|
|
18
|
+
san: move.san,
|
|
19
|
+
side: (index + offset) % 2 === 0 ? "white" : "black",
|
|
20
|
+
number: first + Math.floor((index + offset) / 2),
|
|
21
|
+
...(review === undefined ? {} : { review }),
|
|
22
|
+
};
|
|
23
|
+
return comment === undefined || comment === "" ? ply : { ...ply, comment };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** The moves with the book's badge on the first `book` of them that have no review of the AI's. */
|
|
27
|
+
export function withBook(plies, book) {
|
|
28
|
+
// A move the AI only drew on keeps its drawings beside the book's badge (D-78).
|
|
29
|
+
return book === 0 ? plies : plies.map((ply, index) => index < book && ply.review?.badge === undefined && ply.review?.note === undefined ? { ...ply, review: { ...ply.review, badge: "book" } } : ply);
|
|
30
|
+
}
|
|
31
|
+
/** The moves without the AI's review: during a game with the helps off the player sees neither its judgments nor its drawings (D-78). */
|
|
32
|
+
export function withoutReviews(plies) {
|
|
33
|
+
return plies.map(({ review, ...ply }) => ply);
|
|
34
|
+
}
|
|
35
|
+
/** The player's last move when the AI judged it, with a badge of its own or a note, and has moved since (D-78); none else. */
|
|
36
|
+
export function judgmentOf(plies, player) {
|
|
37
|
+
const judged = plies.at(-2);
|
|
38
|
+
const review = judged?.review;
|
|
39
|
+
const answered = plies.at(-1)?.side !== player && judged?.side === player;
|
|
40
|
+
return answered && review !== undefined && (review.note !== undefined || (review.badge !== undefined && review.badge !== "book")) ? judged : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** How long a line of the player's may be: the server's limit (D-54), which a test keeps equal. */
|
|
43
|
+
export const talkLimits = { line: 200 };
|
|
44
|
+
/** How many times the AI has spoken in the game, its comments on its moves and its lines on the page: the page sees it speak when the count grows (T-84). */
|
|
45
|
+
export function aiSpoke(plies, ai, talk) {
|
|
46
|
+
return plies.filter((ply) => ply.side === ai && ply.comment !== undefined).length + talk.filter((line) => line.who === "ai").length;
|
|
47
|
+
}
|
|
48
|
+
/** The AI's words in the game going on (D-53), in the order said: its comments on its moves and what it said on the page. */
|
|
49
|
+
export function aiWords(plies, ai, talk) {
|
|
50
|
+
// A comment counts as said just after its move, before what the AI said there; the sort keeps that order.
|
|
51
|
+
const comments = plies.flatMap((ply, index) => ply.side === ai && ply.comment !== undefined ? [{ text: ply.comment, ply: index + 1 }] : []);
|
|
52
|
+
return [...comments, ...talk.filter((line) => line.who === "ai")].sort((one, other) => one.ply - other.ply).map((word) => word.text);
|
|
53
|
+
}
|
|
54
|
+
/** The player's words in the game going on (D-53), in the order said. */
|
|
55
|
+
export function playerWords(talk) {
|
|
56
|
+
return talk.filter((line) => line.who === "player");
|
|
57
|
+
}
|
|
58
|
+
/** How many characters the player has written since the AI's last move, of the 200 they may until its next one (D-54). */
|
|
59
|
+
export function playerSpent(plies, ai, talk) {
|
|
60
|
+
const moved = plies.findLastIndex((ply) => ply.side === ai);
|
|
61
|
+
return talk.filter((line) => line.who === "player" && line.ply > moved).reduce((sum, line) => sum + line.text.length, 0);
|
|
62
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
|
|
2
|
+
// The profiles on the page (D-23, D-45): the wizard that completes the first (D-71), the player's picture to change, and the menu that puts another in use or makes one.
|
|
3
|
+
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
4
|
+
import { DrawnAvatar, PlayerPicture } from "./avatars.js";
|
|
5
|
+
import { Dialog } from "./dialog.js";
|
|
6
|
+
import { avatarIds, chooseAvatar, imageAddress, levels, makeProfile, nameLimit, openDataFolder, ratingLimits, ratingSites, sendPhoto, useProfile } from "./profile.js";
|
|
7
|
+
import { Torretta } from "./torretta.js";
|
|
8
|
+
/**
|
|
9
|
+
* The page at the first start, while the profile Chesspal made is new: who plays, and the game comes after (D-23, D-71); a game played before stays in it.
|
|
10
|
+
* The system's account is only a suggestion for the name (T-120), and the page says where the games go (T-119).
|
|
11
|
+
*/
|
|
12
|
+
export function Wizard({ text, hasGame, account, dataFolder, onMade }) {
|
|
13
|
+
return (_jsxs("main", { class: "wizard", children: [_jsx(Torretta, { size: 56 }), _jsx("h1", { children: text.profile.welcome }), _jsxs("p", { class: "lead", children: [text.profile.lead, hasGame ? _jsx("span", { children: text.profile.gameMoves }) : null] }), _jsx(ProfileForm, { text: text, submit: text.profile.start, suggestion: account, onMade: onMade }), _jsx(DataFolder, { folders: dataFolder, text: text })] }));
|
|
14
|
+
}
|
|
15
|
+
/** The player's picture on their card: a click opens the change of picture; a mark on it says so where nothing hovers. */
|
|
16
|
+
export function PictureButton({ profile, onOpen, text }) {
|
|
17
|
+
return (_jsxs("button", { class: "me", type: "button", title: text.profile.changePicture, "aria-label": text.profile.yourPicture, "aria-haspopup": "dialog", onClick: onOpen, children: [_jsx(PlayerPicture, { profile: profile }), _jsx("span", { class: "me-cover", children: cameraIcon }), _jsx("span", { class: "me-badge", children: cameraIcon })] }));
|
|
18
|
+
}
|
|
19
|
+
/** The player's name on their card: a click opens the menu of the players. */
|
|
20
|
+
export function NameButton({ profile, onOpen, text }) {
|
|
21
|
+
return (_jsxs("button", { class: "profile-name", type: "button", title: text.profile.changePlayer, "aria-label": `${profile.name}: ${text.profile.changePlayer}`, "aria-haspopup": "dialog", onClick: onOpen, children: [_jsx("span", { children: profile.name }), _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M7 10L12 15L17 10" }) })] }));
|
|
22
|
+
}
|
|
23
|
+
/** The change of picture, open while `open`: one of the drawn avatars, or a photo of the player's, the one they have shown among them. */
|
|
24
|
+
export function PictureDialog({ open, profile, onClose, text }) {
|
|
25
|
+
const [problem, setProblem] = useState(undefined);
|
|
26
|
+
const done = (refused) => {
|
|
27
|
+
setProblem(refused === undefined ? undefined : refusalText(refused, text));
|
|
28
|
+
if (refused === undefined) {
|
|
29
|
+
onClose();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return (_jsxs(Dialog, { open: open, title: text.profile.changePicture, onClose: onClose, text: text, children: [_jsx("div", { class: "avatars", children: avatarIds.map((id) => (_jsx("button", { class: "avatar-choice", type: "button", title: text.profile.avatars[id], "aria-pressed": profile.image === undefined && profile.avatar === id, onClick: () => void chooseAvatar(profile.id, id).then(done), children: _jsx(DrawnAvatar, { id: id, size: 52, label: text.profile.avatars[id] }) }, id))) }), _jsx(PhotoChoice, { on: profile.image !== undefined, current: imageAddress(profile), onPhoto: (photo) => void sendPhoto(profile.id, photo).then(done), text: text }), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem })] }));
|
|
33
|
+
}
|
|
34
|
+
/** The menu of the players, open while `open`: another put in use, or a new one made, never while a game goes on. */
|
|
35
|
+
export function ProfilesDialog({ open, profiles, active, playing, dataFolder, onClose, text }) {
|
|
36
|
+
const [making, setMaking] = useState(false);
|
|
37
|
+
const [problem, setProblem] = useState(undefined);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setMaking(false);
|
|
40
|
+
setProblem(undefined);
|
|
41
|
+
}, [open]);
|
|
42
|
+
const use = (id) => {
|
|
43
|
+
if (id === active.id) {
|
|
44
|
+
onClose();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
void useProfile(id).then((refused) => (refused === undefined ? onClose() : setProblem(refusalText(refused, text))));
|
|
48
|
+
};
|
|
49
|
+
return (_jsx(Dialog, { open: open, title: making ? text.profile.newProfile : text.profile.profiles, onBack: making ? () => setMaking(false) : undefined, onClose: onClose, text: text, children: making ? _jsx(ProfileForm, { text: text, submit: text.profile.create, onMade: onClose }) : (_jsxs(_Fragment, { children: [_jsx("p", { class: "note", children: text.profile.eachOwn }), _jsx("div", { class: "profile-list", role: "radiogroup", "aria-label": text.profile.profiles, children: profiles.map((profile) => (_jsxs("button", { class: "choice", type: "button", role: "radio", title: profile.name, "aria-checked": profile.id === active.id, disabled: playing && profile.id !== active.id, onClick: () => use(profile.id), children: [_jsx(PlayerPicture, { profile: profile, size: 32 }), _jsx("span", { children: profile.name }), profile.id === active.id ? checkIcon : null] }, profile.id))) }), playing ? _jsx("p", { class: "note", children: text.profile.whilePlaying }) : null, _jsx("button", { class: "command new-profile", type: "button", disabled: playing, onClick: () => setMaking(true), children: text.profile.newProfile }), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem }), _jsx(DataFolder, { folders: dataFolder, text: text })] })) }));
|
|
50
|
+
}
|
|
51
|
+
/** Where the games are, as the file manager walks there, and a button that opens it there (T-119). */
|
|
52
|
+
function DataFolder({ folders, text }) {
|
|
53
|
+
const words = text.profile;
|
|
54
|
+
const [problem, setProblem] = useState(undefined);
|
|
55
|
+
// Opened a moment ago: Explorer may open behind the browser, and another click would open another window.
|
|
56
|
+
const [opened, setOpened] = useState(false);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!opened) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const timer = setTimeout(() => setOpened(false), 2000);
|
|
62
|
+
return () => clearTimeout(timer);
|
|
63
|
+
}, [opened]);
|
|
64
|
+
// A folder's name breaks only after its ›, never in its own spaces.
|
|
65
|
+
const shown = folders.map((name) => (name === "Documents" ? words.documents : name).replaceAll(" ", " ")).join(" › ");
|
|
66
|
+
const open = () => {
|
|
67
|
+
void openDataFolder().then((refused) => {
|
|
68
|
+
setProblem(refused === undefined ? undefined : words.noFolder);
|
|
69
|
+
setOpened(refused === undefined);
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
return (_jsxs("div", { class: "data-folder", children: [_jsxs("p", { class: "note", children: [words.gamesIn, " ", _jsx("b", { children: shown })] }), _jsx("button", { class: "command", type: "button", disabled: opened, onClick: open, children: opened ? words.opened : words.openFolder }), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem })] }));
|
|
73
|
+
}
|
|
74
|
+
/** The form that makes a profile and puts it in use: the wizard's, with the system's account as a suggestion (T-120), and the menu's for another player. */
|
|
75
|
+
function ProfileForm({ text, submit, suggestion, onMade }) {
|
|
76
|
+
const words = text.profile;
|
|
77
|
+
const [name, setName] = useState("");
|
|
78
|
+
const [avatar, setAvatar] = useState(avatarIds[0]);
|
|
79
|
+
const [photo, setPhoto] = useState(undefined);
|
|
80
|
+
const [level, setLevel] = useState("casual");
|
|
81
|
+
const [rating, setRating] = useState("");
|
|
82
|
+
const [site, setSite] = useState("lichess");
|
|
83
|
+
const [problem, setProblem] = useState(undefined);
|
|
84
|
+
const [busy, setBusy] = useState(false);
|
|
85
|
+
const send = (event) => {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
setBusy(true);
|
|
88
|
+
const value = rating.trim() === "" ? undefined : Number(rating);
|
|
89
|
+
void makeProfile({ name, level, site, ...(value === undefined ? {} : { rating: value }), ...(photo === undefined ? { avatar } : { photo }) }).then((refused) => {
|
|
90
|
+
setBusy(false);
|
|
91
|
+
setProblem(refused === undefined ? undefined : refusalText(refused, text));
|
|
92
|
+
if (refused === undefined) {
|
|
93
|
+
onMade();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
return (_jsxs("form", { class: "profile-form", onSubmit: send, children: [_jsxs("label", { class: "field", children: [_jsx("span", { children: words.name }), _jsx("input", { type: "text", required: true, maxLength: nameLimit, placeholder: words.namePlaceholder, autocomplete: "given-name", list: suggestion === undefined ? undefined : "name-suggestion", value: name, onInput: (event) => setName(event.currentTarget.value) }), suggestion === undefined ? null : (_jsx("datalist", { id: "name-suggestion", children: _jsx("option", { value: suggestion }) }))] }), _jsxs("fieldset", { children: [_jsx("legend", { children: words.avatar }), _jsx("div", { class: "avatars", children: avatarIds.map((id) => (_jsxs("label", { class: "avatar-choice", title: words.avatars[id], children: [_jsx("input", { type: "radio", name: "avatar", checked: photo === undefined && avatar === id, onChange: () => {
|
|
98
|
+
setAvatar(id);
|
|
99
|
+
setPhoto(undefined);
|
|
100
|
+
} }), _jsx("span", { class: "visually-hidden", children: words.avatars[id] }), _jsx(DrawnAvatar, { id: id, size: 52 })] }, id))) }), _jsx(PhotoChoice, { on: photo !== undefined, photo: photo, onPhoto: setPhoto, text: text })] }), _jsxs("fieldset", { children: [_jsx("legend", { children: words.level }), _jsx("div", { class: "levels", children: levels.map((id) => (_jsxs("label", { class: "level", children: [_jsx("input", { type: "radio", name: "level", checked: level === id, onChange: () => setLevel(id) }), _jsxs("span", { children: [_jsx("b", { children: words.levels[id][0] }), _jsx("small", { children: words.levels[id][1] })] })] }, id))) })] }), _jsxs("fieldset", { children: [_jsxs("legend", { children: [words.rating, " ", _jsx("small", { children: words.optional })] }), _jsxs("div", { class: "rating", children: [_jsx("input", { type: "number", inputmode: "numeric", min: ratingLimits.lowest, max: ratingLimits.highest, step: 1, placeholder: words.ratingPlaceholder, "aria-label": words.ratingLabel, value: rating, onInput: (event) => setRating(event.currentTarget.value) }), _jsx("select", { "aria-label": words.siteLabel, value: site, onChange: (event) => setSite(event.currentTarget.value), children: ratingSites.map((id) => _jsx("option", { value: id, children: words.sites[id] }, id)) })] })] }), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem }), _jsx("button", { class: "start", type: "submit", disabled: busy, children: submit })] }));
|
|
101
|
+
}
|
|
102
|
+
/** The choice of a photo of the player's, on a line of its own under the drawn avatars: the one chosen, or the one they have, shows in the ring. */
|
|
103
|
+
function PhotoChoice({ on, photo, current, onPhoto, text }) {
|
|
104
|
+
const preview = useMemo(() => (photo === undefined ? undefined : URL.createObjectURL(photo)), [photo]);
|
|
105
|
+
useEffect(() => () => preview === undefined ? undefined : URL.revokeObjectURL(preview), [preview]);
|
|
106
|
+
const shown = preview ?? current;
|
|
107
|
+
return (_jsxs("label", { class: `avatar-choice upload${on ? " on" : ""}`, children: [_jsx("input", { type: "file", accept: "image/*", onChange: (event) => {
|
|
108
|
+
const chosen = event.currentTarget.files?.[0];
|
|
109
|
+
if (chosen !== undefined) {
|
|
110
|
+
onPhoto(chosen);
|
|
111
|
+
}
|
|
112
|
+
} }), shown === undefined ? _jsx("span", { class: "ring", children: cameraIcon }) : _jsx("img", { class: "avatar photo", src: shown, width: 36, height: 36, alt: "" }), _jsx("span", { children: text.profile.ownImage })] }));
|
|
113
|
+
}
|
|
114
|
+
function refusalText(refused, text) {
|
|
115
|
+
if (refused.refusal === "profile-game") {
|
|
116
|
+
return text.profile.whilePlaying;
|
|
117
|
+
}
|
|
118
|
+
return refused.refusal === "bad-image" ? text.profile.badImage : refused.error;
|
|
119
|
+
}
|
|
120
|
+
// Zoe's camera, on the picture to change.
|
|
121
|
+
const cameraIcon = (_jsxs("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { d: "M4 8H7L9 5H15L17 8H20V19H4Z" }), _jsx("circle", { cx: "12", cy: "13", r: "3.5" })] }));
|
|
122
|
+
// The profile in use, in the menu.
|
|
123
|
+
const checkIcon = (_jsx("svg", { class: "icon in-use", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M5 12.5L10 17.5L19 7.5" }) }));
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The player's profile on the page (D-23, D-45): as the server sends it, the choices the wizard offers, and the requests that change it.
|
|
2
|
+
/** How the player says they play, as the server keeps it. */
|
|
3
|
+
export const levels = ["learning", "casual", "regular", "tournament"];
|
|
4
|
+
/** Where a rating comes from. */
|
|
5
|
+
export const ratingSites = ["lichess", "chess.com", "fide"];
|
|
6
|
+
/** The lowest and highest rating the server takes. */
|
|
7
|
+
export const ratingLimits = { lowest: 100, highest: 3500 };
|
|
8
|
+
/** The longest name the server keeps. */
|
|
9
|
+
export const nameLimit = 40;
|
|
10
|
+
/** The drawn avatars, by the ids the server keeps (D-45, T-102). */
|
|
11
|
+
export const avatarIds = ["short-hair", "bun", "glasses", "beanie", "beard", "curls", "moustache", "silver-bob", "cat", "owl"];
|
|
12
|
+
/** The address of a profile's own image: it changes with the image. */
|
|
13
|
+
export function imageAddress(profile) {
|
|
14
|
+
return profile.image === undefined ? undefined : `/api/profile/image?profile=${encodeURIComponent(profile.id)}&v=${encodeURIComponent(profile.image)}`;
|
|
15
|
+
}
|
|
16
|
+
/** Makes a profile and puts it in use, with the photo after it, made small first so that a photo that cannot be read makes no profile: the refusal, or undefined. */
|
|
17
|
+
export async function makeProfile({ name, level, rating, site, avatar, photo }) {
|
|
18
|
+
const image = photo === undefined ? undefined : await shrunk(photo).catch(() => undefined);
|
|
19
|
+
if (photo !== undefined && image === undefined) {
|
|
20
|
+
return badImage;
|
|
21
|
+
}
|
|
22
|
+
const answer = await post("/api/profile/new", jsonOf({ name: name.trim(), level, ...(rating === undefined ? {} : { rating, site }), ...(avatar === undefined ? {} : { avatar }) }));
|
|
23
|
+
if ("refused" in answer) {
|
|
24
|
+
return answer.refused;
|
|
25
|
+
}
|
|
26
|
+
return image === undefined ? undefined : refusalOf(await post(forProfile("/api/profile/image", String(answer.done.profile)), imageOf(image)));
|
|
27
|
+
}
|
|
28
|
+
/** Puts another profile in use: the server's refusal, or undefined. */
|
|
29
|
+
export async function useProfile(id) {
|
|
30
|
+
return refusalOf(await post("/api/profile/use", jsonOf({ profile: id })));
|
|
31
|
+
}
|
|
32
|
+
/** A drawn avatar for the profile the page shows: the server's refusal, or undefined. */
|
|
33
|
+
export async function chooseAvatar(profile, avatar) {
|
|
34
|
+
return refusalOf(await post(forProfile("/api/profile/avatar", profile), jsonOf({ avatar })));
|
|
35
|
+
}
|
|
36
|
+
/** Opens the data folder in the system's file manager, where the page runs (T-119): the server's refusal, or undefined. */
|
|
37
|
+
export async function openDataFolder() {
|
|
38
|
+
return refusalOf(await post("/api/data-folder/open", jsonOf({})));
|
|
39
|
+
}
|
|
40
|
+
/** Keeps the page's settings in the profile the page shows, so that they follow the player; the browser keeps them too. */
|
|
41
|
+
export function keepSettings(profile, settings) {
|
|
42
|
+
void post(forProfile("/api/settings", profile), jsonOf(settings));
|
|
43
|
+
}
|
|
44
|
+
/** A photo of the player's, made small and square in the browser, sent as the picture of the profile the page shows: the refusal, or undefined. */
|
|
45
|
+
export async function sendPhoto(profile, photo) {
|
|
46
|
+
const image = await shrunk(photo).catch(() => undefined);
|
|
47
|
+
return image === undefined ? badImage : refusalOf(await post(forProfile("/api/profile/image", profile), imageOf(image)));
|
|
48
|
+
}
|
|
49
|
+
// The side of the square picture sent: twice the largest the page shows it.
|
|
50
|
+
const pictureSide = 192;
|
|
51
|
+
const badImage = { error: "", refusal: "bad-image" };
|
|
52
|
+
// The square taken from a photo: the middle across, and a third of the way down, where a face usually is.
|
|
53
|
+
async function shrunk(photo) {
|
|
54
|
+
const bitmap = await createImageBitmap(photo);
|
|
55
|
+
const side = Math.min(bitmap.width, bitmap.height);
|
|
56
|
+
const canvas = document.createElement("canvas");
|
|
57
|
+
canvas.width = pictureSide;
|
|
58
|
+
canvas.height = pictureSide;
|
|
59
|
+
canvas.getContext("2d").drawImage(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 3, side, side, 0, 0, pictureSide, pictureSide);
|
|
60
|
+
bitmap.close();
|
|
61
|
+
// A browser that cannot write WebP writes PNG.
|
|
62
|
+
const blob = await new Promise((done) => canvas.toBlob(done, "image/webp", 0.9));
|
|
63
|
+
if (blob === null) {
|
|
64
|
+
throw new Error("The picture could not be made.");
|
|
65
|
+
}
|
|
66
|
+
return blob;
|
|
67
|
+
}
|
|
68
|
+
// A request about the profile the page shows: the server refuses it when another is in use.
|
|
69
|
+
function forProfile(path, profile) {
|
|
70
|
+
return `${path}?profile=${encodeURIComponent(profile)}`;
|
|
71
|
+
}
|
|
72
|
+
function jsonOf(body) {
|
|
73
|
+
return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
|
74
|
+
}
|
|
75
|
+
function imageOf(image) {
|
|
76
|
+
return { headers: { "Content-Type": image.type }, body: image };
|
|
77
|
+
}
|
|
78
|
+
async function post(path, request) {
|
|
79
|
+
let response;
|
|
80
|
+
try {
|
|
81
|
+
response = await fetch(path, { method: "POST", ...request });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return { refused: { error: String(error) } };
|
|
85
|
+
}
|
|
86
|
+
const body = await response.json().catch(() => ({}));
|
|
87
|
+
if (response.ok) {
|
|
88
|
+
return { done: body };
|
|
89
|
+
}
|
|
90
|
+
const error = typeof body.error === "string" ? body.error : response.statusText;
|
|
91
|
+
return { refused: typeof body.refusal === "string" ? { error, refusal: body.refusal } : { error } };
|
|
92
|
+
}
|
|
93
|
+
function refusalOf(answer) {
|
|
94
|
+
return "refused" in answer ? answer.refused : undefined;
|
|
95
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "preact/hooks";
|
|
3
|
+
import { Piece } from "./pieces.js";
|
|
4
|
+
import { looks, pieceSets } from "./settings.js";
|
|
5
|
+
/** The panel of the settings, open while `open` is true: every change goes to `onChange` at once. */
|
|
6
|
+
export function SettingsPanel({ open, settings, onChange, onClose, text }) {
|
|
7
|
+
const dialog = useRef(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const element = dialog.current;
|
|
10
|
+
if (open && !element.open) {
|
|
11
|
+
element.showModal();
|
|
12
|
+
}
|
|
13
|
+
else if (!open && element.open) {
|
|
14
|
+
element.close();
|
|
15
|
+
}
|
|
16
|
+
}, [open]);
|
|
17
|
+
const set = (key, value) => onChange({ ...settings, [key]: value });
|
|
18
|
+
return (_jsxs("dialog", { ref: dialog, class: "settings", "aria-label": text.settings, onClose: onClose, onClick: (event) => event.target === dialog.current && onClose(), children: [_jsxs("header", { children: [_jsx("h2", { children: text.settings }), _jsx("button", { class: "theme", type: "button", title: text.close, "aria-label": text.close, onClick: onClose, children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M6 6L18 18M18 6L6 18" }) }) })] }), _jsx("h3", { children: text.look }), _jsx(Choices, { label: text.themeChoice, children: ["system", "light", "dark"].map((theme) => _jsx(Choice, { on: settings.theme === theme, onPick: () => set("theme", theme), children: text.themes[theme] }, theme)) }), _jsx(Swatches, { look: "board", label: text.boardSkin, settings: settings, onPick: (id) => set("board", id), text: text }), _jsx(Choices, { label: text.pieceSet, children: pieceSets.map((pieceSet) => (_jsxs(Choice, { on: settings.set === pieceSet, onPick: () => set("set", pieceSet), children: [_jsxs("span", { class: "sample", children: [_jsx(Piece, { letter: "N", set: pieceSet }), _jsx(Piece, { letter: "n", set: pieceSet })] }), text.names[pieceSet] ?? pieceSet] }, pieceSet))) }), settings.set === "cburnett" ? null : _jsx(Swatches, { look: "pieces", label: text.pieceColours, settings: settings, onPick: (id) => set("pieces", id), text: text }), _jsx(Swatches, { look: "marks", label: text.marksColour, settings: settings, onPick: (id) => set("marks", id), text: text }), _jsx("h3", { children: text.play }), _jsx(Toggle, { on: settings.dots, onSwitch: (on) => set("dots", on), children: text.dots }), _jsx(Toggle, { on: settings.explain, onSwitch: (on) => set("explain", on), children: text.explain }), _jsx(Toggle, { on: settings.book, onSwitch: (on) => set("book", on), children: text.bookBadges }), _jsx(Toggle, { on: settings.helps, onSwitch: (on) => set("helps", on), children: text.helps }), _jsx(Toggle, { on: settings.sound, onSwitch: (on) => set("sound", on), children: text.sounds }), _jsx(Toggle, { on: settings.voice, onSwitch: (on) => set("voice", on), children: text.voice }), _jsx("h3", { children: text.languageChoice }), _jsx(Choices, { label: text.languageChoice, children: ["auto", "it", "en"].map((language) => _jsx(Choice, { on: settings.language === language, onPick: () => set("language", language), children: text.languages[language] }, language)) })] }));
|
|
19
|
+
}
|
|
20
|
+
/** The choices of a look, each a swatch drawn by the style sheet with the look's own colours. */
|
|
21
|
+
function Swatches({ look, label, settings, onPick, text }) {
|
|
22
|
+
return (_jsx(Choices, { label: label, children: looks[look].map((id) => (_jsxs(Choice, { on: settings[look] === id, onPick: () => onPick(id), children: [_jsxs("span", { class: "swatch", [`data-${look}`]: id, children: [_jsx("i", {}), look === "marks" ? null : _jsx("i", {}), look === "board" ? _jsx("i", {}) : null] }), text.names[id] ?? id] }, id))) }));
|
|
23
|
+
}
|
|
24
|
+
function Choices({ label, children }) {
|
|
25
|
+
return (_jsxs("div", { class: "choices", role: "radiogroup", "aria-label": label, children: [_jsx("span", { class: "label", children: label }), _jsx("div", { class: "options", children: children })] }));
|
|
26
|
+
}
|
|
27
|
+
function Choice({ on, onPick, children }) {
|
|
28
|
+
return (_jsx("button", { class: "choice", type: "button", role: "radio", "aria-checked": on, onClick: onPick, children: children }));
|
|
29
|
+
}
|
|
30
|
+
function Toggle({ on, onSwitch, children }) {
|
|
31
|
+
return (_jsxs("label", { class: "toggle", children: [_jsx("span", { children: children }), _jsx("input", { type: "checkbox", role: "switch", checked: on, onChange: (event) => onSwitch(event.currentTarget.checked) })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The player's settings on the page (D-56): the look, the game's aids and the language, kept in the profile in use (T-19) and remembered by this browser.
|
|
2
|
+
/** The piece sets: our two, whose colours the player picks, and cburnett with its own (D-52). */
|
|
3
|
+
export const pieceSets = ["nitida", "torretta", "cburnett"];
|
|
4
|
+
/** The choices of each look, by the ids the style sheet knows; the first is the default (T-82). */
|
|
5
|
+
export const looks = {
|
|
6
|
+
board: ["azzurro", "verde", "noce", "ardesia", "grigio", "lavanda", "ciliegio", "menta", "carta"],
|
|
7
|
+
pieces: ["classico", "avorio", "notte", "bordeaux"],
|
|
8
|
+
marks: ["arancione", "rosso", "blu", "verde"],
|
|
9
|
+
};
|
|
10
|
+
/** The settings this browser remembers, each one it does not know taking its default; the theme and the sounds chosen before the panel come along. */
|
|
11
|
+
export function loadSettings() {
|
|
12
|
+
const saved = read(remembered);
|
|
13
|
+
const oldTheme = read("chesspal-theme");
|
|
14
|
+
return settingsFrom(saved === null ? {} : parsed(saved), { theme: oldTheme === "light" || oldTheme === "dark" ? oldTheme : "system", sound: read("chesspal-sound") !== "off" });
|
|
15
|
+
}
|
|
16
|
+
/** Settings from what was kept, by this browser or in the profile: each one not known takes its default. */
|
|
17
|
+
export function settingsFrom(value, defaults = { theme: "system", sound: true }) {
|
|
18
|
+
const stored = typeof value === "object" && value !== null ? value : {};
|
|
19
|
+
return {
|
|
20
|
+
theme: oneOf(stored.theme, ["system", "light", "dark"], defaults.theme),
|
|
21
|
+
board: oneOf(stored.board, looks.board, looks.board[0]),
|
|
22
|
+
set: oneOf(stored.set, pieceSets, pieceSets[0]),
|
|
23
|
+
pieces: oneOf(stored.pieces, looks.pieces, looks.pieces[0]),
|
|
24
|
+
marks: oneOf(stored.marks, looks.marks, looks.marks[0]),
|
|
25
|
+
dots: typeof stored.dots === "boolean" ? stored.dots : true,
|
|
26
|
+
explain: typeof stored.explain === "boolean" ? stored.explain : true,
|
|
27
|
+
book: typeof stored.book === "boolean" ? stored.book : true,
|
|
28
|
+
helps: typeof stored.helps === "boolean" ? stored.helps : false,
|
|
29
|
+
sound: typeof stored.sound === "boolean" ? stored.sound : defaults.sound,
|
|
30
|
+
voice: typeof stored.voice === "boolean" ? stored.voice : false,
|
|
31
|
+
language: oneOf(stored.language, ["auto", "it", "en"], "auto"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Remembers the settings in this browser. */
|
|
35
|
+
export function saveSettings(settings) {
|
|
36
|
+
try {
|
|
37
|
+
localStorage.setItem(remembered, JSON.stringify(settings));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// The choices last as long as the page.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Shows the settings' look on the page: the theme, the board's skin, the pieces' set and colours and the marks' colour, as attributes the style sheet reads. */
|
|
44
|
+
export function showSettings(settings) {
|
|
45
|
+
const root = document.documentElement.dataset;
|
|
46
|
+
root.theme = settings.theme === "system" ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : settings.theme;
|
|
47
|
+
root.board = settings.board;
|
|
48
|
+
root.set = settings.set;
|
|
49
|
+
root.pieces = settings.pieces;
|
|
50
|
+
root.marks = settings.marks;
|
|
51
|
+
}
|
|
52
|
+
/** The languages the page speaks in, by the setting: the browser's, or the one chosen. */
|
|
53
|
+
export function languagesOf(settings) {
|
|
54
|
+
return settings.language === "auto" ? navigator.languages : [settings.language];
|
|
55
|
+
}
|
|
56
|
+
const remembered = "chesspal-settings";
|
|
57
|
+
function read(key) {
|
|
58
|
+
try {
|
|
59
|
+
return localStorage.getItem(key);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function parsed(saved) {
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(saved);
|
|
68
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function oneOf(value, choices, fallback) {
|
|
75
|
+
return choices.find((choice) => choice === value) ?? fallback;
|
|
76
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const folder = "/page/sounds/";
|
|
2
|
+
/** The sound of the game's last move, from how the board stands after it; none before the first move. */
|
|
3
|
+
export function soundOf(game) {
|
|
4
|
+
const last = game.lastMove;
|
|
5
|
+
const { outcome } = game;
|
|
6
|
+
if (last === undefined) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
if (outcome.over) {
|
|
10
|
+
return outcome.ending === "checkmate" ? "mate" : "draw";
|
|
11
|
+
}
|
|
12
|
+
return game.position.check ? "check" : last.san.includes("x") ? "capture" : "move";
|
|
13
|
+
}
|
|
14
|
+
/** The sound of a game ended without a move: a resignation, a flag fallen, a draw agreed; none for the ends that come with a move. */
|
|
15
|
+
export function endSoundOf(how) {
|
|
16
|
+
return how === "resignation" ? "resign" : how === "time" ? "flag" : how === "agreement" ? "draw" : undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Lets the page play sounds: loads the recordings, and since a browser plays none before the player has touched the page, the first touch wakes them. */
|
|
19
|
+
export function wakeSounds() {
|
|
20
|
+
loadRecordings();
|
|
21
|
+
addEventListener("pointerdown", () => void audio()?.resume(), { once: true });
|
|
22
|
+
}
|
|
23
|
+
/** Plays a sound: its recordings together, else its tone; a browser without Web Audio plays none. */
|
|
24
|
+
export function playSound(sound) {
|
|
25
|
+
const context = audio();
|
|
26
|
+
if (context === undefined) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const recorded = layers[sound].flatMap((layer) => recordings.get(layer) ?? []);
|
|
30
|
+
if (recorded.length < layers[sound].length) {
|
|
31
|
+
playTone(context, sound);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const recording of recorded) {
|
|
35
|
+
const source = new AudioBufferSourceNode(context, { buffer: recording });
|
|
36
|
+
source.connect(context.destination);
|
|
37
|
+
source.start();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Fetches and decodes the recordings, offline so that the page wakes no sound before a touch; one that fails leaves its tone. */
|
|
41
|
+
function loadRecordings() {
|
|
42
|
+
let decoder;
|
|
43
|
+
try {
|
|
44
|
+
decoder = new OfflineAudioContext({ length: 1, sampleRate: 48000 });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const sound of Object.keys(tones)) {
|
|
50
|
+
void fetch(`${folder}${sound}.ogg`)
|
|
51
|
+
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(`No ${sound}.ogg.`)))
|
|
52
|
+
.then((bytes) => decoder.decodeAudioData(bytes))
|
|
53
|
+
.then((buffer) => recordings.set(sound, buffer), () => undefined);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function playTone(context, sound) {
|
|
57
|
+
for (const { frequency, at, length, wave, volume } of tones[sound]) {
|
|
58
|
+
const oscillator = new OscillatorNode(context, { type: wave, frequency });
|
|
59
|
+
const gain = new GainNode(context, { gain: 0 });
|
|
60
|
+
const start = context.currentTime + at;
|
|
61
|
+
// A quick attack and a fall, as a struck note has.
|
|
62
|
+
gain.gain.linearRampToValueAtTime(volume, start + 0.005);
|
|
63
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, start + length);
|
|
64
|
+
oscillator.connect(gain).connect(context.destination);
|
|
65
|
+
oscillator.start(start);
|
|
66
|
+
oscillator.stop(start + length);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// A wooden knock for a piece set down, two for a capture, a call for a check, a chord for a mate, a falling pair for a draw, three falling notes for a
|
|
70
|
+
// resignation, a timer's beeps for a flag, a rising pair for a draw offered.
|
|
71
|
+
const tones = {
|
|
72
|
+
move: [{ frequency: 190, at: 0, length: 0.08, wave: "triangle", volume: 0.5 }, { frequency: 1200, at: 0, length: 0.02, wave: "sine", volume: 0.12 }],
|
|
73
|
+
capture: [{ frequency: 170, at: 0, length: 0.08, wave: "triangle", volume: 0.5 }, { frequency: 240, at: 0.06, length: 0.1, wave: "triangle", volume: 0.45 }],
|
|
74
|
+
check: [{ frequency: 660, at: 0, length: 0.14, wave: "sine", volume: 0.25 }, { frequency: 880, at: 0.11, length: 0.2, wave: "sine", volume: 0.25 }],
|
|
75
|
+
mate: [523, 659, 784, 1047].map((frequency, index) => ({ frequency, at: index * 0.06, length: 0.9, wave: "sine", volume: 0.18 })),
|
|
76
|
+
draw: [{ frequency: 440, at: 0, length: 0.3, wave: "sine", volume: 0.2 }, { frequency: 349, at: 0.22, length: 0.45, wave: "sine", volume: 0.2 }],
|
|
77
|
+
resign: [523, 392, 262].map((frequency, index) => ({ frequency, at: index * 0.11, length: index === 2 ? 0.5 : 0.25, wave: "triangle", volume: 0.25 })),
|
|
78
|
+
flag: [
|
|
79
|
+
...[0, 0.11, 0.22].map((at) => ({ frequency: 988, at, length: 0.07, wave: "square", volume: 0.06 })),
|
|
80
|
+
{ frequency: 440, at: 0.36, length: 0.4, wave: "triangle", volume: 0.25 },
|
|
81
|
+
],
|
|
82
|
+
offer: [{ frequency: 659, at: 0, length: 0.45, wave: "sine", volume: 0.2 }, { frequency: 880, at: 0.16, length: 0.5, wave: "sine", volume: 0.2 }],
|
|
83
|
+
};
|
|
84
|
+
/** The recordings each sound plays at once. */
|
|
85
|
+
const layers = {
|
|
86
|
+
move: ["move"],
|
|
87
|
+
capture: ["move", "capture"],
|
|
88
|
+
check: ["move", "check"],
|
|
89
|
+
mate: ["move", "mate"],
|
|
90
|
+
draw: ["draw"],
|
|
91
|
+
resign: ["resign"],
|
|
92
|
+
flag: ["flag"],
|
|
93
|
+
offer: ["offer"],
|
|
94
|
+
};
|
|
95
|
+
const recordings = new Map();
|
|
96
|
+
let shared;
|
|
97
|
+
function audio() {
|
|
98
|
+
try {
|
|
99
|
+
shared ??= new AudioContext();
|
|
100
|
+
return shared;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Both sides of a game in analysis, and the one at the bottom: the player's, when one of them is, else White. */
|
|
2
|
+
export function studiedSides(tags, you, ai, sides) {
|
|
3
|
+
const sideOf = (side) => {
|
|
4
|
+
const tag = (side === "white" ? tags.White : tags.Black)?.trim() ?? "";
|
|
5
|
+
if (tag === "Player" || tag.toLowerCase() === you.trim().toLowerCase()) {
|
|
6
|
+
return { name: you, who: "player" };
|
|
7
|
+
}
|
|
8
|
+
// A tag PGN leaves unknown names the side.
|
|
9
|
+
return tag === "AI" ? { name: ai, who: "ai" } : { name: /^[?\-\s]*$/.test(tag) ? sides[side] : tag, who: "other" };
|
|
10
|
+
};
|
|
11
|
+
const white = sideOf("white");
|
|
12
|
+
const black = sideOf("black");
|
|
13
|
+
return { white, black, bottom: black.who === "player" && white.who !== "player" ? "black" : "white" };
|
|
14
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
// The dialog of the analyses (T-92, T-106): the games analysed or brought to study, to open again, and a game played elsewhere brought as a PGN, pasted or read
|
|
3
|
+
// from a file, which the server keeps to study and puts in analysis.
|
|
4
|
+
import { useEffect, useState } from "preact/hooks";
|
|
5
|
+
import { Dialog } from "./dialog.js";
|
|
6
|
+
import { signature } from "./model-name.js";
|
|
7
|
+
import { studiedSides } from "./studied.js";
|
|
8
|
+
/**
|
|
9
|
+
* The dialog the header's button opens; `onStudy` sends the choice and gives back why the server refused it, or undefined once it took it. The games played
|
|
10
|
+
* here name their sides as their analysis does: the player by `you`, the AI by its model when the game keeps it (D-82), else by `ai`.
|
|
11
|
+
*/
|
|
12
|
+
export function StudyDialog({ open, onStudy, onClose, you, ai, text }) {
|
|
13
|
+
return (_jsx(Dialog, { open: open, title: text.study.title, onClose: onClose, text: text, children: _jsx(StudyForm, { onStudy: onStudy, you: you, ai: ai, text: text }) }));
|
|
14
|
+
}
|
|
15
|
+
/** The form, made again at each opening: the list read anew, empty, with no problem shown. */
|
|
16
|
+
function StudyForm({ onStudy, you, ai, text }) {
|
|
17
|
+
const words = text.study;
|
|
18
|
+
const [pgn, setPgn] = useState("");
|
|
19
|
+
const [problem, setProblem] = useState(undefined);
|
|
20
|
+
const [sending, setSending] = useState(false);
|
|
21
|
+
const analyses = useAnalyses();
|
|
22
|
+
const study = (studied) => {
|
|
23
|
+
setSending(true);
|
|
24
|
+
void onStudy(studied).then((refused) => {
|
|
25
|
+
setSending(false);
|
|
26
|
+
setProblem(refused);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
const read = (file) => {
|
|
30
|
+
void file?.text().then((read) => {
|
|
31
|
+
setPgn(read);
|
|
32
|
+
setProblem(undefined);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
return (_jsxs("form", { class: "profile-form study", onSubmit: (event) => {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
study({ pgn });
|
|
38
|
+
}, children: [analyses.length === 0 ? null : (_jsxs("section", { class: "analyses", "aria-label": words.yours, children: [_jsx("h3", { children: words.yours }), _jsx("ul", { children: analyses.map((item) => (_jsx("li", { children: _jsxs("button", { type: "button", disabled: sending, onClick: () => study({ game: item.name }), children: [_jsx("b", { children: sidesNamed(item, you, ai, text) }), _jsx("span", { class: "result", children: item.result }), _jsx("span", { class: "note", children: [item.date, item.opening, words.moves(item.moves)].filter((part) => part !== undefined).join(" · ") }), _jsx("span", { class: item.reviewed ? "state reviewed" : "state", children: item.reviewed ? words.reviewed : words.unreviewed })] }) }, item.name))) }), _jsx("h3", { children: words.bring })] })), _jsxs("label", { class: "field", children: [_jsx("span", { children: words.paste }), _jsx("textarea", { rows: analyses.length === 0 ? 10 : 6, value: pgn, autofocus: true, spellcheck: false, placeholder: '[White "…"]\n[Black "…"]\n\n1. e4 e5 2. Nf3 …', onInput: (event) => {
|
|
39
|
+
setPgn(event.currentTarget.value);
|
|
40
|
+
setProblem(undefined);
|
|
41
|
+
} })] }), _jsxs("label", { class: "field file", children: [_jsx("span", { children: words.file }), _jsx("input", { type: "file", accept: ".pgn,application/x-chess-pgn,text/plain", onChange: (event) => read(event.currentTarget.files?.[0]) })] }), _jsx("p", { class: "note", children: words.note }), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem }), _jsx("button", { class: "start", type: "submit", disabled: pgn.trim() === "" || sending, children: words.start })] }));
|
|
42
|
+
}
|
|
43
|
+
/** Who played a game listed, as its analysis names them: «Stefano – Opus 5.5 · xhigh». */
|
|
44
|
+
function sidesNamed(item, you, ai, text) {
|
|
45
|
+
const { white, black } = studiedSides({ White: item.white, Black: item.black }, you, item.aiModel === undefined ? ai : signature(item.aiModel.model, item.aiModel.effort), text.sides);
|
|
46
|
+
return `${white.name} – ${black.name}`;
|
|
47
|
+
}
|
|
48
|
+
/** The analyses of the player's folder, read once the dialog opens; none while they come, or when the server cannot give them. */
|
|
49
|
+
function useAnalyses() {
|
|
50
|
+
const [analyses, setAnalyses] = useState([]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
void fetch("/api/analyses")
|
|
53
|
+
.then(async (response) => response.ok ? await response.json() : [])
|
|
54
|
+
.catch(() => [])
|
|
55
|
+
.then(setAnalyses);
|
|
56
|
+
}, []);
|
|
57
|
+
return analyses;
|
|
58
|
+
}
|