diff --git a/components/Book/ContentPage.tsx b/components/Book/ContentPage.tsx index 79dd0ee..b41170a 100644 --- a/components/Book/ContentPage.tsx +++ b/components/Book/ContentPage.tsx @@ -1,15 +1,13 @@ -import Page, { PageProps } from "./page"; +import Page, { PageProps } from "./Page"; export default function ContentPage( props: PageProps, ) { - const className = `p-12 print:after:w-0 mb-2 bg-red-400 print:mb-0 ${ - props.pageNumber !== 0 && (props.pageNumber % 2 === 0 + const className = `p-12 print:after:w-0 mb-2 bg-red-400 print:mb-0 ${props.pageNumber !== 0 && (props.pageNumber % 2 === 0 ? "page-even" : "page-odd") - } ${ - props.settings.pageSize === "A4" ? "after:max-2xl:w-0" : "after:max-xl:w-0" - }`; + } ${props.settings.pageSize === "A4" ? "after:max-2xl:w-0" : "after:max-xl:w-0" + }`; return ( diff --git a/components/Book/CoverPage.tsx b/components/Book/CoverPage.tsx index 324a4af..e8d2ed6 100644 --- a/components/Book/CoverPage.tsx +++ b/components/Book/CoverPage.tsx @@ -1,4 +1,4 @@ -import Page, { PageProps } from "./page"; +import Page, { PageProps } from "./Page"; export default function CoverPage( props: PageProps, diff --git a/components/Book/OneGamePage.tsx b/components/Book/OneGamePage.tsx index 691fb0e..6330777 100644 --- a/components/Book/OneGamePage.tsx +++ b/components/Book/OneGamePage.tsx @@ -1,7 +1,6 @@ -import { Fragment } from "react"; import { Game } from "../../types"; import ChessBoard from "../Chess/ChessBoard"; -import { type PageProps } from "./page"; +import { type PageProps } from "./Page"; import GameTitle from "../GameTitle"; import ContentPage from "./ContentPage"; import MoveListTable from "../Chess/MoveListTable"; diff --git a/components/Book/page.tsx b/components/Book/Page.tsx similarity index 100% rename from components/Book/page.tsx rename to components/Book/Page.tsx diff --git a/components/Book/SixGamePage.tsx b/components/Book/SixGamePage.tsx index b002550..bbce0bd 100644 --- a/components/Book/SixGamePage.tsx +++ b/components/Book/SixGamePage.tsx @@ -1,22 +1,16 @@ import { Game, Settings } from "../../types"; import ChessBoard from "../Chess/ChessBoard"; -import { type PageProps } from "./page"; +import { type PageProps } from "./Page"; import GameTitle from "../GameTitle"; import ContentPage from "./ContentPage"; import MoveListTable from "../Chess/MoveListTable"; +import MoveListInline from "../Chess/MoveListInline"; -function GameRow(props: { index: number; settings: Settings; game: Game }) { +function GameRow(props: { index: number; settings: Settings; game: Game, totalMoveRows: number, fullLength: Boolean }) { const moves = props.game.moves.split(" "); // Find the best variant to use to get the most out the move data. let size = "text-xs"; - if (moves.length < 20 * 2) { - size = "text-xs"; - } else if (moves.length < 33 * 2) { - size = "text-xs"; - } else { - size = "text-xs"; - } return (
@@ -28,21 +22,46 @@ function GameRow(props: { index: number; settings: Settings; game: Game }) { white={props.game.white} />
- + {props.totalMoveRows > 35 * (Number(props.fullLength) + 1) && moves.length > 40 * (Number(props.fullLength) + 1) + ? ( + + ) + : ( + + )} ); } export default function SixGamePage(props: PageProps & { games: Game[] }) { + const sortedByMoveCountGames = [...props.games].sort((a, b) => { + return b.movesCount - a.movesCount; + }); + + const totalMoveRows = sortedByMoveCountGames[0].movesCount + (sortedByMoveCountGames[3]?.movesCount ?? 0) / 2; + + let yGap = 'gap-y-4'; + if (totalMoveRows < 80) { + yGap = 'gap-y-20'; + } else if (totalMoveRows < 160) { + yGap = 'gap-y-12'; + } else if (totalMoveRows < 220) { + yGap = 'gap-y-8'; + } else { + yGap = 'gap-y-4'; + } + return ( -
- {props.games.map((game, i) => ( +
+ {sortedByMoveCountGames.map((game, i) => ( ))}
diff --git a/components/Book/ThreeGamePage.tsx b/components/Book/ThreeGamePage.tsx index 10cbc5f..cff54d2 100644 --- a/components/Book/ThreeGamePage.tsx +++ b/components/Book/ThreeGamePage.tsx @@ -1,7 +1,7 @@ import { Fragment } from "react"; import { Game, Settings } from "../../types"; import ChessBoard from "../Chess/ChessBoard"; -import { type PageProps } from "./page"; +import { type PageProps } from "./Page"; import GameTitle from "../GameTitle"; import ContentPage from "./ContentPage"; import MoveListTable from "../Chess/MoveListTable"; diff --git a/components/Book/index.tsx b/components/Book/index.tsx index 2108904..e649760 100644 --- a/components/Book/index.tsx +++ b/components/Book/index.tsx @@ -2,104 +2,19 @@ import { ReactNode, useCallback, useId, useState } from "react"; import type { Game, Settings, User } from "../../types"; import OneGamePage from "./OneGamePage"; import ThreeGamePage from "./ThreeGamePage"; -import Page from "./page"; +import Page from "./Page"; import Head from "next/head"; import ContentPage from "./ContentPage"; import CoverPage from "./CoverPage"; import SixGamePage from "./SixGamePage"; - -function stringToNumber(input: string, min: number, max: number) { - let hash = 0; - for (let i = 0; i < input.length; i++) { - hash = (hash * 31 + input.charCodeAt(i)) >>> 0; - } - return (hash % max) + min; -} - -type ChessPage = { - type: 'one', - games: [Game], -} | { - type: 'three', - games: [Game, Game, Game | undefined], -} | { - type: 'four', - games: [Game, Game, Game, Game], -} | { - type: 'six', - games: [Game, Game, Game, Game, Game | undefined, Game | undefined], -} - -function convertGamesToPages(settings: Settings, games: Game[]): ChessPage[] { - const result: ChessPage[] = []; - - function scoreGame(game: Game): number { - return game.black.rating * 100 - + game.white.rating * 100 - - (Number(game.black.ratingProvisional) * 1) - - (Number(game.white.ratingProvisional) * 1); - } - const gamesSortedByStrength = games.sort((a, b) => { - const aScore = scoreGame(a); - const bScore = scoreGame(b); - - if (aScore === bScore) { - return 0; - } - - return aScore > bScore ? -1 : 1; - }); - - // Best Game gets a full page - if (gamesSortedByStrength[0]) { - result.push({ - type: 'one', - games: [gamesSortedByStrength[0]] - }); - } - - const batch: Game[] = []; - for (let i = 1; i < gamesSortedByStrength.length; i++) { - batch.push(gamesSortedByStrength[i]); - - // @todo: Highlight games without any blunders/mistakes. - // @todo: Maybe highlight games that are full of blunders. - - if (batch.length < 4 && (i > gamesSortedByStrength.length * 0.33 || settings.pageSize === 'A5')) { - if (batch.length === 3) { - result.push({ - type: 'three', - games: [...batch] as [Game, Game, Game], - }); - batch.length = 0; - continue; - } - } else { - if (batch.length === 6) { - result.push({ - type: 'six', - games: [...batch] as [Game, Game, Game, Game, Game, Game], - }); - batch.length = 0; - continue; - } - } - } - - // No more games to add to batch, what was left over in the batching? - if (batch.length === 1) result.push({ type: 'one', games: [...batch] as [Game] }); - if (batch.length === 2 || batch.length === 3) result.push({ type: 'three', games: [...batch] as [Game, Game, Game | undefined] }); - if (batch.length > 3) result.push({ type: 'six', games: [...batch] as any }); - batch.length = 0; - - return result; -} +import stringToSemiRandomNumber from "../../utils/stringToNumber"; +import convertGamesToPages from "../../utils/convertGamesToPages"; export default function Book( props: { data: { user: User; games: Game[]; settings: Settings } }, ) { const [activePage, setActivePage] = useState(0); - const cover = stringToNumber(useId(), 1, 9); + const cover = stringToSemiRandomNumber(useId(), 1, 9); const pageClickHandler = useCallback((pageNumber: number) => { if (pageNumber < 0) { @@ -188,7 +103,6 @@ export default function Book( ); const gamePages = convertGamesToPages(props.data.settings, props.data.games); - console.log({ gamePages }) for (const gamePage of gamePages) { if (gamePage.type === 'one') { const game = gamePage.games[0]; @@ -213,18 +127,6 @@ export default function Book( onClick={pageClickHandler} /> ); - } else if (gamePage.type === 'four') { - // @todo: Four Game Page - // pages.push( - // game.id).join("-")} - // pageNumber={pages.length} - // games={gamePage.games} - // settings={props.data.settings} - // className={activePage > pages.length ? "turned" : ""} - // onClick={pageClickHandler} - // /> - // ); } else if (gamePage.type === 'six') { pages.push( 1 && (chess.moveNumber() - 1) * 2 + Number(chess.turn() === 'b') > props.game.board.ply) { - chess.undo(); - } - } catch (e) { - console.log("Invalid game: " + props.game.id) - } - - const board = [...chess.board()]; - - if (chess.turn() === 'b') { - board.reverse(); - board.map(row => row.reverse()); - } - return (
@@ -43,12 +25,12 @@ export default function ChessBoard(props: { game: Game, className?: string }) { key={i} className={`${isDark ? "" : "bg-white"} overflow-hidden`} > - {board[row][col]?.type + {props.game.board.grid[row][col]?.type ? ( // eslint-disable-next-line ) @@ -59,13 +41,13 @@ export default function ChessBoard(props: { game: Game, className?: string }) {
{(new Array(8).fill(true)).map((_, i) => { - return
{chess.turn() === 'b' ? columns[7 - i] : columns[i]}
; + return
{props.game.board.turn === 'b' ? columns[7 - i] : columns[i]}
; })}
{(new Array(8).fill(true)).map((_, i) => { return
{ - chess.turn() === 'w' + props.game.board.turn === 'w' ? 8 - i : i + 1 }
; diff --git a/components/Chess/MoveListInline.tsx b/components/Chess/MoveListInline.tsx index 5283ea3..1e5104b 100644 --- a/components/Chess/MoveListInline.tsx +++ b/components/Chess/MoveListInline.tsx @@ -1,5 +1,7 @@ import { Fragment } from "react"; +const highlightedStyle = "outline-2 bg-black text-white outline-black"; + export default function MoveListInline(props: { moves: string[], highlightedPly: number, size: string }) { const pairs: [string, string?][] = []; @@ -17,9 +19,9 @@ export default function MoveListInline(props: { moves: string[], highlightedPly: {i + 1}   - {moves[0]} + {i * 2 === props.highlightedPly ? {moves[0]} : moves[0]}   - {moves[1]}. + {i * 2 + 1 === props.highlightedPly ? {moves[1]} : moves[1]}. {" "} diff --git a/components/Chess/MoveListTable.tsx b/components/Chess/MoveListTable.tsx index 1b3a99e..ae204af 100644 --- a/components/Chess/MoveListTable.tsx +++ b/components/Chess/MoveListTable.tsx @@ -21,8 +21,8 @@ export default function MoveListTable(props: { moves: string[], highlightedPly: {i + 1} - {moves[0]} - {moves[1]} + {moves[0]} + {moves[1]} ))} diff --git a/components/Form/index.tsx b/components/Form/index.tsx index 3767e19..27dff34 100644 --- a/components/Form/index.tsx +++ b/components/Form/index.tsx @@ -121,7 +121,7 @@ export default function Form( } try { const data = JSON.parse(line) as LichessGame; - const result = await processGame(data, user.username, settings); + const result = processGame(data, user.username, settings); if (result) { // Game will only be added if it's interesting. games.push(result); diff --git a/lichess.ts b/lichess.ts index f8d5c6f..c1a6cdb 100644 --- a/lichess.ts +++ b/lichess.ts @@ -1,4 +1,5 @@ import { Game as GenericGame, Settings } from './types'; +import { Chess } from "chess.js"; export interface User { id: string; @@ -67,6 +68,15 @@ export interface Game { clock: Clock; tournament?: string; pgn: string; + analysis?: { + eval: number, + best?: string, + variation?: string, + judgment?: { + name: string, + comment: string, + }, + }[] } export interface Players { @@ -96,19 +106,88 @@ export interface Clock { } function randomNumberBetween(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; + // Box-Muller transform for normal distribution + const mean = (min + max) / 2; + const stddev = (max - min) / 6; // ~99.7% within [min, max] + let val; + do { + const u = 1 - Math.random(); + const v = Math.random(); + val = Math.round(mean + stddev * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v)); + } while (val < min || val > max); + return val; } -export default async function processGame(game: Game, username: string, settings: Settings): Promise { +const judgementMap = { + mistake: '?', + blunder: '??', + inaccuracy: '?!', +} as const; + +export default function processGame(game: Game, username: string, settings: Settings): GenericGame | undefined { + // No AI games. + if (game.source === 'ai') { + return undefined; + } + + // No variants other than standard chess. + if (game.variant !== 'standard') { + return undefined; + } + + // Filter by result if needed. const color = game.players.black.user.name === username ? 'black' : 'white'; if ((settings.result !== 'all' && !['black', 'white'].includes(game.winner)) || (settings.result === 'wins' && color !== game.winner) || (settings.result === 'losses' && color === game.winner) ) { // The game did not match the requirement of only showing wins or only losses. - console.log("filtering out " + game.id) - return; + console.debug("filtering out " + game.id) + return undefined; } + + if ((game.pgn ?? '').trim() === '') { + throw new Error("Game has no PGN: " + game.id); + } + + const moves = game.moves.split(" "); + const movesCount = moves.length; + let positionDelta = 0; + let biggestDelta = 0; + let biggestDeltaPly = -1; + for (let i = 0; i < (game.analysis?.length ?? 0); i++) { + const analysis = game.analysis![i]!; + const name = analysis.judgment?.name.toLowerCase() as keyof typeof judgementMap | undefined; + const delta = Math.abs(analysis.eval - positionDelta); + if (name !== undefined) { + moves[i] += judgementMap[name]; + } + + if (delta > biggestDelta) { + // If it's a blunder, always pick that. + biggestDelta = name === 'blunder' ? Infinity : delta; + biggestDeltaPly = i; + } + + positionDelta = analysis.eval; + } + const plyOfInterest = biggestDeltaPly !== -1 + ? biggestDeltaPly + : randomNumberBetween(Math.max(0, Math.min(movesCount - 2, 3)), movesCount) + + const chess = new Chess(undefined, { skipValidation: true }); + chess.loadPgn(game.pgn); + const turn = chess.turn(); + while (chess.moveNumber() > 1 && (chess.moveNumber() - 1) * 2 + Number(turn === 'b') > plyOfInterest) { + chess.undo(); + } + + const board = chess.board(); + if (turn === 'b') { + board.reverse(); + board.map(row => row.reverse()); + } + return { id: game.id, white: { @@ -121,10 +200,18 @@ export default async function processGame(game: Game, username: string, settings rating: game.players.black.rating, ratingProvisional: game.players.black.provisional === true, }, - moves: game.moves, + moves: moves.join(" "), + movesCount, board: { - pgn: game.pgn, - ply: randomNumberBetween(1, game.moves.split(" ").length), - } + grid: board, + ply: plyOfInterest - 1, + turn, + }, + analysis: game.analysis?.map(entry => ({ + eval: entry.eval, + type: entry.judgment?.name + ? (entry.judgment.name.toLowerCase() as 'mistake' /** ? */ | 'blunder' /** ?? */ | 'inaccuracy' /** ?! */) + : undefined + })), }; } \ No newline at end of file diff --git a/package.json b/package.json index 447332a..f8ed96b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@happypaul55/my-chess-book", "version": "1.0.0", - "private": true, + "license": "AGPL-3.0-only", "scripts": { "dev": "next dev", "build": "next build", diff --git a/types.ts b/types.ts index f139c72..c310197 100644 --- a/types.ts +++ b/types.ts @@ -1,3 +1,19 @@ +import { Color, PieceSymbol, Square } from "chess.js"; + +export type ChessPage = { + type: 'one', + games: [Game], +} | { + type: 'three', + games: [Game, Game, Game | undefined], +} | { + type: 'four', + games: [Game, Game, Game, Game], +} | { + type: 'six', + games: [Game, Game, Game, Game, Game | undefined, Game | undefined], +} + export type User = { name: string, rating: number, @@ -10,14 +26,20 @@ export type Game = { white: User, black: User, board: { - pgn: string, + grid: ({ + square: Square; + type: PieceSymbol; + color: Color; + } | null)[][], ply: number, + turn: Color, }, moves: string, + movesCount: number, analysis?: { eval: number, - type: 'mistake' /** ? */ | 'blunder' /** ?? */ | 'inaccuracy' /** ?! */ - } + type?: 'mistake' /** ? */ | 'blunder' /** ?? */ | 'inaccuracy' /** ?! */ + }[] }; export type Settings = { diff --git a/utils/convertGamesToPages.ts b/utils/convertGamesToPages.ts new file mode 100644 index 0000000..1e1f47d --- /dev/null +++ b/utils/convertGamesToPages.ts @@ -0,0 +1,155 @@ +import type { ChessPage, Game, Settings } from "../types"; + +/* ---------- helpers ------------------------------------------------------ */ + +/** Strength score used for the primary sort. */ +function scoreGame(g: Game): number { + const w = g.white.rating ?? 0; + const b = g.black.rating ?? 0; + const wp = g.white.ratingProvisional ? 1 : 0; + const bp = g.black.ratingProvisional ? 1 : 0; + return (w + b) * 100 - wp - bp; +} + +/** Total half-moves (ply) for a game. */ +function ply(g: Game): number { + return g.movesCount; +} + +/** Total ply for an array of games. */ +function totalPly(games: Game[]): number { + return games.reduce((s, g) => s + ply(g), 0); +} + +/** Max ply in an array of games. */ +function maxPly(games: Game[]): number { + return games.length ? Math.max(...games.map(ply)) : 0; +} + +/** Median ply of an array (used for similarity). */ +function medianPly(games: Game[]): number { + if (!games.length) return 0; + const sorted = games.map(ply).sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** Distance between two games for clustering (percentile based). */ +function distance(a: Game, b: Game): number { + const longer = Math.max(ply(a), ply(b)); + const shorter = Math.min(ply(a), ply(b)); + return (longer - shorter) / longer; // 0..1 +} + +/** Cluster games into groups of “similar” length. */ +function clusterByLength(games: Game[]): Game[][] { + const CLUSTERS = 8; // tweak if you want more/fewer buckets + const sorted = [...games].sort((a, b) => ply(a) - ply(b)); + const step = Math.ceil(sorted.length / CLUSTERS); + const out: Game[][] = []; + for (let i = 0; i < sorted.length; i += step) { + out.push(sorted.slice(i, i + step)); + } + return out; +} + +/* ---------- page builders ------------------------------------------------ */ + +/** Try to build a 3-page (3 games) that respects the 165-move limit. */ +function tryThree(pool: Game[]): Game[] | null { + if (pool.length < 3) return null; + // prefer candidates whose median is close to the pool median + const poolMedian = medianPly(pool); + const candidates = pool + .map((_, i) => pool.slice(i, i + 3)) + .filter((g) => g.length === 3 && totalPly(g) <= 330) + .sort( + (a, b) => + Math.abs(medianPly(a) - poolMedian) - + Math.abs(medianPly(b) - poolMedian) + ); + return candidates.length ? candidates[0] : null; +} + +/** Try to build a 6-page (6 games) that respects the 100-move row limit. */ +function trySix(pool: Game[]): Game[] | null { + if (pool.length < 6) return null; + // brute-force all 6-length slices (cheap for small arrays) + const candidates = pool + .map((_, i) => pool.slice(i, i + 6)) + .filter((g) => g.length === 6) + .filter((g) => { + const top = maxPly(g.slice(0, 3)); + const bot = maxPly(g.slice(3, 6)); + return top + bot <= 200; + }) + .sort((a, b) => totalPly(a) - totalPly(b)); // prefer tighter packing + return candidates.length ? candidates[0] : null; +} + +/** Try to build a 6-page with only 3 games (template allows empty slots). */ +function trySixWithThree(pool: Game[]): Game[] | null { + if (pool.length < 3) return null; + return pool.slice(0, 3); +} + +/* ---------- main export -------------------------------------------------- */ + +export default function convertGamesToPages( + _settings: Settings, + games: Game[] +): ChessPage[] { + if (!games.length) return []; + + /* 1. Strength order ---------------------------------------------------- */ + const strength = [...games].sort((a, b) => scoreGame(b) - scoreGame(a)); + + /* 2. Best game always gets a full page --------------------------------- */ + const pages: ChessPage[] = []; + const best = strength.shift()!; + pages.push({ type: "one", games: [best] }); + + /* 3. Cluster remaining games by similar length ------------------------- */ + const clusters = clusterByLength(strength); // array of Game[] + + /* 4. Greedy page building inside each cluster -------------------------- */ + for (const cluster of clusters) { + let pool = [...cluster].sort((a, b) => scoreGame(b) - scoreGame(a)); // still strength order inside cluster + + while (pool.length) { + let used: Game[] | null = null; + + /* 6-page (6 games) */ + if ((used = trySix(pool))) { + pages.push({ type: "six", games: used as [Game, Game, Game, Game, Game, Game] }); + pool = pool.slice(used.length); + continue; + } + + /* 3-page (3 games) */ + if ((used = tryThree(pool))) { + pages.push({ type: "three", games: used as [Game, Game, Game] }); + pool = pool.slice(used.length); + continue; + } + + /* 6-page with only 3 games */ + if ((used = trySixWithThree(pool))) { + pages.push({ type: "six", games: used as [Game, Game, Game, Game, Game, Game] }); + pool = pool.slice(used.length); + continue; + } + + /* Nothing fitted – pad with single pages */ + pages.push({ type: "one", games: [pool.shift()!] }); + } + } + + /* 5. Done -------------------------------------------------------------- */ + console.log({ pageCount: pages.length }); + pages.length = Math.min(40, pages.length); // limit to 40 pages + + return pages; +} diff --git a/utils/stringToNumber.ts b/utils/stringToNumber.ts new file mode 100644 index 0000000..4c86340 --- /dev/null +++ b/utils/stringToNumber.ts @@ -0,0 +1,7 @@ +export default function stringToSemiRandomNumber(input: string, min: number, max: number) { + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = (hash * 31 + input.charCodeAt(i)) >>> 0; + } + return (hash % max) + min; +} \ No newline at end of file