├── jitpack.yml ├── project ├── build.properties └── plugins.sbt ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── .vscode └── settings.json ├── Pipfile ├── test-kit └── src │ ├── test │ ├── scala │ │ ├── PlayOneMoveTest.scala │ │ ├── PieceTest.scala │ │ ├── ColorTest.scala │ │ ├── opening │ │ │ └── StartingPositionTest.scala │ │ ├── format │ │ │ ├── pgn │ │ │ │ ├── MacrosTest.scala │ │ │ │ ├── RoundtripTest.scala │ │ │ │ ├── TimeFormatTest.scala │ │ │ │ ├── ParserCheck.scala │ │ │ │ └── TagTest.scala │ │ │ ├── UciPathTest.scala │ │ │ ├── UciCharPairTest.scala │ │ │ └── Visual.scala │ │ ├── PromotionTest.scala │ │ ├── ByColorLawsTests.scala │ │ ├── bitboard │ │ │ └── FenFixtures.scala │ │ ├── NodeLawsTest.scala │ │ ├── RookTest.scala │ │ ├── KnightTest.scala │ │ ├── BishopTest.scala │ │ ├── QueenTest.scala │ │ ├── HasIdTest.scala │ │ ├── MergeableTest.scala │ │ ├── OutcomeTest.scala │ │ ├── HistoryTest.scala │ │ ├── PlyTest.scala │ │ ├── KingTest.scala │ │ ├── HordeInsufficientMaterialTest.scala │ │ ├── StatsTest.scala │ │ ├── SquareTest.scala │ │ ├── perft │ │ │ ├── Parser.scala │ │ │ ├── PerftTest.scala │ │ │ └── Perft.scala │ │ ├── GameTest.scala │ │ ├── tiebreak │ │ │ ├── TiebreakSnapshotTest.scala │ │ │ └── Helper.scala │ │ ├── Chess960Test.scala │ │ ├── KingSafetyTest.scala │ │ ├── CrazyhouseDataTest.scala │ │ ├── CastlesTest.scala │ │ ├── DecayingStatsTest.scala │ │ ├── BerserkTest.scala │ │ ├── RacingKingsVariantTest.scala │ │ ├── InsufficientMatingMaterialTest.scala │ │ ├── TournamentClockTest.scala │ │ ├── CastlingTest.scala │ │ ├── CastlingKingSideTest.scala │ │ ├── TreeBuilderTest.scala │ │ └── PlayTest.scala │ └── resources │ │ ├── 3check.perft │ │ ├── snapshot │ │ ├── canplay │ │ │ ├── playPositions_racing_kings.txt │ │ │ └── forward_standard.txt │ │ └── tiebreak │ │ │ └── uzchesscup.txt │ │ ├── racingkings.perft │ │ ├── horde.perft │ │ ├── crazyhouse.perft │ │ ├── antichess.perft │ │ └── atomic.perft │ └── main │ └── scala │ └── chess │ ├── macros.scala │ └── NodeArbitraries.scala ├── rating └── src │ └── main │ └── scala │ ├── glicko │ ├── impl │ │ ├── README.md │ │ ├── Rating.scala │ │ └── results.scala │ ├── model.scala │ └── GlickoCalculator.scala │ └── model.scala ├── .scalafix.conf ├── core └── src │ └── main │ └── scala │ ├── package.scala │ ├── HasPosition.scala │ ├── Moveable.scala │ ├── MoveMetrics.scala │ ├── Timestamp.scala │ ├── Rated.scala │ ├── opening │ ├── model.scala │ ├── Opening.scala │ └── OpeningDb.scala │ ├── Side.scala │ ├── variant │ ├── FromPosition.scala │ ├── KingOfTheHill.scala │ └── ThreeCheck.scala │ ├── DecayingStats.scala │ ├── format │ ├── pgn │ │ ├── model.scala │ │ ├── San.scala │ │ ├── parsingModel.scala │ │ └── Dumper.scala │ ├── UciDump.scala │ ├── UciPath.scala │ ├── UciCharPair.scala │ └── Fen.scala │ ├── Stats.scala │ ├── File.scala │ ├── Rank.scala │ ├── CorrespondenceClock.scala │ ├── TournamentClock.scala │ ├── Piece.scala │ ├── Status.scala │ ├── History.scala │ ├── Centis.scala │ ├── model.scala │ ├── HasId.scala │ ├── Color.scala │ ├── Role.scala │ ├── Speed.scala │ ├── PlayerTitle.scala │ ├── ByRole.scala │ ├── InsufficientMatingMaterial.scala │ ├── UnmovedRooks.scala │ ├── Replay.scala │ ├── LagTracker.scala │ ├── Divider.scala │ ├── eval.scala │ └── Castles.scala ├── .gitignore ├── .scalafmt.conf ├── Pipfile.lock ├── LICENSE ├── README.md ├── .git-blame-ignore-revs ├── sync-openings.py ├── bench └── src │ └── main │ └── scala │ └── benchmarks │ ├── HashBench.scala │ ├── InsufficientMaterialBench.scala │ ├── BinaryFenBench.scala │ ├── PlayBench.scala │ ├── PgnBench.scala │ ├── DestinationsBench.scala │ ├── FenWriterBench.scala │ ├── FenReaderBench.scala │ ├── PerftBench.scala │ └── TiebreakBench.scala └── playJson └── src └── main └── scala └── Json.scala /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk21 3 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.7 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://lichess.org/patron 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/target": true 4 | } 5 | } -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | 8 | [dev-packages] 9 | chess = "*" 10 | 11 | [requires] 12 | python_version = "3.12" 13 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/PlayOneMoveTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import Square.* 4 | 5 | class PlayOneMoveTest extends ChessTest: 6 | 7 | test("only process things once"): 8 | assert(makeGame.playMoves(E2 -> E4).isRight) 9 | -------------------------------------------------------------------------------- /rating/src/main/scala/glicko/impl/README.md: -------------------------------------------------------------------------------- 1 | Loosely ported from java: https://github.com/goochjs/glicko2 2 | 3 | The implementation is not idiomatic scala and should not be used directly. 4 | Use the public API `chess.rating.glicko.GlickoCalculator` instead. 5 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | DisableSyntax 3 | OrganizeImports 4 | RemoveUnused 5 | ] 6 | 7 | OrganizeImports { 8 | groupedImports = AggressiveMerge 9 | targetDialect = Scala3 10 | importSelectorsOrder = Ascii 11 | importsOrder = Ascii 12 | } 13 | -------------------------------------------------------------------------------- /core/src/main/scala/package.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | export scalalib.newtypes.* 4 | export scalalib.zeros.* 5 | export scalalib.extensions.* 6 | 7 | export Color.{ Black, White } 8 | export Side.{ KingSide, QueenSide } 9 | 10 | type PieceMap = Map[Square, Piece] 11 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.5") 2 | 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") 4 | 5 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.8") 6 | 7 | addSbtPlugin("com.siriusxm" % "sbt-snapshot4s" % "0.2.4") 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | groups: 8 | ci-dependencies: 9 | patterns: 10 | - "*" # Match all CI dependencies to one PR. 11 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/3check.perft: -------------------------------------------------------------------------------- 1 | id 3check-kiwipete 2 | epd r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 1+1 3 | perft 1 48 4 | perft 2 2039 5 | perft 3 97848 6 | 7 | id 3check-castling 8 | epd r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 1+1 9 | perft 1 26 10 | perft 2 562 11 | perft 3 13410 12 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/PieceTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | class PieceTest extends ChessTest: 4 | 5 | test("compare objects and - method"): 6 | assertNotEquals(White - Pawn, Black - Pawn) 7 | test("compare value and - method"): 8 | val color = White 9 | assertNotEquals(color - Pawn, Black - Pawn) 10 | -------------------------------------------------------------------------------- /core/src/main/scala/HasPosition.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.variant.Variant 4 | 5 | trait HasPosition[A]: 6 | extension (a: A) 7 | def position: Position 8 | inline def variant: Variant = position.variant 9 | inline def color: Color = position.color 10 | inline def history: History = position.history 11 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/snapshot/canplay/playPositions_racing_kings.txt: -------------------------------------------------------------------------------- 1 | 8/8/8/8/8/8/krbnNBRK/qrbnNBRQ w - - 0 1 2 | 8/8/8/8/8/4B3/krbnN1RK/qrbnNBRQ b - - 1 1 3 | 8/8/8/8/4n3/4B3/krb1N1RK/qrbnNBRQ w - - 2 1 4 | 8/8/8/8/4n3/4B1R1/krb1N2K/qrbnNBRQ b - - 3 1 5 | 8/8/8/8/4n3/4n1R1/krb1N2K/qrb1NBRQ w - - 0 1 6 | 8/8/8/8/4n3/4R3/krb1N2K/qrb1NBRQ b - - 0 1 -------------------------------------------------------------------------------- /test-kit/src/test/resources/racingkings.perft: -------------------------------------------------------------------------------- 1 | id racingkings-start 2 | epd 8/8/8/8/8/8/krbnNBRK/qrbnNBRQ w - - 3 | perft 1 21 4 | perft 2 421 5 | perft 3 11264 6 | perft 4 296242 7 | 8 | id occupied-goal 9 | epd 4brn1/2K2k2/8/8/8/8/8/8 w - - 10 | perft 1 6 11 | perft 2 33 12 | perft 3 178 13 | perft 4 3151 14 | perft 5 12981 15 | perft 6 265932 16 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/ColorTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | class ColorTest extends ChessTest: 4 | 5 | test("unary !"): 6 | assertEquals(!White, Black) 7 | assertEquals(!Black, White) 8 | 9 | test("passablePawnRank"): 10 | assertEquals(White.passablePawnRank, Rank.Fifth) 11 | assertEquals(Black.passablePawnRank, Rank.Fourth) 12 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/opening/StartingPositionTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package opening 3 | 4 | class StartingPositionTest extends ChessTest: 5 | 6 | // simple test to guard against faulty starting positions 7 | test("search should find the starting position"): 8 | StartingPosition.all.foreach: p => 9 | assert(p.featurable || true) 10 | -------------------------------------------------------------------------------- /core/src/main/scala/Moveable.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | /** 4 | * A representation of a move or drop in chess. 5 | * 6 | * It can be applied to a specific position to get a MoveOrDrop. 7 | * It is either an Uci or a parsed San 8 | */ 9 | trait Moveable: 10 | def apply(position: Position): Either[ErrorStr, MoveOrDrop] 11 | def rawString: Option[String] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | project/.bloop/ 2 | project/metals.sbt 3 | project/project 4 | project/target 5 | target 6 | 7 | # auto-generated eclipse files. 8 | /.classpath 9 | /.project 10 | /.settings/ 11 | 12 | .idea 13 | .bsp 14 | .bloop/ 15 | .metals/ 16 | # docker sbt 17 | /? 18 | 19 | # VS Code 20 | *.code-workspace 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | !.vscode/*.code-snippets 27 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/pgn/MacrosTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import macros.* 4 | 5 | class MacrosTest extends munit.FunSuite: 6 | 7 | test("pgn macro"): 8 | val pgn = pgn"1. e4 e5 2. Nf3 Nc6" 9 | assert(pgn.tree.isDefined) 10 | assertEquals(pgn.toPgn.toString, "1. e4 e5 2. Nf3 Nc6") 11 | 12 | test("uci macro"): 13 | val uci = uci"d2d4" 14 | assert(uci.isInstanceOf[chess.format.Uci.Move]) 15 | assertEquals(uci.uci, "d2d4") 16 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/pgn/RoundtripTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format.pgn 3 | 4 | class RoundtripTest extends ChessTest: 5 | 6 | test("roundtrip with special chars for tags"): 7 | val value = "aä\\\"'$%/°á \t\b \"\\\\/" 8 | val parsed = Parser 9 | .full(Pgn(tags = Tags(List(Tag(_.Site, value))), InitialComments.empty, None, Ply.initial).render) 10 | .toOption 11 | .get 12 | assertEquals(parsed.tags("Site"), Some(value)) 13 | -------------------------------------------------------------------------------- /core/src/main/scala/MoveMetrics.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | case class MoveMetrics( 4 | clientLag: Option[Centis] = None, 5 | clientMoveTime: Option[Centis] = None, 6 | frameLag: Option[Centis] = None 7 | ): 8 | 9 | // Calculate client reported lag given the server's duration for the move. 10 | def reportedLag(elapsed: Centis): Option[Centis] = 11 | clientMoveTime.fold(clientLag)(mt => Option(elapsed - mt)) 12 | 13 | object MoveMetrics: 14 | val empty: MoveMetrics = MoveMetrics() 15 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/horde.perft: -------------------------------------------------------------------------------- 1 | id horde-start 2 | epd rnbqkbnr/pppppppp/8/1PP2PP1/PPPPPPPP/PPPPPPPP/PPPPPPPP/PPPPPPPP w kq - 3 | perft 1 8 4 | perft 2 128 5 | perft 3 1274 6 | perft 4 23310 7 | 8 | id horde-open-flank 9 | epd 4k3/pp4q1/3P2p1/8/P3PP2/PPP2r2/PPP5/PPPP4 b - - 10 | perft 1 30 11 | perft 2 241 12 | perft 3 6633 13 | perft 4 56539 14 | 15 | id horde-en-passant 16 | epd k7/5p2/4p2P/3p2P1/2p2P2/1p2P2P/p2P2P1/2P2P2 w - - 17 | perft 1 13 18 | perft 2 172 19 | perft 3 2205 20 | perft 4 33781 21 | -------------------------------------------------------------------------------- /core/src/main/scala/Timestamp.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | opaque type Timestamp = Long 4 | object Timestamp extends OpaqueLong[Timestamp]: 5 | extension (t: Timestamp) 6 | def -(o: Timestamp): Centis = Centis.ofMillis(t - o.value) 7 | def +(o: Centis): Timestamp = Timestamp(t + o.millis) 8 | 9 | trait Timestamper: 10 | def now: Timestamp 11 | def toNow(ts: Timestamp) = now - ts 12 | 13 | private[chess] object RealTimestamper extends Timestamper: 14 | def now = Timestamp(System.currentTimeMillis) 15 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/PromotionTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.format.FullFen 4 | import chess.variant.Standard 5 | 6 | import scala.language.implicitConversions 7 | 8 | class PromotionTest extends ChessTest: 9 | 10 | test("Not allow promotion to a king in a standard game "): 11 | val fen = FullFen("8/1P6/8/8/8/8/7k/1K6 w - -") 12 | val game = fenToGame(fen, Standard) 13 | 14 | val failureGame = game(Square.B7, Square.B8, Option(King)).map(_._1) 15 | 16 | assert(failureGame.isLeft) 17 | -------------------------------------------------------------------------------- /core/src/main/scala/Rated.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | opaque type Rated = Boolean 4 | 5 | object Rated extends YesNo[Rated]: 6 | 7 | extension (r: Rated) 8 | def name: String = if r then "rated" else "casual" 9 | def id: Int = if r then 1 else 0 10 | 11 | val all: List[Rated] = List(No, Yes) 12 | 13 | val byId: Map[Int, Rated] = Map(0 -> No, 1 -> Yes) 14 | 15 | def apply(id: Int): Option[Rated] = byId.get(id) 16 | 17 | val default: Rated = No 18 | 19 | def orDefault(id: Int): Rated = byId.getOrElse(id, default) 20 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/crazyhouse.perft: -------------------------------------------------------------------------------- 1 | id zh-all-drop-types 2 | epd 2k5/8/8/8/8/8/8/4K3[QRBNPqrbnp] w - - 3 | perft 1 301 4 | perft 2 75353 5 | 6 | id zh-drops 7 | epd 2k5/8/8/8/8/8/8/4K3[Qn] w - - 8 | perft 1 67 9 | perft 2 3083 10 | perft 3 88634 11 | perft 4 932554 12 | 13 | id zh-middlegame 14 | epd r1bqk2r/pppp1ppp/2n1p3/4P3/1b1Pn3/2NB1N2/PPP2PPP/R1BQK2R[] b KQkq - 15 | perft 1 42 16 | perft 2 1347 17 | perft 3 58057 18 | perft 4 2083382 19 | 20 | id zh-promoted 21 | epd 4k3/1Q~6/8/8/4b3/8/Kpp5/8/ b - - 0 1 22 | perft 1 20 23 | perft 2 360 24 | perft 3 5445 25 | perft 4 132758 26 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/ByColorLawsTests.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.laws.discipline.{ ApplicativeTests, FunctorTests, TraverseTests } 4 | import munit.DisciplineSuite 5 | import org.scalacheck.* 6 | 7 | import CoreArbitraries.given 8 | 9 | class ByColorLawsTest extends DisciplineSuite: 10 | checkAll("ByColor.FunctorLaws", FunctorTests[ByColor].functor[Int, Int, String]) 11 | checkAll("ByColor.TraverseLaws", TraverseTests[ByColor].traverse[Int, Int, Int, Int, Option, Option]) 12 | checkAll("ByColor.ApplicativeLaws", ApplicativeTests[ByColor].applicative[Int, Int, String]) 13 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/antichess.perft: -------------------------------------------------------------------------------- 1 | id antichess-start 2 | epd rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 3 | perft 1 20 4 | perft 2 400 5 | perft 3 8067 6 | perft 4 153299 7 | 8 | id a-pawn-vs-b-pawn 9 | epd 8/1p6/8/8/8/8/P7/8 w - - 10 | perft 1 2 11 | perft 2 4 12 | perft 3 4 13 | perft 4 3 14 | perft 5 1 15 | perft 6 0 16 | 17 | id a-pawn-vs-c-pawn 18 | epd 8/2p5/8/8/8/8/P7/8 w - - 19 | perft 1 2 20 | perft 2 4 21 | perft 3 4 22 | perft 4 4 23 | perft 5 4 24 | perft 6 4 25 | perft 7 4 26 | perft 8 4 27 | perft 9 12 28 | perft 10 36 29 | perft 11 312 30 | perft 12 2557 31 | perft 13 30873 32 | -------------------------------------------------------------------------------- /rating/src/main/scala/model.scala: -------------------------------------------------------------------------------- 1 | package chess.rating 2 | 3 | import alleycats.Zero 4 | import scalalib.newtypes.* 5 | 6 | opaque type IntRatingDiff = Int 7 | object IntRatingDiff extends RichOpaqueInt[IntRatingDiff]: 8 | extension (diff: IntRatingDiff) 9 | def positive: Boolean = diff > 0 10 | def negative: Boolean = diff < 0 11 | def zero: Boolean = diff == 0 12 | given Zero[IntRatingDiff] = Zero(0) 13 | 14 | opaque type Rating = Double 15 | object Rating extends OpaqueDouble[Rating] 16 | 17 | opaque type RatingProvisional = Boolean 18 | object RatingProvisional extends YesNo[RatingProvisional] 19 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.10.2" 2 | runner.dialect = scala3 3 | 4 | align.preset = none 5 | maxColumn = 110 6 | spaces.inImportCurlyBraces = true 7 | rewrite.rules = [SortModifiers] 8 | rewrite.redundantBraces.stringInterpolation = true 9 | project.excludeFilters = [ 10 | "FullOpeningPart*" 11 | "EcopeningDB.scala" 12 | "Fixtures.scala" 13 | ] 14 | docstrings.style = keep // don't format comment 15 | 16 | rewrite.scala3.convertToNewSyntax = yes 17 | rewrite.scala3.removeOptionalBraces = yes 18 | rewrite.rules = [AvoidInfix] 19 | 20 | rewrite.rules = [Imports] 21 | rewrite.imports.removeRedundantSelectors = true 22 | 23 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/bitboard/FenFixtures.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package bitboard 3 | 4 | import chess.format.FullFen 5 | 6 | object FenFixtures: 7 | val fens = List( 8 | "2rqkb1r/1b2pppp/p1n2n2/1p1p4/3P1B2/2NBP2P/PP3PP1/2RQK1NR w Kk - 1 10", 9 | "2rq1rk1/1b2bppp/p1n1pn2/3p4/1p1P1B2/P1NBPN1P/1P2QPP1/2R2RK1 w - - 0 14", 10 | "8/P7/8/8/4k3/1B5P/3p2PK/5r2 w - - 0 54", 11 | "1nbqkbnr/pppp1ppp/4p3/8/4K3/4r3/PPPPPPPP/RNBQ1BNR w - - 0 1", 12 | "r2qk1nr/ppp2ppp/2nb4/3p4/3P2b1/2PB1N2/PP3PPP/RNBQK2R w KQkq - 3 7", 13 | "r2qk1nr/ppp2ppp/3b4/3p4/3P2b1/2PB1Nn1/PP3PPP/RNBQK2R w KQkq - 3 7", 14 | "r3k1nr/ppp2ppp/q2b1n2/3p4/3PB1b1/2P2N2/PP3PPP/RNBQK2R w KQkq - 3 7" 15 | ).map(FullFen(_)) 16 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/NodeLawsTest.scala: -------------------------------------------------------------------------------- 1 | // package chess 2 | 3 | // import cats.laws.discipline.FunctorTests 4 | // import munit.DisciplineSuite 5 | // import org.scalacheck.* 6 | // import Arbitraries.given 7 | // import cats.laws.discipline.TraverseTests 8 | // 9 | // class NodeLawTests extends DisciplineSuite: 10 | // checkAll("Node.FunctorLaws", FunctorTests[Node].functor[Int, Int, String]) 11 | // checkAll("Node.TraverseLaws", TraverseTests[Node].traverse[Int, Int, Int, Int, Option, Option]) 12 | // checkAll("Varitation.FunctorLaws", FunctorTests[Variation].functor[Int, Int, String]) 13 | // checkAll("Varitation.TraverseLaws", TraverseTests[Variation].traverse[Int, Int, Int, Int, Option, Option]) 14 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/snapshot/canplay/forward_standard.txt: -------------------------------------------------------------------------------- 1 | r1b3k1/ppqn2pp/4p3/2p3N1/3p1P2/3P1Q2/PPP3P1/4RBK1 b - - 4 1 2 | rn1q1rk1/pbp3pp/1p1ppn2/6B1/2PP4/2PQ1N2/P3BPPP/R3K2R w KQ - 0 1 3 | 5r1r/1bqnk1pN/p1pNpP2/1p1n3Q/2pP4/P5P1/1P3PBP/R4RK1 b - - 0 1 4 | 3r1rk1/ppp2pp1/5q1p/3B4/3RP3/2Q5/PPP2PPP/5RK1 b - - 0 1 5 | r2q1rk1/1b1nbppp/pp1ppn2/2p3B1/4P3/P1NP1N1P/BPP2PP1/R2Q1RK1 w - - 4 1 6 | r1bq1rk1/pp1nn1bp/2p3p1/3pp3/5B2/2PBPN1P/PP1N1PP1/2RQ1RK1 w - - 0 1 7 | 1r2k2r/2qbbp2/p3nn1p/1pp1p1p1/3pP1P1/N2P1PQN/PPPB2BP/R3K2R w KQk - 4 1 8 | r2qk2r/pp1nbp2/2pp2p1/3np1Bp/2P1P3/2N2B1P/PP1Q1PP1/R4RK1 w kq - 0 1 9 | 8/1p3Nk1/p5p1/3p4/2bP1QP1/7P/PP3K2/8 b - - 0 1 10 | 2kr3r/1pq3p1/p1nbbp2/2p2n1p/Q7/2P1BN2/PP1N1PPP/2KR1B1R w - - 6 1 -------------------------------------------------------------------------------- /core/src/main/scala/opening/model.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package opening 3 | 4 | opaque type Eco = String 5 | object Eco extends OpaqueString[Eco] 6 | 7 | opaque type OpeningName = String 8 | object OpeningName extends OpaqueString[OpeningName] 9 | 10 | // d2d4 g8f6 c2c4 e7e6 g2g3 11 | opaque type UcisStr = String 12 | object UcisStr extends OpaqueString[UcisStr] 13 | 14 | opaque type OpeningKey = String 15 | object OpeningKey extends OpaqueString[OpeningKey]: 16 | export Opening.nameToKey as fromName 17 | 18 | case class OpeningFamily(name: OpeningName): 19 | lazy val key = Opening.nameToKey(name) 20 | 21 | opaque type OpeningVariation = String 22 | object OpeningVariation extends OpaqueString[OpeningVariation] 23 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/RookTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | import Square.* 6 | 7 | class RookTest extends ChessTest: 8 | 9 | import compare.dests 10 | 11 | test("not move to positions that are occupied by the same colour"): 12 | assertEquals( 13 | """ 14 | k B 15 | 16 | 17 | 18 | N R P 19 | 20 | PPPPPPPP 21 | NBQKBNR 22 | """.destsFrom(C4), 23 | Set(C3, C5, C6, C7, B4, D4, E4, F4, G4) 24 | ) 25 | 26 | test("capture opponent pieces"): 27 | assertEquals( 28 | """ 29 | k 30 | b 31 | 32 | 33 | n R p 34 | 35 | PPPPPPPP 36 | NBQKBNR 37 | """.destsFrom(C4), 38 | Set(C3, C5, C6, C7, B4, A4, D4, E4, F4, G4) 39 | ) 40 | -------------------------------------------------------------------------------- /core/src/main/scala/Side.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.Eq 4 | import cats.derived.* 5 | 6 | enum Side derives Eq: 7 | case KingSide, QueenSide 8 | 9 | inline def fold[A](inline k: A, inline q: A): A = if isKingSide then k else q 10 | 11 | def unary_! = fold(QueenSide, KingSide) 12 | 13 | lazy val castledKingFile: File = fold(File.G, File.C) 14 | lazy val castledRookFile: File = fold(File.F, File.D) 15 | 16 | private lazy val isKingSide = this == Side.KingSide 17 | 18 | object Side: 19 | 20 | val all = List(KingSide, QueenSide) 21 | 22 | def kingRookSide(king: Square, rook: Square): Option[Side] = 23 | Option.when(king.onSameRank(rook)): 24 | if king ?> rook then QueenSide else KingSide 25 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/pgn/TimeFormatTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format.pgn 3 | 4 | import scalalib.model.Seconds 5 | import scalalib.time.* 6 | 7 | class TimeFormatTest extends ChessTest: 8 | 9 | test("format seconds"): 10 | def f(s: Int) = Move.formatPgnSeconds(Seconds(s)) 11 | assertEquals(f(0), "0:00:00") 12 | assertEquals(f(9), "0:00:09") 13 | assertEquals(f(60), "0:01:00") 14 | assertEquals(f(79835), "22:10:35") 15 | assertEquals(f(979835), "272:10:35") 16 | 17 | test("format PGN tags"): 18 | assertEquals(Tag.UTCDate.format.format(millisToDateTime(1680424483730L)), "2023.04.02") 19 | assertEquals(Tag.UTCTime.format.format(millisToDateTime(1680424483730L)), "08:34:43") 20 | -------------------------------------------------------------------------------- /core/src/main/scala/variant/FromPosition.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package variant 3 | 4 | case object FromPosition 5 | extends Variant( 6 | id = Variant.Id(3), 7 | key = Variant.LilaKey("fromPosition"), 8 | uciKey = Variant.UciKey("chess"), 9 | name = "From Position", 10 | shortName = "FEN", 11 | title = "Custom starting position", 12 | standardInitialPosition = false 13 | ): 14 | 15 | override val initialBoard: Board = Board.standard 16 | override def initialPieces: Map[Square, Piece] = initialBoard.pieceMap 17 | 18 | override def validMoves(position: Position): List[Move] = 19 | Standard.validMoves(position) 20 | 21 | override def validMovesAt(position: Position, square: Square): List[Move] = 22 | super.validMovesAt(position, square).filter(kingSafety) 23 | -------------------------------------------------------------------------------- /core/src/main/scala/DecayingStats.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | trait DecayingRecorder: 4 | def record(value: Float): DecayingStats 5 | 6 | case class DecayingStats( 7 | mean: Float, 8 | deviation: Float, 9 | decay: Float 10 | ) extends DecayingRecorder: 11 | def record(value: Float): DecayingStats = 12 | val delta = mean - value 13 | copy( 14 | mean = value + decay * delta, 15 | deviation = decay * deviation + (1 - decay) * Math.abs(delta) 16 | ) 17 | 18 | def record[T](values: Iterable[T])(using n: Numeric[T]): DecayingStats = 19 | values.foldLeft(this)((s, v) => s.record(n.toFloat(v))) 20 | 21 | object DecayingStats: 22 | val empty = new DecayingRecorder: 23 | def record(value: Float) = 24 | DecayingStats( 25 | mean = value, 26 | deviation = 4f, 27 | decay = 0.85f 28 | ) 29 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/snapshot/tiebreak/uzchesscup.txt: -------------------------------------------------------------------------------- 1 | PlayerWithScore(Player(12539929,Some(2691)),4.5,List(1.5, 20.5, 2.0, 1.0, 3.5)) 2 | PlayerWithScore(Player(25059530,Some(2767)),5.5,List(2.0, 25.25, 4.0, 1.0, 3.5)) 3 | PlayerWithScore(Player(738590,Some(2714)),4.5,List(1.0, 18.75, 2.0, 2.0, 2.5)) 4 | PlayerWithScore(Player(14205483,Some(2710)),5.5,List(0.5, 22.25, 3.0, 1.0, 3.0)) 5 | PlayerWithScore(Player(14203987,Some(2659)),4.5,List(0.5, 19.5, 2.0, 1.0, 2.5)) 6 | PlayerWithScore(Player(5072786,Some(2749)),2.5,List(0.0, 11.25, 0.0, 0.0, 1.5)) 7 | PlayerWithScore(Player(14204223,Some(2644)),4.0,List(0.0, 16.5, 2.0, 1.0, 2.5)) 8 | PlayerWithScore(Player(35009192,Some(2782)),5.0,List(0.0, 21.5, 2.0, 2.0, 3.0)) 9 | PlayerWithScore(Player(4168119,Some(2757)),3.5,List(0.0, 16.0, 0.0, 0.0, 3.0)) 10 | PlayerWithScore(Player(14204118,Some(2767)),5.5,List(0.5, 22.5, 4.0, 0.0, 3.0)) -------------------------------------------------------------------------------- /test-kit/src/test/scala/KnightTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | import Square.* 6 | 7 | class KnightTest extends ChessTest: 8 | 9 | test("not move to positions that are occupied by the same colour"): 10 | val board = """ 11 | k B 12 | 13 | B 14 | P 15 | N 16 | P 17 | PPP PPP 18 | NBQKBNR 19 | """ 20 | assertEquals( 21 | visualDests(board, board.destsFrom(C4)), 22 | """ 23 | k B 24 | 25 | x B 26 | x P 27 | N 28 | x P 29 | PPPx PPP 30 | NBQKBNR 31 | """ 32 | ) 33 | 34 | test("capture opponent pieces"): 35 | val board = """ 36 | k B 37 | 38 | b B 39 | n 40 | N 41 | b 42 | PPP PPP 43 | NBQKBNR 44 | """ 45 | assertEquals( 46 | visualDests(board, board.destsFrom(C4)), 47 | """ 48 | k B 49 | 50 | x B 51 | x x 52 | N 53 | x x 54 | PPPx PPP 55 | NBQKBNR 56 | """ 57 | ) 58 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/BishopTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | import Square.* 6 | 7 | class BishopTest extends ChessTest: 8 | 9 | test("not move to positions that are occupied by the same colour"): 10 | val board = """ 11 | k B 12 | 13 | 14 | 15 | N B P 16 | 17 | PPPPPPPP 18 | NBQKBNR 19 | """ 20 | assertEquals( 21 | visualDests(board, board.destsFrom(C4)), 22 | """ 23 | k B x 24 | x 25 | x x 26 | x x 27 | N B P 28 | x x 29 | PPPPPPPP 30 | NBQKBNR 31 | """ 32 | ) 33 | 34 | test("capture opponent pieces"): 35 | val board = """ 36 | k B 37 | q 38 | p 39 | 40 | N B P 41 | 42 | PPPPPPPP 43 | NBQKBNR 44 | """ 45 | assertEquals( 46 | visualDests(board, board.destsFrom(C4)), 47 | """ 48 | k B 49 | x 50 | x x 51 | x x 52 | N B P 53 | x x 54 | PPPPPPPP 55 | NBQKBNR 56 | """ 57 | ) 58 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/QueenTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | import Square.* 6 | 7 | class QueenTest extends ChessTest: 8 | 9 | test("not move to positions that are occupied by the same colour"): 10 | val board = """ 11 | k B 12 | 13 | 14 | 15 | N Q P 16 | 17 | PPPPPPPP 18 | NBQKBNR 19 | """ 20 | assertEquals( 21 | visualDests(board, board.destsFrom(C4)), 22 | """ 23 | k B x 24 | x x 25 | x x x 26 | xxx 27 | NxQxxxxP 28 | xxx 29 | PPPPPPPP 30 | NBQKBNR 31 | """ 32 | ) 33 | 34 | test("capture opponent pieces"): 35 | val board = """ 36 | k B 37 | q 38 | p 39 | 40 | N QP P 41 | 42 | PPPPPPPP 43 | NBQKBNR 44 | """ 45 | assertEquals( 46 | visualDests(board, board.destsFrom(C4)), 47 | """ 48 | k B 49 | x x 50 | x x x 51 | xxx 52 | NxQP P 53 | xxx 54 | PPPPPPPP 55 | NBQKBNR 56 | """ 57 | ) 58 | -------------------------------------------------------------------------------- /test-kit/src/main/scala/chess/macros.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.format.Uci 4 | import chess.format.pgn.* 5 | import org.typelevel.literally.Literally 6 | 7 | object macros: 8 | extension (inline ctx: StringContext) 9 | 10 | inline def pgn(inline args: Any*): ParsedPgn = 11 | ${ PgnLiteral('ctx, 'args) } 12 | 13 | inline def uci(inline args: Any*): Uci = 14 | ${ UciLiteral('ctx, 'args) } 15 | 16 | object PgnLiteral extends Literally[ParsedPgn]: 17 | def validate(s: String)(using Quotes) = 18 | Parser.full(PgnStr(s)) match 19 | case Right(_) => Right('{ Parser.full(PgnStr(${ Expr(s) })).toOption.get }) 20 | case Left(err) => Left(err.toString) 21 | 22 | object UciLiteral extends Literally[Uci]: 23 | def validate(s: String)(using Quotes) = 24 | Uci(s) match 25 | case Some(_) => Right('{ Uci(${ Expr(s) }).get }) 26 | case _ => Left(s"Invalid UCI: $s") 27 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "b673e25b6f124b9b9c3f842696d39b183aa91f79f1d11abd5678d03d5f2dfc2e" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.12" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": {}, 19 | "develop": { 20 | "chess": { 21 | "hashes": [ 22 | "sha256:48ff7c084a370811819cfc753c2ee159942356ada70824666bd01ee3fca170d0", 23 | "sha256:bccde105f54aa436e899f92b4ba953731c65012a863fd9235683d0e2863ccd54" 24 | ], 25 | "index": "pypi", 26 | "markers": "python_version >= '3.7'", 27 | "version": "==1.10.0" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /core/src/main/scala/format/pgn/model.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | package pgn 4 | 5 | // Nf6 6 | opaque type SanStr = String 7 | object SanStr extends OpaqueString[SanStr] 8 | 9 | // 1. d4 Nf6 2. c4 e6 3. g3 10 | opaque type PgnMovesStr = String 11 | object PgnMovesStr extends OpaqueString[PgnMovesStr] 12 | 13 | // full PGN game 14 | opaque type PgnStr = String 15 | object PgnStr extends OpaqueString[PgnStr] 16 | 17 | opaque type Comment = String 18 | object Comment extends TotalWrapper[Comment, String]: 19 | extension (c: Comment) def trimNonEmpty: Option[Comment] = Option.unless(c.isBlank)(c.trim) 20 | extension (cs: List[Comment]) def trimNonEmpty: List[Comment] = cs.flatMap(Comment.trimNonEmpty) 21 | 22 | opaque type InitialComments = List[Comment] 23 | object InitialComments extends TotalWrapper[InitialComments, List[Comment]]: 24 | val empty: InitialComments = Nil 25 | 26 | extension (ip: InitialComments) inline def comments: List[Comment] = ip 27 | -------------------------------------------------------------------------------- /core/src/main/scala/Stats.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | // Welford's numerically stable online variance. 4 | final case class Stats(samples: Int, mean: Float, sn: Float): 5 | 6 | def record(value: Float) = 7 | val newSamples = samples + 1 8 | val delta = value - mean 9 | val newMean = mean + delta / newSamples 10 | val newSN = sn + delta * (value - newMean) 11 | 12 | Stats( 13 | samples = newSamples, 14 | mean = newMean, 15 | sn = newSN 16 | ) 17 | 18 | def record[T](values: Iterable[T])(using n: Numeric[T]): Stats = 19 | values.foldLeft(this) { (s, v) => 20 | s.record(n.toFloat(v)) 21 | } 22 | 23 | def variance = (samples > 1).option(sn / (samples - 1)) 24 | 25 | def stdDev = variance.map { Math.sqrt(_).toFloat } 26 | 27 | def total = samples * mean 28 | 29 | object Stats: 30 | val empty: Stats = Stats(0, 0, 0) 31 | def apply(value: Float): Stats = empty.record(value) 32 | def apply[T: Numeric](values: Iterable[T]): Stats = empty.record(values) 33 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/HasIdTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import munit.ScalaCheckSuite 4 | import org.scalacheck.Prop.{ forAll, propBoolean } 5 | 6 | class HasIdTest extends ScalaCheckSuite: 7 | 8 | given HasId[Int, Int] with 9 | extension (a: Int) def id: Int = a 10 | 11 | test("if there is no id, removeById does nothing"): 12 | forAll: (xs: List[Int], id: Int) => 13 | xs.indexOf(id) == -1 ==> (xs.removeById(id) == xs) 14 | 15 | test("removeById.size <= size"): 16 | forAll: (xs: List[Int], id: Int) => 17 | val removed = xs.removeById(id) 18 | val epsilon = if xs.find(_ == id).isDefined then 1 else 0 19 | xs.size == removed.size + epsilon 20 | 21 | test("removeById reserves order"): 22 | forAll: (xs: List[Int], id: Int) => 23 | val sorted = xs.sorted 24 | val removed = sorted.removeById(id) 25 | removed == removed.sorted 26 | 27 | test("removeById only remove first items"): 28 | val xs = List(1, 2, 1) 29 | xs.removeById(1) == List(2, 1) 30 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/UciPathTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | 4 | class UciPathTest extends ChessTest: 5 | 6 | test("empty intersect"): 7 | assertEquals(UciPath("/?UE)8\\M(DYQDMTM'*`Y('aR-5").intersect(UciPath(")8VN")), UciPath.root) 8 | 9 | test("full intersect"): 10 | val p = UciPath("/?UE)8\\M(DYQDMTM'*`Y('aR-5") 11 | assertEquals(p.intersect(p), p) 12 | 13 | test("partial left"): 14 | assertEquals(UciPath("/?UE)8\\M(DYQDMTM'*`Y('aR-5").intersect(UciPath("/?UE)8")), UciPath("/?UE)8")) 15 | 16 | test("partial right"): 17 | assertEquals(UciPath("/?UE)8").intersect(UciPath("/?UE)8\\M(DYQDMTM'*`Y('aR-5")), UciPath("/?UE)8")) 18 | 19 | test("depth"): 20 | val p = UciPath( 21 | """.>VF-=F=/?WG)8`<%.<.&.G>8>aP$5^W'#_b08UE>-\M(=]O=OXO.N[^NWMW&^`^*&^&5&aX, 12 | val added = xs.add(foo) 13 | val diff = if xs.exists(_.sameId(foo)) then 0 else 1 14 | xs.size == added.size - diff 15 | 16 | test("add size"): 17 | forAll: (xs: List[Foo], other: List[Foo]) => 18 | val added = xs.add(other) 19 | added.size >= xs.size && added.size <= xs.size + other.size 20 | 21 | test("associativity"): 22 | forAll: (xs: List[Foo], ys: List[Foo], zs: List[Foo]) => 23 | val left = xs.add(ys).add(zs) 24 | val right = xs.add(ys.add(zs)) 25 | left.size == right.size 26 | 27 | test("merge.merge == merge"): 28 | forAll: (xs: List[Foo]) => 29 | xs.merge == xs.merge.merge 30 | 31 | test("after merge, ids are unique"): 32 | forAll: (xs: List[Foo]) => 33 | val merged = xs.merge 34 | merged.size == merged.map(_.id).toSet.size 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2014 Thibault Duplessis 2 | 3 | The MIT license 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test-kit/src/test/resources/atomic.perft: -------------------------------------------------------------------------------- 1 | id atomic-start 2 | epd rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 3 | perft 1 20 4 | perft 2 400 5 | perft 3 8902 6 | perft 4 197326 7 | 8 | id programfox-1 9 | epd rn2kb1r/1pp1p2p/p2q1pp1/3P4/2P3b1/4PN2/PP3PPP/R2QKB1R b KQkq - 10 | perft 1 40 11 | perft 2 1238 12 | perft 3 45237 13 | perft 4 1434825 14 | 15 | id programfox-2 16 | epd rn1qkb1r/p5pp/2p5/3p4/N3P3/5P2/PPP4P/R1BQK3 w Qkq - 17 | perft 1 28 18 | perft 2 833 19 | perft 3 23353 20 | perft 4 714499 21 | 22 | id atomic960-castle-1 23 | epd 8/8/8/8/8/8/2k5/rR4KR w KQ - 24 | perft 1 18 25 | perft 2 180 26 | perft 3 4364 27 | perft 4 61401 28 | perft 5 1603055 29 | 30 | id atomic960-castle-2 31 | epd r3k1rR/5K2/8/8/8/8/8/8 b kq - 32 | perft 1 25 33 | perft 2 282 34 | perft 3 6753 35 | perft 4 98729 36 | perft 5 2587730 37 | 38 | id atomic960-castle-3 39 | epd Rr2k1rR/3K4/3p4/8/8/8/7P/8 w kq - 40 | perft 1 21 41 | perft 2 465 42 | perft 3 10631 43 | perft 4 241478 44 | perft 5 5800275 45 | 46 | id shakmaty-bench 47 | epd rn2kb1r/1pp1p2p/p2q1pp1/3P4/2P3b1/4PN2/PP3PPP/R2QKB1R b KQkq - 48 | perft 1 40 49 | perft 2 1238 50 | perft 3 45237 51 | perft 4 1434825 52 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/pgn/ParserCheck.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format.pgn 3 | 4 | import chess.variant.Standard 5 | import munit.ScalaCheckSuite 6 | import org.scalacheck.Prop.forAll 7 | 8 | import ChessTreeArbitraries.* 9 | 10 | class ParserCheck extends ScalaCheckSuite: 11 | 12 | test("parse >>= render == identity"): 13 | forAll(genPgn(Standard.initialPosition)): pgn => 14 | val str = pgn.render 15 | val result = Parser.full(str).toOption.get.toPgn.render 16 | assertEquals(result, str) 17 | 18 | test("mainline == full.mainlineWithMetas"): 19 | forAll(genPgn(Standard.initialPosition)): pgn => 20 | val str = pgn.render 21 | val expected = Parser.full(str).toOption.map(_.mainlineWithMetas) 22 | val mainline = Parser.mainlineWithMetas(str).toOption.map(_.moves) 23 | assertEquals(mainline, expected) 24 | 25 | test("mainlineWithSan == full.mainline"): 26 | forAll(genPgn(Standard.initialPosition)): pgn => 27 | val str = pgn.render 28 | val expected = Parser.full(str).toOption.map(_.mainline) 29 | val mainline = Parser.mainline(str).toOption.map(_.moves) 30 | assertEquals(mainline, expected) 31 | -------------------------------------------------------------------------------- /core/src/main/scala/File.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | opaque type File = Int 4 | object File: 5 | 6 | extension (a: File) 7 | inline def value: Int = a 8 | 9 | inline infix def >(inline o: File): Boolean = a > o 10 | inline infix def <(inline o: File): Boolean = a < o 11 | inline infix def >=(inline o: File): Boolean = a >= o 12 | inline infix def <=(inline o: File): Boolean = a <= o 13 | 14 | inline def char: Char = (97 + a).toChar 15 | 16 | inline def upperCaseChar: Char = (65 + a).toChar 17 | inline def toUpperCaseString: String = upperCaseChar.toString 18 | 19 | // the bitboard of the file 20 | inline def bb: Bitboard = Bitboard.file(value) 21 | end extension 22 | 23 | inline def of(inline square: Square): File = square.value & 0x7 24 | 25 | inline def fromChar(inline ch: Char): Option[File] = File(ch.toInt - 97) 26 | 27 | def apply(value: Int): Option[File] = Option.when(0 <= value && value < 8)(value) 28 | 29 | val A: File = 0 30 | val B: File = 1 31 | val C: File = 2 32 | val D: File = 3 33 | val E: File = 4 34 | val F: File = 5 35 | val G: File = 6 36 | val H: File = 7 37 | 38 | val all = List(A, B, C, D, E, F, G, H) 39 | -------------------------------------------------------------------------------- /core/src/main/scala/Rank.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | opaque type Rank = Int 4 | object Rank: 5 | extension (a: Rank) 6 | inline def value: Int = a 7 | 8 | inline infix def >(inline o: Rank): Boolean = value > o.value 9 | inline infix def <(inline o: Rank): Boolean = value < o.value 10 | inline infix def >=(inline o: Rank): Boolean = value >= o.value 11 | inline infix def <=(inline o: Rank): Boolean = value <= o.value 12 | 13 | inline def char: Char = (49 + a).toChar 14 | 15 | // the bitboard of the rank 16 | inline def bb: Bitboard = Bitboard.rank(value) 17 | end extension 18 | 19 | inline def apply(index: Int): Option[Rank] = Option.when(0 <= index && index < 8)(index) 20 | 21 | inline def of(inline square: Square): Rank = square.value >> 3 22 | 23 | inline def fromChar(inline ch: Char): Option[Rank] = Rank(ch.toInt - 49) 24 | 25 | val First: Rank = 0 26 | val Second: Rank = 1 27 | val Third: Rank = 2 28 | val Fourth: Rank = 3 29 | val Fifth: Rank = 4 30 | val Sixth: Rank = 5 31 | val Seventh: Rank = 6 32 | val Eighth: Rank = 7 33 | 34 | val all = List(First, Second, Third, Fourth, Fifth, Sixth, Seventh, Eighth) 35 | val allReversed = all.reverse 36 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/UciCharPairTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | 4 | import munit.ScalaCheckSuite 5 | import org.scalacheck.Prop 6 | 7 | import CoreArbitraries.given 8 | 9 | class UciCharPairTest extends ScalaCheckSuite: 10 | 11 | test("convert move to pair"): 12 | assertEquals(UciCharPair(Uci.Move(Square.E2, Square.E4)).toString, "/?") 13 | 14 | test("convert drop to pair"): 15 | assertEquals(UciCharPair(Uci.Drop(Pawn, Square.C7)), UciCharPair('U', '\u008f')) 16 | assertEquals(UciCharPair(Uci.Drop(Knight, Square.C7)), UciCharPair('U', '\u008e')) 17 | assertEquals(UciCharPair(Uci.Drop(Bishop, Square.C7)), UciCharPair('U', '\u008d')) 18 | assertEquals(UciCharPair(Uci.Drop(Rook, Square.C7)), UciCharPair('U', '\u008c')) 19 | assertEquals(UciCharPair(Uci.Drop(Queen, Square.C7)), UciCharPair('U', '\u008b')) 20 | 21 | test("apply.toUci == identity"): 22 | Prop.forAll: (uci: Uci) => 23 | assertEquals(UciCharPair(uci).toUci, uci) 24 | 25 | test("List[Uci] => UciPath => String => List[Uci]"): 26 | Prop.forAll: (xs: List[Uci]) => 27 | val str = UciPath.fromIds(xs.map(UciCharPair(_))).debug 28 | assertEquals(str.split(" ").toList.map(Uci.apply).flatten, xs) 29 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/OutcomeTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import munit.ScalaCheckSuite 4 | 5 | class OutcomeTest extends ScalaCheckSuite: 6 | 7 | import Outcome.* 8 | 9 | test("standard outcomes"): 10 | assertEquals(fromResult("1-0"), Some(white)) 11 | assertEquals(fromResult("0-1"), Some(black)) 12 | assertEquals(fromResult("1/2-1/2"), Some(draw)) 13 | assertEquals(fromResult("0.5-0.5"), Some(draw)) 14 | assertEquals(fromResult(""), None) 15 | assertEquals(fromResult("*"), None) 16 | 17 | test("silly format outcomes"): 18 | assertEquals(fromResult("+_-"), Some(white)) 19 | assertEquals(fromResult("0:1"), Some(black)) 20 | assertEquals(fromResult("½-½"), Some(draw)) 21 | assertEquals(fromResult("WHITEWIN"), Some(white)) 22 | assertEquals(fromResult("DRAW"), Some(draw)) 23 | 24 | test("points"): 25 | def normalize(s: String) = showPoints(pointsFromResult(s)) 26 | assertEquals(normalize("1-0"), "1-0") 27 | assertEquals(normalize("0-1"), "0-1") 28 | assertEquals(normalize("—:—"), "0-0") 29 | assertEquals(normalize("1/2_0"), "1/2-0") 30 | assertEquals(normalize("0-½"), "0-1/2") 31 | assertEquals(normalize("0.5-0"), "1/2-0") 32 | assertEquals(normalize("*"), "*") 33 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/HistoryTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | class ThreefoldRepetitionTest extends ChessTest: 4 | 5 | def toHash(a: Int) = PositionHash(Hash(a << 8)) 6 | def makeHistory(positions: List[Int]) = 7 | (positions 8 | .map(toHash)) 9 | .foldLeft(defaultHistory()): (history, hash) => 10 | history.copy(positionHashes = hash.combine(history.positionHashes)) 11 | 12 | test("empty history"): 13 | assert(!defaultHistory().threefoldRepetition) 14 | test("not 3 same elements"): 15 | val history = makeHistory(List(1, 2, 3, 4, 5, 2, 5, 6, 23, 55)) 16 | assert(!history.threefoldRepetition) 17 | test("not 3 elements same to the last one"): 18 | val history = makeHistory(List(1, 2, 3, 4, 5, 2, 5, 6, 23, 2, 55)) 19 | assert(!history.threefoldRepetition) 20 | test("positive"): 21 | val history = makeHistory(List(1, 2, 3, 4, 5, 2, 5, 6, 23, 2)) 22 | assert(history.threefoldRepetition) 23 | 24 | class HalfMoveClockTest extends ChessTest: 25 | 26 | test("set 0"): 27 | assertEquals(defaultHistory().setHalfMoveClock(HalfMoveClock.initial).halfMoveClock, HalfMoveClock(0)) 28 | test("set 5"): 29 | assertEquals(defaultHistory().setHalfMoveClock(HalfMoveClock(5)).halfMoveClock, HalfMoveClock(5)) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Chess API written in scala for [lichess.org](https://lichess.org) 2 | 3 | It is entirely functional, immutable, and free of side effects. 4 | 5 | [![](https://jitpack.io/v/lichess-org/scalachess.svg)](https://jitpack.io/#lichess-org/scalachess) 6 | 7 | ## INSTALL 8 | 9 | Clone scalachess 10 | 11 | git clone https://github.com/lichess-org/scalachess 12 | 13 | Start [sbt](http://www.scala-sbt.org/download.html) in scalachess directory 14 | 15 | sbt 16 | 17 | In the sbt shell, to compile scalachess, run 18 | 19 | compile 20 | 21 | To run the tests 22 | 23 | testKit / test 24 | 25 | To run benchmarks (takes more than 1 hour to finish): 26 | 27 | bench / Jmh / run 28 | 29 | Or to output a json file 30 | 31 | bench / Jmh / run -rf json 32 | 33 | To run quick benchmarks (results may be inaccurate): 34 | 35 | bench / Jmh / run -i 1 -wi 1 -f1 -t1 36 | 37 | To run benchmarks for a specific class: 38 | 39 | bench / Jmh / run -rf json .*PlayBench.* 40 | 41 | To run [scalafmt](https://scalameta.org/scalafmt/docs/installation.html) and [scalafix](https://scalacenter.github.io/scalafix): 42 | 43 | sbt prepare 44 | 45 | ## Install (python) 46 | 47 | For python code, [install pipenv](https://pipenv.pypa.io/en/latest/installation.html#installing-pipenv), and run `$ pipenv install` from project root. 48 | 49 | -------------------------------------------------------------------------------- /core/src/main/scala/variant/KingOfTheHill.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package variant 3 | 4 | case object KingOfTheHill 5 | extends Variant( 6 | id = Variant.Id(4), 7 | key = Variant.LilaKey("kingOfTheHill"), 8 | uciKey = Variant.UciKey("kingofthehill"), 9 | name = "King of the Hill", 10 | shortName = "KotH", 11 | title = "Bring your King to the center to win the game.", 12 | standardInitialPosition = true 13 | ): 14 | 15 | override val initialBoard: Board = Board.standard 16 | override def initialPieces: Map[Square, Piece] = initialBoard.pieceMap 17 | 18 | override def validMoves(position: Position): List[Move] = 19 | Standard.validMoves(position) 20 | 21 | override def validMovesAt(position: Position, square: Square): List[Move] = 22 | super.validMovesAt(position, square).filter(kingSafety) 23 | 24 | override def valid(position: Position, strict: Boolean): Boolean = 25 | Standard.valid(position, strict) 26 | 27 | override def specialEnd(position: Position): Boolean = 28 | position.kingOf(!position.color).intersects(Bitboard.center) 29 | 30 | /** You only need a king to be able to win in this variant 31 | */ 32 | override def opponentHasInsufficientMaterial(position: Position): Boolean = false 33 | override def isInsufficientMaterial(position: Position): Boolean = false 34 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/PlyTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | // https://www.chessprogramming.org/Forsyth-Edwards_Notation#Fullmove_counter 4 | // ply FEN (ends with full move number) 5 | // 0 rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 6 | // 1 rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1 7 | // 2 rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2 8 | // 3 rnbqkbnr/pp1ppppp/8/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2 9 | 10 | class PlyTest extends ChessTest: 11 | 12 | test("to full move number"): 13 | assertEquals(Ply(0).fullMoveNumber, FullMoveNumber(1)) // root 14 | assertEquals(Ply(1).fullMoveNumber, FullMoveNumber(1)) // e4 15 | assertEquals(Ply(2).fullMoveNumber, FullMoveNumber(2)) // e5 16 | assertEquals(Ply(3).fullMoveNumber, FullMoveNumber(2)) // f4 17 | assertEquals(Ply(4).fullMoveNumber, FullMoveNumber(3)) // Nf6 18 | assertEquals(Ply(5).fullMoveNumber, FullMoveNumber(3)) 19 | assertEquals(Ply(6).fullMoveNumber, FullMoveNumber(4)) 20 | 21 | test("from full move number"): 22 | assertEquals(FullMoveNumber(1).ply(White), Ply(0)) 23 | assertEquals(FullMoveNumber(1).ply(Black), Ply(1)) 24 | assertEquals(FullMoveNumber(2).ply(White), Ply(2)) 25 | assertEquals(FullMoveNumber(2).ply(Black), Ply(3)) 26 | assertEquals(FullMoveNumber(3).ply(White), Ply(4)) 27 | assertEquals(FullMoveNumber(3).ply(Black), Ply(5)) 28 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/KingTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | import Square.* 6 | 7 | class KingTest extends ChessTest: 8 | 9 | import compare.dests 10 | 11 | val king = White - King 12 | 13 | test("move 1 position in any direction"): 14 | assertEquals(pieceMoves(king, D4), Set(D3, C3, C4, C5, D5, E5, E4, E3)) 15 | 16 | test("move 1 position in any direction, even from the edges"): 17 | assertEquals(pieceMoves(king, H8), Set(H7, G7, G8)) 18 | 19 | test("move behind pawn barrier"): 20 | assertEquals( 21 | """ 22 | PPPPPPPP 23 | R QK NR""".destsFrom(E1), 24 | Set(F1) 25 | ) 26 | 27 | test("not move to positions that are occupied by the same colour"): 28 | val board = """ 29 | P 30 | NPKP P 31 | 32 | PPPPPPPP 33 | NBQQBNN 34 | """ 35 | assertEquals( 36 | visualDests(board, board.destsFrom(C4)), 37 | """ 38 | 39 | 40 | 41 | xxP 42 | NPKP P 43 | xxx 44 | PPPPPPPP 45 | NBQQBNN 46 | """ 47 | ) 48 | 49 | test("capture hanging opponent pieces"): 50 | val board = """ 51 | bpp k 52 | Kp 53 | p 54 | 55 | """ 56 | assertEquals( 57 | visualDests(board, board.destsFrom(C3)), 58 | """ 59 | 60 | 61 | 62 | 63 | xxx k 64 | Kp 65 | x 66 | 67 | """ 68 | ) 69 | test("not move near from the other king"): 70 | assertEquals( 71 | """ 72 | k 73 | K 74 | """.destsFrom(B1), 75 | Set(A1, A2, B2) 76 | ) 77 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.7.4 2 | 1dec7613ca85d2a50a2ccfee7d5b8574f5df9838 3 | 4 | # Reformat with scalafmt 3.7.6 & syntax rewrite 5 | 2362192cfe132ef94467b2fc4eb87165a8e23668 6 | 7 | # Scala Steward: Reformat with scalafmt 3.7.11 8 | a0eab17cf6f5d23011b65c63277d10dd4d4cc840 9 | 10 | # Scala Steward: Reformat with scalafmt 3.7.12 11 | c246e4ac8bbaed067af7cd5b745e6d06d6307b95 12 | 13 | # Scala Steward: Reformat with scalafmt 3.7.15 14 | 1ad04d022a59358830b99e15b224d64038430713 15 | 16 | # Scala 3.4.0 rewrite 17 | 6014883408ed36bdf36642662e2e3bfcffdd0cf8 18 | f0ba792f8b9bdcb5c178facda2375648d88defb8 19 | 74f22f07d26a53f5cad5b798089043d47f637326 20 | e27af773ebaeae2ccabdc2eea15afe60107f2c99 21 | 22 | # Scalafix 23 | 5d3fb14c0a346a863de95cd9124dba592c3233df 24 | 25 | # Scala Steward: Reformat with scalafmt 3.8.2 26 | bfb4e29c0fe449fb539105fd58a34aa8fb81deae 27 | 28 | # Scala Steward: Reformat with scalafmt 3.8.6 29 | 68aafee3746adc5edd5e16bc34e71cee0201f1b5 30 | 31 | # Scala Steward: Reformat with scalafmt 3.9.0 32 | 519ac1e3001b1d225195dc1cba3ae91fd1b29333 33 | 34 | # Scala Steward: Reformat with scalafmt 3.9.7 35 | 75c3e38d7a008c9609a3c0416ff9741c06bf6ccc 36 | 37 | # scalafmt align.preset = none 38 | 4765295c1cd366eec50050569b9ce14320f4f883 39 | 40 | # Scala Steward: Reformat with scalafmt 3.10.1 41 | 74631faf25ab53b6adbbd0b8ca3e96ab7f7c18d3 42 | 43 | # Scala Steward: Reformat with scalafmt 3.10.2 44 | 8282287b0862bdef354425bb8fab215d519bc790 45 | -------------------------------------------------------------------------------- /sync-openings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import io 4 | import itertools 5 | import os.path 6 | import sys 7 | 8 | try: 9 | import chess.pgn 10 | except ImportError: 11 | print("Need python-chess: ", file=sys.stderr) 12 | print("$ pip3 install chess", file=sys.stderr) 13 | print(file=sys.stderr) 14 | raise 15 | 16 | 17 | def sync(abcde, src, dst): 18 | f = open(dst, "w") 19 | 20 | print("package chess.opening", file=f) 21 | print(file=f) 22 | print("// Generated from https://github.com/lichess-org/chess-openings", file=f) 23 | print("// format: off", file=f) 24 | print(f"private[opening] def openingDbPart{abcde.upper()}: Vector[Opening] = Vector(", file=f) 25 | 26 | for line in itertools.islice(open(src), 1, None): 27 | eco, name, pgn = line.rstrip().split("\t") 28 | board = chess.pgn.read_game(io.StringIO(pgn), Visitor=chess.pgn.BoardBuilder) 29 | uci = " ".join(m.uci() for m in board.move_stack) 30 | print(f"""Opening("{eco}", "{name}", "{board.epd()}", "{uci}", "{pgn}"),""", file=f) 31 | 32 | print(")", file=f) 33 | 34 | 35 | if __name__ == "__main__": 36 | for abcde in ["a", "b", "c", "d", "e"]: 37 | print(abcde) 38 | sync( 39 | abcde, 40 | os.path.join(os.path.dirname(__file__), "..", "chess-openings", f"{abcde}.tsv"), 41 | os.path.join(os.path.dirname(__file__), "core", "src", "main", "scala", "opening", f"OpeningDbPart{abcde.upper()}.scala") 42 | ) 43 | -------------------------------------------------------------------------------- /core/src/main/scala/CorrespondenceClock.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scalalib.time.{ nowSeconds, toSeconds } 4 | 5 | import java.time.Instant 6 | 7 | // times are expressed in seconds 8 | case class CorrespondenceClock( 9 | increment: Int, 10 | whiteTime: Float, 11 | blackTime: Float 12 | ): 13 | import CorrespondenceClock.* 14 | 15 | def daysPerTurn: Int = increment / 60 / 60 / 24 16 | 17 | def remainingTime(c: Color): Float = c.fold(whiteTime, blackTime) 18 | 19 | def outoftime(c: Color): Boolean = remainingTime(c) == 0 20 | 21 | def moretimeable(c: Color): Boolean = remainingTime(c) < (increment - hourSeconds) 22 | 23 | def giveTime(c: Color): CorrespondenceClock = 24 | c.fold( 25 | copy(whiteTime = whiteTime + daySeconds), 26 | copy(blackTime = blackTime + daySeconds) 27 | ) 28 | 29 | // in seconds 30 | def estimateTotalTime: Int = increment * 40 / 2 31 | 32 | def incrementHours: Int = increment / 60 / 60 33 | 34 | object CorrespondenceClock: 35 | val hourSeconds = 60 * 60 36 | val daySeconds = 24 * hourSeconds 37 | 38 | def apply(daysPerTurn: Int, turnColor: Color, lastMoveAt: Instant): CorrespondenceClock = 39 | val increment = daysPerTurn * 24 * 60 * 60 40 | val secondsLeft = (lastMoveAt.toSeconds + increment - nowSeconds).toInt.max(0) 41 | CorrespondenceClock( 42 | increment = increment, 43 | whiteTime = turnColor.fold(secondsLeft, increment).toFloat, 44 | blackTime = turnColor.fold(increment, secondsLeft).toFloat 45 | ) 46 | -------------------------------------------------------------------------------- /core/src/main/scala/format/UciDump.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | 4 | import cats.syntax.all.* 5 | import chess.variant.{ Chess960, Variant } 6 | 7 | object UciDump: 8 | 9 | def apply( 10 | moves: Seq[pgn.SanStr], 11 | initialFen: Option[FullFen], 12 | variant: Variant, 13 | // some API clients can't handle e1h1, so we need to send them e1g1 14 | legacyStandardCastling: Boolean = false 15 | ): Either[ErrorStr, List[String]] = 16 | if moves.isEmpty then Nil.asRight 17 | else 18 | Position(variant, initialFen) 19 | .play(moves, Ply.initial): step => 20 | move(step.move, legacyStandardCastling && variant.standard) 21 | 22 | def move(mod: MoveOrDrop, legacyStandardCastling: Boolean = false): String = 23 | mod match 24 | case m: Move => 25 | m.castle 26 | .fold(m.toUci.uci): c => 27 | if legacyStandardCastling 28 | then c.king.key + c.kingTo.key 29 | else c.king.key + c.rook.key 30 | case d: Drop => d.toUci.uci 31 | 32 | // Keys to highlight to show the last move made on the board. 33 | // Does not render as UCI. 34 | def lastMove(uci: Uci, variant: Variant): String = uci match 35 | case d: Uci.Drop => d.square.key * 2 36 | case m: Uci.Move => 37 | if variant == Chess960 then m.keys 38 | else 39 | m.keys match 40 | case "e1h1" => "e1g1" 41 | case "e8h8" => "e8g8" 42 | case "e1a1" => "e1c1" 43 | case "e8a8" => "e8c8" 44 | case k => k 45 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/HordeInsufficientMaterialTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.effect.IO 4 | import cats.kernel.Monoid 5 | import cats.syntax.all.* 6 | import chess.format.{ Fen, FullFen } 7 | import chess.variant.* 8 | import fs2.* 9 | import fs2.io.file.Files 10 | import weaver.* 11 | 12 | object HordeInsufficientMaterialTest extends SimpleIOSuite: 13 | 14 | test("horde"): 15 | run("test-kit/src/test/resources/horde_insufficient_material.csv", Horde).map(expect(_)) 16 | 17 | given Monoid[Boolean] with 18 | def empty = true 19 | def combine(x: Boolean, y: Boolean) = x && y 20 | 21 | private def run(file: String, variant: Variant): IO[Boolean] = 22 | parser(file) 23 | .foldMap(_.run(variant)) 24 | .compile 25 | .lastOrError 26 | 27 | private def parser(file: String): Stream[IO, Case] = 28 | Files[IO] 29 | .readAll(fs2.io.file.Path(file)) 30 | .through(csvParser) 31 | .map(parseSample) 32 | 33 | private def csvParser[F[_]]: Pipe[F, Byte, List[String]] = 34 | _.through(text.utf8Decode) 35 | .through(text.lines) 36 | .filter(_.nonEmpty) 37 | .map(_.split(',').toList) 38 | 39 | private def parseSample(sample: List[String]): Case = 40 | Case(FullFen(sample(0)), sample(1).toBoolean, sample.get(2)) 41 | 42 | private case class Case(fen: FullFen, expected: Boolean, comment: Option[String]): 43 | def run(variant: Variant): Boolean = 44 | val board = Fen.read(variant, fen).get 45 | Horde.hasInsufficientMaterial(board.board, !board.color) == expected 46 | -------------------------------------------------------------------------------- /core/src/main/scala/TournamentClock.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.syntax.all.* 4 | 5 | import Clock.{ LimitSeconds, LimitMinutes, IncrementSeconds } 6 | 7 | case class TournamentClock(limitSeconds: LimitSeconds, incrementSeconds: IncrementSeconds): 8 | 9 | def limit: Centis = Centis.ofSeconds(limitSeconds.value) 10 | def increment: Centis = Centis.ofSeconds(incrementSeconds.value) 11 | 12 | def limitMinutes = LimitMinutes(limitSeconds.value / 60) 13 | 14 | def toClockConfig: Option[Clock.Config] = Clock.Config(limitSeconds, incrementSeconds).some 15 | 16 | override def toString = s"$limitMinutes+$incrementSeconds" 17 | 18 | object TournamentClock: 19 | 20 | object parse: 21 | 22 | private val cleanRegex = "(/move|minutes|minute|min|m|seconds|second|sec|s|'|\")".r 23 | 24 | private def make(strict: Boolean)(a: Int, b: Int) = 25 | val limit = if strict then LimitSeconds(a) else LimitSeconds(if a > 180 then a else a * 60) 26 | TournamentClock(limit, IncrementSeconds(b)) 27 | 28 | // `strict` uses PGN specification https://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm#c9.6.1 29 | // where time control is always in seconds 30 | def apply(strict: Boolean)(str: String): Option[TournamentClock] = 31 | cleanRegex 32 | .replaceAllIn(str.toLowerCase.replace(" ", ""), "") 33 | .split('+') 34 | .match 35 | case Array(a) => a.toIntOption.map(make(strict)(_, 0)) 36 | case Array(a, b) => (a.toIntOption, b.toIntOption).mapN(make(strict)) 37 | case _ => none 38 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/HashBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import cats.syntax.all.* 8 | import chess.format.pgn.Fixtures 9 | import chess.{ Position, Hash, Replay } 10 | 11 | @State(Scope.Thread) 12 | @BenchmarkMode(Array(Mode.Throughput)) 13 | @OutputTimeUnit(TimeUnit.SECONDS) 14 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 15 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 16 | @Fork(value = 3) 17 | @Threads(value = 1) 18 | class HashBench: 19 | 20 | // the unit of CPU work per iteration 21 | private val Work: Long = 10 22 | 23 | var boards: List[Position] = scala.compiletime.uninitialized 24 | 25 | @Setup 26 | def setup() = 27 | val results = for 28 | results <- Fixtures.gamesForPerfTest.traverse(Replay.mainline(_)) 29 | replays <- results.traverse(_.valid) 30 | yield replays.flatMap(_.moves).map(_.after) 31 | boards = results.toOption.get 32 | 33 | @Benchmark 34 | def hashes(bh: Blackhole) = 35 | val result = boards.map: x => 36 | Blackhole.consumeCPU(Work) 37 | Hash(x) 38 | bh.consume(result) 39 | 40 | @Benchmark 41 | def repetition5(bh: Blackhole) = 42 | val result = boards.map: x => 43 | Blackhole.consumeCPU(Work) 44 | x.history.fivefoldRepetition 45 | bh.consume(result) 46 | 47 | @Benchmark 48 | def repetition3(bh: Blackhole) = 49 | val result = boards.map: x => 50 | Blackhole.consumeCPU(Work) 51 | x.history.threefoldRepetition 52 | bh.consume(result) 53 | -------------------------------------------------------------------------------- /core/src/main/scala/Piece.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.Eq 4 | import cats.derived.* 5 | 6 | case class Piece(color: Color, role: Role) derives Eq: 7 | 8 | def is(c: Color): Boolean = c == color 9 | def is(r: Role): Boolean = r == role 10 | def isNot(r: Role): Boolean = r != role 11 | def unary_! : Piece = Piece(!color, role) 12 | 13 | def forsyth: Char = if color.white then role.forsythUpper else role.forsyth 14 | 15 | // the piece at from can attack the target to when mask are all the occupied squares 16 | def eyes(from: Square, to: Square, mask: Bitboard): Boolean = 17 | role match 18 | case King => from.kingAttacks.contains(to) 19 | case Queen => from.queenAttacks(mask).contains(to) 20 | case Rook => from.rookAttacks(mask).contains(to) 21 | case Bishop => from.bishopAttacks(mask).contains(to) 22 | case Knight => from.knightAttacks.contains(to) 23 | case Pawn => from.pawnAttacks(color).contains(to) 24 | 25 | override def toString = s"$color-$role".toLowerCase 26 | 27 | object Piece: 28 | 29 | private val allByFen: Map[Char, Piece] = 30 | Map( 31 | 'P' -> Piece(White, Pawn), 32 | 'N' -> Piece(White, Knight), 33 | 'B' -> Piece(White, Bishop), 34 | 'R' -> Piece(White, Rook), 35 | 'Q' -> Piece(White, Queen), 36 | 'K' -> Piece(White, King), 37 | 'p' -> Piece(Black, Pawn), 38 | 'n' -> Piece(Black, Knight), 39 | 'b' -> Piece(Black, Bishop), 40 | 'r' -> Piece(Black, Rook), 41 | 'q' -> Piece(Black, Queen), 42 | 'k' -> Piece(Black, King) 43 | ) 44 | 45 | def fromChar(c: Char): Option[Piece] = 46 | allByFen.get(c) 47 | -------------------------------------------------------------------------------- /core/src/main/scala/format/UciPath.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | 4 | /* Compact representation of a path to a game node, 5 | * Made from concatenated UciCharPair strings */ 6 | opaque type UciPath = String 7 | object UciPath extends OpaqueString[UciPath]: 8 | def fromId(id: UciCharPair): UciPath = id.toString 9 | def fromIds(ids: Iterable[UciCharPair]): UciPath = ids.mkString 10 | 11 | extension (e: UciPath) 12 | 13 | def computeIds: Iterator[UciCharPair] = e.grouped(2).flatMap(strToId) 14 | def ids: List[UciCharPair] = computeIds.toList 15 | 16 | def head: Option[UciCharPair] = strToId(e) 17 | 18 | def parent: UciPath = e.dropRight(2) 19 | 20 | def split: Option[(UciCharPair, UciPath)] = head.map(_ -> e.drop(2)) 21 | 22 | inline def isEmpty: Boolean = e.isEmpty 23 | inline def nonEmpty: Boolean = !isEmpty 24 | 25 | def lastId: Option[UciCharPair] = strToId(e.takeRight(2)) 26 | 27 | def +(id: UciCharPair): UciPath = e + id.toString 28 | def +(more: UciPath): UciPath = e + more 29 | 30 | def prepend(id: UciCharPair): UciPath = id.toString + e 31 | 32 | def intersect(other: UciPath): UciPath = 33 | val p = e.zip(other).takeWhile(_ == _).map(_._1) 34 | // `/ 2 * 2` makes sure the size is even. It's necessary! 35 | p.take(p.size / 2 * 2).mkString 36 | 37 | def depth = e.size / 2 38 | 39 | def debug: String = e.computeIds.map(_.toUci.uci).mkString(" ").trim 40 | 41 | private inline def strToId(inline str: String): Option[UciCharPair] = 42 | for 43 | a <- str.headOption 44 | b <- str.lift(1) 45 | yield UciCharPair(a, b) 46 | 47 | val root: UciPath = "" 48 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/InsufficientMaterialBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | 5 | import java.util.concurrent.TimeUnit 6 | import chess.format.{ FullFen, Fen } 7 | import chess.variant.Horde 8 | 9 | @State(Scope.Thread) 10 | @BenchmarkMode(Array(Mode.Throughput)) 11 | @OutputTimeUnit(TimeUnit.SECONDS) 12 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 13 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 14 | @Fork(value = 3) 15 | @Threads(value = 1) 16 | class InsufficientMaterialBench: 17 | 18 | var hordeGames = List( 19 | "k7/ppP5/brp5/8/8/8/8/8 b - -", 20 | "8/2k5/3q4/8/8/8/1P6/8 b - -", 21 | "8/2k5/3q4/8/8/8/1P6/8 w - -", 22 | "r7/2Bb4/q3k3/8/8/3q4/8/5qqr b - -", 23 | "8/2k5/3q4/8/8/1Q6/8/8 b - -", 24 | "8/2k5/3q4/8/8/1Q6/8/8 w - -", 25 | "8/2k5/3q4/8/8/1B2N3/8/8 b - -", 26 | "8/2k5/3q4/8/8/1B2N3/8/8 w - -", 27 | "8/2k5/3q4/8/8/3B4/4NB2/8 b - -", 28 | "8/5k2/7q/7P/6rP/6P1/6P1/8 b - - 0 52", 29 | "8/p7/pk6/P7/P7/8/8/8 b - -", 30 | "QNBRRBNQ/PPpPPpPP/P1P2PkP/8/8/8/8/8 b - -", 31 | "b7/pk6/P7/P7/8/8/8/8 b - - 0 1", 32 | "8/1b5r/1P6/1Pk3q1/1PP5/r1P5/P1P5/2P5 b - - 0 52", 33 | "7B/6k1/8/8/8/8/8/8 b - -", 34 | "k7/5p2/4p2P/3p2P1/2p2P2/1p2P2P/p2P2P1/2P2P2 w - - 0 1", 35 | "8/N7/8/8/8/8/bqnnbqbr/k7 b - - 0 1", 36 | "8/6PP/8/8/8/8/3npqrn/7k b - - 0 1", 37 | "8/P1P5/8/8/8/8/bbnb4/k7 b - - 0 1", 38 | "8/6PP/8/8/8/8/5rrb/7k b - - 0 1" 39 | ).map(FullFen(_)).map(Fen.read(Horde, _).get) 40 | 41 | @Benchmark 42 | def horde() = 43 | hordeGames.map: board => 44 | board.variant.isInsufficientMaterial(board) 45 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/StatsTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | class StatsTest extends ChessTest: 4 | 5 | def realMean(elts: Seq[Float]): Float = elts.sum / elts.size 6 | 7 | def realVar(elts: Seq[Float]): Float = 8 | val mean = realMean(elts).toDouble 9 | elts 10 | .map: x => 11 | Math.pow(x - mean, 2) 12 | .sum 13 | .toFloat / (elts.size - 1) 14 | 15 | def beApprox(f: Float, comp: Float)(using munit.Location) = 16 | if comp.isNaN then assert(f.isNaN) 17 | else assertCloseTo(f, comp, 0.001f * comp) 18 | 19 | def beLike(comp: Stats) = 20 | (s: Stats) => 21 | assertEquals(s.samples, comp.samples) 22 | beApprox(s.mean, comp.mean) 23 | (s.variance, comp.variance) match 24 | case (Some(sv), Some(cv)) => beApprox(sv, cv) 25 | case (sv, cv) => assertEquals(sv, cv) 26 | 27 | test("empty stats: have good defaults"): 28 | assertEquals(Stats.empty.variance, None) 29 | assertEquals(Stats.empty.mean, 0f) 30 | assertEquals(Stats.empty.samples, 0) 31 | 32 | test("empty stats: make Stats"): 33 | 34 | assertEquals(Stats(5).samples, 1) 35 | assertEquals(Stats(5).variance, None) 36 | assertEquals(Stats(5).mean, 5f) 37 | 38 | test("large values"): 39 | // Tight data w/ large mean. Shuffled for Stats. 40 | val base = (1 to 100) ++ (1 to 100) ++ (1 to 200) 41 | val data = base.map { _ + 1e5f } 42 | val shuffledData = base.sortWith(_ % 8 > _ % 8).map { _ + 1e5f } 43 | 44 | val statsN = Stats.empty.record(shuffledData) 45 | beApprox(statsN.mean, realMean(data)) 46 | beApprox(statsN.variance.get, realVar(data)) 47 | assertEquals(statsN.samples, 400) 48 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/SquareTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.syntax.all.* 4 | import munit.ScalaCheckSuite 5 | import org.scalacheck.Prop.forAll 6 | 7 | import Square.* 8 | import CoreArbitraries.given 9 | 10 | class SquareTest extends ScalaCheckSuite: 11 | 12 | test("keys"): 13 | assertEquals(D5.key, "d5") 14 | 15 | test("chars for some squares"): 16 | assertEquals(A1.asChar, 'a') 17 | assertEquals(B4.asChar, 'z') 18 | assertEquals(C4.asChar, 'A') 19 | assertEquals(D7.asChar, 'Z') 20 | assertEquals(E7.asChar, '0') 21 | assertEquals(F7.asChar, '1') 22 | assertEquals(F8.asChar, '9') 23 | assertEquals(G8.asChar, '!') 24 | assertEquals(H8.asChar, '?') 25 | 26 | val allChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?" 27 | 28 | test("Square.asChar"): 29 | assertEquals(Square.all.map(_.asChar).mkString, allChars) 30 | 31 | test("chars are unique"): 32 | assertEquals(allChars.toList.traverse(Square.fromChar(_)).get, Square.all) 33 | 34 | test("keys.fromKey == some.identity"): 35 | Square.all.foreach: square => 36 | assertEquals(Square.fromKey(square.key), square.some) 37 | 38 | test("keys are unique"): 39 | Square.all.map(_.key).toSet.size == 64 40 | 41 | test("x onSame x == true"): 42 | Square.all.foreach: square => 43 | assert(square.onSameLine(square)) 44 | assert(square.onSameRank(square)) 45 | assert(square.onSameDiagonal(square)) 46 | 47 | test("x onSame y == y onSame x"): 48 | forAll: (x: Square, y: Square) => 49 | assertEquals(x.onSameLine(y), y.onSameLine(x)) 50 | assertEquals(x.onSameRank(y), y.onSameRank(x)) 51 | assertEquals(x.onSameDiagonal(y), y.onSameDiagonal(x)) 52 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/perft/Parser.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package perft 3 | 4 | import cats.parse.{ DefaultParser0, Numbers as N, Parser as P, Parser0 as P0, Rfc5234 as R } 5 | import cats.syntax.all.* 6 | import chess.format.FullFen 7 | 8 | /** 9 | * Perft parser specification 10 | | * 11 | * perfts = comment* perft* comment* "\n"* 12 | * 13 | * perft -> id "\n" epd "\n" case* "\n" 14 | * id -> "id " STRING 15 | * epd -> "epd " FullFen 16 | * case -> "perft " INT LONG "\n" 17 | * 18 | * -- only support comment at the begining of the line 19 | * comment = "#" STRING "\n" 20 | * 21 | */ 22 | 23 | object Parser extends DefaultParser0[List[Perft]]: 24 | 25 | private val whitespace = R.cr | R.lf | R.wsp 26 | private val blank = P.until(!whitespace) 27 | private val nonNegative = N.nonNegativeIntString 28 | 29 | private val comment = (P.caret.filter(_.col == 0) *> P.char('#')).endWith(R.lf) 30 | private val ignored = (comment | blank).void 31 | 32 | private val id: P[String] = "id".prefix 33 | private val epd: P[FullFen] = "epd".prefix.map(FullFen.clean) 34 | private val testCase: P[TestCase] = 35 | ((nonNegative.map(_.toInt) <* P.char(' ')) ~ nonNegative.map(_.toLong)).map(TestCase.apply) 36 | private val oneTestCase: P[TestCase] = P.string("perft ") *> testCase <* R.lf.? 37 | private val cases: P[List[TestCase]] = oneTestCase.rep.map(_.toList) <* (ignored.rep | R.lf.rep0) 38 | private val perft: P[Perft] = (id, epd, cases).mapN(Perft.apply) <* R.lf.? 39 | def parser0: P0[List[Perft]] = ignored.rep0 *> perft.rep.map(_.toList) 40 | 41 | extension (p: P0[Any]) 42 | private def endWith(p1: P[Any]): P[String] = p.with1 *> (p1.string | (P.until(p1) <* p1)) 43 | 44 | extension (str: String) private def prefix: P[String] = P.string(s"$str ").endWith(R.lf) 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: ['**'] 6 | push: 7 | branches: ['**'] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | env: 13 | JAVA_OPTS: "-Xmx6G -XX:+UseG1GC" 14 | SBT_OPTS: "-Dsbt.ci=true" 15 | steps: 16 | 17 | - name: Checkout current branch 18 | uses: actions/checkout@v6 19 | 20 | - name: Setup JVM 21 | uses: actions/setup-java@v5 22 | with: 23 | distribution: temurin 24 | java-version: 21 25 | cache: sbt 26 | 27 | - name: Setup sbt 28 | uses: sbt/setup-sbt@v1 29 | 30 | - name: Test 31 | run: sbt testKit/test 32 | 33 | compile: 34 | runs-on: ubuntu-latest 35 | env: 36 | JAVA_OPTS: "-Xmx6G -XX:+UseG1GC" 37 | SBT_OPTS: "-Dsbt.ci=true" 38 | steps: 39 | 40 | - name: Checkout current branch 41 | uses: actions/checkout@v6 42 | 43 | - name: Setup JVM 44 | uses: actions/setup-java@v5 45 | with: 46 | distribution: temurin 47 | java-version: 21 48 | cache: sbt 49 | 50 | - name: Setup sbt 51 | uses: sbt/setup-sbt@v1 52 | 53 | - name: compile 54 | run: sbt compile 55 | 56 | format: 57 | runs-on: ubuntu-latest 58 | env: 59 | SBT_OPTS: "-Dsbt.ci=true" 60 | steps: 61 | 62 | - name: Checkout current branch 63 | uses: actions/checkout@v6 64 | 65 | - name: Setup JVM 66 | uses: actions/setup-java@v5 67 | with: 68 | distribution: temurin 69 | java-version: 21 70 | cache: sbt 71 | 72 | - name: Setup sbt 73 | uses: sbt/setup-sbt@v1 74 | 75 | - name: Check Formatting 76 | run: sbt check 77 | -------------------------------------------------------------------------------- /core/src/main/scala/Status.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | enum Status(val id: Int): 4 | 5 | val name = s"${toString.head.toLower}${toString.tail}" 6 | 7 | inline def is(inline s: Status): Boolean = this == s 8 | inline def is(inline f: Status.type => Status): Boolean = is(f(Status)) 9 | 10 | inline infix def >=(inline s: Status): Boolean = id >= s.id 11 | inline infix def >(inline s: Status): Boolean = id > s.id 12 | inline infix def <=(inline s: Status): Boolean = id <= s.id 13 | inline infix def <(inline s: Status): Boolean = id < s.id 14 | 15 | case Created extends Status(10) 16 | case Started extends Status(20) 17 | case Aborted extends Status(25) // from this point the game is finished 18 | case Mate extends Status(30) 19 | case Resign extends Status(31) 20 | case Stalemate extends Status(32) 21 | case Timeout extends Status(33) // when player leaves the game 22 | case Draw extends Status(34) 23 | case Outoftime extends Status(35) // clock flag 24 | case Cheat extends Status(36) 25 | case NoStart extends Status(37) // the player did not make the first move in time 26 | case UnknownFinish extends Status(38) // we don't know why the game ended 27 | // When the side that cannot lose offers (claims) a draw. 28 | // Not to be confused with automatic draws by insufficient material. 29 | case InsufficientMaterialClaim extends Status(39) 30 | case VariantEnd extends Status(60) // the variant has a special ending 31 | 32 | object Status: 33 | 34 | val all = values.toList 35 | 36 | given Ordering[Status] = Ordering.by(_.id) 37 | 38 | val finishedNotCheated = all.filter { s => 39 | s.id >= Mate.id && s.id != Cheat.id 40 | } 41 | 42 | val finishedWithWinner = List(Mate, Resign, Timeout, Outoftime, Cheat, NoStart, VariantEnd) 43 | 44 | val byId = all.mapBy(_.id) 45 | 46 | def apply(id: Int): Option[Status] = byId.get(id) 47 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/perft/PerftTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package perft 3 | 4 | import cats.effect.IO 5 | import cats.kernel.Monoid 6 | import cats.syntax.all.* 7 | import chess.variant.* 8 | import weaver.* 9 | 10 | object PerftTest extends SimpleIOSuite: 11 | 12 | given Monoid[Boolean] with 13 | def empty = true 14 | def combine(x: Boolean, y: Boolean) = x && y 15 | 16 | val nodeLimits = 1_000L 17 | 18 | test("random.perft"): 19 | perfts(Perft.randomPerfts, Chess960, 10_000L) 20 | .map(expect(_)) 21 | 22 | test("threeCheck.perft"): 23 | perfts(Perft.threeCheckPerfts, ThreeCheck, nodeLimits) 24 | .map(expect(_)) 25 | 26 | test("antichess.perft"): 27 | perfts(Perft.antichessPerfts, Antichess, nodeLimits) 28 | .map(expect(_)) 29 | 30 | test("atomic.perft"): 31 | perfts(Perft.atomicPerfts, Atomic, nodeLimits) 32 | .map(expect(_)) 33 | 34 | test("crazyhouse.perft"): 35 | perfts(Perft.crazyhousePerfts, Crazyhouse, nodeLimits) 36 | .map(expect(_)) 37 | 38 | test("horde.perft"): 39 | perfts(Perft.hordePerfts, Horde, nodeLimits) 40 | .map(expect(_)) 41 | 42 | test("racingkings.perft"): 43 | perfts(Perft.racingkingsPerfts, RacingKings, nodeLimits) 44 | .map(expect(_)) 45 | 46 | test("tricky.perft"): 47 | perfts(Perft.trickyPerfts, Chess960, nodeLimits) 48 | .map(expect(_)) 49 | 50 | test("chess960.perft"): 51 | perfts(Perft.chess960, Chess960, nodeLimits) 52 | .map(expect(_)) 53 | 54 | private def perfts(perfts: List[Perft], variant: Variant, nodeLimit: Long): IO[Boolean] = 55 | perfts.parFoldMapA(perft => IO(perftTest(perft, variant, nodeLimit))) 56 | 57 | private def perftTest(perft: Perft, variant: Variant, nodeLimit: Long): Boolean = 58 | perft 59 | .withLimit(nodeLimit) 60 | .calculate(variant) 61 | .forall(r => r.result === r.expected) 62 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/GameTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.format.{ FullFen, Uci } 4 | 5 | import scala.language.implicitConversions 6 | 7 | import Square.* 8 | 9 | class GameTest extends ChessTest: 10 | 11 | val game = Game( 12 | """ 13 | k 14 | b 15 | R K""".withColor(color = Black) 16 | ) 17 | 18 | test("prevent castle by capturing a rook: can castle queenside"): 19 | assert(game.position.history.canCastle(White, QueenSide)) 20 | test("prevent castle by capturing a rook: can still castle queenside"): 21 | assert(game.playMoves(B2 -> A3).get.position.history.canCastle(White, QueenSide)) 22 | test("prevent castle by capturing a rook: can not castle queenside anymore"): 23 | assertNot(game.playMoves(B2 -> A1).get.position.history.canCastle(White, QueenSide), false) 24 | 25 | test("update half move clock: start at 0"): 26 | assertEquals(Game(variant.Standard).halfMoveClock, 0) 27 | test("update half move clock: increment"): 28 | Game(variant.Standard)(G1, F3).assertRight: (game, _) => 29 | assertEquals(game.halfMoveClock, 1) 30 | test("update half move clock: not increment"): 31 | Game(variant.Standard)(E2, E4).assertRight: (game, _) => 32 | assertEquals(game.halfMoveClock, 0) 33 | 34 | test("Castle lastMove UCI normalization: standard"): 35 | val from = fenToGame( 36 | FullFen("rnbqkbnr/ppp2ppp/8/3pp3/4P3/3B1N2/PPPP1PPP/RNBQK2R w KQkq - 0 4"), 37 | variant.Standard 38 | ) 39 | assertEquals(from(E1, G1).get._1.history.lastMove, Uci("e1h1")) 40 | assertEquals(from(E1, H1).get._1.history.lastMove, Uci("e1h1")) 41 | 42 | test("Castle lastMove UCI normalization: chess960"): 43 | val from = fenToGame( 44 | FullFen("rnbqkbnr/ppp2ppp/8/3pp3/4P3/3B1N2/PPPP1PPP/RNBQK2R w KQkq - 0 4"), 45 | variant.Chess960 46 | ) 47 | assertEquals(from(E1, H1).get._1.history.lastMove, Uci("e1h1")) 48 | -------------------------------------------------------------------------------- /core/src/main/scala/opening/Opening.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package opening 3 | 4 | import chess.format.pgn.PgnMovesStr 5 | import chess.format.{ StandardFen, Uci } 6 | 7 | final class Opening( 8 | val eco: Eco, 9 | val name: OpeningName, 10 | val fen: StandardFen, 11 | val uci: UcisStr, 12 | val pgn: PgnMovesStr 13 | ): 14 | 15 | val (family: OpeningFamily, variation: Option[OpeningVariation]) = name.value.split(":", 2) match 16 | case Array(f, v) => OpeningFamily(OpeningName(f)) -> Some(OpeningVariation(v.takeWhile(',' !=).trim)) 17 | case Array(f) => OpeningFamily(OpeningName(f)) -> None 18 | case _ => OpeningFamily(name) -> None 19 | 20 | lazy val nbMoves: Int = uci.value.count(' ' ==) + 1 21 | lazy val lastUci: Option[Uci.Move] = uci.value.split(' ').lastOption.flatMap(Uci.Move.apply) 22 | lazy val key: OpeningKey = Opening.nameToKey(name) 23 | 24 | override def toString = name.value 25 | 26 | def atPly(ply: Ply) = Opening.AtPly(this, ply) 27 | 28 | object Opening: 29 | 30 | private[opening] def apply(eco: String, name: String, fen: String, uci: String, pgn: String): Opening = 31 | new Opening(Eco(eco), OpeningName(name), StandardFen(fen), UcisStr(uci), PgnMovesStr(pgn)) 32 | 33 | case class AtPly(opening: Opening, ply: Ply) 34 | 35 | object nameToKey: 36 | private val splitAccentRegex = "[\u0300-\u036f]".r 37 | private val multiSpaceRegex = """\s+""".r 38 | private val badChars = """[^\w\-]+""".r 39 | def apply(name: OpeningName) = OpeningKey: 40 | badChars.replaceAllIn( 41 | multiSpaceRegex.replaceAllIn( 42 | splitAccentRegex.replaceAllIn( 43 | // split an accented letter in the base letter and the accent 44 | java.text.Normalizer.normalize(name.value, java.text.Normalizer.Form.NFD), 45 | "" 46 | ), 47 | "_" 48 | ), 49 | "" 50 | ) 51 | -------------------------------------------------------------------------------- /core/src/main/scala/History.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.variant.Crazyhouse 4 | 5 | import format.Uci 6 | 7 | case class History( 8 | lastMove: Option[Uci] = None, 9 | positionHashes: PositionHash = PositionHash.empty, 10 | castles: Castles = Castles.init, 11 | checkCount: CheckCount = CheckCount(0, 0), 12 | unmovedRooks: UnmovedRooks, 13 | halfMoveClock: HalfMoveClock = HalfMoveClock.initial, 14 | crazyData: Option[Crazyhouse.Data] 15 | ): 16 | 17 | def setHalfMoveClock(v: HalfMoveClock): History = copy(halfMoveClock = v) 18 | 19 | inline def threefoldRepetition: Boolean = positionHashes.isRepetition(3) 20 | inline def fivefoldRepetition: Boolean = positionHashes.isRepetition(5) 21 | 22 | inline def canCastle(inline color: Color): Boolean = castles.can(color) 23 | inline def canCastle(inline color: Color, inline side: Side): Boolean = castles.can(color, side) 24 | 25 | inline def withoutCastles(inline color: Color): History = copy(castles = castles.without(color)) 26 | 27 | inline def withoutAnyCastles: History = copy(castles = Castles.none) 28 | 29 | inline def withoutCastle(color: Color, side: Side): History = copy(castles = castles.without(color, side)) 30 | 31 | inline def withCastles(inline c: Castles): History = copy(castles = c) 32 | 33 | def withCheck(color: Color, check: Check): History = 34 | if check.yes then copy(checkCount = checkCount.add(color)) else this 35 | 36 | def withCheckCount(cc: CheckCount): History = copy(checkCount = cc) 37 | 38 | // Checks received by the respective side. 39 | case class CheckCount(white: Int = 0, black: Int = 0): 40 | 41 | def add(color: Color): CheckCount = 42 | copy( 43 | white = white + color.fold(1, 0), 44 | black = black + color.fold(0, 1) 45 | ) 46 | 47 | def apply(color: Color): Int = color.fold(white, black) 48 | 49 | def nonEmpty: Boolean = white > 0 || black > 0 50 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/BinaryFenBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import chess.{ FullMoveNumber, Position } 8 | import chess.variant.Chess960 9 | import chess.* 10 | import chess.format.{ Fen, BinaryFen } 11 | import chess.perft.Perft 12 | 13 | @State(Scope.Thread) 14 | @BenchmarkMode(Array(Mode.Throughput)) 15 | @OutputTimeUnit(TimeUnit.SECONDS) 16 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 17 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 18 | @Fork(value = 3) 19 | @Threads(value = 1) 20 | class BinaryFenBench: 21 | 22 | // the unit of CPU work per iteration 23 | private val Work: Long = 10 24 | 25 | @Param(Array("10", "100", "1000")) 26 | var games: Int = scala.compiletime.uninitialized 27 | var sits: List[Position.AndFullMoveNumber] = scala.compiletime.uninitialized 28 | var fens: List[BinaryFen] = scala.compiletime.uninitialized 29 | 30 | @Setup 31 | def setup(): Unit = 32 | sits = makeBoards(Perft.randomPerfts, games) 33 | fens = sits.map(BinaryFen.write) 34 | 35 | private def makeBoards(perfts: List[Perft], games: Int): List[Position.AndFullMoveNumber] = 36 | perfts 37 | .take(games) 38 | .flatMap(x => Fen.read(Chess960, x.epd)) 39 | .map(Position.AndFullMoveNumber(_, FullMoveNumber(1))) 40 | 41 | @Benchmark 42 | def write(bh: Blackhole) = 43 | val games = this.sits 44 | var i = 0 45 | while i < games.size do 46 | val game = games(i) 47 | Blackhole.consumeCPU(Work) 48 | bh.consume(BinaryFen.write(game)) 49 | i += 1 50 | 51 | @Benchmark 52 | def read(bh: Blackhole) = 53 | val games = this.fens 54 | var i = 0 55 | while i < games.size do 56 | val fen = games(i) 57 | Blackhole.consumeCPU(Work) 58 | bh.consume(fen.read) 59 | i += 1 60 | -------------------------------------------------------------------------------- /core/src/main/scala/Centis.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import alleycats.Zero 4 | import cats.kernel.Monoid 5 | import scalalib.model.Seconds 6 | 7 | import scala.concurrent.duration.* 8 | 9 | // maximum centis = Int.MaxValue / 100 / 60 / 60 / 24 = 248 days 10 | opaque type Centis = Int 11 | object Centis extends RichOpaqueInt[Centis]: 12 | 13 | extension (centis: Centis) 14 | 15 | inline def centis: Int = centis 16 | 17 | inline def *(inline o: Int): Centis = centis * o 18 | 19 | def roundTenths: Int = (if centis > 0 then centis + 5 else centis - 4) / 10 20 | def roundSeconds: Seconds = Seconds(Math.round(centis * 0.01f)) 21 | 22 | inline def toSeconds: BigDecimal = java.math.BigDecimal.valueOf(centis, 2) 23 | inline def millis: Long = centis * 10L 24 | def toDuration: FiniteDuration = FiniteDuration(millis, MILLISECONDS) 25 | 26 | def *~(scalar: Float): Centis = ofFloat(scalar * centis) 27 | def /(div: Int): Option[Centis] = (div != 0).option(centis / div) 28 | 29 | def avg(other: Centis): Centis = (centis + other.value) >> 1 30 | 31 | inline def nonNeg: Centis = Math.max(centis, 0) 32 | 33 | end extension 34 | 35 | given Zero[Centis] = Zero(0) 36 | 37 | given Monoid[Centis] with 38 | def combine(c1: Centis, c2: Centis) = c1 + c2 39 | val empty = 0 40 | 41 | def ofLong(l: Long): Centis = 42 | try Math.toIntExact(l) 43 | catch 44 | case _: ArithmeticException => 45 | if l > 0 then Integer.MAX_VALUE 46 | else Integer.MIN_VALUE 47 | 48 | def apply(d: FiniteDuration): Centis = 49 | ofMillis: 50 | if d.unit eq MILLISECONDS then d.length 51 | else d.toMillis 52 | 53 | inline def ofFloat(f: Float): Centis = Math.round(f) 54 | inline def ofDouble(d: Double): Centis = ofLong(Math.round(d)) 55 | 56 | inline def ofSeconds(s: Int): Centis = 100 * s 57 | inline def ofMillis(l: Long): Centis = ofLong(if l > 0 then l + 5 else l - 4) / 10 58 | -------------------------------------------------------------------------------- /core/src/main/scala/model.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.kernel.Semigroup 4 | 5 | /** Fullmove number: The number of the full move. 6 | * It starts at 1, and is incremented after Black's move. */ 7 | opaque type FullMoveNumber = Int 8 | object FullMoveNumber extends RichOpaqueInt[FullMoveNumber]: 9 | val initial: FullMoveNumber = 1 10 | extension (e: FullMoveNumber) 11 | def ply(turn: Color): Ply = Ply(e * 2 - turn.fold(2, 1)) 12 | def next: FullMoveNumber = e + 1 13 | 14 | opaque type Ply = Int 15 | object Ply extends RelaxedOpaqueInt[Ply]: 16 | val initial: Ply = 0 17 | val firstMove: Ply = 1 18 | extension (e: Ply) 19 | inline def turn: Color = Color.fromWhite(e.isEven) // whose turn it is to play now 20 | def fullMoveNumber: FullMoveNumber = FullMoveNumber(1 + e / 2) 21 | inline def isEven: Boolean = (e & 1) == 0 22 | inline def isOdd: Boolean = !e.isEven 23 | inline def next: Ply = Ply(e + 1) 24 | 25 | /* The halfmove clock specifies a decimal number of half moves with respect to the 50 move draw rule. 26 | * It is reset to zero after a capture or a pawn move and incremented otherwise. */ 27 | opaque type HalfMoveClock = Int 28 | object HalfMoveClock extends RichOpaqueInt[HalfMoveClock]: 29 | val initial: HalfMoveClock = 0 30 | extension (e: HalfMoveClock) 31 | inline def incr: HalfMoveClock = 32 | HalfMoveClock(e + 1) 33 | 34 | opaque type Check = Boolean 35 | object Check extends YesNo[Check] 36 | 37 | opaque type ErrorStr = String 38 | object ErrorStr extends OpaqueString[ErrorStr]: 39 | given Semigroup[ErrorStr] = Semigroup.instance[ErrorStr]((a, b) => s"$a\n$b") 40 | 41 | opaque type FideId = Int 42 | object FideId extends OpaqueInt[FideId] 43 | 44 | enum FideTC: 45 | case standard, rapid, blitz 46 | 47 | opaque type PlayerName = String 48 | object PlayerName extends OpaqueString[PlayerName] 49 | 50 | opaque type IntRating = Int 51 | object IntRating extends RichOpaqueInt[IntRating] 52 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/PlayBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import cats.syntax.all.* 8 | import chess.Square.* 9 | import chess.format.pgn.{ Fixtures, SanStr } 10 | import chess.* 11 | import chess.variant.Standard 12 | 13 | @State(Scope.Thread) 14 | @BenchmarkMode(Array(Mode.Throughput)) 15 | @OutputTimeUnit(TimeUnit.SECONDS) 16 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 17 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 18 | @Fork(value = 3) 19 | @Threads(value = 1) 20 | class PlayBench: 21 | 22 | // the unit of CPU work per iteration 23 | private val Work: Long = 10 24 | 25 | var dividerGames: List[List[Board]] = scala.compiletime.uninitialized 26 | var gameMoves: List[List[SanStr]] = scala.compiletime.uninitialized 27 | 28 | def gameReplay(sans: String) = 29 | Standard.initialPosition.playBoards(SanStr.from(sans.split(' ')).toList).toOption.get 30 | 31 | @Setup 32 | def setup() = 33 | dividerGames = Fixtures.prod500standard.map(gameReplay) 34 | 35 | var nb = 50 36 | var games = Fixtures.prod500standard 37 | gameMoves = games.take(nb).map(g => SanStr.from(g.split(' ').toList)) 38 | 39 | @Benchmark 40 | def divider(bh: Blackhole) = 41 | var result = dividerGames.map: x => 42 | Blackhole.consumeCPU(Work) 43 | Divider(x) 44 | bh.consume(result) 45 | result 46 | 47 | @Benchmark 48 | def playMoveOrDropWithPly(bh: Blackhole) = 49 | val games = this.gameMoves 50 | var i = 0 51 | while i < games.size do 52 | val moves = games(i) 53 | Blackhole.consumeCPU(Work) 54 | val init = chess.Position.AndFullMoveNumber(chess.variant.Standard, chess.format.Fen.initial.some) 55 | bh.consume(init.position.play(moves, init.ply)(step => chess.MoveOrDrop.WithPly(step.move, step.ply))) 56 | i += 1 57 | -------------------------------------------------------------------------------- /core/src/main/scala/HasId.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.annotation.tailrec 4 | 5 | trait HasId[A, Id]: 6 | extension (a: A) 7 | def id: Id 8 | inline def sameId(other: A): Boolean = a.id == other.id 9 | inline def hasId(id: Id): Boolean = a.id == id 10 | 11 | extension (xs: List[A]) 12 | final def remove(v: A): List[A] = 13 | xs.removeById(v.id) 14 | 15 | // Remove first item with the given id 16 | // if there is no match return the original list 17 | // This behavior is to accomodate the lila study tree current implementation 18 | // We should change it after We finally migrate it to this new tree 19 | final def removeById(id: Id): List[A] = 20 | @tailrec 21 | def loop(acc: List[A], xs: List[A]): List[A] = 22 | xs match 23 | case (v :: vs) if v.hasId(id) => acc ++ vs 24 | case (v :: vs) => loop(acc :+ v, vs) 25 | case Nil => acc 26 | loop(Nil, xs) 27 | 28 | trait Mergeable[A]: 29 | 30 | extension (a: A) 31 | 32 | // laws 33 | // a1.sameId(a2) => Some 34 | // !a1.sameId(a2) => None 35 | // a1.merge(a2).flatMap(_.merge(a3)) == a2.merge(a3).flatMap(a1.merge(_)) 36 | def merge(other: A): Option[A] 37 | 38 | // laws 39 | // canMerge == merge.isDefined 40 | def canMerge[Id](other: A): HasId[A, Id] ?=> Boolean = a.sameId(other) 41 | 42 | extension (xs: List[A]) 43 | 44 | def add(ys: List[A]): List[A] = 45 | ys.foldLeft(xs)(_.add(_)) 46 | 47 | def add(v: A): List[A] = 48 | @tailrec 49 | def loop(acc: List[A], rest: List[A]): List[A] = 50 | rest match 51 | case Nil => acc :+ v 52 | case y :: ys => 53 | y.merge(v) match 54 | case Some(m) => acc ++ (m +: ys) 55 | case _ => loop(acc :+ y, ys) 56 | 57 | loop(Nil, xs) 58 | 59 | // merge all elements that can be merged together 60 | def merge: List[A] = 61 | Nil.add(xs) 62 | -------------------------------------------------------------------------------- /core/src/main/scala/Color.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.Eq 4 | import cats.derived.* 5 | 6 | import scala.annotation.targetName 7 | 8 | enum Color(val name: String, val letter: Char) derives Eq: 9 | 10 | case White extends Color("white", 'w') 11 | case Black extends Color("black", 'b') 12 | 13 | lazy val white = this == Color.White 14 | lazy val black = this == Color.Black 15 | 16 | inline def fold[A](inline w: A, inline b: A): A = if white then w else b 17 | 18 | @targetName("negate") 19 | def unary_! = fold(Black, White) 20 | 21 | lazy val backRank: Rank = fold(Rank.First, Rank.Eighth) 22 | lazy val thirdRank: Rank = fold(Rank.Third, Rank.Sixth) 23 | lazy val fourthRank: Rank = fold(Rank.Fourth, Rank.Fifth) 24 | lazy val fifthRank: Rank = fold(Rank.Fifth, Rank.Fourth) 25 | lazy val sixthRank: Rank = fold(Rank.Sixth, Rank.Third) 26 | lazy val seventhRank: Rank = fold(Rank.Seventh, Rank.Second) 27 | lazy val lastRank: Rank = fold(Rank.Eighth, Rank.First) 28 | lazy val passablePawnRank: Rank = fifthRank 29 | lazy val promotablePawnRank: Rank = lastRank 30 | 31 | inline def -(inline role: Role) = Piece(this, role) 32 | 33 | inline def pawn = this - Pawn 34 | inline def bishop = this - Bishop 35 | inline def knight = this - Knight 36 | inline def rook = this - Rook 37 | inline def queen = this - Queen 38 | inline def king = this - King 39 | 40 | override def hashCode = fold(1, 2) 41 | 42 | object Color: 43 | 44 | def fromName(n: String): Option[Color] = 45 | if n == "white" then Option(White) 46 | else if n == "black" then Option(Black) 47 | else None 48 | 49 | def apply(c: Char): Option[Color] = 50 | if c == 'w' then Option(White) 51 | else if c == 'b' then Option(Black) 52 | else None 53 | 54 | val white: Color = White 55 | val black: Color = Black 56 | 57 | val all = List(White, Black) 58 | 59 | inline def fromWhite(inline white: Boolean): Color = if white then White else Black 60 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/tiebreak/TiebreakSnapshotTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package tiebreak 3 | 4 | import snapshot4s.generated.snapshotConfig 5 | import snapshot4s.munit.SnapshotAssertions 6 | 7 | import Helper.* 8 | 9 | class TiebreakSnapshotTest extends MunitExtensions with SnapshotAssertions: 10 | 11 | test("tiebreak games snapshot") { 12 | val gamesMap = games("FWWRC.pgn") 13 | val result = Tiebreak 14 | .compute( 15 | gamesMap, 16 | List( 17 | AverageOfOpponentsBuchholz, 18 | AveragePerfectPerformanceOfOpponents, 19 | DirectEncounter, 20 | PerfectTournamentPerformance, 21 | SonnebornBerger(CutModifier.None) 22 | ), 23 | lastRoundId(gamesMap) 24 | ) 25 | .mkString("\n") 26 | assertFileSnapshot(result, "tiebreak/tournament.txt") 27 | } 28 | 29 | // https://chess-results.com/tnr1074691.aspx?lan=1&art=1&flag=30 30 | test("Women's world rapid championship") { 31 | val gamesMap = games("FWWRC.pgn") 32 | val result = Tiebreak 33 | .compute( 34 | gamesMap, 35 | List( 36 | Buchholz(CutModifier.Cut1), 37 | Buchholz(CutModifier.None), 38 | AverageRatingOfOpponents(CutModifier.Cut1) 39 | ), 40 | lastRoundId(gamesMap) 41 | ) 42 | .mkString("\n") 43 | assertFileSnapshot(result, "tiebreak/official_tournament.txt") 44 | } 45 | 46 | // https://chess-results.com/tnr1175851.aspx?art=1 47 | test("Uzchess Cup"): 48 | val gamesMap = games("uzchesscup.pgn") 49 | val result = Tiebreak 50 | .compute( 51 | gamesMap, 52 | List( 53 | DirectEncounter, 54 | SonnebornBerger(CutModifier.None), 55 | NbWins, 56 | NbBlackWins, 57 | KoyaSystem(LimitModifier.default) 58 | ), 59 | lastRoundId(gamesMap) 60 | ) 61 | .mkString("\n") 62 | assertFileSnapshot(result, "tiebreak/uzchesscup.txt") 63 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/Chess960Test.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.format.FullFen 4 | 5 | import scala.language.implicitConversions 6 | 7 | import variant.Chess960 8 | 9 | class Chess960Test extends ChessTest: 10 | 11 | test("recognize position numbers"): 12 | import Chess960.positionNumber as pn 13 | assertEquals(pn(FullFen("k7/ppP5/brp5/8/8/8/8/8 b - -")), None) 14 | 15 | assertEquals(pn(FullFen("rnqbbknr/pppppppp/8/8/8/8/PPPPPPPP/RNQBBKNR w KQkq - 0 1")), Some(521)) 16 | assertEquals(pn(FullFen("rnqbbknr/pppppppp/8/8/8/8/PPPPPPPP/RNBBBKNR w KQkq - 0 1")), None) 17 | assertEquals(pn(FullFen("rnqbbknr/pppppppp/8/8/8/8/PPPPPPPP/RNqBBKNR w KQkq - 0 1")), None) 18 | assertEquals(pn(FullFen("rnqbbknr/pppppppp/8/8/8/8/PPPPPPPP/RNQBBKNR b KQkq - 0 1")), None) 19 | assertEquals(pn(FullFen("rnqbbknr/pppppppp/8/8/8/8/PPPPPPPP/RNQBBKNR w Kkq - 0 1")), None) 20 | assertEquals(pn(FullFen("rnqbbknr/pppppppp/8/8/8/8/PPPPPPPP/RNQBBKNR w KQkq - 1 1")), None) 21 | 22 | assertEquals(pn(FullFen("bbqnnrkr/pppppppp/8/8/8/8/PPPPPPPP/BBQNNRKR w KQkq - 0 1")), Some(0)) 23 | assertEquals(pn(FullFen("rkrnnqbb/pppppppp/8/8/8/8/PPPPPPPP/RKRNNQBB w KQkq - 0 1")), Some(959)) 24 | 25 | assertEquals(pn(FullFen("rnqbbknr/pppppppp/8/8/8/8/PPPPPPPP/RNQBBKNR w AHa - 0 1")), None) 26 | assertEquals(pn(FullFen("bbqnnrkr/pppppppp/8/8/8/8/PPPPPPPP/BBQNNRKR w AHah - 0 1")), None) 27 | 28 | test("Castles when a1 is being taken"): 29 | val pgn = """ 30 | [Variant "Chess960"] 31 | [FEN "brnqknrb/pppppppp/8/8/8/8/PPPPPPPP/BRNQKNRB w KQkq - 0 1"] 32 | 1. d4 g6 2. e4 b6 3. g3 f5 4. exf5 Bxh1 5. Rxh1 gxf5 6. Qh5+ Rg6 7. Qxf5 Nd6 8. Qd3 Ne6 9. Ne2 c5 10. b3 Qc7 11. d5 Bxa1 12. dxe6 Bf6 13. exd7+ Qxd7 14. Ne3 O-O-O 33 | """ 34 | 35 | Replay 36 | .mainline(pgn) 37 | .assertRight: 38 | case Replay.Result(replay, None) => 39 | assertEquals( 40 | replay.state.position.legalMoves.find(_.castles).map(_.toUci), 41 | Some(format.Uci.Move(Square.E1, Square.B1)) 42 | ) 43 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/tiebreak/Helper.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package tiebreak 3 | 4 | import chess.format.pgn.PgnStr 5 | import chess.rating.Elo 6 | import chess.tiebreak.Tiebreak.* 7 | 8 | object Helper: 9 | 10 | def parsedTags(pgnLines: List[String]) = 11 | pgnLines.flatMap(pgnstr => chess.format.pgn.Parser.tags(PgnStr(pgnstr)).toOption) 12 | 13 | def playerFromTag( 14 | name: Option[String], 15 | rating: Option[IntRating], 16 | fideId: Option[Int] 17 | ): Option[Player] = 18 | fideId 19 | .map(_.toString) 20 | .orElse(name) 21 | .map: id => 22 | Player(id, rating.map(_.into(Elo))) 23 | 24 | def tiebreakGames(pgnSplit: List[String]): List[(Player, Game)] = 25 | parsedTags(pgnSplit).foldLeft(List.empty): (acc, tags) => 26 | val names = tags.names 27 | val ratings = tags.ratings 28 | val fideIds = tags.fideIds 29 | val result = tags.outcome 30 | val white = playerFromTag(names.white.map(_.value), ratings.white, fideIds.white.map(_.value)) 31 | val black = playerFromTag(names.black.map(_.value), ratings.black, fideIds.black.map(_.value)) 32 | val roundId = tags.roundNumber.map(_.toString) 33 | val byColorPoints = result.map(chess.Outcome.outcomeToPoints) 34 | (white, black, byColorPoints) match 35 | case (Some(w), Some(b), Some(points)) => 36 | List( 37 | w -> Game(points.white, b, White, roundId), 38 | b -> Game(points.black, w, Black, roundId) 39 | ) ++ acc 40 | case _ => acc 41 | 42 | def games(fileName: String): Map[String, PlayerWithGames] = 43 | val pgnText = scala.io.Source.fromResource(fileName).mkString 44 | val pgnSplit = pgnText.split("\n\n").toList 45 | 46 | tiebreakGames(pgnSplit) 47 | .groupBy(_._1) 48 | .map: (player, games) => 49 | player.id -> PlayerWithGames(player, games.map(_._2)) 50 | 51 | def lastRoundId(gamesMap: Map[String, PlayerWithGames]): Option[String] = 52 | gamesMap.values.maxByOption(_.games.size).flatMap(_.games.lastOption.flatMap(_.roundId)) 53 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/KingSafetyTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | import Square.* 6 | 7 | class KingSafetyTest extends ChessTest: 8 | 9 | import compare.dests 10 | 11 | test("not commit suicide"): 12 | assertEquals( 13 | """ 14 | P n 15 | PPPP P 16 | RNBQK R""".destsFrom(E1), 17 | Set(F2) 18 | ) 19 | test("not commit suicide even if immobilized"): 20 | assertEquals( 21 | """ 22 | b n 23 | PPPP P 24 | RNBQK R""".destsFrom(E1), 25 | Set() 26 | ) 27 | test("escape from danger"): 28 | assertEquals( 29 | """ 30 | r 31 | 32 | PPPP P 33 | RNBQK R""".destsFrom(E1), 34 | Set(F1, F2) 35 | ) 36 | test("move to defend"): 37 | assertEquals( 38 | """ 39 | r 40 | 41 | PPPP P 42 | RNBQK R""".destsFrom(D1), 43 | Set(E2) 44 | ) 45 | assertEquals( 46 | """ 47 | r 48 | 49 | PPPP P 50 | RNBQK NR""".destsFrom(G1), 51 | Set(E2) 52 | ) 53 | assertEquals( 54 | """ 55 | K r 56 | PPPP P 57 | RNBQ NR""".destsFrom(D2), 58 | Set(D3) 59 | ) 60 | assertEquals( 61 | """ 62 | K r 63 | 64 | PPPP P 65 | RNBQ NR""".destsFrom(D2), 66 | Set(D4) 67 | ) 68 | assertEquals( 69 | """ 70 | K r 71 | 72 | PPPP P 73 | RNBQ NR""".destsFrom(H2), 74 | Set() 75 | ) 76 | assertEquals( 77 | """ 78 | r 79 | 80 | PPPPK Q 81 | RNB R""".destsFrom(G2), 82 | Set(E4) 83 | ) 84 | assertEquals( 85 | """ 86 | r 87 | 88 | PPPPQ 89 | RNB K R""".destsFrom(E2), 90 | Set(E3, E4) 91 | ) 92 | assertEquals( 93 | """ 94 | r 95 | P 96 | PPPP 97 | RNB K R""".destsFrom(F3), 98 | Set(E4) 99 | ) 100 | test("stay to defend"): 101 | assertEquals( 102 | """ 103 | r 104 | 105 | PPPPB 106 | RNB K R""".destsFrom(E2), 107 | Set() 108 | ) 109 | assertEquals( 110 | """ 111 | 112 | K P r 113 | PPP 114 | RNB R""".destsFrom(D3), 115 | Set() 116 | ) 117 | -------------------------------------------------------------------------------- /core/src/main/scala/Role.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | sealed trait Role: 4 | val forsyth: Char 5 | lazy val forsythUpper: Char = forsyth.toUpper 6 | lazy val pgn: Char = forsythUpper 7 | lazy val name = toString.toLowerCase 8 | inline def forsythBy(color: Color): Char = 9 | if color.white then forsythUpper else forsyth 10 | 11 | sealed trait PromotableRole extends Role 12 | 13 | /** Promotable in antichess. */ 14 | case object King extends PromotableRole: 15 | val forsyth = 'k' 16 | 17 | case object Queen extends PromotableRole: 18 | val forsyth = 'q' 19 | 20 | case object Rook extends PromotableRole: 21 | val forsyth = 'r' 22 | 23 | case object Bishop extends PromotableRole: 24 | val forsyth = 'b' 25 | 26 | case object Knight extends PromotableRole: 27 | val forsyth = 'n' 28 | 29 | case object Pawn extends Role: 30 | val forsyth = 'p' 31 | 32 | object Role: 33 | 34 | val all: List[Role] = List(King, Queen, Rook, Bishop, Knight, Pawn) 35 | val allPromotable: List[PromotableRole] = List(Queen, Rook, Bishop, Knight, King) 36 | val allByForsyth: Map[Char, Role] = all.mapBy(_.forsyth) 37 | val allByPgn: Map[Char, Role] = all.mapBy(_.pgn) 38 | val allByName: Map[String, Role] = all.mapBy(_.name) 39 | val allPromotableByName: Map[String, PromotableRole] = allPromotable.mapBy(_.toString) 40 | val allPromotableByForsyth: Map[Char, PromotableRole] = allPromotable.mapBy(_.forsyth) 41 | val allPromotableByPgn: Map[Char, PromotableRole] = allPromotable.mapBy(_.pgn) 42 | 43 | def forsyth(c: Char): Option[Role] = allByForsyth.get(c) 44 | 45 | def promotable(c: Char): Option[PromotableRole] = 46 | allPromotableByForsyth.get(c) 47 | 48 | def promotable(name: String): Option[PromotableRole] = 49 | allPromotableByName.get(name.capitalize) 50 | 51 | def promotable(name: Option[String]): Option[PromotableRole] = 52 | name.flatMap(promotable) 53 | 54 | def valueOf(r: Role): Option[Int] = 55 | r match 56 | case Pawn => Option(1) 57 | case Knight => Option(3) 58 | case Bishop => Option(3) 59 | case Rook => Option(5) 60 | case Queen => Option(9) 61 | case King => None 62 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/CrazyhouseDataTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.variant.Crazyhouse.Data 4 | import munit.ScalaCheckSuite 5 | import org.scalacheck.Prop.{ forAll, propBoolean } 6 | 7 | import CoreArbitraries.given 8 | 9 | class CrazyhouseDataTest extends ScalaCheckSuite: 10 | 11 | property("store a piece and drop it"): 12 | forAll: (piece: Piece, square: Square) => 13 | (piece.role != King) ==> Data.init.store(piece, square).drop(!piece).isDefined 14 | 15 | property("store a promoted piece and drop it"): 16 | forAll: (piece: Piece, square: Square) => 17 | (piece.role != King && piece.role != Pawn) ==> 18 | Data.init.promote(square).store(piece, square).drop(!piece).isEmpty 19 | 20 | property("store a promoted piece and drop Pawn"): 21 | forAll: (piece: Piece, square: Square) => 22 | (piece.role != King && piece.role != Pawn) ==> 23 | Data.init.promote(square).store(piece, square).drop(!piece.color.pawn).isDefined 24 | 25 | property("move a promoted piece and drop Pawn"): 26 | forAll: (piece: Piece, from: Square, to: Square) => 27 | (piece.role != King) ==> 28 | Data.init 29 | .promote(from) 30 | .move(from, to) 31 | .store(piece, to) 32 | .drop(!piece.color.pawn) 33 | .isDefined 34 | 35 | property("store and drop multiple pieces"): 36 | forAll: (ps: List[Piece], square: Square) => 37 | val data = ps.foldLeft(Data.init) { (data, piece) => 38 | data.store(piece, square) 39 | } 40 | val result = ps 41 | .filter(_.role != King) 42 | .foldLeft(Option(data)) { (data, piece) => 43 | data.flatMap(_.drop(!piece)) 44 | } 45 | result.isDefined && result.get.isEmpty 46 | 47 | property("store and drop multiple pieces with promotion"): 48 | forAll: (ps: List[Piece], square: Square, promoted: Bitboard) => 49 | val filtered = ps.filter(_.role != King) 50 | val data = filtered.foldLeft(Data.init.copy(promoted = promoted)) { (data, piece) => 51 | data.store(piece, square) 52 | } 53 | assertEquals(data.size, filtered.size) 54 | -------------------------------------------------------------------------------- /rating/src/main/scala/glicko/model.scala: -------------------------------------------------------------------------------- 1 | package chess.rating 2 | package glicko 3 | 4 | import chess.{ ByColor, IntRating, Outcome } 5 | import scalalib.newtypes.OpaqueDouble 6 | 7 | import java.time.Instant 8 | 9 | case class Glicko( 10 | rating: Double, 11 | deviation: Double, 12 | volatility: Double 13 | ): 14 | def intRating: IntRating = IntRating(rating.toInt) 15 | def intDeviation = deviation.toInt 16 | def provisional = RatingProvisional(deviation >= provisionalDeviation) 17 | def established = provisional.no 18 | def establishedIntRating = Option.when(established)(intRating) 19 | def clueless = deviation >= cluelessDeviation 20 | def display = s"$intRating${if provisional.yes then "?" else ""}" 21 | def average(other: Glicko, weight: Float = 0.5f): Glicko = 22 | if weight >= 1 then other 23 | else if weight <= 0 then this 24 | else 25 | Glicko( 26 | rating = rating * (1 - weight) + other.rating * weight, 27 | deviation = deviation * (1 - weight) + other.deviation * weight, 28 | volatility = volatility * (1 - weight) + other.volatility * weight 29 | ) 30 | override def toString = f"$intRating/$intDeviation/${volatility}%.3f" 31 | 32 | val provisionalDeviation = 110 33 | val cluelessDeviation = 230 34 | 35 | case class Player( 36 | glicko: Glicko, 37 | numberOfResults: Int = 0, 38 | lastRatingPeriodEnd: Option[Instant] = None 39 | ): 40 | export glicko.* 41 | 42 | case class Game(players: ByColor[Player], outcome: Outcome) 43 | 44 | opaque type Tau = Double 45 | object Tau extends OpaqueDouble[Tau]: 46 | val default: Tau = 0.75d 47 | 48 | opaque type RatingPeriodsPerDay = Double 49 | object RatingPeriodsPerDay extends OpaqueDouble[RatingPeriodsPerDay]: 50 | val default: RatingPeriodsPerDay = 0d 51 | 52 | opaque type ColorAdvantage = Double 53 | object ColorAdvantage extends OpaqueDouble[ColorAdvantage]: 54 | val zero: ColorAdvantage = 0d 55 | val standard: ColorAdvantage = 11.782457d 56 | val crazyhouse: ColorAdvantage = 20.30966d 57 | extension (c: ColorAdvantage) def half: ColorAdvantage = c / 2.0d 58 | extension (c: ColorAdvantage) def negate: ColorAdvantage = -c 59 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/CastlesTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import munit.ScalaCheckSuite 4 | import org.scalacheck.Prop 5 | 6 | import CoreArbitraries.given 7 | import Castles.* 8 | 9 | class CastlesTest extends ScalaCheckSuite: 10 | 11 | test("can(color, side) should be consistent with properties"): 12 | Prop.forAll { (c: Castles) => 13 | (c.can(White, KingSide) == c.whiteKingSide) && 14 | (c.can(White, QueenSide) == c.whiteQueenSide) && 15 | (c.can(Black, KingSide) == c.blackKingSide) && 16 | (c.can(Black, QueenSide) == c.blackQueenSide) 17 | } 18 | 19 | test("apply with booleans"): 20 | Prop.forAll: 21 | (whiteKingSide: Boolean, whiteQueenSide: Boolean, blackKingSide: Boolean, blackQueenSide: Boolean) => 22 | val c = Castles(whiteKingSide, whiteQueenSide, blackKingSide, blackQueenSide) 23 | (c.can(White, KingSide) == whiteKingSide) && 24 | (c.can(White, QueenSide) == whiteQueenSide) && 25 | (c.can(Black, KingSide) == blackKingSide) && 26 | (c.can(Black, QueenSide) == blackQueenSide) 27 | 28 | test("without color"): 29 | Prop.forAll { (c: Castles, color: Color) => 30 | val updated = c.without(color) 31 | updated.can(color) == false && 32 | updated.can(!color) == c.can(!color) 33 | } 34 | 35 | test("without color and side"): 36 | Prop.forAll { (c: Castles, color: Color, side: Side) => 37 | val updated = c.without(color, side) 38 | updated.can(color, side) == false && 39 | updated.can(color, !side) == c.can(color, !side) && 40 | updated.can(!color) == c.can(!color) 41 | } 42 | 43 | test("add"): 44 | Prop.forAll { (c: Castles, color: Color, side: Side) => 45 | val updated = c.add(color, side) 46 | updated.can(color) == true && 47 | updated.can(!color) == c.can(!color) 48 | } 49 | 50 | test("update"): 51 | Prop.forAll { (c: Castles, color: Color, kingSide: Boolean, queenSide: Boolean) => 52 | val updated = c.update(color, kingSide, queenSide) 53 | updated.can(color, KingSide) == kingSide && 54 | updated.can(color, QueenSide) == queenSide && 55 | updated.can(!color) == c.can(!color) 56 | } 57 | -------------------------------------------------------------------------------- /core/src/main/scala/format/pgn/San.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format.pgn 3 | 4 | import cats.syntax.all.* 5 | 6 | // Standard Algebraic Notation 7 | sealed trait San extends Moveable 8 | 9 | case class Std( 10 | dest: Square, 11 | role: Role, 12 | capture: Boolean = false, 13 | file: Option[File] = None, 14 | rank: Option[Rank] = None, 15 | promotion: Option[PromotableRole] = None, 16 | override val rawString: Option[String] = None 17 | ) extends San: 18 | 19 | def apply(position: Position): Either[ErrorStr, chess.Move] = 20 | position 21 | .byPiece(position.color, role) 22 | .first: square => 23 | if compare(file, square.file) && compare(rank, square.rank) 24 | then position.variant.move(position, square, dest, promotion).toOption 25 | else None 26 | .toRight(ErrorStr(s"Cannot play $this")) 27 | 28 | override def toString = s"$role ${dest.key}" 29 | 30 | private inline def compare[A](a: Option[A], b: A) = a.fold(true)(b ==) 31 | 32 | case class Drop(role: Role, square: Square, override val rawString: Option[String] = None) extends San: 33 | 34 | def apply(position: Position): Either[ErrorStr, chess.Drop] = 35 | position.drop(role, square) 36 | 37 | case class Castle(side: Side, override val rawString: Option[String] = None) extends San: 38 | 39 | def apply(position: Position): Either[ErrorStr, chess.Move] = 40 | 41 | import position.{ genCastling, ourKing, variant } 42 | if !variant.allowsCastling then ErrorStr(s"Cannot castle in $variant").asLeft 43 | else 44 | ourKing 45 | .flatMap: k => 46 | genCastling(k) 47 | .filter(variant.kingSafety) 48 | .find(_.castle.exists(_.side == side)) 49 | .toRight(ErrorStr(s"Cannot castle ${side.fold("kingside", "queenside")}")) 50 | 51 | opaque type Sans = List[San] 52 | object Sans extends TotalWrapper[Sans, List[San]] 53 | 54 | case class Metas(check: Check, checkmate: Boolean, comments: List[Comment], glyphs: Glyphs) 55 | 56 | case class SanWithMetas(san: San, metas: Metas): 57 | export metas.* 58 | export san.* 59 | 60 | object Metas: 61 | val empty: Metas = Metas(Check.No, false, Nil, Glyphs.empty) 62 | -------------------------------------------------------------------------------- /core/src/main/scala/variant/ThreeCheck.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package variant 3 | 4 | import chess.format.FullFen 5 | 6 | case object ThreeCheck 7 | extends Variant( 8 | id = Variant.Id(5), 9 | key = Variant.LilaKey("threeCheck"), 10 | uciKey = Variant.UciKey("3check"), 11 | name = "Three-check", 12 | shortName = "3check", 13 | title = "Check your opponent 3 times to win the game.", 14 | standardInitialPosition = true 15 | ): 16 | 17 | override val initialBoard: Board = Board.standard 18 | override def initialPieces: Map[Square, Piece] = initialBoard.pieceMap 19 | 20 | override val initialFen: FullFen = FullFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 +0+0") 21 | 22 | override def validMoves(position: Position): List[Move] = 23 | Standard.validMoves(position).map(updateCheckCount) 24 | 25 | override def validMovesAt(position: Position, square: Square): List[Move] = 26 | super.validMovesAt(position, square).view.filter(kingSafety).map(updateCheckCount).toList 27 | 28 | override def valid(position: Position, strict: Boolean): Boolean = Standard.valid(position, strict) 29 | 30 | override def specialEnd(position: Position): Boolean = 31 | position.check.yes && { 32 | val checks = position.history.checkCount 33 | position.color.fold(checks.white, checks.black) >= 3 34 | } 35 | 36 | /** It's not possible to check or checkmate the opponent with only a king 37 | */ 38 | override def opponentHasInsufficientMaterial(position: Position): Boolean = 39 | position.kingsOnlyOf(!position.color) 40 | 41 | /** 42 | * When there is insufficient mating material, there is still potential to win by checking the opponent 3 times 43 | * by the variant ending. However, no players can check if there are only kings remaining 44 | */ 45 | override def isInsufficientMaterial(position: Position): Boolean = position.kingsOnly 46 | 47 | private def updateCheckCount(move: Move): Move = 48 | move.copy(afterWithoutHistory = move.afterWithoutHistory.updateHistory: 49 | _.withCheck(Color.White, checkWhite(move.afterWithoutHistory.board)) 50 | .withCheck(Color.Black, checkBlack(move.afterWithoutHistory.board))) 51 | -------------------------------------------------------------------------------- /core/src/main/scala/format/pgn/parsingModel.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format.pgn 3 | 4 | import cats.syntax.all.* 5 | import chess.Position.AndFullMoveNumber 6 | 7 | // We don't support variation without move now, 8 | // but we can in the future when we support null move 9 | case class PgnNodeData( 10 | san: San, 11 | metas: Metas, // describes the position after the move `san` is played 12 | /** `variationComments` are comments before the first move of a variation. Example: 13 | * `1.d4 ( { on the other hand } 1.e4 )` 14 | * => PgnNodeData(1.d4, Metas.empty, List(Node(1.e4, metas = Metas.empty, variationComments = List("on the other hand")))) 15 | */ 16 | variationComments: List[Comment] 17 | ): 18 | export metas.* 19 | 20 | private[pgn] def toMove(context: Position): Option[(Position, Move)] = 21 | san(context).toOption.map: x => 22 | val move = Move( 23 | san = x.toSanStr, 24 | comments = comments, 25 | glyphs = glyphs, 26 | variationComments = variationComments 27 | ) 28 | (x.after, move) 29 | 30 | type ParsedPgnTree = Node[PgnNodeData] 31 | 32 | case class ParsedPgn(initialPosition: InitialComments, tags: Tags, tree: Option[ParsedPgnTree]): 33 | 34 | def mainline: List[San] = 35 | tree.fold(List.empty[San])(_.mainline.map(_.value.san)) 36 | 37 | def mainlineWithMetas: List[SanWithMetas] = 38 | tree.fold(List.empty)(_.mainline.map(x => SanWithMetas(x.value.san, x.value.metas))) 39 | 40 | def toGame: Game = 41 | Game(tags) 42 | 43 | def toPosition: Position = 44 | Position(tags) 45 | 46 | def toPgn: Pgn = 47 | val positionWithMove = AndFullMoveNumber(tags.variant, tags.fen) 48 | Pgn(tags, initialPosition, treeToPgn(positionWithMove.position), positionWithMove.ply.next) 49 | 50 | private def treeToPgn(position: Position): Option[Node[Move]] = 51 | tree.flatMap: 52 | _.mapAccumlOption_(position): (ctx, d) => 53 | d.toMove(ctx) 54 | .fold(ctx -> None)(_ -> _.some) 55 | 56 | case class ParsedMainline[A](initialPosition: InitialComments, tags: Tags, moves: List[A]): 57 | 58 | def toGame: Game = 59 | Game(tags) 60 | 61 | def toPosition: Position = 62 | Position(tags) 63 | -------------------------------------------------------------------------------- /core/src/main/scala/format/pgn/Dumper.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format.pgn 3 | 4 | object Dumper: 5 | 6 | def apply(position: Position, data: chess.Move, next: Position): SanStr = 7 | import data.* 8 | 9 | val base = (promotion, piece.role) match 10 | case _ if castles => 11 | if orig ?> dest then "O-O-O" else "O-O" 12 | 13 | case _ if enpassant => s"${orig.file.char}x${dest.key}" 14 | 15 | case (promotion, Pawn) => 16 | (if captures then s"${orig.file.char}x" else "") + 17 | promotion.fold(dest.key)(p => s"${dest.key}=${p.pgn}") 18 | 19 | case (_, role) => 20 | // Check whether there is a need to disambiguate: 21 | // - can a piece of same role move to/capture on the same square? 22 | // - if so, disambiguate, in order or preference, by: 23 | // - file 24 | // - rank 25 | // - both (only happens w/ at least 3 pieces of the same role) 26 | // We know Role ≠ Pawn, so it is fine to always pass None as promotion target 27 | val candidates = (position.byPiece(piece) ^ orig.bl) 28 | .filter(square => 29 | piece.eyes(square, dest, position.occupied) && { 30 | position.move(square, dest, None).isRight 31 | } 32 | ) 33 | 34 | val disambiguation: String = 35 | if candidates.isEmpty then "" 36 | else if !candidates.exists(_.onSameFile(orig)) then orig.file.char.toString 37 | else if !candidates.exists(_.onSameRank(orig)) then orig.rank.char.toString 38 | else orig.key 39 | 40 | val x = if captures then "x" else "" 41 | s"${role.pgn}$disambiguation$x${dest.key}" 42 | 43 | SanStr(s"$base${checkOrWinnerSymbol(next)}") 44 | 45 | def apply(data: chess.Drop, next: Position): SanStr = 46 | SanStr(s"${data.toUci.uci}${checkOrWinnerSymbol(next)}") 47 | 48 | def apply(data: chess.Move): SanStr = 49 | apply(data.before, data, data.after) 50 | 51 | def apply(data: chess.Drop): SanStr = 52 | apply(data, data.after) 53 | 54 | private def checkOrWinnerSymbol(next: Position): String = 55 | if next.winner.isDefined then "#" 56 | else if next.check.yes then "+" 57 | else "" 58 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/Visual.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | 4 | import chess.variant.{ Crazyhouse, Variant } 5 | 6 | /** r bqkb r 7 | * p ppp pp 8 | * pr 9 | * P p 10 | * QnB 11 | * PP N 12 | * P PPP 13 | * RN K R 14 | */ 15 | object Visual: 16 | 17 | def <<(source: String): Position = 18 | val lines = augmentString(source).linesIterator.to(List) 19 | val filtered = lines.size match 20 | case 8 => lines 21 | case n if n > 8 => lines.slice(1, 9) 22 | case n => (List.fill(8 - n)("")) ::: lines 23 | val p = createPosition( 24 | pieces = (for 25 | (l, y) <- filtered.zipWithIndex 26 | (c, x) <- l.zipWithIndex 27 | role <- Role.forsyth(c.toLower) 28 | yield Square.at(x, 7 - y).map { square => 29 | square -> (Color.fromWhite(c.isUpper) - role) 30 | }).flatten, 31 | variant = chess.variant.Variant.default 32 | ) 33 | p.updateHistory(_ => History(unmovedRooks = UnmovedRooks.from(p.board), crazyData = None)) 34 | 35 | def >>(board: Position): String = >>|(board, Map.empty) 36 | 37 | def >>|(board: Position, marks: Map[Iterable[Square], Char]): String = { 38 | val markedPoss: Map[Square, Char] = marks.foldLeft(Map[Square, Char]()) { case (marks, (poss, char)) => 39 | marks ++ (poss.toList.map { square => 40 | (square, char) 41 | }) 42 | } 43 | for y <- Rank.allReversed yield { 44 | for x <- File.all yield 45 | val square = Square(x, y) 46 | markedPoss.get(square).getOrElse(board.pieceAt(square).fold(' ')(_.forsyth)) 47 | }.mkString 48 | }.map { """\s*$""".r.replaceFirstIn(_, "") }.mkString("\n") 49 | 50 | def addNewLines(str: String) = "\n" + str + "\n" 51 | 52 | def createPosition(pieces: Iterable[(Square, Piece)], variant: Variant): Position = 53 | val board = Board.fromMap(pieces.toMap) 54 | val unmovedRooks = if variant.allowsCastling then UnmovedRooks(board.rooks) else UnmovedRooks.none 55 | Position( 56 | board, 57 | History( 58 | castles = variant.castles, 59 | unmovedRooks = unmovedRooks, 60 | crazyData = variant.crazyhouse.option(Crazyhouse.Data.init) 61 | ), 62 | variant, 63 | White 64 | ) 65 | -------------------------------------------------------------------------------- /core/src/main/scala/Speed.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | opaque type SpeedId = Int 4 | object SpeedId extends OpaqueInt[SpeedId] 5 | 6 | opaque type SpeedKey = String 7 | object SpeedKey extends OpaqueString[SpeedKey] 8 | 9 | sealed abstract class Speed( 10 | val id: SpeedId, 11 | val key: SpeedKey, 12 | val range: Range, 13 | val name: String, 14 | val title: String 15 | ) extends Ordered[Speed]: 16 | 17 | def compare(that: Speed) = range.min.compare(that.range.min) 18 | 19 | object Speed: 20 | 21 | case object UltraBullet 22 | extends Speed( 23 | SpeedId(0), 24 | SpeedKey("ultraBullet"), 25 | 0 to 29, 26 | "UltraBullet", 27 | "Insanely fast games: less than 30 seconds" 28 | ) 29 | case object Bullet 30 | extends Speed( 31 | SpeedId(1), 32 | SpeedKey("bullet"), 33 | 30 to 179, 34 | "Bullet", 35 | "Very fast games: less than 3 minutes" 36 | ) 37 | case object Blitz 38 | extends Speed(SpeedId(2), SpeedKey("blitz"), 180 to 479, "Blitz", "Fast games: 3 to 8 minutes") 39 | case object Rapid 40 | extends Speed(SpeedId(5), SpeedKey("rapid"), 480 to 1499, "Rapid", "Rapid games: 8 to 25 minutes") 41 | case object Classical 42 | extends Speed( 43 | SpeedId(3), 44 | SpeedKey("classical"), 45 | 1500 to 21599, 46 | "Classical", 47 | "Classical games: 25 minutes and more" 48 | ) 49 | case object Correspondence 50 | extends Speed( 51 | SpeedId(4), 52 | SpeedKey("correspondence"), 53 | 21600 to Int.MaxValue, 54 | "Correspondence", 55 | "Correspondence games: one or several days per move" 56 | ) 57 | 58 | val all = List(UltraBullet, Bullet, Blitz, Rapid, Classical, Correspondence) 59 | 60 | given Ordering[Speed] = Ordering.by(_.range.min) 61 | 62 | val limited = List[Speed](Bullet, Blitz, Rapid, Classical) 63 | 64 | val byId = all.mapBy(_.id) 65 | 66 | export byId.{ contains as exists, get as apply } 67 | 68 | def apply(clock: Clock.Config) = byTime(clock.estimateTotalSeconds) 69 | 70 | def apply(clock: Option[Clock.Config]) = byTime(clock.fold(Int.MaxValue)(_.estimateTotalSeconds)) 71 | 72 | def byTime(seconds: Int): Speed = all.find(_.range.contains(seconds)) | Correspondence 73 | -------------------------------------------------------------------------------- /test-kit/src/main/scala/chess/NodeArbitraries.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.kernel.Eq 4 | import org.scalacheck.{ Arbitrary, Gen } 5 | 6 | object NodeArbitraries: 7 | 8 | given [A](using Arbitrary[A]): Arbitrary[Tree[A]] = Arbitrary(Gen.oneOf(genNode, genVariation)) 9 | given [A](using Arbitrary[A]): Arbitrary[Node[A]] = Arbitrary(genNode) 10 | given [A](using Arbitrary[A]): Arbitrary[Variation[A]] = Arbitrary(genVariation) 11 | 12 | type NodeWithPath[A] = (Node[A], List[A]) 13 | given [A](using Arbitrary[A]): Arbitrary[NodeWithPath[A]] = Arbitrary(genNodeWithPath) 14 | 15 | given treeEq[A]: Eq[Tree[A]] = Eq.fromUniversalEquals 16 | given nodeEq[A]: Eq[Node[A]] = Eq.fromUniversalEquals 17 | given variationEq[A]: Eq[Variation[A]] = Eq.fromUniversalEquals 18 | 19 | def genNodeWithPath[A](using Arbitrary[A]) = 20 | for 21 | node <- genNode 22 | path <- genPath(node) 23 | yield (node, path) 24 | 25 | def genPath[A](node: Node[A]): Gen[List[A]] = 26 | val prob = if node.variations.isEmpty then 0.90 else 0.6 27 | Gen 28 | .prob(prob) 29 | .flatMap: 30 | case true => node.child.fold(Gen.const(Nil))(genPath(_)).map(node.value :: _) 31 | case false => 32 | if node.variations.isEmpty 33 | then Gen.const(Nil) 34 | else 35 | Gen 36 | .prob(0.95) 37 | .flatMap: 38 | case true => Gen.oneOf(node.variations).flatMap(v => genPath(v.toNode)) 39 | case false => Gen.const(node.value :: Nil) 40 | 41 | def genNode[A](using Arbitrary[A]): Gen[Node[A]] = 42 | Gen.sized: size => 43 | val sqrt = size / 2 44 | for 45 | a <- Arbitrary.arbitrary[A] 46 | c <- genChild[A](size) 47 | s <- Gen.choose(0, sqrt) 48 | v <- Gen.listOfN(s, Gen.resize(sqrt, genVariation[A])) 49 | yield Node(a, c, v) 50 | 51 | def genChild[A](size: Int)(using Arbitrary[A]): Gen[Option[Node[A]]] = 52 | if size == 0 then Gen.const(None) 53 | else Gen.resize(size - 1, genNode.map(Some(_))) 54 | 55 | def genVariation[A](using Arbitrary[A]): Gen[Variation[A]] = 56 | Gen.sized: size => 57 | val sqrt = Math.sqrt(size.toDouble).toInt / 2 58 | for 59 | a <- Arbitrary.arbitrary[A] 60 | c <- genChild[A](sqrt) 61 | yield Variation(a, c) 62 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/DecayingStatsTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | class DecayingStatsTest extends ChessTest: 4 | 5 | import chess.DecayingStats as DS 6 | 7 | val random = java.util.Random(2286825201242408115L) 8 | 9 | def realMean(elts: Seq[Float]): Float = elts.sum / elts.size 10 | 11 | def realVar(elts: Seq[Float]): Float = 12 | val mean = realMean(elts).toDouble 13 | elts 14 | .map: x => 15 | Math.pow(x - mean, 2) 16 | .sum 17 | .toFloat / (elts.size - 1) 18 | 19 | val randoms: Array[Float] = Array.fill(1000) { random.nextGaussian.toFloat } 20 | val data10: Array[Float] = randoms.map { _ + 10 } 21 | 22 | val stats10 = DS(10, 100, .9f).record(data10) 23 | val stats10d = DS(10, 100, 0.99f).record(data10) 24 | 25 | test("gaussian data: eventually converge with constant mean"): 26 | assertCloseTo(stats10.deviation, 1f, 0.25f) 27 | assertCloseTo(stats10.mean, 10f, 0.25f) 28 | 29 | assertCloseTo(stats10d.deviation, 1f, 0.25f) 30 | assertCloseTo(stats10d.mean, 10f, 0.1f) 31 | 32 | test("gaussian data: eventually converge with second mean"): 33 | val stats2 = stats10.record(randoms) 34 | val stats2d = stats10d.record(randoms) 35 | 36 | assertCloseTo(stats2.deviation, 1f, 0.25f) 37 | assertCloseTo(stats2.mean, 0f, 0.25f) 38 | 39 | assertCloseTo(stats2d.deviation, 1f, 0.25f) 40 | assertCloseTo(stats2d.mean, 0f, 0.1f) 41 | 42 | test("gaussian data: quickly converge with new mean"): 43 | assertCloseTo(stats10.record(randoms.take(20)).mean, 0f, 1f) 44 | // Not so quick with high decay... 45 | assertCloseTo(stats10d.record(randoms.take(100)).mean, 0f, 4f) 46 | 47 | test("gaussian data: converge with interleave"): 48 | val dataI = Array(data10, randoms).flatMap(_.zipWithIndex).sortBy(_._2).map(_._1) 49 | val statsIa = DS(10, 100, .9f).record(dataI) 50 | val statsIb = DS(10, 100, 0.99f).record(dataI) 51 | 52 | assertCloseTo(statsIa.deviation, 5f, 1f) 53 | assertCloseTo(statsIa.mean, 5f, 1f) 54 | 55 | assertCloseTo(statsIb.deviation, 5f, 0.25f) 56 | assertCloseTo(statsIb.mean, 5f, 0.25f) 57 | 58 | test("flip flop data should converge reasonably"): 59 | val data = Array.iterate(0f, 1000) { 1f - _ } 60 | val stats = DS(0, 10, .9f).record(data) 61 | assertCloseTo(stats.mean, .5f, 0.05f) 62 | assertCloseTo(stats.deviation, .5f, 0.05f) 63 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/BerserkTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | class BerserkTest extends ChessTest: 6 | 7 | import clockConv.given 8 | 9 | def whiteBerserk(minutes: Int, seconds: Int) = 10 | Clock(minutes * 60, seconds).goBerserk(White).remainingTime(White).centis * .01 11 | 12 | test("berserkable: yep"): 13 | assertEquals(Clock.Config(60 * 60, 0).berserkable, true) 14 | assertEquals(Clock.Config(1 * 60, 0).berserkable, true) 15 | assertEquals(Clock.Config(60 * 60, 60).berserkable, true) 16 | assertEquals(Clock.Config(1 * 60, 0).berserkable, true) 17 | test("berserkable: nope"): 18 | assertEquals(Clock.Config(0 * 60, 1).berserkable, false) 19 | assertEquals(Clock.Config(0 * 60, 10).berserkable, false) 20 | test("berserk flags: white"): 21 | assertEquals(Clock(60, 0).berserked(White), false) 22 | assertEquals(Clock(60, 0).goBerserk(White).berserked(White), true) 23 | test("berserk flags: black"): 24 | assertEquals(Clock(60, 0).berserked(Black), false) 25 | assertEquals(Clock(60, 0).goBerserk(Black).berserked(Black), true) 26 | test("initial time penalty, no increment: 10+0"): 27 | assertEquals(whiteBerserk(10, 0), 5 * 60d) 28 | test("initial time penalty, no increment: 5+0"): 29 | assertEquals(whiteBerserk(5, 0), 2.5 * 60d) 30 | test("initial time penalty, no increment: 3+0"): 31 | assertEquals(whiteBerserk(3, 0), 1.5 * 60d) 32 | test("initial time penalty, no increment: 1+0"): 33 | assertEquals(whiteBerserk(1, 0), 0.5 * 60d) 34 | test("initial time penalty, with increment: 4+4"): 35 | assertEquals(whiteBerserk(4, 4), 2 * 60d) 36 | test("initial time penalty, with increment: 3+2"): 37 | assertEquals(whiteBerserk(3, 2), 1.5 * 60d) 38 | test("initial time penalty, with increment: 2+10"): 39 | assertEquals(whiteBerserk(2, 10), 2 * 60d) 40 | test("initial time penalty, with increment: 10+5"): 41 | assertEquals(whiteBerserk(10, 5), 5 * 60d) 42 | test("initial time penalty, with increment: 10+2"): 43 | assertEquals(whiteBerserk(10, 2), 5 * 60d) 44 | test("initial time penalty, with increment: 1+1"): 45 | assertEquals(whiteBerserk(1, 1), 0.5 * 60d) 46 | test("initial time penalty, with increment: 1+3"): 47 | assertEquals(whiteBerserk(1, 3), 1 * 60d) 48 | test("initial time penalty, with increment: 1+5"): 49 | assertEquals(whiteBerserk(1, 5), 1 * 60d) 50 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/PgnBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import cats.syntax.all.* 8 | import chess.format.pgn.{ Fixtures, ParsedPgn, Parser, Pgn, PgnStr } 9 | 10 | @State(Scope.Thread) 11 | @BenchmarkMode(Array(Mode.Throughput)) 12 | @OutputTimeUnit(TimeUnit.SECONDS) 13 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 14 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 15 | @Fork(value = 3) 16 | @Threads(value = 1) 17 | class PgnBench: 18 | 19 | // the unit of CPU work per iteration 20 | private val Work: Long = 10 21 | 22 | var pgnStrs: List[PgnStr] = scala.compiletime.uninitialized 23 | var pgns: List[Pgn] = scala.compiletime.uninitialized 24 | var parsedPgn: List[ParsedPgn] = scala.compiletime.uninitialized 25 | 26 | @Setup 27 | def setup() = 28 | pgnStrs = Fixtures.gamesForPerfTest ++ Fixtures.wcc2023.map(PgnStr.apply) 29 | parsedPgn = pgnStrs.traverse(Parser.full).toOption.get 30 | pgns = pgnStrs.traverse(Parser.full).toOption.get.map(_.toPgn) 31 | 32 | @Benchmark 33 | def pgnFullParser(bh: Blackhole) = 34 | var games = this.pgnStrs 35 | var i = 0 36 | while i < games.size do 37 | val game = games(i) 38 | Blackhole.consumeCPU(Work) 39 | bh.consume(Parser.full(game)) 40 | i += 1 41 | 42 | @Benchmark 43 | def pgnMainlineWithMetasParser(bh: Blackhole) = 44 | var games = this.pgnStrs 45 | var i = 0 46 | while i < games.size do 47 | val game = games(i) 48 | Blackhole.consumeCPU(Work) 49 | bh.consume(Parser.mainlineWithMetas(game)) 50 | i += 1 51 | 52 | @Benchmark 53 | def pgnMainlineParser(bh: Blackhole) = 54 | var games = this.pgnStrs 55 | var i = 0 56 | while i < games.size do 57 | val game = games(i) 58 | Blackhole.consumeCPU(Work) 59 | bh.consume(Parser.mainline(game)) 60 | i += 1 61 | 62 | @Benchmark 63 | def pgnRender(bh: Blackhole) = 64 | val result = pgns.map: x => 65 | Blackhole.consumeCPU(Work) 66 | x.render 67 | bh.consume(result) 68 | result 69 | 70 | @Benchmark 71 | def pgnBuildAndRender(bh: Blackhole) = 72 | val result = 73 | parsedPgn.map: x => 74 | Blackhole.consumeCPU(Work) 75 | x.toPgn.render 76 | bh.consume(result) 77 | result 78 | -------------------------------------------------------------------------------- /rating/src/main/scala/glicko/impl/Rating.scala: -------------------------------------------------------------------------------- 1 | package chess.rating.glicko 2 | package impl 3 | 4 | final private[glicko] class Rating( 5 | var rating: Double, 6 | var ratingDeviation: Double, 7 | var volatility: Double, 8 | var numberOfResults: Int, 9 | var lastRatingPeriodEnd: Option[java.time.Instant] = None 10 | ): 11 | 12 | import RatingCalculator.* 13 | 14 | // the following variables are used to hold values temporarily whilst running calculations 15 | private[impl] var workingRating: Double = scala.compiletime.uninitialized 16 | private[impl] var workingRatingDeviation: Double = scala.compiletime.uninitialized 17 | private[impl] var workingVolatility: Double = scala.compiletime.uninitialized 18 | 19 | /** Return the average skill value of the player 20 | * scaled down to the scale used by the algorithm's internal workings. 21 | */ 22 | private[impl] def getGlicko2Rating: Double = convertRatingToGlicko2Scale(this.rating) 23 | 24 | private[impl] def getGlicko2RatingWithAdvantage(advantage: ColorAdvantage): Double = 25 | convertRatingToGlicko2Scale(this.rating + advantage.value) 26 | 27 | /** Set the average skill value, taking in a value in Glicko2 scale. 28 | */ 29 | private[impl] def setGlicko2Rating(r: Double) = 30 | rating = convertRatingToOriginalGlickoScale(r) 31 | 32 | /** Return the rating deviation of the player scaled down to the scale used by the algorithm's internal 33 | * workings. 34 | */ 35 | private[impl] def getGlicko2RatingDeviation: Double = convertRatingDeviationToGlicko2Scale(ratingDeviation) 36 | 37 | /** Set the rating deviation, taking in a value in Glicko2 scale. 38 | */ 39 | private[impl] def setGlicko2RatingDeviation(rd: Double) = 40 | ratingDeviation = convertRatingDeviationToOriginalGlickoScale(rd) 41 | 42 | /** Used by the calculation engine, to move interim calculations into their "proper" places. 43 | */ 44 | private[impl] def finaliseRating() = 45 | setGlicko2Rating(workingRating) 46 | setGlicko2RatingDeviation(workingRatingDeviation) 47 | volatility = workingVolatility 48 | workingRatingDeviation = 0d 49 | workingRating = 0d 50 | workingVolatility = 0d 51 | 52 | private[impl] def incrementNumberOfResults(increment: Int) = 53 | numberOfResults = numberOfResults + increment 54 | 55 | override def toString = f"Rating($rating%1.2f, $ratingDeviation%1.2f, $volatility%1.2f, $numberOfResults)" 56 | -------------------------------------------------------------------------------- /playJson/src/main/scala/Json.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package json 3 | 4 | import chess.format.pgn.{ Glyph, Glyphs } 5 | import chess.format.{ Uci, UciCharPair } 6 | import chess.opening.Opening 7 | import chess.variant.Crazyhouse 8 | import play.api.libs.json.{ Json as PlayJson, * } 9 | import scalalib.json.Json as LibJson 10 | import scalalib.json.Json.given 11 | 12 | object Json: 13 | 14 | given Writes[chess.Color] = LibJson.writeAs(_.name) 15 | 16 | given Reads[Uci] = LibJson.optRead(Uci.apply) 17 | given Writes[Uci] = LibJson.writeAs(_.uci) 18 | 19 | given OWrites[Crazyhouse.Pocket] = OWrites: p => 20 | JsObject: 21 | p.flatMap((role, nb) => Option.when(nb > 0)(role.name -> JsNumber(nb))) 22 | 23 | given OWrites[Crazyhouse.Data] = OWrites: v => 24 | PlayJson.obj("pockets" -> v.pockets.all) 25 | 26 | given Writes[UciCharPair] with 27 | def writes(ucp: UciCharPair) = JsString(ucp.toString) 28 | 29 | given Writes[Square] with 30 | def writes(square: Square) = JsString(square.key) 31 | 32 | given Writes[Opening] with 33 | def writes(o: Opening) = PlayJson.obj("eco" -> o.eco, "name" -> o.name) 34 | 35 | given Writes[Glyph] = PlayJson.writes[Glyph] 36 | given Writes[Glyphs] = Writes[Glyphs]: gs => 37 | PlayJson.toJson(gs.toList) 38 | 39 | given Writes[Centis] = Writes: clock => 40 | JsNumber(clock.centis) 41 | 42 | given Writes[Map[Square, Bitboard]] with 43 | def writes(dests: Map[Square, Bitboard]) = JsString(destString(dests)) 44 | 45 | given OWrites[Division] = OWrites: o => 46 | o.middle.fold(PlayJson.obj())(m => PlayJson.obj("middle" -> m)) ++ 47 | o.end.fold(PlayJson.obj())(e => PlayJson.obj("end" -> e)) 48 | 49 | given OWrites[CorrespondenceClock] = OWrites: c => 50 | PlayJson.obj( 51 | "daysPerTurn" -> c.daysPerTurn, 52 | "increment" -> c.increment, 53 | "white" -> c.whiteTime, 54 | "black" -> c.blackTime 55 | ) 56 | 57 | given OWrites[chess.opening.Opening.AtPly] = OWrites: o => 58 | PlayJson.obj( 59 | "eco" -> o.opening.eco, 60 | "name" -> o.opening.name, 61 | "ply" -> o.ply 62 | ) 63 | 64 | def destString(dests: Map[Square, Bitboard]): String = 65 | val sb = new java.lang.StringBuilder(80) 66 | var first = true 67 | dests.foreach: (orig, dests) => 68 | if first then first = false 69 | else sb.append(" ") 70 | sb.append(orig.asChar) 71 | dests.foreach(d => sb.append(d.asChar)) 72 | sb.toString 73 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/RacingKingsVariantTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.format.FullFen 4 | 5 | import variant.RacingKings 6 | 7 | class RacingKingsVariantTest extends ChessTest: 8 | 9 | test("disallow discovered check"): 10 | val fenPosition = FullFen("1r6/8/5qRK/8/7Q/8/2knNn2/2b2B2 b - - 11 11") 11 | val game = fenToGame(fenPosition, RacingKings) 12 | assertEquals(game.position.destinations.get(Square.D2), None) 13 | 14 | test("game end to black"): 15 | val fenPosition = FullFen("4krn1/K2b4/8/8/8/8/8/8 w - - 4 3") 16 | val game = fenToGame(fenPosition, RacingKings) 17 | assert(game.position.end) 18 | assertEquals(game.position.winner, Option(Black)) 19 | 20 | test("game end to black 2"): 21 | val fenPosition = FullFen("4brk1/8/5n2/K7/8/8/8/8 w - - 6 4") 22 | val game = fenToGame(fenPosition, RacingKings) 23 | assert(game.position.end) 24 | assertEquals(game.position.winner, Option(Black)) 25 | 26 | test("game end to black 3"): 27 | val fenPosition = FullFen("3kbrn1/8/8/K7/8/8/8/8 w - - 4 3") 28 | val game = fenToGame(fenPosition, RacingKings) 29 | assert(game.position.end) 30 | assertEquals(game.position.winner, Option(Black)) 31 | 32 | test("game end to black 4"): 33 | val fenPosition = FullFen("4brk1/4n3/8/K7/8/8/8/8 w - - 4 3") 34 | val game = fenToGame(fenPosition, RacingKings) 35 | assert(game.position.end) 36 | assertEquals(game.position.winner, Option(Black)) 37 | 38 | test("game end to white"): 39 | val fenPosition = FullFen("K3br2/5k2/8/8/6n1/8/8/8 w - - 4 3") 40 | val game = fenToGame(fenPosition, RacingKings) 41 | assert(game.position.end) 42 | assertEquals(game.position.winner, Option(White)) 43 | 44 | test("game end to white 2"): 45 | val fenPosition = FullFen("K3b2r/5k2/5n2/8/8/8/8/8 w - - 4 3") 46 | val game = fenToGame(fenPosition, RacingKings) 47 | assert(game.position.end) 48 | assertEquals(game.position.winner, Option(White)) 49 | 50 | test("game is draw if both kings are in 8th rank"): 51 | val fenPosition = FullFen("K3brk1/8/5n2/8/8/8/8/8 w - - 4 3") 52 | val game = fenToGame(fenPosition, RacingKings) 53 | assert(game.position.end) 54 | assertEquals(game.position.winner, None) 55 | 56 | test("game is not end when Black's King can go to the 8th rank"): 57 | val fenPosition = FullFen("1K2br2/5k2/5n2/8/8/8/8/8 b - - 3 2") 58 | val game = fenToGame(fenPosition, RacingKings) 59 | assertNot(game.position.end) 60 | -------------------------------------------------------------------------------- /core/src/main/scala/PlayerTitle.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scalalib.Render 4 | 5 | opaque type PlayerTitle = String 6 | 7 | /* Not extending TotalWrapper 8 | * so that apply is private, 9 | * preventing creating exotic titles */ 10 | object PlayerTitle: 11 | 12 | private inline def apply(inline s: String): PlayerTitle = s.asInstanceOf[PlayerTitle] 13 | extension (t: PlayerTitle) 14 | inline def value: String = t 15 | def isLichess: Boolean = t == "LM" || t == "BOT" 16 | def isFederation: Boolean = !isLichess 17 | 18 | given Render[PlayerTitle] = _.value 19 | 20 | val GM: PlayerTitle = "GM" 21 | val WGM: PlayerTitle = "WGM" 22 | val IM: PlayerTitle = "IM" 23 | val WIM: PlayerTitle = "WIM" 24 | val FM: PlayerTitle = "FM" 25 | val WFM: PlayerTitle = "WFM" 26 | val NM: PlayerTitle = "NM" 27 | val CM: PlayerTitle = "CM" 28 | val WCM: PlayerTitle = "WCM" 29 | val WNM: PlayerTitle = "WNM" 30 | val LM: PlayerTitle = "LM" 31 | val BOT: PlayerTitle = "BOT" 32 | 33 | // names are as stated on FIDE profile pages 34 | val all = List[(PlayerTitle, String)]( 35 | GM -> "Grandmaster", 36 | WGM -> "Woman Grandmaster", 37 | IM -> "International Master", 38 | WIM -> "Woman Intl. Master", 39 | FM -> "FIDE Master", 40 | WFM -> "Woman FIDE Master", 41 | NM -> "National Master", 42 | CM -> "Candidate Master", 43 | WCM -> "Woman Candidate Master", 44 | WNM -> "Woman National Master", 45 | LM -> "Lichess Master", 46 | BOT -> "Chess Robot" 47 | ) 48 | 49 | val names: Map[PlayerTitle, String] = all.toMap 50 | lazy val fromNames: Map[String, PlayerTitle] = all.map(_.swap).toMap 51 | 52 | val acronyms: List[PlayerTitle] = all.map(_._1) 53 | 54 | def titleName(title: PlayerTitle): String = names.getOrElse(title, title.value) 55 | 56 | def get(str: String): Option[PlayerTitle] = Option(PlayerTitle(str.toUpperCase)).filter(names.contains) 57 | def get(strs: List[String]): List[PlayerTitle] = strs.flatMap { get(_) } 58 | 59 | // ordered by difficulty to achieve 60 | // if a player has multiple titles, the most valuable one is used 61 | private val titleRank: Map[PlayerTitle, Int] = 62 | List(GM, IM, WGM, FM, WIM, NM, CM, WFM, WCM, WNM).zipWithIndex.toMap 63 | 64 | def mostValuable(t1: Option[PlayerTitle], t2: Option[PlayerTitle]): Option[PlayerTitle] = 65 | t1.flatMap(titleRank.get) 66 | .fold(t2): v1 => 67 | t2.flatMap(titleRank.get) 68 | .fold(t1): v2 => 69 | if v1 < v2 then t1 else t2 70 | -------------------------------------------------------------------------------- /core/src/main/scala/ByRole.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.Functor 4 | 5 | case class ByRole[A](pawn: A, knight: A, bishop: A, rook: A, queen: A, king: A): 6 | def apply(role: Role): A = role match 7 | case Pawn => pawn 8 | case Knight => knight 9 | case Bishop => bishop 10 | case Rook => rook 11 | case Queen => queen 12 | case King => king 13 | 14 | inline def update(role: Role, f: A => A): ByRole[A] = role match 15 | case Pawn => copy(pawn = f(pawn)) 16 | case Knight => copy(knight = f(knight)) 17 | case Bishop => copy(bishop = f(bishop)) 18 | case Rook => copy(rook = f(rook)) 19 | case Queen => copy(queen = f(queen)) 20 | case King => copy(king = f(king)) 21 | 22 | inline def find(f: A => Boolean): Option[A] = 23 | if f(pawn) then Some(pawn) 24 | else if f(knight) then Some(knight) 25 | else if f(bishop) then Some(bishop) 26 | else if f(rook) then Some(rook) 27 | else if f(queen) then Some(queen) 28 | else if f(king) then Some(king) 29 | else None 30 | 31 | inline def fold[B](z: B)(f: (B, A) => B): B = 32 | f(f(f(f(f(f(z, pawn), knight), bishop), rook), queen), king) 33 | 34 | inline def fold[B](z: B)(f: (B, Role, A) => B): B = 35 | f(f(f(f(f(f(z, Pawn, pawn), Knight, knight), Bishop, bishop), Rook, rook), Queen, queen), King, king) 36 | 37 | inline def foreach[U](f: A => U): Unit = 38 | f(pawn): Unit 39 | f(knight): Unit 40 | f(bishop): Unit 41 | f(rook): Unit 42 | f(queen): Unit 43 | f(king): Unit 44 | 45 | inline def foreach[U](f: (Role, A) => U): Unit = 46 | f(Pawn, pawn): Unit 47 | f(Knight, knight): Unit 48 | f(Bishop, bishop): Unit 49 | f(Rook, rook): Unit 50 | f(Queen, queen): Unit 51 | f(King, king): Unit 52 | 53 | inline def findRole(f: A => Boolean): Option[Role] = 54 | if f(pawn) then Some(Pawn) 55 | else if f(knight) then Some(Knight) 56 | else if f(bishop) then Some(Bishop) 57 | else if f(rook) then Some(Rook) 58 | else if f(queen) then Some(Queen) 59 | else if f(king) then Some(King) 60 | else None 61 | 62 | def values: List[A] = List(pawn, knight, bishop, rook, queen, king) 63 | 64 | object ByRole: 65 | 66 | def fill[A](a: A): ByRole[A] = ByRole(a, a, a, a, a, a) 67 | 68 | given Functor[ByRole] with 69 | def map[A, B](byRole: ByRole[A])(f: A => B): ByRole[B] = 70 | ByRole( 71 | f(byRole.pawn), 72 | f(byRole.knight), 73 | f(byRole.bishop), 74 | f(byRole.rook), 75 | f(byRole.queen), 76 | f(byRole.king) 77 | ) 78 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/InsufficientMatingMaterialTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.format.FullFen 4 | import chess.variant.Standard 5 | 6 | import InsufficientMatingMaterial.* 7 | 8 | class InsufficientMatingMaterialTest extends ChessTest: 9 | 10 | test("bishops on Opposite colors"): 11 | 12 | val trues = List( 13 | "8/4b3/8/8/8/8/4B3/8 w - - 0 1", 14 | "8/4b3/8/8/2Q5/1K6/4B3/5B2 w - - 0 1", 15 | "5b2/1k2b1n1/3q4/8/2Q5/1K6/4B3/5B2 w - - 0 1", 16 | "6b1/1k3bn1/3q4/8/2Q5/1K6/4bB2/8 w - - 0 1", 17 | "6b1/1k3bn1/3B4/8/2Q2B2/1K6/4bB2/8 w - - 0 1", 18 | "2k2b2/5b2/8/8/8/3R4/1K2Q3/5B2 w - - 0 1", 19 | "2k2b2/6b1/7b/8/8/3R2B1/1K2Q3/5B2 w - - 0 1", 20 | "2k5/8/8/8/8/3R2B1/1K2Q3/5B2 w - - 0 1" 21 | ).map(FullFen(_)) 22 | 23 | val falses = List( 24 | "4b3/8/8/8/8/8/4B3/8 w - - 0 1", 25 | "5b2/8/8/8/8/3R4/1K2QB2/8 w - - 0 1", 26 | "8/8/8/8/8/3R4/1K2B3/8 w - - 0 1", 27 | "5b2/8/8/8/8/3R4/1K2Q3/8 w - - 0 1" 28 | ).map(FullFen(_)) 29 | 30 | trues.foreach: fen => 31 | assert(bishopsOnOppositeColors(fenToGame(fen, Standard).position.board)) 32 | 33 | falses.foreach: fen => 34 | assertNot(bishopsOnOppositeColors(fenToGame(fen, Standard).position.board)) 35 | 36 | // Determines whether a color does not have mating material. 37 | test("apply with board and color"): 38 | val trues = List( 39 | "8/6R1/K7/2NNN3/5NN1/4KN2/8/k7 w - - 0 1", 40 | "8/8/K7/8/1k6/8/8/8 w - - 0 1", 41 | "7k/8/8/8/3K4/8/8/8 w - - 0 1", 42 | "7k/5R2/5NQ1/8/3K4/8/8/8 w - - 0 1", 43 | "krq5/bqqq4/qqr5/1qq5/8/8/8/3qB2K b - -", 44 | "8/3k4/2q5/8/8/K1N5/8/8 b - -", 45 | "7k/8/6Q1/8/3K4/8/1n6/8 w - - 0 1" 46 | ).map(FullFen(_)) 47 | 48 | val falses = List( 49 | "krq5/bqqq4/qqrp4/1qq5/8/8/8/3qB2K b - - 0 1", 50 | "8/7B/K7/2b5/1k6/8/8/8 b - -", 51 | "8/8/K7/2b5/1k6/5N2/8/8 b - - 0 1", 52 | "7k/5R2/5NQ1/8/3K4/8/1p6/8 w - - 0 1", 53 | "7k/5R2/5NQ1/8/3K4/8/2n5/8 w - - 0 1", 54 | "7k/5R2/5NQ1/8/3K4/1r6/8/8 w - - 0 1", 55 | "7k/8/8/5R2/3K4/8/1n6/8 w - - 0 1", 56 | "7k/8/6B1/8/3K4/8/1n6/8 w - - 0 1", 57 | "7k/5P2/8/8/3K4/8/1n6/8 w - - 0 1", 58 | "7k/6N1/8/8/3K4/8/1n6/8 w - - 0 1" 59 | ).map(FullFen(_)) 60 | 61 | trues.foreach: fen => 62 | val position = fenToGame(fen, Standard).position 63 | assert(apply(position.board, !position.color)) 64 | 65 | falses.foreach: fen => 66 | val position = fenToGame(fen, Standard).position 67 | assertNot(apply(position.board, !position.color)) 68 | -------------------------------------------------------------------------------- /core/src/main/scala/InsufficientMatingMaterial.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | /** Utility methods for helping to determine whether a board is a draw or a draw 4 | * on a player flagging. 5 | * 6 | * See http://www.e4ec.org/immr.html 7 | */ 8 | object InsufficientMatingMaterial: 9 | 10 | // verify if there are at least two bishops of opposite color 11 | // no matter which sides they are on 12 | def bishopsOnOppositeColors(board: Board): Boolean = 13 | board.bishops.intersects(Bitboard.lightSquares) && 14 | board.bishops.intersects(Bitboard.darkSquares) 15 | 16 | /* 17 | * Returns true if a pawn cannot progress forward because it is blocked by a pawn 18 | * and it doesn't have any capture 19 | */ 20 | def pawnBlockedByPawn(pawn: Square, position: Position): Boolean = 21 | position 22 | .pieceAt(pawn) 23 | .exists(p => 24 | p.is(Pawn) && 25 | position.withColor(p.color).generateMovesAt(pawn).isEmpty && { 26 | val blockingPosition = pawn.nextRank(p.color) 27 | blockingPosition.flatMap(position.pieceAt).exists(_.is(Pawn)) 28 | } 29 | ) 30 | 31 | /* 32 | * Determines whether a board position is an automatic draw due to neither player 33 | * being able to mate the other as informed by the traditional chess rules. 34 | */ 35 | def apply(board: Board): Boolean = 36 | board.kingsAndMinorsOnly && 37 | (board.nbPieces <= 3 || (board.kingsAndBishopsOnly && !bishopsOnOppositeColors(board))) 38 | 39 | /* 40 | * Determines whether a color does not have mating material. In general: 41 | * King by itself is not mating material 42 | * King + knight mates against king + any(rook, bishop, knight, pawn) 43 | * King + bishop mates against king + any(bishop, knight, pawn) 44 | * King + bishop(s) versus king + bishop(s) depends upon bishop square colors 45 | * So this function returns true in three cases: 46 | * - if color has only king 47 | * - if color has king + knight and opponent has king + queen(s) 48 | * - if color has king + bishop and opponent doesn't have: 49 | * - opposite color bishop(s) 50 | * - or knight(s) or pawn(s) 51 | */ 52 | def apply(board: Board, color: Color): Boolean = 53 | import board.* 54 | inline def onlyKing = kingsOnlyOf(color) 55 | inline def KN = 56 | onlyOf(color, King, Knight) && count(color, Knight) == 1 && onlyOf(!color, King, Queen) 57 | inline def KB = 58 | onlyOf(color, King, Bishop) && 59 | !(bishopsOnOppositeColors(board) || byPiece(!color, Knight, Pawn).nonEmpty) 60 | 61 | onlyKing || KN || KB 62 | -------------------------------------------------------------------------------- /rating/src/main/scala/glicko/GlickoCalculator.scala: -------------------------------------------------------------------------------- 1 | package chess.rating 2 | package glicko 3 | 4 | import chess.{ ByColor, Outcome } 5 | 6 | import java.time.Instant 7 | import scala.util.Try 8 | 9 | /* Purely functional interface hiding the mutable implementation */ 10 | final class GlickoCalculator( 11 | tau: Tau = Tau.default, 12 | ratingPeriodsPerDay: RatingPeriodsPerDay = RatingPeriodsPerDay.default, 13 | colorAdvantage: ColorAdvantage = ColorAdvantage.zero 14 | ): 15 | 16 | private val calculator = new impl.RatingCalculator(tau, ratingPeriodsPerDay, colorAdvantage) 17 | 18 | // Simpler use case: a single game 19 | def computeGame(game: Game, skipDeviationIncrease: Boolean = false): Try[ByColor[Player]] = 20 | val ratings = game.players.map(conversions.toRating) 21 | val gameResult = conversions.toGameResult(ratings, game.outcome) 22 | val periodResults = impl.GameRatingPeriodResults(List(gameResult)) 23 | Try: 24 | calculator.updateRatings(periodResults, skipDeviationIncrease) 25 | ratings.map(conversions.toPlayer) 26 | 27 | /** This is the formula defined in step 6. It is also used for players who have not competed during the rating period. */ 28 | def previewDeviation(player: Player, ratingPeriodEndDate: Instant, reverse: Boolean): Double = 29 | calculator.previewDeviation(conversions.toRating(player), ratingPeriodEndDate, reverse) 30 | 31 | /** Apply rating calculations and return updated players. 32 | * Note that players who did not compete during the rating period will have see their deviation increase. 33 | * This requires players to have some sort of unique identifier. 34 | */ 35 | // def computeGames( games: List[Game], skipDeviationIncrease: Boolean = false): List[Player] 36 | 37 | private object conversions: 38 | 39 | import impl.* 40 | 41 | def toGameResult(ratings: ByColor[Rating], outcome: Outcome): GameResult = 42 | GameResult(ratings.white, ratings.black, outcome) 43 | 44 | def toRating(player: Player) = impl.Rating( 45 | rating = player.rating, 46 | ratingDeviation = player.deviation, 47 | volatility = player.volatility, 48 | numberOfResults = player.numberOfResults, 49 | lastRatingPeriodEnd = player.lastRatingPeriodEnd 50 | ) 51 | 52 | def toPlayer(rating: Rating) = Player( 53 | glicko = Glicko( 54 | rating = rating.rating, 55 | deviation = rating.ratingDeviation, 56 | volatility = rating.volatility 57 | ), 58 | numberOfResults = rating.numberOfResults, 59 | lastRatingPeriodEnd = rating.lastRatingPeriodEnd 60 | ) 61 | -------------------------------------------------------------------------------- /core/src/main/scala/UnmovedRooks.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.annotation.targetName 4 | 5 | opaque type UnmovedRooks = Long 6 | object UnmovedRooks: 7 | // for lila testing only 8 | val default: UnmovedRooks = UnmovedRooks(Bitboard.rank(Rank.First) | Bitboard.rank(Rank.Eighth)) 9 | val corners: UnmovedRooks = 0x8100000000000081L 10 | val none: UnmovedRooks = 0L 11 | 12 | @targetName("applyUnmovedRooks") 13 | def apply(b: Bitboard): UnmovedRooks = b.value 14 | def apply(l: Long): UnmovedRooks = l 15 | inline def apply(inline xs: Iterable[Square]): UnmovedRooks = xs.foldLeft(none)((b, s) => b | s.bl) 16 | 17 | // guess unmovedRooks from board 18 | // we assume rooks are on their initial position 19 | def from(board: Board): UnmovedRooks = 20 | val wr = board.rooks & board.white & Bitboard.rank(White.backRank) 21 | val br = board.rooks & board.black & Bitboard.rank(Black.backRank) 22 | UnmovedRooks(wr | br) 23 | 24 | extension (ur: UnmovedRooks) 25 | inline def bb: Bitboard = Bitboard(ur) 26 | def isEmpty = ur == 0L 27 | def value: Long = ur 28 | def toList: List[Square] = ur.bb.squares 29 | 30 | def without(color: Color): UnmovedRooks = 31 | ur & Bitboard.rank(color.lastRank).value 32 | 33 | /** 34 | * Guess the side of the rook at the given square 35 | * 36 | * If the position is not a ummovedRook return None 37 | * If the position is a ummovedRook but there is no other rook on the 38 | * same rank return Some(None) (because we cannot guess) 39 | * If there are two rooks on the same rank, return the side of the rook 40 | */ 41 | def side(square: Square): Option[Option[Side]] = 42 | val rook = square.bb 43 | if rook.isDisjoint(ur) then None 44 | else 45 | (Bitboard.rank(square.rank) & ~rook & ur.value).first match 46 | case Some(otherRook) => 47 | if otherRook.file > square.file then Some(Some(QueenSide)) 48 | else Some(Some(KingSide)) 49 | case None => Some(None) 50 | 51 | def contains(square: Square): Boolean = 52 | (ur & (1L << square.value)) != 0L 53 | 54 | inline def unary_~ : UnmovedRooks = ~ur 55 | inline infix def &(inline o: Long): UnmovedRooks = ur & o 56 | inline infix def ^(inline o: Long): UnmovedRooks = ur ^ o 57 | inline infix def |(inline o: Long): UnmovedRooks = ur | o 58 | 59 | @targetName("and") 60 | inline infix def &(o: Bitboard): UnmovedRooks = ur & o.value 61 | @targetName("xor") 62 | inline infix def ^(o: Bitboard): UnmovedRooks = ur ^ o.value 63 | @targetName("or") 64 | inline infix def |(o: Bitboard): UnmovedRooks = ur | o.value 65 | -------------------------------------------------------------------------------- /core/src/main/scala/format/UciCharPair.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | 4 | import cats.syntax.all.* 5 | 6 | case class UciCharPair(a: Char, b: Char): 7 | 8 | override def toString = s"$a$b" 9 | 10 | def toUci: Uci = 11 | import UciCharPair.implementation.* 12 | val from: Square = unsafeCharToSquare(a) // :o 13 | 14 | char2squareMap.get(b) match 15 | case Some(sq) => Uci.Move(from, sq, None) 16 | case None => 17 | char2promotionMap.get(b) match 18 | case Some((file, prom)) => Uci.Move(from, Square(file, lastRank(from)), Some(prom)) 19 | case None => Uci.Drop(unsafeCharToDropRole(b), from) 20 | 21 | object UciCharPair: 22 | 23 | import implementation.* 24 | 25 | def apply(uci: Uci): UciCharPair = 26 | uci match 27 | case Uci.Move(orig, dest, None) => UciCharPair(toChar(orig), toChar(dest)) 28 | case Uci.Move(orig, dest, Some(role)) => UciCharPair(toChar(orig), toChar(dest.file, role)) 29 | case Uci.Drop(role, square) => UciCharPair(toChar(square), dropRole2charMap.getOrElse(role, voidChar)) 30 | 31 | object implementation: 32 | 33 | val charShift = 35 // Start at Char(35) == '#' 34 | val voidChar = 33.toChar // '!'. We skipped Char(34) == '"'. 35 | 36 | val square2charMap: Map[Square, Char] = Square.all.map { square => 37 | square -> (square.hashCode + charShift).toChar 38 | }.toMap 39 | 40 | lazy val char2squareMap: Map[Char, Square] = square2charMap.map(_.swap) 41 | export char2squareMap.apply as unsafeCharToSquare 42 | 43 | inline def toChar(inline square: Square) = square2charMap.getOrElse(square, voidChar) 44 | 45 | val promotion2charMap: Map[(File, PromotableRole), Char] = for 46 | (role, index) <- Role.allPromotable.zipWithIndex.toMap 47 | file <- File.all 48 | yield (file, role) -> (charShift + square2charMap.size + index * 8 + file.value).toChar 49 | 50 | lazy val char2promotionMap: Map[Char, (File, PromotableRole)] = 51 | promotion2charMap.map(_.swap) 52 | 53 | def toChar(file: File, prom: PromotableRole) = 54 | promotion2charMap.getOrElse(file -> prom, voidChar) 55 | 56 | val dropRole2charMap: Map[Role, Char] = 57 | Role.all 58 | .filterNot(King == _) 59 | .mapWithIndex: (role, index) => 60 | role -> (charShift + square2charMap.size + promotion2charMap.size + index).toChar 61 | .toMap 62 | 63 | lazy val char2dropRoleMap: Map[Char, Role] = dropRole2charMap.map(_.swap) 64 | export char2dropRoleMap.apply as unsafeCharToDropRole 65 | 66 | private[chess] def lastRank(from: Square): Rank = 67 | if from.rank == Rank.Second 68 | then Rank.First 69 | else Rank.Eighth 70 | -------------------------------------------------------------------------------- /core/src/main/scala/opening/OpeningDb.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package opening 3 | 4 | import cats.Foldable 5 | import cats.syntax.all.* 6 | import chess.format.pgn.SanStr 7 | import chess.format.{ FullFen, StandardFen } 8 | 9 | object OpeningDb: 10 | 11 | lazy val all: Vector[Opening] = 12 | openingDbPartA ++ openingDbPartB ++ openingDbPartC ++ openingDbPartD ++ openingDbPartE 13 | 14 | private lazy val byFen: collection.Map[StandardFen, Opening] = all.mapBy(_.fen) 15 | 16 | lazy val families: Set[OpeningFamily] = byFen.values.map(_.family).toSet 17 | 18 | // Keep only one opening per unique key: the shortest one 19 | lazy val shortestLines: Map[OpeningKey, Opening] = OpeningDb.all 20 | .foldLeft(Map.empty) { case (acc, op) => 21 | acc.updatedWith(op.key): 22 | case Some(prev) if prev.uci.value.size < op.uci.value.size => prev.some 23 | case _ => op.some 24 | } 25 | 26 | def isShortest(op: Opening) = shortestLines.get(op.key).contains(op) 27 | 28 | def findByFullFen(fen: FullFen): Option[Opening] = findByStandardFen(fen.opening) 29 | 30 | def findByStandardFen(fen: StandardFen): Option[Opening] = byFen.get(fen) 31 | 32 | val SEARCH_MAX_PLIES = 40 33 | val SEARCH_MIN_PIECES = 20 34 | 35 | // assumes standard initial Fen and variant 36 | def search(sans: Iterable[SanStr]): Option[Opening.AtPly] = 37 | chess.variant.Standard.initialPosition 38 | .playPositions(sans.take(SEARCH_MAX_PLIES).takeWhile(!_.value.contains('@')).toList) 39 | .toOption 40 | .flatMap(searchInPositions) 41 | 42 | @scala.annotation.targetName("searchMoveOrDrops") 43 | def search(moveOrDrops: Iterable[MoveOrDrop]): Option[Opening.AtPly] = 44 | searchInPositions: 45 | val moves: Vector[Move] = moveOrDrops.view 46 | .take(SEARCH_MAX_PLIES) 47 | .takeWhile: 48 | case move: Move => move.before.board.nbPieces >= SEARCH_MIN_PIECES 49 | case _ => false 50 | .collect { case move: Move => move } 51 | .toVector 52 | moves.map(_.before) ++ moves.lastOption.map(_.after).toVector 53 | 54 | // first position is initial position 55 | def searchInPositions[F[_]: Foldable](positions: F[Position]) = 56 | positions 57 | .takeWhile_(_.board.nbPieces >= SEARCH_MIN_PIECES) 58 | .zipWithIndex 59 | .drop(1) 60 | .foldRight(none[Opening.AtPly]): 61 | case ((board, ply), None) => byFen.get(format.Fen.writeOpening(board)).map(_.atPly(Ply(ply))) 62 | case (_, found) => found 63 | 64 | def searchInFens(fens: Iterable[StandardFen]): Option[Opening] = 65 | fens.foldRight(none[Opening]): 66 | case (fen, None) => findByStandardFen(fen) 67 | case (_, found) => found 68 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/TournamentClockTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import Clock.* 4 | 5 | class TournamentClockTest extends ChessTest: 6 | 7 | val parse = TournamentClock.parse(false) 8 | val parseStrict = TournamentClock.parse(true) 9 | 10 | def someClock(seconds: Int, inc: Int) = Some: 11 | TournamentClock(LimitSeconds(seconds), IncrementSeconds(inc)) 12 | 13 | test("parse empty"): 14 | assertEquals(parse(""), None) 15 | assertEquals(parse("nope"), None) 16 | 17 | test("parse standard"): 18 | assertEquals(parse("300+5"), someClock(300, 5)) 19 | assertEquals(parse("300+0"), someClock(300, 0)) 20 | assertEquals(parse("5400+60"), someClock(5400, 60)) 21 | 22 | test("parse as minutes for compat"): 23 | assertEquals(parse("3+2"), someClock(3 * 60, 2)) 24 | assertEquals(parse("60+30"), someClock(60 * 60, 30)) 25 | assertEquals(parse("180+30"), someClock(180 * 60, 30)) 26 | 27 | test("parse strict"): 28 | assertEquals(parseStrict("60+0"), someClock(60, 0)) 29 | assertEquals(parseStrict("120+1"), someClock(120, 1)) 30 | 31 | test("parse weird shit"): 32 | assertEquals(parse("15m + 10s"), someClock(15 * 60, 10)) 33 | assertEquals(parse("15 m + 10 s"), someClock(15 * 60, 10)) 34 | assertEquals(parse("15min + 10sec"), someClock(15 * 60, 10)) 35 | assertEquals(parse("15m + 10 sec"), someClock(15 * 60, 10)) 36 | assertEquals(parse("15 min + 10 sec"), someClock(15 * 60, 10)) 37 | assertEquals(parse("15 min + 10 s"), someClock(15 * 60, 10)) 38 | assertEquals(parse("15 minutes + 10 seconds"), someClock(15 * 60, 10)) 39 | assertEquals(parse(" 15 MiNUTes+10SECOnds "), someClock(15 * 60, 10)) 40 | 41 | assertEquals(parse("15 min + 10 sec / move"), someClock(15 * 60, 10)) 42 | assertEquals(parse("15 min + 10 s / move"), someClock(15 * 60, 10)) 43 | assertEquals(parse("15 min + 10 seconds / move"), someClock(15 * 60, 10)) 44 | assertEquals(parse("15 minutes + 10 seconds / move"), someClock(15 * 60, 10)) 45 | 46 | assertEquals(parse("90 min + 30 sec / move"), someClock(90 * 60, 30)) 47 | 48 | assertEquals(parse("120' + 12\""), someClock(120 * 60, 12)) 49 | assertEquals(parse("120' + 12\"/move"), someClock(120 * 60, 12)) 50 | assertEquals(parse("120' + 12\" / move"), someClock(120 * 60, 12)) 51 | 52 | assertEquals(parse("3600"), someClock(3600, 0)) 53 | assertEquals(parse("60"), someClock(60 * 60, 0)) 54 | assertEquals(parse("180"), someClock(180 * 60, 0)) 55 | assertEquals(parse("240"), someClock(240, 0)) 56 | 57 | assertEquals(parse("120 min / 40 moves + 30 min"), None) 58 | 59 | // we're not there yet 60 | // assertEquals(parse("90 min / 40 moves + 30 min + 30 sec / move"), ???) 61 | -------------------------------------------------------------------------------- /core/src/main/scala/Replay.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.Traverse 4 | import cats.syntax.all.* 5 | import chess.format.Fen 6 | import chess.format.pgn.Sans.* 7 | import chess.format.pgn.{ Parser, PgnStr, San, SanStr } 8 | import chess.variant.Variant 9 | 10 | case class Replay(setup: Game, moves: List[MoveOrDrop], state: Game): 11 | 12 | lazy val chronoMoves: List[MoveOrDrop] = moves.reverse 13 | 14 | def addMove(moveOrDrop: MoveOrDrop): Replay = 15 | copy( 16 | moves = moveOrDrop :: moves, 17 | state = moveOrDrop.applyGame(state) 18 | ) 19 | 20 | def moveAtPly(ply: Ply): Option[MoveOrDrop] = 21 | chronoMoves.lift(ply.value - 1 - setup.startedAtPly.value) 22 | 23 | object Replay: 24 | 25 | def apply(game: Game): Replay = Replay(game, Nil, game) 26 | 27 | def plyAtFen( 28 | sans: Iterable[SanStr], 29 | initialFen: Option[Fen.Full], 30 | variant: Variant, 31 | atFen: Fen.Full 32 | ): Either[ErrorStr, Ply] = 33 | if Fen.read(variant, atFen).isEmpty then ErrorStr(s"Invalid Fen $atFen").asLeft 34 | else 35 | // we don't want to compare the full move number, to match transpositions 36 | def truncateFen(fen: Fen.Full) = fen.value.split(' ').take(4).mkString(" ") 37 | val atFenTruncated = truncateFen(atFen) 38 | def compareFen(fen: Fen.Full) = truncateFen(fen) == atFenTruncated 39 | 40 | @scala.annotation.tailrec 41 | def recursivePlyAtFen(position: Position, sans: List[San], ply: Ply): Either[ErrorStr, Ply] = 42 | sans match 43 | case Nil => ErrorStr(s"Can't find $atFenTruncated, reached ply $ply").asLeft 44 | case san :: rest => 45 | san(position) match 46 | case Left(err) => err.asLeft 47 | case Right(moveOrDrop) => 48 | val after = moveOrDrop.after 49 | val fen = Fen.write(after.withColor(ply.turn), ply.fullMoveNumber) 50 | if compareFen(fen) then ply.asRight 51 | else recursivePlyAtFen(after.withColor(!position.color), rest, ply.next) 52 | 53 | Parser 54 | .moves(sans) 55 | .flatMap(moves => recursivePlyAtFen(Position(variant, initialFen), moves.value, Ply.firstMove)) 56 | 57 | case class Result(replay: Replay, failure: Option[ErrorStr]): 58 | def valid: Either[ErrorStr, Replay] = 59 | failure.fold(replay.asRight)(_.asLeft) 60 | 61 | def mainline(pgn: PgnStr): Either[ErrorStr, Result] = 62 | Parser.mainline(pgn).map(ml => makeReplay(ml.toGame, ml.moves)) 63 | 64 | def makeReplay[F[_]: Traverse](game: Game, sans: F[San]): Result = 65 | val (state, moves, error) = game.playWhileValidReverse(sans, game.ply)(_.move) 66 | Result(Replay(game, moves, state), error) 67 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/CastlingTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.format.Fen 4 | import chess.variant.Standard 5 | import monocle.syntax.all.* 6 | 7 | import scala.language.implicitConversions 8 | 9 | import Square.* 10 | 11 | class CastlingTest extends ChessTest: 12 | 13 | import compare.dests 14 | 15 | val board: Position = """R K R""" 16 | 17 | test("threat on king prevents castling: by a rook"): 18 | assertEquals( 19 | board.place(Black.rook, E3).flatMap(_.destsFrom(E1)), 20 | Set(D1, D2, F2, F1) 21 | ) 22 | 23 | test("threat on king prevents castling: by a knight"): 24 | assertEquals(board.place(Black.knight, D3).flatMap(_.destsFrom(E1)), Set(D1, D2, E2, F1)) 25 | 26 | test("threat on king prevents castling: by a bishop"): 27 | assertEquals(board.place(Black.bishop, A5).flatMap(_.destsFrom(E1)), Set(D1, E2, F2, F1)) 28 | 29 | test("threat on castle trip prevents castling: king side"): 30 | val board: Position = """R QK R""" 31 | assertEquals(board.place(Black.rook, F3).flatMap(_.destsFrom(E1)), Set(D2, E2)) 32 | assertEquals(board.place(Black.rook, G3).flatMap(_.destsFrom(E1)), Set(D2, E2, F2, F1)) 33 | 34 | test("threat on castle trip prevents castling: queen side"): 35 | val board: Position = """R KB R""" 36 | assertEquals(board.place(Black.rook, D3).flatMap(_.destsFrom(E1)), Set(E2, F2)) 37 | assertEquals(board.place(Black.rook, C3).flatMap(_.destsFrom(E1)), Set(D1, D2, E2, F2)) 38 | 39 | test("threat on castle trip prevents castling: chess 960"): 40 | val board: Position = """BK R""" 41 | assertEquals(board.place(Black.rook, F3).flatMap(_.destsFrom(B1)), Set(A2, B2, C2, C1)) 42 | assertEquals(board.place(Black.king, E2).flatMap(_.destsFrom(B1)), Set(A2, B2, C2, C1)) 43 | 44 | test("threat on rook does not prevent castling king side"): 45 | val board: Position = """R QK R""" 46 | assertEquals(board.place(Black.rook, H3).flatMap(_.destsFrom(E1)), Set(D2, E2, F1, F2, G1, H1)) 47 | 48 | test("threat on rook does not prevent castling queen side"): 49 | val board: Position = """R KB R""" 50 | assertEquals(board.place(Black.rook, A3).flatMap(_.destsFrom(E1)), Set(A1, C1, D1, D2, E2, F2)) 51 | 52 | test("threat on rooks trip does not prevent castling queen side"): 53 | val board: Position = """R KB R""" 54 | assertEquals(board.place(Black.rook, B3).flatMap(_.destsFrom(E1)), Set(A1, C1, D1, D2, E2, F2)) 55 | 56 | test("unmovedRooks and castles are consistent"): 57 | val s1 = Fen.read(Standard, Fen.Full("rnbqk2r/pppppppp/8/8/8/8/PPPPPPPP/RNBQK2R w Qq - 0 1")).get 58 | val s2 = s1.focus(_.history.unmovedRooks).replace(UnmovedRooks.corners) 59 | assertEquals(s2.legalMoves.filter(_.castles), Nil) 60 | -------------------------------------------------------------------------------- /core/src/main/scala/LagTracker.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | final case class LagTracker( 4 | quotaGain: Centis, 5 | quota: Centis, 6 | quotaMax: Centis, 7 | lagEstimator: DecayingRecorder, 8 | uncompStats: Stats = Stats.empty, 9 | lagStats: Stats = Stats.empty, 10 | // We can remove compEst fields after tuning estimate. 11 | compEstSqErr: Int = 0, 12 | compEstOvers: Centis = Centis(0), 13 | compEstimate: Option[Centis] = None 14 | ): 15 | 16 | def onMove(lag: Centis) = 17 | val comp = lag.atMost(quota) 18 | val uncomped = lag - comp 19 | val ceDiff = compEstimate.getOrElse(Centis(1)) - comp 20 | 21 | ( 22 | comp, 23 | copy( 24 | quota = (quota + quotaGain - comp).atMost(quotaMax), 25 | uncompStats = 26 | // start recording after first uncomp. 27 | if uncomped == Centis(0) && uncompStats.samples == 0 then uncompStats 28 | else uncompStats.record(uncomped.centis.toFloat), 29 | lagStats = lagStats.record((lag.atMost(Centis(2000))).centis.toFloat), 30 | compEstSqErr = compEstSqErr + ceDiff.centis * ceDiff.centis, 31 | compEstOvers = compEstOvers + ceDiff.nonNeg 32 | ).recordLag(lag) 33 | ) 34 | 35 | def recordLag(lag: Centis) = 36 | val e = lagEstimator.record((lag.atMost(quotaMax)).centis.toFloat) 37 | copy( 38 | lagEstimator = e, 39 | compEstimate = Option(Centis.ofFloat(e.mean - .8f * e.deviation).nonNeg.atMost(quota)) 40 | ) 41 | 42 | def moves = lagStats.samples 43 | 44 | def lagMean: Option[Centis] = (moves > 0).option(Centis.ofFloat(lagStats.mean)) 45 | 46 | def compEstStdErr: Option[Float] = 47 | (moves > 2).option(Math.sqrt(compEstSqErr).toFloat / (moves - 2)) 48 | 49 | def compAvg: Option[Centis] = totalComp / moves 50 | 51 | def totalComp: Centis = totalLag - totalUncomped 52 | 53 | def totalLag: Centis = Centis.ofFloat(lagStats.total) 54 | 55 | def totalUncomped: Centis = Centis.ofFloat(uncompStats.total) 56 | 57 | def withFrameLag(frameLag: Centis, clock: Clock.Config) = copy( 58 | quotaGain = LagTracker.maxQuotaGainFor(clock).atMost { 59 | frameLag + LagTracker.estimatedCpuLag 60 | } 61 | ) 62 | 63 | object LagTracker: 64 | 65 | private val estimatedCpuLag = Centis(14) 66 | 67 | // https://github.com/lichess-org/lila/issues/12097 68 | private def maxQuotaGainFor(config: Clock.Config) = 69 | Centis(math.min(100, config.estimateTotalSeconds * 2 / 5 + 15)) 70 | 71 | def init(config: Clock.Config) = 72 | val quotaGain = maxQuotaGainFor(config) 73 | LagTracker( 74 | quotaGain = quotaGain, 75 | quota = quotaGain * 3, 76 | quotaMax = quotaGain * 7, 77 | lagEstimator = DecayingStats.empty 78 | ) 79 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/CastlingKingSideTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.language.implicitConversions 4 | 5 | import Square.* 6 | import variant.FromPosition 7 | 8 | class CastlingKingSideTest extends ChessTest: 9 | 10 | import compare.dests 11 | 12 | val goodHist = """ 13 | PPPPPPPP 14 | R QK R""" 15 | val badHist = goodHist.updateHistory(_.withoutCastles(White)) 16 | test("impossible"): 17 | assertEquals(goodHist.place(White.bishop, F1).flatMap(_.destsFrom(E1)), Set()) 18 | assertEquals(goodHist.place(White.knight, G1).flatMap(_.destsFrom(E1)), Set(F1)) 19 | assertEquals(badHist.destsFrom(E1), Set(F1)) 20 | val board960 = """ 21 | PPPPPPPP 22 | RQK R """.chess960.updateHistory(_ => castleHistory(White, kingSide = true, queenSide = true)) 23 | assertEquals(board960.place(White.bishop, D1).flatMap(_.destsFrom(C1)), Set()) 24 | assertEquals(board960.place(White.knight, F1).flatMap(_.destsFrom(C1)), Set(D1)) 25 | test("possible"): 26 | val game = Game(goodHist) 27 | assertEquals(game.position.destsFrom(E1), Set(F1, G1, H1)) 28 | assertGame( 29 | game.playMove(E1, G1).get, 30 | """ 31 | PPPPPPPP 32 | R Q RK """ 33 | ) 34 | val board: Position = """ 35 | PPPPP 36 | B KR""".chess960 37 | val g2 = Game(board) 38 | assertEquals(board.destsFrom(G1), Set(F1, H1)) 39 | assertGame( 40 | g2.playMove(G1, H1).get, 41 | """ 42 | PPPPP 43 | B RK """ 44 | ) 45 | test("chess960 close kingside with 2 rooks around"): 46 | val board: Position = """ 47 | PPPPPPPP 48 | RKRBB """.chess960 49 | assertEquals(board.destsFrom(B1), Set()) 50 | test("chess960 close queenside"): 51 | val board: Position = """ 52 | PPPPPPPP 53 | RK B""".chess960 54 | val game = Game(board) 55 | assertEquals(board.destsFrom(B1), Set(A1, C1)) 56 | assertGame( 57 | game.playMove(B1, A1).get, 58 | """ 59 | PPPPPPPP 60 | KR B""" 61 | ) 62 | test("chess960 close queenside as black"): 63 | val game = Game( 64 | """ 65 | b rkr q 66 | p pppppp 67 | p n 68 | 69 | 70 | 71 | 72 | K""".chess960.withColor(Black) 73 | ) 74 | assertEquals(game.position.destsFrom(E8), Set(D8, F8)) 75 | assertGame( 76 | game.playMove(E8, D8).get, 77 | """ 78 | bkr r q 79 | p pppppp 80 | p n 81 | 82 | 83 | 84 | 85 | K""" 86 | ) 87 | test("from position with chess960 castling"): 88 | val game = Game( 89 | makeBoard( 90 | """rk r 91 | pppbnppp 92 | p n 93 | P Pp 94 | P q 95 | R NP 96 | PP PP 97 | KNQRB""", 98 | FromPosition 99 | ).withColor(Black) 100 | ) 101 | assertEquals(game.position.destsFrom(B8), Set(A8, C8, E8)) 102 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/format/pgn/TagTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format.pgn 3 | 4 | class TagTest extends ChessTest: 5 | 6 | // http://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm#c8.1.1 7 | test("be sorted"): 8 | assertEquals( 9 | Tags( 10 | List( 11 | Tag(Tag.Site, "https://lichess.org/QuCzSfxw"), 12 | Tag(Tag.Round, "-"), 13 | Tag(Tag.Date, "2018.05.04"), 14 | Tag(Tag.Black, "penguingim1"), 15 | Tag(Tag.White, "DrDrunkenstein"), 16 | Tag(Tag.Result, "1-0"), 17 | Tag(Tag.UTCDate, "2018.05.04"), 18 | Tag(Tag.UTCTime, "20:59:23"), 19 | Tag(Tag.WhiteElo, "2870"), 20 | Tag(Tag.BlackElo, "2862"), 21 | Tag(Tag.WhiteRatingDiff, "+12"), 22 | Tag(Tag.BlackRatingDiff, "-7"), 23 | Tag(Tag.Event, "Titled Arena 5") 24 | ) 25 | ).sorted.value.map(_.name), 26 | List( 27 | Tag.Event, 28 | Tag.Site, 29 | Tag.Date, 30 | Tag.Round, 31 | Tag.White, 32 | Tag.Black, 33 | Tag.Result, 34 | Tag.UTCDate, 35 | Tag.UTCTime, 36 | Tag.WhiteElo, 37 | Tag.BlackElo, 38 | Tag.WhiteRatingDiff, 39 | Tag.BlackRatingDiff 40 | ) 41 | ) 42 | 43 | test("be trimmed"): 44 | assertEquals( 45 | List( 46 | Tag(_.Site, " https://lichess.org/QuCzSfxw "), 47 | Tag(_.Black, " penguingim1 ") 48 | ), 49 | List( 50 | Tag(_.Site, "https://lichess.org/QuCzSfxw"), 51 | Tag(_.Black, "penguingim1") 52 | ) 53 | ) 54 | 55 | test("sanitize contents"): 56 | assertEquals(Tags.sanitize(List(Tag(Tag.TimeControl, "5+3"))).value, List(Tag(Tag.TimeControl, "5+3"))) 57 | assertEquals( 58 | Tags.sanitize(List(Tag(Tag.TimeControl, "5400/40+30:1800+30\""))).value, 59 | List(Tag(Tag.TimeControl, "5400/40+30:1800+30")) 60 | ) 61 | assertEquals( 62 | Tags.sanitize(List(Tag(Tag.White, "Schwarzenegger, Arnold \"The Terminator\""))).value, 63 | List(Tag(Tag.White, "Schwarzenegger, Arnold \"The Terminator\"")) 64 | ) 65 | 66 | test("clocks MM:SS"): 67 | val tags = Tags(List(Tag(_.WhiteClock, "00:01"), Tag(_.BlackClock, "10:02"))) 68 | assertEquals(tags.clocks.white, Some(Centis.ofSeconds(1))) 69 | assertEquals(tags.clocks.black, Some(Centis.ofSeconds(10 * 60 + 2))) 70 | 71 | test("clocks HH:MM:SS"): 72 | val tags = Tags(List(Tag(_.WhiteClock, "0:00:01"), Tag(_.BlackClock, "10:00:02"))) 73 | assertEquals(tags.clocks.white, Some(Centis.ofSeconds(1))) 74 | assertEquals(tags.clocks.black, Some(Centis.ofSeconds(10 * 3600 + 2))) 75 | 76 | test("clocks seconds"): 77 | val tags = Tags(List(Tag(_.WhiteClock, "1"), Tag(_.BlackClock, "12345"))) 78 | assertEquals(tags.clocks.white, Some(Centis.ofSeconds(1))) 79 | assertEquals(tags.clocks.black, Some(Centis.ofSeconds(12345))) 80 | -------------------------------------------------------------------------------- /rating/src/main/scala/glicko/impl/results.scala: -------------------------------------------------------------------------------- 1 | package chess.rating.glicko 2 | package impl 3 | 4 | private[glicko] trait Result: 5 | 6 | def getAdvantage(advantage: ColorAdvantage, p: Rating): ColorAdvantage 7 | 8 | def getScore(player: Rating): Double 9 | 10 | def getOpponent(player: Rating): Rating 11 | 12 | def participated(player: Rating): Boolean 13 | 14 | def players: List[Rating] 15 | 16 | // score from 0 (opponent wins) to 1 (player wins) 17 | final private[glicko] class FloatingResult(player: Rating, opponent: Rating, score: Float) extends Result: 18 | 19 | def getAdvantage(advantage: ColorAdvantage, p: Rating): ColorAdvantage = ColorAdvantage.zero 20 | 21 | def getScore(p: Rating) = if p == player then score else 1 - score 22 | 23 | def getOpponent(p: Rating) = if p == player then opponent else player 24 | 25 | def participated(p: Rating) = p == player || p == opponent 26 | 27 | def players = List(player, opponent) 28 | 29 | final private[glicko] class GameResult(first: Rating, second: Rating, outcome: chess.Outcome) extends Result: 30 | private val POINTS_FOR_WIN = 1.0d 31 | private val POINTS_FOR_LOSS = 0.0d 32 | private val POINTS_FOR_DRAW = 0.5d 33 | 34 | def players = List(first, second) 35 | 36 | def participated(player: Rating) = player == first || player == second 37 | 38 | def getAdvantage(advantage: ColorAdvantage, player: Rating): ColorAdvantage = 39 | if player == first then advantage.half else advantage.negate.half 40 | 41 | /** Returns the "score" for a match. 42 | * 43 | * @param player 44 | * @return 45 | * 1 for a win, 0.5 for a draw and 0 for a loss 46 | * @throws IllegalArgumentException 47 | */ 48 | def getScore(player: Rating): Double = outcome.winner match 49 | case Some(chess.Color.White) => if player == first then POINTS_FOR_WIN else POINTS_FOR_LOSS 50 | case Some(chess.Color.Black) => if player == first then POINTS_FOR_LOSS else POINTS_FOR_WIN 51 | case _ => 52 | if participated(player) then POINTS_FOR_DRAW 53 | else throw new IllegalArgumentException("Player did not participate in match"); 54 | 55 | def getOpponent(player: Rating) = 56 | if first == player then second 57 | else if second == player then first 58 | else throw new IllegalArgumentException("Player did not participate in match"); 59 | 60 | override def toString = s"$first vs $second = $outcome" 61 | 62 | private[glicko] trait RatingPeriodResults[R <: Result](): 63 | val results: List[R] 64 | def getResults(player: Rating): List[R] = results.filter(_.participated(player)) 65 | def getParticipants: Set[Rating] = results.flatMap(_.players).toSet 66 | 67 | final private[glicko] class GameRatingPeriodResults(val results: List[GameResult]) 68 | extends RatingPeriodResults[GameResult] 69 | 70 | final private[glicko] class FloatingRatingPeriodResults(val results: List[FloatingResult]) 71 | extends RatingPeriodResults[FloatingResult] 72 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/TreeBuilderTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.syntax.all.* 4 | import munit.ScalaCheckSuite 5 | import org.scalacheck.Prop.{ forAll, propBoolean } 6 | 7 | class TreeBuilderTest extends ScalaCheckSuite: 8 | 9 | test("build an empty list return none"): 10 | assertEquals(Tree.build(Nil), None) 11 | assertEquals(Tree.build(Nil, identity), None) 12 | assertEquals(Tree.buildWithIndex(Nil, ((_, _) => ())), None) 13 | assertEquals(Tree.build(List.empty[Node[Int]]), None) 14 | assertEquals(Tree.build[Int, Int](List.empty[Int], Node(_)), None) 15 | assertEquals(Tree.build[Int, Int, Option](Nil, Node(_).some), none.some) 16 | assertEquals(Tree.buildAccumulate[Int, Int, Int](Nil, 0, ((y, x) => (y, Node(x)))), None) 17 | assertEquals( 18 | Tree.buildAccumulate[Int, Int, Int, Option](Nil, 0, ((y, x) => (y, Node(x)).some)), 19 | none.some 20 | ) 21 | 22 | test("build.mainLineValues == identity"): 23 | forAll: (xs: List[Int]) => 24 | Tree.build(xs).fold(Nil)(_.mainlineValues) == xs 25 | Tree.build(xs, identity).fold(Nil)(_.mainlineValues) == xs 26 | 27 | test("buildWithNode.mainLineValues == identity"): 28 | forAll: (xs: List[Int]) => 29 | Tree.build(xs.map(Node(_))).fold(Nil)(_.mainlineValues) == xs 30 | Tree.build[Int, Int](xs, Node(_)).fold(Nil)(_.mainlineValues) == xs 31 | 32 | test("buildWithIndex.mainLineValues == identity"): 33 | forAll: (xs: List[Int]) => 34 | Tree.buildWithIndex(xs, _ -> _).fold(Nil)(_.mainlineValues.map(_._1)) == xs 35 | Tree.buildWithIndex(xs, _ -> _).fold(Nil)(_.mainlineValues.map(_._2)) == (0 until xs.size).toList 36 | 37 | test("buildWithNodeF(identity effect).mainLineValues == identity"): 38 | forAll: (xs: List[Int]) => 39 | Tree.build[Int, Int, Option](xs, Node(_).some).flatten.fold(Nil)(_.mainlineValues) == xs 40 | 41 | test("buildWithNodeM.mainLineValues ~= traverse"): 42 | forAll: (xs: List[Int], f: Int => Option[Int]) => 43 | Tree.build[Int, Int, Option](xs, f(_).map(Node(_))).map(_.fold(Nil)(_.mainlineValues)) == 44 | xs.traverse(f) 45 | 46 | test("buildAccumulate"): 47 | forAll: (xs: List[Int]) => 48 | Tree 49 | .buildAccumulate[Int, Long, Long](xs, 0L, (x, y) => (x + y) -> Node(x + y)) 50 | .fold(0L)(_.lastMainlineNode.value) == xs.map(_.toLong).sum 51 | 52 | test("buildAccumulateM"): 53 | forAll: (xs: List[Int]) => 54 | Tree 55 | .buildAccumulate[Int, Long, Long, Option](xs, 0L, (x, y) => ((x + y) -> Node(x + y)).some) 56 | .flatten 57 | .map(_.lastMainlineNode.value) 58 | .getOrElse(0L) == xs.map(_.toLong).sum 59 | 60 | test("buildAccumulateM ~= traverse"): 61 | forAll: (xs: List[Int], f: Int => Option[Int]) => 62 | Tree 63 | .buildAccumulate[Int, Int, Int, Option](xs, 0, (_, y) => f(y).map(0 -> Node(_))) 64 | .map(_.fold(Nil)(_.mainlineValues)) == xs.traverse(f) 65 | -------------------------------------------------------------------------------- /core/src/main/scala/Divider.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import cats.syntax.all.* 4 | 5 | import scala.annotation.switch 6 | 7 | case class Division(middle: Option[Ply], end: Option[Ply], plies: Ply): 8 | 9 | def openingSize: Ply = middle | plies 10 | def middleSize: Option[Ply] = middle.map((end | plies) - _) 11 | def endSize: Option[Ply] = end.map(plies - _) 12 | def openingBounds: Option[(Int, Ply)] = middle.map(0 -> _) 13 | def middleBounds: Option[(Ply, Ply)] = (middle, end).tupled 14 | def endBounds: Option[(Ply, Ply)] = end.map(_ -> plies) 15 | 16 | object Division: 17 | val empty = Division(None, None, Ply.initial) 18 | 19 | object Divider: 20 | 21 | def apply(boards: List[Board]): Division = 22 | 23 | val indexedBoards: List[(Board, Int)] = boards.zipWithIndex 24 | 25 | val midGame = indexedBoards.collectFirst: 26 | case (board, index) 27 | if (majorsAndMinors(board) <= 10 || backrankSparse(board) || mixedness(board) > 150) => 28 | index 29 | 30 | val endGame = 31 | if midGame.isDefined then 32 | indexedBoards.collectFirst: 33 | case (board, index) if majorsAndMinors(board) <= 6 => index 34 | else None 35 | 36 | Division( 37 | Ply.from(midGame.filter(m => endGame.fold(true)(m < _))), 38 | Ply.from(endGame), 39 | Ply(boards.size) 40 | ) 41 | 42 | private def majorsAndMinors(board: Board): Int = 43 | (board.occupied & ~(board.kings | board.pawns)).count 44 | 45 | // Sparse back-rank indicates that pieces have been developed 46 | private def backrankSparse(board: Board): Boolean = 47 | (Bitboard.firstRank & board.white).count < 4 || 48 | (Bitboard.lastRank & board.black).count < 4 49 | 50 | private def score(y: Int)(white: Int, black: Int): Int = 51 | ((white, black): @switch) match 52 | case (0, 0) => 0 53 | 54 | case (1, 0) => 1 + (8 - y) 55 | case (2, 0) => if y > 2 then 2 + (y - 2) else 0 56 | case (3, 0) => if y > 1 then 3 + (y - 1) else 0 57 | case (4, 0) => 58 | if y > 1 then 3 + (y - 1) else 0 // group of 4 on the homerow = 0 59 | 60 | case (0, 1) => 1 + y 61 | case (1, 1) => 5 + (4 - y).abs 62 | case (2, 1) => 4 + (y - 1) 63 | case (3, 1) => 5 + (y - 1) 64 | 65 | case (0, 2) => if y < 6 then 2 + (6 - y) else 0 66 | case (1, 2) => 4 + (7 - y) 67 | case (2, 2) => 7 68 | 69 | case (0, 3) => if y < 7 then 3 + (7 - y) else 0 70 | case (1, 3) => 5 + (7 - y) 71 | 72 | case (0, 4) => if y < 7 then 3 + (7 - y) else 0 73 | 74 | case _ => 0 75 | 76 | private val mixednessRegions: List[(Long, Int)] = { 77 | val smallSquare = 0x0303L 78 | for 79 | y <- 0 to 6 80 | x <- 0 to 6 81 | yield (smallSquare << (x + 8 * y), y + 1) 82 | }.toList 83 | 84 | private def mixedness(board: Board): Int = 85 | mixednessRegions.foldLeft(0): 86 | case (acc, (region, y)) => 87 | acc + board.byColor.mapReduce(c => (c & region).count)(score(y)) 88 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/DestinationsBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import java.util.concurrent.TimeUnit 5 | 6 | import org.openjdk.jmh.infra.Blackhole 7 | import chess.format.* 8 | import chess.perft.Perft 9 | import chess.variant.* 10 | import chess.Position 11 | 12 | @State(Scope.Thread) 13 | @BenchmarkMode(Array(Mode.Throughput)) 14 | @OutputTimeUnit(TimeUnit.MILLISECONDS) 15 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 16 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 17 | @Fork(3) 18 | @Threads(value = 1) 19 | class DestinationsBench: 20 | 21 | private val Work: Long = 5 22 | 23 | @Param(Array("100")) 24 | var games: Int = scala.compiletime.uninitialized 25 | 26 | var threecheckInput: List[Position] = scala.compiletime.uninitialized 27 | var antichessInput: List[Position] = scala.compiletime.uninitialized 28 | var atomicInput: List[Position] = scala.compiletime.uninitialized 29 | var crazyhouseInput: List[Position] = scala.compiletime.uninitialized 30 | var racingkingsInput: List[Position] = scala.compiletime.uninitialized 31 | var hordeInput: List[Position] = scala.compiletime.uninitialized 32 | var randomInput: List[Position] = scala.compiletime.uninitialized 33 | var trickyInput: List[Position] = scala.compiletime.uninitialized 34 | 35 | @Setup 36 | def setup(): Unit = 37 | threecheckInput = makeBoards(Perft.threeCheckPerfts, ThreeCheck, games) 38 | antichessInput = makeBoards(Perft.antichessPerfts, Antichess, games) 39 | atomicInput = makeBoards(Perft.atomicPerfts, Atomic, games) 40 | crazyhouseInput = makeBoards(Perft.crazyhousePerfts, Crazyhouse, games) 41 | racingkingsInput = makeBoards(Perft.racingkingsPerfts, RacingKings, games) 42 | hordeInput = makeBoards(Perft.hordePerfts, Horde, games) 43 | randomInput = makeBoards(Perft.randomPerfts, Chess960, games) 44 | trickyInput = makeBoards(Perft.trickyPerfts, Chess960, games) 45 | 46 | @Benchmark 47 | def threecheck(bh: Blackhole) = 48 | bench(threecheckInput)(bh) 49 | 50 | @Benchmark 51 | def antichess(bh: Blackhole) = 52 | bench(antichessInput)(bh) 53 | 54 | @Benchmark 55 | def atomic(bh: Blackhole) = 56 | bench(atomicInput)(bh) 57 | 58 | @Benchmark 59 | def crazyhouse(bh: Blackhole) = 60 | bench(crazyhouseInput)(bh) 61 | 62 | @Benchmark 63 | def horde(bh: Blackhole) = 64 | bench(hordeInput)(bh) 65 | 66 | @Benchmark 67 | def racingkings(bh: Blackhole) = 68 | bench(racingkingsInput)(bh) 69 | 70 | @Benchmark 71 | def chess960(bh: Blackhole) = 72 | bench(randomInput)(bh) 73 | 74 | @Benchmark 75 | def tricky(bh: Blackhole) = 76 | bench(trickyInput)(bh) 77 | 78 | private def bench(sits: List[Position])(bh: Blackhole) = 79 | val x = sits.map: x => 80 | Blackhole.consumeCPU(Work) 81 | x.destinations 82 | bh.consume(x) 83 | 84 | private def makeBoards(perfts: List[Perft], variant: Variant, games: Int): List[Position] = 85 | perfts 86 | .take(games) 87 | .map(p => Fen.read(variant, p.epd).getOrElse(throw RuntimeException("boooo"))) 88 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/FenWriterBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import chess.variant.* 8 | import chess.* 9 | import chess.perft.Perft 10 | import chess.format.Fen 11 | 12 | @State(Scope.Thread) 13 | @BenchmarkMode(Array(Mode.Throughput)) 14 | @OutputTimeUnit(TimeUnit.MILLISECONDS) 15 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 16 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 17 | @Fork(value = 3) 18 | @Threads(value = 1) 19 | class FenWriterBench: 20 | 21 | private val Work: Long = 5 22 | 23 | @Param(Array("100")) 24 | var games: Int = scala.compiletime.uninitialized 25 | 26 | var threecheckInput: List[Game] = scala.compiletime.uninitialized 27 | var antichessInput: List[Game] = scala.compiletime.uninitialized 28 | var atomicInput: List[Game] = scala.compiletime.uninitialized 29 | var crazyhouseInput: List[Game] = scala.compiletime.uninitialized 30 | var racingkingsInput: List[Game] = scala.compiletime.uninitialized 31 | var hordeInput: List[Game] = scala.compiletime.uninitialized 32 | var randomInput: List[Game] = scala.compiletime.uninitialized 33 | var trickyInput: List[Game] = scala.compiletime.uninitialized 34 | 35 | @Setup 36 | def setup(): Unit = 37 | threecheckInput = makeBoards(Perft.threeCheckPerfts, ThreeCheck, games) 38 | antichessInput = makeBoards(Perft.antichessPerfts, Antichess, games) 39 | atomicInput = makeBoards(Perft.atomicPerfts, Atomic, games) 40 | crazyhouseInput = makeBoards(Perft.crazyhousePerfts, Crazyhouse, games) 41 | racingkingsInput = makeBoards(Perft.racingkingsPerfts, RacingKings, games) 42 | hordeInput = makeBoards(Perft.hordePerfts, Horde, games) 43 | randomInput = makeBoards(Perft.randomPerfts, Chess960, games) 44 | trickyInput = makeBoards(Perft.trickyPerfts, Chess960, games) 45 | 46 | @Benchmark 47 | def threecheck(bh: Blackhole) = 48 | bench(threecheckInput)(bh) 49 | 50 | @Benchmark 51 | def antichess(bh: Blackhole) = 52 | bench(antichessInput)(bh) 53 | 54 | @Benchmark 55 | def atomic(bh: Blackhole) = 56 | bench(atomicInput)(bh) 57 | 58 | @Benchmark 59 | def crazyhouse(bh: Blackhole) = 60 | bench(crazyhouseInput)(bh) 61 | 62 | @Benchmark 63 | def horde(bh: Blackhole) = 64 | bench(hordeInput)(bh) 65 | 66 | @Benchmark 67 | def racingkings(bh: Blackhole) = 68 | bench(racingkingsInput)(bh) 69 | 70 | @Benchmark 71 | def chess960(bh: Blackhole) = 72 | bench(randomInput)(bh) 73 | 74 | @Benchmark 75 | def tricky(bh: Blackhole) = 76 | bench(trickyInput)(bh) 77 | 78 | private def bench(sits: List[Game])(bh: Blackhole) = 79 | val x = sits.map: x => 80 | Blackhole.consumeCPU(Work) 81 | Fen.write(x) 82 | bh.consume(x) 83 | 84 | private def makeBoards(perfts: List[Perft], variant: Variant, games: Int): List[Game] = 85 | perfts 86 | .take(games) 87 | .map(p => Fen.read(variant, p.epd).getOrElse(throw RuntimeException("boooo"))) 88 | .map(s => Game(s, ply = Ply.initial)) 89 | -------------------------------------------------------------------------------- /core/src/main/scala/eval.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package eval 3 | 4 | import scalalib.model.Percent 5 | 6 | enum Score: 7 | case Cp(c: Eval.Cp) 8 | case Mate(m: Eval.Mate) 9 | 10 | inline def fold[A](w: Eval.Cp => A, b: Eval.Mate => A): A = this match 11 | case Cp(cp) => w(cp) 12 | case Mate(mate) => b(mate) 13 | 14 | inline def cp: Option[Eval.Cp] = fold(Some(_), _ => None) 15 | inline def mate: Option[Eval.Mate] = fold(_ => None, Some(_)) 16 | 17 | inline def isCheckmate = mate.exists(_.value == 0) 18 | inline def mateFound = mate.isDefined 19 | 20 | inline def invert: Score = fold(c => Cp(c.invert), m => Mate(m.invert)) 21 | inline def invertIf(cond: Boolean): Score = if cond then invert else this 22 | 23 | object Score: 24 | val initial = Cp(Eval.Cp.initial) 25 | def cp(cp: Int): Score = Cp(Eval.Cp(cp)) 26 | def mate(mate: Int): Score = Mate(Eval.Mate(mate)) 27 | 28 | object Eval: 29 | opaque type Cp = Int 30 | object Cp extends OpaqueInt[Cp]: 31 | val CEILING = Cp(1000) 32 | val initial = Cp(15) 33 | inline def ceilingWithSignum(signum: Int) = CEILING.invertIf(signum < 0) 34 | 35 | extension (cp: Cp) 36 | inline def centipawns = cp.value 37 | 38 | inline def pawns: Float = cp.value / 100f 39 | inline def showPawns: String = "%.2f".format(pawns) 40 | 41 | inline def ceiled: Cp = 42 | if cp.value > Cp.CEILING then Cp.CEILING 43 | else if cp.value < -Cp.CEILING then -Cp.CEILING 44 | else cp 45 | 46 | inline def invert: Cp = Cp(-cp.value) 47 | inline def invertIf(cond: Boolean): Cp = if cond then invert else cp 48 | 49 | def signum: Int = Math.signum(cp.value.toFloat).toInt 50 | 51 | end Cp 52 | 53 | opaque type Mate = Int 54 | object Mate extends OpaqueInt[Mate]: 55 | extension (mate: Mate) 56 | inline def moves: Int = mate.value 57 | 58 | inline def invert: Mate = Mate(-moves) 59 | inline def invertIf(cond: Boolean): Mate = if cond then invert else mate 60 | 61 | inline def signum: Int = if positive then 1 else -1 62 | 63 | inline def positive = mate.value > 0 64 | inline def negative = mate.value < 0 65 | 66 | // How likely one is to win a position, based on subjective Stockfish centipawns 67 | opaque type WinPercent = Double 68 | object WinPercent extends OpaqueDouble[WinPercent]: 69 | 70 | // given lila.db.NoDbHandler[WinPercent] with {} 71 | given Percent[WinPercent] = Percent.of(WinPercent) 72 | 73 | extension (a: WinPercent) def toInt = Percent.toInt(a) 74 | 75 | def fromScore(score: Score): WinPercent = score.fold(fromCentiPawns, fromMate) 76 | 77 | def fromMate(mate: Eval.Mate) = fromCentiPawns(Eval.Cp.ceilingWithSignum(mate.signum)) 78 | 79 | // [0, 100] 80 | def fromCentiPawns(cp: Eval.Cp) = WinPercent: 81 | 50 + 50 * winningChances(cp.ceiled) 82 | 83 | inline def fromPercent(int: Int) = WinPercent(int.toDouble) 84 | 85 | // [-1, +1] 86 | def winningChances(cp: Eval.Cp) = { 87 | val MULTIPLIER = -0.00368208 // https://github.com/lichess-org/lila/pull/11148 88 | 2 / (1 + Math.exp(MULTIPLIER * cp.value)) - 1 89 | }.atLeast(-1).atMost(+1) 90 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/FenReaderBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import chess.variant.* 8 | import chess.format.FullFen 9 | import chess.perft.Perft 10 | import chess.format.Fen 11 | 12 | @State(Scope.Thread) 13 | @BenchmarkMode(Array(Mode.Throughput)) 14 | @OutputTimeUnit(TimeUnit.MILLISECONDS) 15 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 16 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 17 | @Fork(value = 3) 18 | @Threads(value = 1) 19 | class FenReaderBench: 20 | 21 | private val Work: Long = 5 22 | 23 | @Param(Array("100")) 24 | var games: Int = scala.compiletime.uninitialized 25 | 26 | var threecheckInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 27 | var antichessInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 28 | var atomicInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 29 | var crazyhouseInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 30 | var racingkingsInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 31 | var hordeInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 32 | var randomInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 33 | var trickyInput: List[(Variant, FullFen)] = scala.compiletime.uninitialized 34 | 35 | @Setup 36 | def setup(): Unit = 37 | threecheckInput = makeFens(Perft.threeCheckPerfts, ThreeCheck, games) 38 | antichessInput = makeFens(Perft.antichessPerfts, Antichess, games) 39 | atomicInput = makeFens(Perft.atomicPerfts, Atomic, games) 40 | crazyhouseInput = makeFens(Perft.crazyhousePerfts, Crazyhouse, games) 41 | racingkingsInput = makeFens(Perft.racingkingsPerfts, RacingKings, games) 42 | hordeInput = makeFens(Perft.hordePerfts, Horde, games) 43 | randomInput = makeFens(Perft.randomPerfts, Chess960, games) 44 | trickyInput = makeFens(Perft.trickyPerfts, Chess960, games) 45 | 46 | @Benchmark 47 | def threecheck(bh: Blackhole) = 48 | bench(threecheckInput)(bh) 49 | 50 | @Benchmark 51 | def antichess(bh: Blackhole) = 52 | bench(antichessInput)(bh) 53 | 54 | @Benchmark 55 | def atomic(bh: Blackhole) = 56 | bench(atomicInput)(bh) 57 | 58 | @Benchmark 59 | def crazyhouse(bh: Blackhole) = 60 | bench(crazyhouseInput)(bh) 61 | 62 | @Benchmark 63 | def horde(bh: Blackhole) = 64 | bench(hordeInput)(bh) 65 | 66 | @Benchmark 67 | def racingkings(bh: Blackhole) = 68 | bench(racingkingsInput)(bh) 69 | 70 | @Benchmark 71 | def chess960(bh: Blackhole) = 72 | bench(randomInput)(bh) 73 | 74 | @Benchmark 75 | def tricky(bh: Blackhole) = 76 | bench(trickyInput)(bh) 77 | 78 | private def bench(sits: List[(Variant, FullFen)])(bh: Blackhole) = 79 | val x = sits.map: x => 80 | Blackhole.consumeCPU(Work) 81 | Fen.read(x._1, x._2) 82 | bh.consume(x) 83 | 84 | private def makeFens(perfts: List[Perft], variant: Variant, games: Int): List[(Variant, FullFen)] = 85 | perfts 86 | .take(games) 87 | .map(p => variant -> p.epd) 88 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/PerftBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import chess.perft.Perft 8 | import chess.variant.* 9 | 10 | @State(Scope.Thread) 11 | @BenchmarkMode(Array(Mode.Throughput)) 12 | @OutputTimeUnit(TimeUnit.SECONDS) 13 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 14 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 15 | @Fork(value = 3) 16 | @Threads(value = 1) 17 | class PerftBench: 18 | 19 | // the unit of CPU work per iteration 20 | private val Work: Long = 10 21 | 22 | @Param(Array("10")) 23 | var games: Int = scala.compiletime.uninitialized 24 | 25 | @Param(Array("10000", "100000", "1000000", "10000000")) 26 | var nodes: Long = scala.compiletime.uninitialized 27 | 28 | var threecheckPerfts: List[Perft] = scala.compiletime.uninitialized 29 | var antichessPerfts: List[Perft] = scala.compiletime.uninitialized 30 | var atomicPerfts: List[Perft] = scala.compiletime.uninitialized 31 | var crazyhousePerfts: List[Perft] = scala.compiletime.uninitialized 32 | var racingkingsPerfts: List[Perft] = scala.compiletime.uninitialized 33 | var hordePerfts: List[Perft] = scala.compiletime.uninitialized 34 | var randomPerfts: List[Perft] = scala.compiletime.uninitialized 35 | var trickyPerfts: List[Perft] = scala.compiletime.uninitialized 36 | 37 | @Setup 38 | def setup(): Unit = 39 | threecheckPerfts = makePerft(Perft.threeCheckPerfts, games, nodes) 40 | antichessPerfts = makePerft(Perft.antichessPerfts, games, nodes) 41 | atomicPerfts = makePerft(Perft.atomicPerfts, games, nodes) 42 | crazyhousePerfts = makePerft(Perft.crazyhousePerfts, games, nodes) 43 | racingkingsPerfts = makePerft(Perft.racingkingsPerfts, games, nodes) 44 | hordePerfts = makePerft(Perft.hordePerfts, games, nodes) 45 | randomPerfts = makePerft(Perft.randomPerfts, games, nodes) 46 | trickyPerfts = makePerft(Perft.trickyPerfts, games, nodes) 47 | 48 | @Benchmark 49 | def threecheck(bh: Blackhole) = 50 | bench(threecheckPerfts, ThreeCheck)(bh) 51 | 52 | @Benchmark 53 | def antichess(bh: Blackhole) = 54 | bench(antichessPerfts, Antichess)(bh) 55 | 56 | @Benchmark 57 | def atomic(bh: Blackhole) = 58 | bench(atomicPerfts, Atomic)(bh) 59 | 60 | @Benchmark 61 | def crazyhouse(bh: Blackhole) = 62 | bench(crazyhousePerfts, Crazyhouse)(bh) 63 | 64 | @Benchmark 65 | def horde(bh: Blackhole) = 66 | bench(hordePerfts, Horde)(bh) 67 | 68 | @Benchmark 69 | def racingkings(bh: Blackhole) = 70 | bench(racingkingsPerfts, RacingKings)(bh) 71 | 72 | @Benchmark 73 | def chess960(bh: Blackhole) = 74 | bench(randomPerfts, Chess960)(bh) 75 | 76 | @Benchmark 77 | def tricky(bh: Blackhole) = 78 | bench(trickyPerfts, Chess960)(bh) 79 | 80 | private def makePerft(perfts: List[Perft], games: Int, nodes: Long) = 81 | perfts.take(games).map(_.withLimit(nodes)) 82 | 83 | private def bench(perfts: List[Perft], variant: Variant)(bh: Blackhole) = 84 | var i = 0 85 | while i < perfts.size do 86 | val game = perfts(i) 87 | Blackhole.consumeCPU(Work) 88 | bh.consume(game.calculate(variant)) 89 | i += 1 90 | -------------------------------------------------------------------------------- /core/src/main/scala/Castles.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import scala.annotation.targetName 4 | 5 | import Square.* 6 | 7 | opaque type Castles = Long 8 | object Castles: 9 | 10 | extension (c: Castles) 11 | 12 | inline def can(inline color: Color): Boolean = Bitboard.rank(color.backRank).intersects(c) 13 | inline def can(inline color: Color, inline side: Side) = c.contains(color.at(side)) 14 | 15 | def isEmpty = c == 0L 16 | 17 | def whiteKingSide: Boolean = c.contains(H1) 18 | def whiteQueenSide: Boolean = c.contains(A1) 19 | def blackKingSide: Boolean = c.contains(H8) 20 | def blackQueenSide: Boolean = c.contains(A8) 21 | 22 | def without(color: Color): Castles = 23 | c & Bitboard.rank(color.lastRank) 24 | 25 | def without(color: Color, side: Side): Castles = 26 | c & ~color.at(side).bl 27 | 28 | def add(color: Color, side: Side): Castles = 29 | c.addSquare(color.at(side)) 30 | 31 | def update(color: Color, kingSide: Boolean, queenSide: Boolean): Castles = 32 | c.without(color) | kingSide.at(color.kingSide) | queenSide.at(color.queenSide) 33 | 34 | def toSeq: Array[Boolean] = Array(whiteKingSide, whiteQueenSide, blackKingSide, blackQueenSide) 35 | 36 | inline def unary_~ : Castles = ~c 37 | inline infix def &(inline o: Long): Castles = c & o 38 | inline infix def ^(inline o: Long): Castles = c ^ o 39 | inline infix def |(inline o: Long): Castles = c | o 40 | 41 | @targetName("andB") 42 | inline infix def &(o: Bitboard): Castles = c & o.value 43 | @targetName("xorB") 44 | inline infix def ^(o: Bitboard): Castles = c ^ o.value 45 | @targetName("orB") 46 | inline infix def |(o: Bitboard): Castles = c | o.value 47 | 48 | inline def value: Long = c 49 | inline def bb: Bitboard = Bitboard(c) 50 | 51 | inline def contains(inline square: Square): Boolean = 52 | (c & (1L << square.value)) != 0L 53 | 54 | def addSquare(square: Square): Castles = c | square.bl 55 | 56 | extension (b: Boolean) inline def at(square: Square) = if b then square.bl else none 57 | 58 | extension (color: Color) 59 | inline def at(side: Side): Square = 60 | (color, side) match 61 | case (White, KingSide) => H1 62 | case (White, QueenSide) => A1 63 | case (Black, KingSide) => H8 64 | case (Black, QueenSide) => A8 65 | 66 | inline def kingSide: Square = at(KingSide) 67 | inline def queenSide: Square = at(QueenSide) 68 | 69 | def apply( 70 | whiteKingSide: Boolean, 71 | whiteQueenSide: Boolean, 72 | blackKingSide: Boolean, 73 | blackQueenSide: Boolean 74 | ): Castles = 75 | whiteKingSide.at(White.kingSide) | whiteQueenSide.at(White.queenSide) | 76 | blackKingSide.at(Black.kingSide) | blackQueenSide.at(Black.queenSide) 77 | 78 | inline def apply(inline l: Long): Castles = init & l 79 | 80 | @targetName("applyBitboard") 81 | def apply(bb: Bitboard): Castles = init & bb.value 82 | 83 | // consider x-fen notation 84 | val charToSquare: (c: Char) => Option[Square] = 85 | case 'k' => Some(H8) 86 | case 'q' => Some(A8) 87 | case 'K' => Some(H1) 88 | case 'Q' => Some(A1) 89 | case _ => None 90 | 91 | val init: Castles = 0x8100000000000081L 92 | val none: Castles = 0L 93 | val black: Castles = 0x8100000000000000L 94 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/perft/Perft.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package perft 3 | 4 | import chess.format.{ Fen, FullFen } 5 | import chess.variant.{ Crazyhouse, Variant } 6 | 7 | case class Perft(id: String, epd: FullFen, cases: List[TestCase]): 8 | import Perft.* 9 | def calculate(variant: Variant): List[Result] = 10 | val board = 11 | Fen.read(variant, epd).getOrElse { throw RuntimeException(s"Invalid fen: $epd for variant: $variant") } 12 | cases.map(c => 13 | // printResult(board.divide(c.depth)) 14 | Result(c.depth, board.perft(c.depth), c.result) 15 | ) 16 | 17 | def withLimit(limit: Long): Perft = 18 | copy(cases = cases.filter(_.result < limit)) 19 | 20 | case class TestCase(depth: Int, result: Long) 21 | case class Result(depth: Int, result: Long, expected: Long) 22 | 23 | case class DivideResult(val move: MoveOrDrop, nodes: Long): 24 | override def toString(): String = 25 | s"${move.toUci.uci} $nodes" 26 | 27 | object Perft: 28 | 29 | lazy val threeCheckPerfts = Perft.read("3check.perft") 30 | lazy val antichessPerfts = Perft.read("antichess.perft") 31 | lazy val atomicPerfts = Perft.read("atomic.perft") 32 | lazy val crazyhousePerfts = Perft.read("crazyhouse.perft") 33 | lazy val hordePerfts = Perft.read("horde.perft") 34 | lazy val racingkingsPerfts = Perft.read("racingkings.perft") 35 | lazy val randomPerfts = Perft.read("random.perft") 36 | lazy val trickyPerfts = Perft.read("tricky.perft") 37 | lazy val chess960 = Perft.read("chess960.perft") 38 | 39 | private def read(file: String): List[Perft] = 40 | import cats.implicits.toShow 41 | val str = io.Source.fromResource(file).mkString 42 | Parser.parseAll(str).fold(ex => throw RuntimeException(s"Parsing error: $file: ${ex.show}"), identity) 43 | 44 | def printResult(results: List[DivideResult]) = 45 | val builder = StringBuilder() 46 | var sum = 0L 47 | results.foreach { r => 48 | sum += r.nodes 49 | builder.append(r).append("\n") 50 | } 51 | builder.append("\n").append(sum) 52 | println(builder) 53 | 54 | extension (s: Position) 55 | 56 | def divide(depth: Int): List[DivideResult] = 57 | if depth == 0 then Nil 58 | else if s.perftEnd then Nil 59 | else 60 | s.perftMoves 61 | .map { move => 62 | val nodes = move.after.perft(depth - 1) 63 | DivideResult(move, nodes) 64 | } 65 | .sortBy(_.move.toUci.uci) 66 | 67 | def perft(depth: Int): Long = 68 | if depth == 0 then 1L 69 | else if s.perftEnd then 0L 70 | else 71 | val moves = s.perftMoves 72 | if depth == 1 then moves.size.toLong 73 | else moves.map(_.after.perft(depth - 1)).sum 74 | 75 | private def perftMoves: List[MoveOrDrop] = 76 | if s.variant == chess.variant.Crazyhouse 77 | then Crazyhouse.legalMoves(s) 78 | else 79 | val legalMoves = s.legalMoves 80 | if s.variant.chess960 then legalMoves 81 | // if variant is not chess960 we need to deduplicated castlings moves 82 | // We filter out castling move that is Standard and king's dest is not in the rook position 83 | else legalMoves.filterNot(m => m.castle.exists(c => c.isStandard && m.dest != c.rook)) 84 | 85 | // when calculate perft we don't do autoDraw 86 | def perftEnd = s.checkMate || s.staleMate || s.variantEnd || s.variant.specialDraw(s) 87 | -------------------------------------------------------------------------------- /test-kit/src/test/scala/PlayTest.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | 3 | import chess.Square.* 4 | import chess.format.Visual.addNewLines 5 | import chess.format.{ Fen, FullFen } 6 | import chess.variant.Standard 7 | 8 | class PlayTest extends ChessTest: 9 | 10 | test("only kingside rights"): 11 | val game = fenToGame( 12 | FullFen("4k2r/8/8/6R1/6r1/3K4/8/8 b k - 3 4"), 13 | Standard 14 | ).playMoves( 15 | G4 -> G2, 16 | G5 -> G8, 17 | G2 -> G8, 18 | D3 -> E3, 19 | G8 -> G5 20 | ) 21 | assertRight(game): game => 22 | val fen = Fen.write(game) 23 | fen == FullFen("4k2r/8/8/6r1/8/4K3/8/8 w k - 2 3") 24 | 25 | test("kingside and queenside rights"): 26 | val game = fenToGame( 27 | FullFen("r3k2r/8/8/6R1/6r1/3K4/8/8 b kq - 3 4"), 28 | Standard 29 | ).playMoves( 30 | G4 -> G2, 31 | G5 -> G8, 32 | G2 -> G8, 33 | D3 -> E3, 34 | G8 -> G5 35 | ) 36 | assertRight(game): game => 37 | val fen = Fen.write(game) 38 | fen == FullFen("r3k2r/8/8/6r1/8/4K3/8/8 w kq - 2 3") 39 | 40 | val game = 41 | makeGame.playMoves(E2 -> E4, E7 -> E5, F1 -> C4, G8 -> F6, D2 -> D3, C7 -> C6, C1 -> G5, H7 -> H6).get 42 | test("current game"): 43 | assertEquals( 44 | addNewLines(game.position.visual), 45 | """ 46 | rnbqkb r 47 | pp p pp 48 | p n p 49 | p B 50 | B P 51 | P 52 | PPP PPP 53 | RN QK NR 54 | """ 55 | ) 56 | test("after recapture"): 57 | assertEquals( 58 | addNewLines(game.playMoves(G5 -> F6, D8 -> F6).get.position.visual), 59 | """ 60 | rnb kb r 61 | pp p pp 62 | p q p 63 | p 64 | B P 65 | P 66 | PPP PPP 67 | RN QK NR 68 | """ 69 | ) 70 | test("Deep Blue vs Kasparov 1"): 71 | val g = makeGame 72 | .playMoves( 73 | E2 -> E4, 74 | C7 -> C5, 75 | C2 -> C3, 76 | D7 -> D5, 77 | E4 -> D5, 78 | D8 -> D5, 79 | D2 -> D4, 80 | G8 -> F6, 81 | G1 -> F3, 82 | C8 -> G4, 83 | F1 -> E2, 84 | E7 -> E6, 85 | H2 -> H3, 86 | G4 -> H5, 87 | E1 -> G1, 88 | B8 -> C6, 89 | C1 -> E3, 90 | C5 -> D4, 91 | C3 -> D4, 92 | F8 -> B4 93 | ) 94 | .get 95 | assertEquals( 96 | addNewLines(g.position.visual), 97 | """ 98 | r k r 99 | pp ppp 100 | n pn 101 | q b 102 | b P 103 | BN P 104 | PP BPP 105 | RN Q RK 106 | """ 107 | ) 108 | 109 | test("Peruvian Immortal"): 110 | val g = makeGame 111 | .playMoves( 112 | E2 -> E4, 113 | D7 -> D5, 114 | E4 -> D5, 115 | D8 -> D5, 116 | B1 -> C3, 117 | D5 -> A5, 118 | D2 -> D4, 119 | C7 -> C6, 120 | G1 -> F3, 121 | C8 -> G4, 122 | C1 -> F4, 123 | E7 -> E6, 124 | H2 -> H3, 125 | G4 -> F3, 126 | D1 -> F3, 127 | F8 -> B4, 128 | F1 -> E2, 129 | B8 -> D7, 130 | A2 -> A3, 131 | E8 -> C8, 132 | A3 -> B4, 133 | A5 -> A1, 134 | E1 -> D2, 135 | A1 -> H1, 136 | F3 -> C6, 137 | B7 -> C6, 138 | E2 -> A6 139 | ) 140 | .get 141 | assertEquals( 142 | addNewLines(g.position.visual), 143 | """ 144 | kr nr 145 | p n ppp 146 | B p p 147 | 148 | P P B 149 | N P 150 | PPK PP 151 | q 152 | """ 153 | ) 154 | -------------------------------------------------------------------------------- /bench/src/main/scala/benchmarks/TiebreakBench.scala: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import org.openjdk.jmh.annotations.* 4 | import org.openjdk.jmh.infra.Blackhole 5 | import java.util.concurrent.TimeUnit 6 | 7 | import cats.syntax.all.* 8 | import chess.tiebreak.Tiebreak.* 9 | import chess.tiebreak.TiebreakPoint 10 | import chess.tiebreak.* 11 | 12 | @State(Scope.Thread) 13 | @BenchmarkMode(Array(Mode.Throughput)) 14 | @OutputTimeUnit(TimeUnit.SECONDS) 15 | @Measurement(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 16 | @Warmup(iterations = 15, timeUnit = TimeUnit.SECONDS, time = 3) 17 | @Fork(value = 3) 18 | @Threads(value = 1) 19 | class TiebreakBench: 20 | 21 | private val Work: Long = 10 22 | 23 | var tournament: Tournament = scala.compiletime.uninitialized 24 | 25 | @Setup 26 | def setup(): Unit = 27 | val games = Helper.games("FWWRC.pgn") 28 | val lastRoundId = Helper.lastRoundId(games) 29 | tournament = Tournament(games, lastRoundId) 30 | 31 | @Benchmark 32 | def averageOfOpponentsBuchholz(bh: Blackhole) = 33 | bh.consume: 34 | AverageOfOpponentsBuchholz.compute(tournament, Map.empty) 35 | 36 | @Benchmark 37 | def averagePerfectPerformanceOfOpponents(bh: Blackhole) = 38 | bh.consume: 39 | AveragePerfectPerformanceOfOpponents.compute(tournament, Map.empty) 40 | 41 | @Benchmark 42 | def directEncounter(bh: Blackhole) = 43 | bh.consume: 44 | DirectEncounter.compute(tournament, Map.empty) 45 | 46 | @Benchmark 47 | def perfectTournamentPerformance(bh: Blackhole) = 48 | bh.consume: 49 | PerfectTournamentPerformance.compute(tournament, Map.empty) 50 | 51 | @Benchmark 52 | def sonnebornBerger(bh: Blackhole) = 53 | bh.consume: 54 | SonnebornBerger(CutModifier.None).compute(tournament, Map.empty) 55 | 56 | @Benchmark 57 | def averageRatingOfOpponents(bh: Blackhole) = 58 | bh.consume: 59 | AverageRatingOfOpponents(CutModifier.None).compute(tournament, Map.empty) 60 | 61 | @Benchmark 62 | def foreBuchholz(bh: Blackhole) = 63 | bh.consume: 64 | ForeBuchholz(CutModifier.None).compute(tournament, Map.empty) 65 | 66 | @Benchmark 67 | def koyaSystem(bh: Blackhole) = 68 | bh.consume: 69 | KoyaSystem(LimitModifier.default).compute(tournament, Map.empty) 70 | 71 | @Benchmark 72 | def blackPlayedGames(bh: Blackhole) = 73 | bh.consume: 74 | NbBlackGames.compute(tournament, Map.empty) 75 | 76 | @Benchmark 77 | def blackWonGames(bh: Blackhole) = 78 | bh.consume: 79 | NbBlackWins.compute(tournament, Map.empty) 80 | 81 | @Benchmark 82 | def gamesWon(bh: Blackhole) = 83 | bh.consume: 84 | NbWins.compute(tournament, Map.empty) 85 | 86 | @Benchmark 87 | def tournamentPerformanceRating(bh: Blackhole) = 88 | bh.consume: 89 | TournamentPerformanceRating.compute(tournament, Map.empty) 90 | 91 | @Benchmark 92 | def averagePerformanceOfOpponents(bh: Blackhole) = 93 | bh.consume: 94 | AveragePerformanceOfOpponents.compute(tournament, Map.empty) 95 | 96 | @Benchmark 97 | def progressiveScores(bh: Blackhole) = 98 | bh.consume: 99 | SumOfProgressiveScores(CutModifier.None).compute(tournament, Map.empty) 100 | 101 | @Benchmark 102 | def fullTournament(bh: Blackhole) = 103 | bh.consume: 104 | tournament.compute( 105 | List( 106 | DirectEncounter, 107 | AverageOfOpponentsBuchholz, 108 | AveragePerfectPerformanceOfOpponents, 109 | PerfectTournamentPerformance, 110 | SonnebornBerger(CutModifier.None) 111 | ) 112 | ) 113 | -------------------------------------------------------------------------------- /core/src/main/scala/format/Fen.scala: -------------------------------------------------------------------------------- 1 | package chess 2 | package format 3 | 4 | object Fen extends FenReader with FenWriter: 5 | export format.{ BoardFen as Board, FullFen as Full, SimpleFen as Simple, StandardFen as Standard } 6 | export FullFen.initial 7 | 8 | // https://www.chessprogramming.org/Extended_Position_Description 9 | // r3k2r/p3n1pp/2q2p2/4n1B1/5Q2/5P2/PP3P1P/R4RK1 b kq - 6 20 10 | // rnbqkbnr/ppp1pppp/8/1B1p4/4P3/8/PPPP1PPP/RNBQK1NR b KQkq - 1 2 +1+0 (3check) 11 | // r1bqkbnr/pppp1Qpp/2n5/4p3/4P3/8/PPPP1PPP/RNB1KBNR b KQkq - 2+3 0 3 (winboards 3check) 12 | opaque type FullFen = String 13 | object FullFen extends OpaqueString[FullFen]: 14 | 15 | extension (a: FullFen) 16 | def parts: (String, Option[Color], String, Option[Square]) = 17 | val parts = a.split(' ') 18 | ( 19 | a.takeWhile(_ != ' '), 20 | getColor(parts), 21 | parts.lift(2).getOrElse("-"), 22 | parts.lift(3).flatMap(Square.fromKey(_)) 23 | ) 24 | def colorOrWhite: Color = SimpleFen.colorOrWhite(a) 25 | 26 | def isInitial: Boolean = a == initial 27 | 28 | def simple: SimpleFen = SimpleFen.fromFull(a) 29 | def opening: StandardFen = SimpleFen.opening(a) 30 | def board: BoardFen = SimpleFen.board(a) 31 | 32 | val initial: FullFen = FullFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") 33 | def clean(source: String): FullFen = FullFen(source.replace("_", " ").trim) 34 | def getColor(arr: Array[String]): Option[Color] = 35 | if arr.length < 2 then None 36 | else if arr(1).size != 1 then None 37 | else Color(arr(1).head) 38 | 39 | // a.k.a. just FEN. 40 | // r3k2r/p3n1pp/2q2p2/4n1B1/5Q2/5P2/PP3P1P/R4RK1 b kq - 41 | opaque type SimpleFen = String 42 | object SimpleFen extends OpaqueString[SimpleFen]: 43 | extension (a: SimpleFen) 44 | def color: Option[Color] = a.split(' ').lift(1).flatMap(_.headOption).flatMap(Color.apply) 45 | def colorOrWhite: Color = color | Color.White 46 | def castling: String = a.split(' ').lift(2) | "-" 47 | def enpassant: Option[Square] = a.split(' ').lift(3).flatMap(Square.fromKey(_)) 48 | def opening: StandardFen = StandardFen.fromSimple(a) 49 | def board: BoardFen = a.takeWhile(_ != ' ') 50 | def fromFull(fen: FullFen): StandardFen = 51 | fen.value.split(' ').take(4) match 52 | case Array(board, turn, castle, ep) => SimpleFen(s"$board $turn $castle $ep") 53 | case _ => fen.into(SimpleFen) 54 | 55 | // Like SimpleFen, but for standard chess, without ZH pockets 56 | opaque type StandardFen = String 57 | object StandardFen extends OpaqueString[StandardFen]: 58 | extension (a: StandardFen) def board: BoardFen = a.value.takeWhile(_ != ' ') 59 | def fromFull(fen: FullFen): StandardFen = fromSimple(FullFen.simple(fen)) 60 | def fromSimple(fen: SimpleFen): StandardFen = 61 | fen.value.split(' ').take(4) match 62 | case Array(board, turn, castle, ep) => 63 | StandardFen(s"${BoardFen(board).removePockets} $turn $castle $ep") 64 | case _ => fen.into(StandardFen) 65 | val initial: StandardFen = FullFen.initial.opening 66 | 67 | // r3k2r/p3n1pp/2q2p2/4n1B1/5Q2/5P2/PP3P1P/R4RK1 b 68 | opaque type BoardAndColorFen = String 69 | object BoardAndColorFen extends OpaqueString[BoardAndColorFen] 70 | 71 | // r3k2r/p3n1pp/2q2p2/4n1B1/5Q2/5P2/PP3P1P/R4RK1 72 | opaque type BoardFen = String 73 | object BoardFen extends OpaqueString[BoardFen]: 74 | extension (a: BoardFen) 75 | def andColor(c: Color) = BoardAndColorFen(s"$a ${c.letter}") 76 | def removePockets: BoardFen = 77 | if a.contains('[') then a.takeWhile('[' !=) 78 | else if a.count('/' == _) == 8 then a.split('/').take(8).mkString("/") 79 | else a 80 | --------------------------------------------------------------------------------