From d33b2b4dd249316f0bba57023e1c6ffde1791a99 Mon Sep 17 00:00:00 2001 From: Cozma Rares Date: Sun, 6 Aug 2023 00:50:13 +0300 Subject: [PATCH] multiple changes - add engdame state to local game - clean engine code - add endgame tests - fix tests - fix san checkmate symbol --- client/src/components/ChessUI.tsx | 52 +++- client/src/components/Chessboard.tsx | 149 ++++++---- client/src/components/icons.tsx | 39 +++ client/src/routes/Home.tsx | 4 +- client/src/routes/OnlineGame.tsx | 32 +-- client/src/utils/utils.ts | 3 + server/src/__test__/gameOver.test.ts | 271 ++++++++++++++++++ ...ovesForSquare.test.ts => getMoves.test.ts} | 6 +- .../src/__test__/insufficientMaterial.test.ts | 78 ----- server/src/__test__/isSquareAttacked.test.ts | 12 - server/src/__test__/makeMove.test.ts | 0 server/src/engine.ts | 200 ++++++------- server/src/server.ts | 2 +- tsconfig.json | 4 +- 14 files changed, 559 insertions(+), 293 deletions(-) create mode 100644 client/src/utils/utils.ts create mode 100644 server/src/__test__/gameOver.test.ts rename server/src/__test__/{getMovesForSquare.test.ts => getMoves.test.ts} (99%) delete mode 100644 server/src/__test__/insufficientMaterial.test.ts delete mode 100644 server/src/__test__/isSquareAttacked.test.ts create mode 100644 server/src/__test__/makeMove.test.ts diff --git a/client/src/components/ChessUI.tsx b/client/src/components/ChessUI.tsx index b86fd8d..32cf6dd 100644 --- a/client/src/components/ChessUI.tsx +++ b/client/src/components/ChessUI.tsx @@ -1,17 +1,51 @@ +import { useNavigate } from "react-router-dom"; +import { COLOR } from "../../../server/src/engine"; import InferProps from "../utils/InferProps"; +import Show from "../utils/Show"; import ChessBoard from "./Chessboard"; import History from "./History"; +import Modal, { ModalButton } from "./Modal"; +import { useState } from "react"; const ChessUI: React.FC> = ( props -) => ( -
- - -
-); +) => { + const [showModal, setShowModal] = useState(true); + const navigate = useNavigate(); + const chess = props.chess; + + return ( + <> +
+ + +
+ + +
+

Game Over

+

+ {chess.isCheckMate() + ? (chess.getTurn() == COLOR.WHITE ? "Black" : "White") + " won!" + : "It's a draw!"} +

+
+ navigate("/")}> + Go to main page + + {props.newGame && ( + New Game + )} + setShowModal(false)}>Close +
+
+
+ + ); +}; export default ChessUI; diff --git a/client/src/components/Chessboard.tsx b/client/src/components/Chessboard.tsx index eec1452..8cdc8c0 100644 --- a/client/src/components/Chessboard.tsx +++ b/client/src/components/Chessboard.tsx @@ -27,9 +27,13 @@ import Chess, { MOVE_FLAGS, PIECE_PROMOTION, PiecePromotionType, + COLOR, + swapColor, } from "../../../server/src/engine"; -import { MouseEventHandler, useState } from "react"; +import { MouseEventHandler, ReactNode, useState } from "react"; import Show from "../utils/Show"; +import InferProps from "../utils/InferProps"; +import { Crown, HalfStar, Hashtag } from "./icons"; const PIECES: Record> = { w: { p: wp, n: wn, b: wb, r: wr, q: wq, k: wk }, @@ -46,24 +50,38 @@ const ChessBoard: React.FC<{ (Pick & { color: Color }) | null >(null); - const tileProps = new Array(64).fill(null).map((_, i) => ({ - tileNumber: i, - piece: chess.getPiece(i), - isAttacked: false, - isPromotion: false, - isActive: false, - })); + const tileProps: Array & { isPromotion: boolean }> = + new Array(64).fill(null).map((_, i) => ({ + tileNumber: i, + piece: chess.getPiece(i), + isAttacked: false, + isPromotion: false, + isActive: false, + EndGameIcon: undefined, + })); if (activeTile != -1) { tileProps[activeTile].isActive = true; - chess.getMovesForSquare(algebraic(activeTile)).forEach(({ to, flags }) => { + chess.getMovesForSquare(activeTile).forEach(({ to, flags }) => { const square = squareIndex(to); tileProps[square].isAttacked = true; - tileProps[square].isPromotion = - flags & MOVE_FLAGS.PROMOTION ? true : false; + tileProps[square].isPromotion = (flags & MOVE_FLAGS.PROMOTION) != 0; }); } + if (chess.isGameOver()) { + const kings = chess.getKings(); + if (chess.isDraw()) { + tileProps[kings[COLOR.BLACK]].EndGameIcon = ; + tileProps[kings[COLOR.WHITE]].EndGameIcon = ; + } else { + const loser = chess.getTurn(), + winner = swapColor(loser); + tileProps[kings[winner]].EndGameIcon = ; + tileProps[kings[loser]].EndGameIcon = ; + } + } + const tiles = tileProps.map((props, i) => ( )); @@ -84,7 +102,7 @@ const ChessBoard: React.FC<{ if (tileProps[tile].isPromotion) return setPromotionMove(moveObj); - makeMove(moveObj); + makeMove({ to: moveObj.to, from: moveObj.from }); setActiveTile(-1); return; } @@ -158,57 +176,70 @@ const Tile: React.FC<{ isActive: boolean; isAttacked: boolean; blackPerspective?: boolean; -}> = ({ tileNumber, piece, isActive, isAttacked, blackPerspective }) => { - const color = squareColor(tileNumber); + EndGameIcon?: ReactNode; +}> = ({ + tileNumber, + piece, + isActive, + isAttacked, + blackPerspective, + EndGameIcon, +}) => { + const color = squareColor(tileNumber); - const { bg: bgColor, text: textColor } = TILE_COLORS[color]; - const activeColor = TILE_COLORS[color].active; + const { bg: bgColor, text: textColor } = TILE_COLORS[color]; + const activeColor = TILE_COLORS[color].active; - const tileFile = file(tileNumber); - const tileRank = rank(tileNumber); - const square = algebraic(tileNumber); + const tileFile = file(tileNumber); + const tileRank = rank(tileNumber); + const square = algebraic(tileNumber); - const isFirstColumn = tileFile == (blackPerspective ? FILE.H : FILE.A); - const isLastRow = tileRank == (blackPerspective ? RANK.EIGHTH : RANK.FIRST); + const isFirstColumn = tileFile == (blackPerspective ? FILE.H : FILE.A); + const isLastRow = tileRank == (blackPerspective ? RANK.EIGHTH : RANK.FIRST); - return ( -
*]:pointer-events-none outline-blue-300 hover:outline outline-4 -outline-offset-4", - isActive ? activeColor : bgColor, - piece ? "cursor-pointer" : "", - ].join(" ")} - data-tile={tileNumber} - > - {/* HACK: image width causes issues, will work as long as ChessUI's height and ChessBoard's border aren't modified */} - {piece && ( - - )} - -
- } - > -
+ return ( +
*]:pointer-events-none outline-blue-300 hover:outline outline-4 -outline-offset-4", + isActive ? activeColor : bgColor, + piece ? "cursor-pointer" : "", + ].join(" ")} + data-tile={tileNumber} + > + {/* HACK: image width causes issues, will work as long as ChessUI's height and ChessBoard's border aren't modified */} + {piece && ( + + )} + +
+ {EndGameIcon} +
- - -
- {square[1]} -
-
- -
- {square[0]} -
-
-
- ); -}; + + + } + > +
+
+
+ +
+ {square[1]} +
+
+ +
+ {square[0]} +
+
+ + ); + }; export default ChessBoard; diff --git a/client/src/components/icons.tsx b/client/src/components/icons.tsx index 961bfaa..2305d80 100644 --- a/client/src/components/icons.tsx +++ b/client/src/components/icons.tsx @@ -79,3 +79,42 @@ export const Plus = () => ( ); + +// Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -- +export const Crown = () => ( + + + +); + +// Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -- +export const Hashtag = () => ( + + + +); + +// Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -- +export const HalfStar = () => ( + + + +); diff --git a/client/src/routes/Home.tsx b/client/src/routes/Home.tsx index 4ee121f..5962fa8 100644 --- a/client/src/routes/Home.tsx +++ b/client/src/routes/Home.tsx @@ -4,6 +4,7 @@ import { Color, COLOR } from "../../../server/src/engine"; import Show from "../utils/Show"; import ErrorNorification from "../components/ErrorNotification"; import Modal, { ModalButton } from "../components/Modal"; +import { removeLocationState } from "../utils/utils"; const Home = () => { const navigate = useNavigate(); @@ -53,9 +54,6 @@ const Home = () => { }); }; - const removeLocationState = () => - window.history.replaceState({ state: null }, document.title); - const errObj = err?.message ? { error: err.message, diff --git a/client/src/routes/OnlineGame.tsx b/client/src/routes/OnlineGame.tsx index 74fa079..fb6464c 100644 --- a/client/src/routes/OnlineGame.tsx +++ b/client/src/routes/OnlineGame.tsx @@ -9,6 +9,7 @@ import Show from "../utils/Show"; import ErrorNorification from "../components/ErrorNotification"; import Modal, { ModalButton } from "../components/Modal"; import ChessUI from "../components/ChessUI"; +import { removeLocationState } from "../utils/utils"; const Game = () => { const navigate = useNavigate(); @@ -26,15 +27,13 @@ const Game = () => { const makeMove = (move: Move) => socket.emit("make move", id, move); useEffect(() => { - if (id == undefined || color == undefined) - return navigate("/", { - state: { error: "Did not receive a game ID or color." }, - }); + if (id == undefined || color == undefined) return navigate("/"); socket.connect(); socket.on("connect", () => { socket.emit("join game", id, color); + removeLocationState(); }); socket.on("join error", () => { @@ -62,6 +61,13 @@ const Game = () => { }; }, []); + useEffect(() => { + if (opponentDisconnect) + navigate("/", { + state: { error: "Opponent disconnected" }, + }); + }, [opponentDisconnect]); + return ( }> { blackPerspective={color === COLOR.BLACK} disabled={color !== chess.getTurn()} /> - - -
-

Game Over

-

- {opponentDisconnect - ? "Opponent disconnected..." - : chess.isCheckMate() - ? (chess.getTurn() == color ? "Opponent" : "You") + " won!" - : "It's a draw!"} -

-
- navigate("/")}> - Go to main page - -
-
-
); }; diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts new file mode 100644 index 0000000..873ce1d --- /dev/null +++ b/client/src/utils/utils.ts @@ -0,0 +1,3 @@ +export function removeLocationState() { + window.history.replaceState({ state: null }, document.title); +} diff --git a/server/src/__test__/gameOver.test.ts b/server/src/__test__/gameOver.test.ts new file mode 100644 index 0000000..91a41f9 --- /dev/null +++ b/server/src/__test__/gameOver.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "vitest"; +import Chess, { Move } from "../engine"; + +const makeMoves = (startingFen: string, moves: Array) => { + const chess = Chess.load(startingFen); + + moves.forEach((move) => chess.makeMove(move)); + + return chess; +}; + +describe("checkmate", () => { + test("Starting Position", () => { + expect(Chess.load().isCheckMate()).toBe(false); + }); + test("Fool's Mate", () => { + expect( + Chess.load( + "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3" + ).isCheckMate() + ).toBe(true); + }); + test("Scholar's Mate", () => { + expect( + Chess.load( + "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3" + ).isCheckMate() + ).toBe(true); + }); +}); + +describe("stalemate", () => { + test("Starting Position", () => { + expect(Chess.load().isStalemate()).toBe(false); + }); + test("Rosen Trap", () => { + expect(Chess.load("8/8/6k1/8/8/8/5q2/7K w - - 0 1").isStalemate()).toBe( + true + ); + }); + test("Troitsky vs. Vogt, 1896", () => { + expect( + Chess.load( + "3k4/1pp2pp1/1b4r1/pP2p3/P3P3/6Nb/5P1P/3qB1KR w - - 0 1" + ).isStalemate() + ).toBe(true); + }); + test("Lukany vs. Smulyan, 1938", () => { + expect( + Chess.load("8/8/8/p1K5/k1P1P3/PpP5/1P6/8 b - - 0 1").isStalemate() + ).toBe(true); + }); +}); + +describe("threefold repetition", () => { + test("Starting Position", () => { + expect(Chess.load().isThreefoldRepetition()).toBe(false); + }); + test("chess.com example", () => { + const chess = makeMoves("1kr5/1b3R2/8/4Pn1p/R7/2P1B1p1/1KP4r/8 w - - 0 1", [ + { to: "a7", from: "e3" }, + { to: "a8", from: "b8" }, + { to: "g1", from: "a7" }, + { to: "b8", from: "a8" }, + { to: "a7", from: "g1" }, + { to: "a8", from: "b8" }, + { to: "g1", from: "a7" }, + { to: "b8", from: "a8" }, + { to: "a7", from: "g1" }, + ]); + expect(chess.isThreefoldRepetition()).toBe(true); + }); + test("bongcloud", () => { + const chess = makeMoves( + "rnbq1bnr/ppppkppp/8/4p3/4P3/8/PPPPKPPP/RNBQ1BNR w - - 2 3", + [ + { to: "e1", from: "e2" }, + { to: "e8", from: "e7" }, + { to: "e2", from: "e1" }, + { to: "e7", from: "e8" }, + { to: "e1", from: "e2" }, + { to: "e8", from: "e7" }, + { to: "e2", from: "e1" }, + { to: "e7", from: "e8" }, + ] + ); + expect(chess.isThreefoldRepetition()).toBe(true); + }); +}); + +describe("50 moves", () => { + test("", () => { + const chess = makeMoves("7k/8/r7/p1p1p1p1/P1P1P1P1/R7/8/7K w - - 0 1", [ + { from: "h1", to: "h2" }, + { from: "h8", to: "h7" }, + { from: "h2", to: "h3" }, + { from: "h7", to: "h6" }, + { from: "h3", to: "g3" }, + { from: "h6", to: "g6" }, + { from: "g3", to: "g2" }, + { from: "g6", to: "g7" }, + { from: "g2", to: "g1" }, + { from: "g7", to: "g8" }, + { from: "g1", to: "f1" }, + { from: "g8", to: "f8" }, + { from: "f1", to: "f2" }, + { from: "f8", to: "f7" }, + { from: "f2", to: "f3" }, + { from: "f7", to: "f6" }, + { from: "f3", to: "e3" }, + { from: "f6", to: "e6" }, + { from: "e3", to: "e2" }, + { from: "e6", to: "e7" }, + { from: "e2", to: "e1" }, + { from: "e7", to: "e8" }, + { from: "e1", to: "d1" }, + { from: "e8", to: "d8" }, + { from: "d1", to: "d2" }, + { from: "d8", to: "d7" }, + { from: "d2", to: "d3" }, + { from: "d7", to: "d6" }, + { from: "d3", to: "c3" }, + { from: "d6", to: "c6" }, + { from: "c3", to: "c2" }, + { from: "c6", to: "c7" }, + { from: "c2", to: "c1" }, + { from: "c7", to: "c8" }, + { from: "c1", to: "b1" }, + { from: "c8", to: "b8" }, + { from: "b1", to: "b2" }, + { from: "b8", to: "b7" }, + { from: "b2", to: "b3" }, + { from: "b7", to: "b6" }, + { from: "a3", to: "a1" }, + { from: "b6", to: "b7" }, + { from: "b3", to: "b2" }, + { from: "b7", to: "b8" }, + { from: "b2", to: "b1" }, + { from: "b8", to: "c8" }, + { from: "b1", to: "c2" }, + { from: "c8", to: "d7" }, + { from: "c2", to: "c1" }, + { from: "d7", to: "c8" }, + { from: "c1", to: "d1" }, + { from: "c8", to: "d8" }, + { from: "d1", to: "e1" }, + { from: "d8", to: "e8" }, + { from: "e1", to: "f1" }, + { from: "e8", to: "f8" }, + { from: "f1", to: "g1" }, + { from: "f8", to: "g8" }, + { from: "g1", to: "h1" }, + { from: "g8", to: "h8" }, + { from: "h1", to: "h2" }, + { from: "a6", to: "a8" }, + { from: "h2", to: "h3" }, + { from: "h8", to: "h7" }, + { from: "h3", to: "g3" }, + { from: "h7", to: "g6" }, + { from: "g3", to: "f3" }, + { from: "g6", to: "f6" }, + { from: "f3", to: "e3" }, + { from: "f6", to: "e6" }, + { from: "e3", to: "d3" }, + { from: "e6", to: "d6" }, + { from: "d3", to: "c3" }, + { from: "d6", to: "c6" }, + { from: "c3", to: "b3" }, + { from: "c6", to: "b6" }, + { from: "b3", to: "b2" }, + { from: "b6", to: "b7" }, + { from: "b2", to: "b1" }, + { from: "b7", to: "b8" }, + { from: "a1", to: "a2" }, + { from: "b8", to: "c8" }, + { from: "b1", to: "c1" }, + { from: "c8", to: "d8" }, + { from: "c1", to: "d1" }, + { from: "d8", to: "e8" }, + { from: "d1", to: "e1" }, + { from: "a8", to: "b8" }, + { from: "e1", to: "f1" }, + { from: "e8", to: "f7" }, + { from: "f1", to: "f2" }, + { from: "f7", to: "f6" }, + { from: "f2", to: "f3" }, + { from: "f6", to: "g7" }, + { from: "f3", to: "g3" }, + { from: "g7", to: "g6" }, + { from: "g3", to: "h3" }, + { from: "g6", to: "h6" }, + { from: "h3", to: "h2" }, + { from: "h6", to: "h7" }, + ]); + expect(chess.is50Rule()).toBe(true); + }); +}); + +describe("insufficient material", () => { + test("2 kings", () => { + expect( + Chess.load("8/k7/8/6K1/8/8/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(true); + }); + + test("king and bishop", () => { + expect( + Chess.load("5b2/k7/8/6K1/8/2B5/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(true); + }); + + test("king and knight", () => { + expect( + Chess.load("8/k7/8/3n2K1/8/5N2/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(true); + }); + + test("king and knight vs king and bishop", () => { + expect( + Chess.load("8/k3n3/8/6K1/8/2B5/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(true); + }); + + test("king and 2 knights", () => { + expect( + Chess.load("8/k7/4N3/6K1/8/5N2/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(true); + }); + + test("starting position", () => { + expect(Chess.load().isInsufficientMaterial()).toBe(false); + }); + + test("king and 2 bishops vs king", () => { + expect( + Chess.load("8/k7/8/4B1K1/4B3/8/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(false); + }); + + test("king and 2 knights vs king and bishop", () => { + expect( + Chess.load("8/k7/3n4/6K1/2n1B3/8/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(false); + }); + + test("king and 2 knights vs king and knight", () => { + expect( + Chess.load("6N1/k7/3n4/6K1/2n5/8/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(false); + }); + + test("king and rook vs king", () => { + expect( + Chess.load("5r2/k7/8/6K1/8/8/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(false); + }); + + test("king and queen vs king", () => { + expect( + Chess.load("5Q2/k7/8/6K1/8/8/8/8 w - - 0 1").isInsufficientMaterial() + ).toBe(false); + }); + + test("only pawns remaining", () => { + expect( + Chess.load( + "1k6/3p4/1p6/6K1/5P2/4P3/8/8 w - - 0 1" + ).isInsufficientMaterial() + ).toBe(false); + }); +}); diff --git a/server/src/__test__/getMovesForSquare.test.ts b/server/src/__test__/getMoves.test.ts similarity index 99% rename from server/src/__test__/getMovesForSquare.test.ts rename to server/src/__test__/getMoves.test.ts index 45f498a..1429104 100644 --- a/server/src/__test__/getMovesForSquare.test.ts +++ b/server/src/__test__/getMoves.test.ts @@ -568,7 +568,11 @@ describe("en passant", () => { square: "d5", moves: [ { from: "d5", to: "d6", flags: MOVE_FLAGS.NORMAL }, - { from: "d5", to: "e6", flags: MOVE_FLAGS.EN_PASSANT }, + { + from: "d5", + to: "e6", + flags: MOVE_FLAGS.CAPTURE | MOVE_FLAGS.EN_PASSANT, + }, ], }, ], diff --git a/server/src/__test__/insufficientMaterial.test.ts b/server/src/__test__/insufficientMaterial.test.ts deleted file mode 100644 index 0b29bc7..0000000 --- a/server/src/__test__/insufficientMaterial.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, test } from "vitest"; -import Chess from "../engine"; - -describe("insufficient material", () => { - test("2 kings", () => { - expect( - Chess.load("8/k7/8/6K1/8/8/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(true); - }); - - test("king and bishop", () => { - expect( - Chess.load("5b2/k7/8/6K1/8/2B5/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(true); - }); - - test("king and knight", () => { - expect( - Chess.load("8/k7/8/3n2K1/8/5N2/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(true); - }); - - test("king and knight vs king and bishop", () => { - expect( - Chess.load("8/k3n3/8/6K1/8/2B5/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(true); - }); - - test("king and 2 knights", () => { - expect( - Chess.load("8/k7/4N3/6K1/8/5N2/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(true); - }); -}); - -describe("sufficient material", () => { - test("starting position", () => { - expect(Chess.load().isInsufficientMaterial()).toBe(false); - }); - - test("king and 2 bishops vs king", () => { - expect( - Chess.load("8/k7/8/4B1K1/4B3/8/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(false); - }); - - test("king and 2 knights vs king and bishop", () => { - expect( - Chess.load("8/k7/3n4/6K1/2n1B3/8/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(false); - }); - - test("king and 2 knights vs king and knight", () => { - expect( - Chess.load("6N1/k7/3n4/6K1/2n5/8/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(false); - }); - - test("king and rook vs king", () => { - expect( - Chess.load("5r2/k7/8/6K1/8/8/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(false); - }); - - test("king and queen vs king", () => { - expect( - Chess.load("5Q2/k7/8/6K1/8/8/8/8 w - - 0 1").isInsufficientMaterial() - ).toBe(false); - }); - - test("only pawns remaining", () => { - expect( - Chess.load( - "1k6/3p4/1p6/6K1/5P2/4P3/8/8 w - - 0 1" - ).isInsufficientMaterial() - ).toBe(false); - }); -}); diff --git a/server/src/__test__/isSquareAttacked.test.ts b/server/src/__test__/isSquareAttacked.test.ts deleted file mode 100644 index 0046e50..0000000 --- a/server/src/__test__/isSquareAttacked.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from "vitest"; -import Chess from "../engine"; - -test("", () => { - const chess = Chess.load(); - - expect(chess.isSquareAttacked("e4", "w")).toBe(true); - expect(chess.isSquareAttacked("e4", "b")).toBe(false); - - expect(chess.isSquareAttacked("e5", "w")).toBe(false); - expect(chess.isSquareAttacked("e5", "b")).toBe(true); -}); diff --git a/server/src/__test__/makeMove.test.ts b/server/src/__test__/makeMove.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/server/src/engine.ts b/server/src/engine.ts index 0c44ca7..16fa6e6 100644 --- a/server/src/engine.ts +++ b/server/src/engine.ts @@ -10,10 +10,6 @@ export const PIECE = Object.freeze({ KING: "k", } as const); -function isPieceValid(string: string): boolean { - return (Object.values(PIECE) as Array).includes(string); -} - export const PIECE_PROMOTION = Object.freeze([ PIECE.KNIGHT, PIECE.BISHOP, @@ -34,10 +30,10 @@ export type Piece = { color: Color; }; -export type Board = Array; +type Board = Array; // prettier-ignore -export const SQUARES = Object.freeze([ +const SQUARES = Object.freeze([ 'a8', 'b8', 'c8', 'd8', 'e8', 'f8', 'g8', 'h8', 'a7', 'b7', 'c7', 'd7', 'e7', 'f7', 'g7', 'h7', 'a6', 'b6', 'c6', 'd6', 'e6', 'f6', 'g6', 'h6', @@ -48,10 +44,10 @@ export const SQUARES = Object.freeze([ 'a1', 'b1', 'c1', 'd1', 'e1', 'f1', 'g1', 'h1', ] as const); -const EN_PASSANT_ATTACK_SQUARES = { +const EN_PASSANT_ATTACK_SQUARES = Object.freeze({ w: ["a6", "b6", "c6", "d6", "e6", "f6", "g6", "h6"], b: ["a3", "b3", "c3", "d3", "e3", "f3", "g3", "h3"], -}; +}); export const EMPTY_SQUARE = "-"; export type Square = (typeof SQUARES)[number] | typeof EMPTY_SQUARE; @@ -64,8 +60,7 @@ export const MOVE_FLAGS = Object.freeze({ PAWN_JUMP: 0b0010000, PROMOTION: 0b0100000, EN_PASSANT: 0b1000000, -} as const); -export type MoveFlag = (typeof MOVE_FLAGS)[keyof typeof MOVE_FLAGS]; +}); export type InternalMove = { piece: Piece; @@ -76,51 +71,41 @@ export type InternalMove = { }; export type Move = Pick; -export const PIECE_MASKS: Record = Object.freeze({ - p: 0b000001, - n: 0b000010, - b: 0b000100, - r: 0b001000, - q: 0b010000, - k: 0b100000, -} as const); +const COLOR_MASKS: Record = Object.freeze({ w: 0b01, b: 0b10 }); -export const COLOR_MASKS: Record = Object.freeze({ - w: 0b01, - b: 0b10, -} as const); +export function rank(square: Square | number): number { + square = squareIndex(square); + return square >> 3; +} -export function rank(squareIdx: number): number { - return squareIdx >> 3; +export function file(square: Square | number): number { + square = squareIndex(square); + return square & 0b111; } export const RANK = Object.freeze({ - FIRST: 7, - SECOND: 6, - THIRD: 5, - FORTH: 4, - FIFTH: 3, - SIXTH: 2, - SEVENTH: 1, - EIGHTH: 0, + FIRST: rank("a1"), + SECOND: rank("a2"), + THIRD: rank("a3"), + FORTH: rank("a4"), + FIFTH: rank("a5"), + SIXTH: rank("a6"), + SEVENTH: rank("a7"), + EIGHTH: rank("a8"), } as const); -export function file(squareIdx: number): number { - return squareIdx & 0b111; -} - export const FILE: Record = Object.freeze({ - A: 0, - B: 1, - C: 2, - D: 3, - E: 4, - F: 5, - G: 6, - H: 7, -}); + A: file("a1"), + B: file("b1"), + C: file("c1"), + D: file("d1"), + E: file("e1"), + F: file("f1"), + G: file("g1"), + H: file("h1"), +} as const); -export function isDigit(c: string): boolean { +function isDigit(c: string): boolean { return /^\d$/.test(c); } @@ -138,7 +123,14 @@ export function squareIndex(square: string | number): number { return rankNum * 8 + fileNum; } -export function algebraic(square: number): Square { +export function algebraic(square: string | number): Square { + if (typeof square == "string") + return (SQUARES as Readonly>).includes(square) + ? (square as Square) + : EMPTY_SQUARE; + + if (square < 0 || square > 63) return EMPTY_SQUARE; + const f = file(square); const r = rank(square); return ("abcdefgh"[f] + "87654321"[r]) as Square; @@ -199,7 +191,11 @@ export function validateFEN(fen: string): void { return; } - if (!isPieceValid(symbol.toLowerCase())) + if ( + !(Object.values(PIECE) as Array).includes( + symbol.toLowerCase() + ) + ) throw new Error( "Invalid FEN - board position contains an invalid piece symbol: " + symbol @@ -336,11 +332,7 @@ function generatePawnMoves( const moves: Array = []; - const generatePromotionMoves = ( - from: number, - to: number, - flag?: MoveFlag - ) => { + const generatePromotionMoves = (from: number, to: number, flag?: number) => { const fromAlgebraic = algebraic(from); const toAlgebraic = algebraic(to); @@ -401,14 +393,14 @@ function generatePawnMoves( piece: { type: PIECE.PAWN, color }, from: algebraic(position), to: algebraic(attackPosition), - flags: isPiece ? MOVE_FLAGS.CAPTURE : MOVE_FLAGS.EN_PASSANT, + flags: MOVE_FLAGS.CAPTURE | (isPiece ? 0 : MOVE_FLAGS.EN_PASSANT), }); }); return moves; } -export function generatePieceMoves( +function generatePieceMoves( position: number, piece: Piece, board: Readonly, @@ -516,8 +508,9 @@ export default class Chess { private _moves: Array = []; private _attacks: Array = []; private _history: Array> = []; - private _enableProcessMoves = true; private _boardPositionCounter = new Map(); + private _enableProcessMoves = true; + private _enableHistory = true; private constructor( board: Board, @@ -527,7 +520,8 @@ export default class Chess { halfMoves: number, fullMoves: number, kings: Record, - enableProcessMoves: boolean + enableProcessMoves: boolean, + enableHistory: boolean ) { this._board = board; this._turn = turn; @@ -537,6 +531,7 @@ export default class Chess { this._fullMoves = fullMoves; this._kings = kings; this._enableProcessMoves = enableProcessMoves; + this._enableHistory = enableHistory; this._processBoardState(); } @@ -621,7 +616,13 @@ export default class Chess { if (!undo) this._modifyPositionCounter(false); } - private static _load(fen: string, enableProcessMoves: boolean) { + private static _load( + fen: string, + { + enableProcessMoves, + enableHistory, + }: { enableProcessMoves: boolean; enableHistory: boolean } + ) { validateFEN(fen); const builder = new Chess.Builder(); @@ -651,22 +652,13 @@ export default class Chess { builder.setFullMoves(fields[5]); if (enableProcessMoves == false) builder.disableComputeMoves(); + if (enableHistory == false) builder.disableHistory(); return builder.build(); } static load(fen = DEFAULT_POSITION) { - return this._load(fen, true); - } - - reset() { - const chess = Chess.load(); - this._board = chess._board; - this._turn = chess._turn; - this._castling = chess._castling; - this._enPassant = chess._enPassant; - this._halfMoves = chess._halfMoves; - this._fullMoves = chess._fullMoves; + return this._load(fen, { enableProcessMoves: true, enableHistory: true }); } getFEN(trim = false) { @@ -723,30 +715,6 @@ export default class Chess { return square < 0 || square > 63 ? null : this._board[square]; } - toAscii() { - let str = ""; - - for (let i = 0; i < 64; i++) { - if (i % 8 == 0) str += `\n${8 - Math.floor(i / 8)}`; - - const piece = this.getPiece(i); - - if (piece == null) { - str += " ."; - continue; - } - - const pieceLetter = - piece.color == COLOR.BLACK - ? piece.type.toLowerCase() - : piece.type.toUpperCase(); - str += ` ${pieceLetter}`; - } - - str += "\n A B C D E F G H"; - return str; - } - getMoves(): Array { return this._moves; } @@ -763,13 +731,17 @@ export default class Chess { private _processMoves() { const currentFEN = this.getFEN(); this._moves = this._moves.filter((move) => { - const chess = Chess._load(currentFEN, false); + const chess = Chess._load(currentFEN, { + enableProcessMoves: false, + enableHistory: false, + }); chess._makeMove(move); return !chess._isKingAttacked(this._turn); }, this); } - getMovesForSquare(square: Square): Array { + getMovesForSquare(square: Square | number): Array { + square = algebraic(square); return this._moves.filter(({ from }) => from == square); } @@ -777,7 +749,7 @@ export default class Chess { const myColor = this._turn; const theirColor = swapColor(this._turn); - if (this._enableProcessMoves) + if (this._enableHistory) this._history.push({ fen: this.getFEN(), san: this._generateSan(move), @@ -832,10 +804,7 @@ export default class Chess { if (piece == PIECE.KING) this._kings[myColor] = squareIndex(move.to); - if ( - piece == PIECE.PAWN || - move.flags & (MOVE_FLAGS.CAPTURE | MOVE_FLAGS.EN_PASSANT) - ) + if (piece == PIECE.PAWN || move.flags & MOVE_FLAGS.CAPTURE) this._halfMoves = 0; else this._halfMoves++; @@ -887,7 +856,10 @@ export default class Chess { if (move.promotion) san += `=${move.promotion.toUpperCase()}`; - const chess = Chess._load(this.getFEN(), false); + const chess = Chess._load(this.getFEN(), { + enableProcessMoves: true, + enableHistory: false, + }); chess._makeMove(move); if (chess.isCheckMate()) san += "#"; @@ -1011,9 +983,13 @@ export default class Chess { return this._getPositionCounter() >= 3; } + is50Rule() { + return this._halfMoves >= 100; // 50 moves per side = 100 half moves + } + isDraw() { return ( - this._halfMoves >= 100 || // 50 moves per side = 100 half moves + this.is50Rule() || this.isStalemate() || this.isInsufficientMaterial() || this.isThreefoldRepetition() @@ -1029,7 +1005,10 @@ export default class Chess { const lastHistory = this._history.pop(); if (lastHistory == undefined) return; - const chess = Chess._load(lastHistory.fen, this._enableProcessMoves); + const chess = Chess._load(lastHistory.fen, { + enableProcessMoves: false, + enableHistory: false, + }); this._board = chess._board; this._turn = chess._turn; this._castling = chess._castling; @@ -1051,6 +1030,10 @@ export default class Chess { return this._history; } + getKings(): Readonly> { + return this._kings; + } + static Builder = class { private _board: Board = new Array(64).fill(null); private _turn: Color = COLOR.WHITE; @@ -1060,9 +1043,10 @@ export default class Chess { private _fullMoves: number = 1; private _kings: Record = { w: 0, b: 0 }; private _enableProcessMoves = true; + private _enableHistory = true; addPiece(square: Square | number, piece: Piece) { - if (typeof square != "number") square = squareIndex(square); + square = squareIndex(square); if (square < 0 || square > 63) return; this._board[square] = piece; if (piece.type == PIECE.KING) this._kings[piece.color] = square; @@ -1097,6 +1081,10 @@ export default class Chess { this._enableProcessMoves = false; } + disableHistory() { + this._enableHistory = false; + } + build() { return new Chess( this._board, @@ -1106,11 +1094,11 @@ export default class Chess { this._halfMoves, this._fullMoves, this._kings, - this._enableProcessMoves + this._enableProcessMoves, + this._enableHistory ); } }; } -// FIX: tests // TODO: add missing tests for features diff --git a/server/src/server.ts b/server/src/server.ts index 31d57d5..5ccf3bf 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -92,7 +92,7 @@ io.on("connection", (socket) => { }); if (process.env.NODE_ENV === "development") - app.get("/games", (_req, res) => { + app.get("/debug/games", (_req, res) => { const m = new Map(); rooms.forEach((room, id) => m.set(id, { diff --git a/tsconfig.json b/tsconfig.json index 5e26aa6..333e812 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,9 +47,9 @@ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./server/dist", /* Specify an output folder for all emitted files. */ + "outDir": "./server/dist", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */