diff --git a/CHANGELOG.md b/CHANGELOG.md index 57277be..b81954b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## 0.13.0 + +**Breaking changes:** + +- Remove `fast_immutable_collections` dependency. Public APIs that previously returned `IMap` or `IList` now return standard Dart collections: + - `Position.legalMoves` returns `Map` (was `IMap`) + - `Board.materialCount` returns `ByRole` / `Map` (was `IMap`) + - `Castles.rooksPositions` returns `BySide>` / `Map` (was `IMap`-backed) + - `Castles.paths` returns `BySide>` / `Map` (was `IMap`-backed) + - `PgnComment.shapes` returns `List` (was `IList`) + - `makeLegalMoves()` returns `Map>` (was `IMap>`) + - The `BySide`, `ByRole`, and `ByCastlingSide` typedefs are now aliases for standard `Map` types. +- `Pockets.value` is removed; use `Pockets.of(side, role)`, `Pockets.count(role)`, `Pockets.size`, `Pockets.hasQuality(side)`, and `Pockets.hasPawn(side)` instead. + +The `Position` class remains completely immutable. Only the return types of some +methods have changed to use standard Dart collections instead of immutable +collections. + ## 0.12.3 - Fix `Crazyhouse.isGameOver` and `Crazyhouse.isCheckmate` in positions where all legal moves are drop moves. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3ae43d3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Run all tests (exclude slow full_perft tests) +dart test -x full_perft + +# Run a single test file +dart test test/position_test.dart + +# Run a specific test by name +dart test --name "some test name" + +# Analyze code (lint) +dart analyze + +# Format code +dart format . + +# Run benchmarks +dart run benchmark/dartchess_benchmark.dart + +# Run perft (move generation correctness/performance test) +dart run example/perft.dart +``` + +## Architecture + +This is a pure Dart chess rules library (`package:dartchess`) supporting standard chess and several variants. It targets native platforms only (not web). + +### Core data flow + +`Setup` (FEN parse) → `Position` (validated legal state) → `Move` (play/apply) → new `Position` + +### Key layers + +**`models.dart`** — fundamental value types: `Side`, `Role`, `Piece`, `File`, `Rank`, `Square`, `Move` (sealed: `NormalMove` / `DropMove`), `Rule` (the variant enum), `Outcome`, and exception types (`FenException`, `PositionSetupException`). + +**`square_set.dart`** — `SquareSet` is a 64-bit integer bitboard (little-endian rank-file mapping). All move generation and attack computation operates on `SquareSet` values. Prefer bitwise operations over iterating squares when working in this layer. + +**`attacks.dart`** — precomputed attack tables for kings and knights; bishop/rook/queen attacks via hyperbola quintessence (sliding piece attacks with `occupied` mask). Exposes `kingAttacks`, `knightAttacks`, `pawnAttacks`, `bishopAttacks`, `rookAttacks`, `queenAttacks`. + +**`board.dart`** — `Board` stores piece placement as a set of overlapping `SquareSet`s (one per side, one per role). Immutable; queries like `board.bySide(side)`, `board.byRole(role)`, `board.kingOf(side)`, `board.attacksTo(square, attacker)`. + +**`castles.dart`** — `Castles` tracks unmoved rooks and the path squares needed for castling legality. Supports Chess960 (king-to-rook encoding for castling moves). + +**`setup.dart`** — `Setup` is a non-validated position read from FEN. Parses/emits FEN, including pocket notation for Crazyhouse and remaining checks for ThreeCheck. + +**`position.dart`** — the heart of the library. `Position` is an immutable abstract base class; each variant subclasses it: `Chess`, `Antichess`, `Atomic`, `Crazyhouse`, `KingOfTheHill`, `ThreeCheck`, `RacingKings`, `Horde`. Concrete private implementations (`_Chess`, etc.) are returned by `fromSetup`. Key API: +- `Position.setupPosition(rule, setup)` — variant-aware factory +- `pos.legalMoves` — `Map` (king-to-rook encoding for castling) +- `pos.play(move)` / `pos.playUnchecked(move)` — returns new position +- `pos.parseSan(san)` / `pos.makeSan(move)` — SAN I/O +- `pos.isCheckmate`, `pos.isStalemate`, `pos.isGameOver`, `pos.outcome` +- Castling moves are encoded as king-to-rook; use `makeLegalMoves()` (from `utils.dart`) to also include the traditional king-to-destination squares. + +**`pgn.dart`** — `PgnGame` holds headers + a `PgnNode` tree of moves (with variations, comments, NAGs, shapes, evaluations). `PgnGame.parsePgn` / `PgnGame.parseMultiGamePgn` for parsing; `game.makePgn()` for serialization. Use `PgnNode.transform` to walk the tree and attach computed data (e.g. FEN per node) without mutating nodes. + +**`utils.dart`** — `makeLegalMoves(pos)` returns `Map>` adding traditional castling destinations alongside king-to-rook destinations. + +**`debug.dart`** — `toSfen` helpers for printing boards in ASCII; primarily for testing and debugging. + +### Immutability + +All `Position`, `Board`, `Setup`, `Castles` instances are immutable (`@immutable`). Standard Dart collections are used throughout; immutability is enforced by the library's internal discipline rather than FIC types. + +### Chess960 + +Castling is internally encoded king-to-rook throughout. `makeLegalMoves` adds the standard king-to-g/c destination for display purposes. When playing a castling move from UI, both encodings are accepted by `isLegal`. diff --git a/benchmark/dartchess_benchmark.dart b/benchmark/dartchess_benchmark.dart index a8849e3..9286d77 100644 --- a/benchmark/dartchess_benchmark.dart +++ b/benchmark/dartchess_benchmark.dart @@ -33,10 +33,14 @@ void main() { final legalMovesPos = Chess.fromSetup(Setup.parseFen( 'rn1qkb1r/pbp2ppp/1p2p3/3n4/8/2N2NP1/PP1PPPBP/R1BQ1RK1 b kq -')); - benchmark('valid fen moves', () { + benchmark('valid moves', () { legalMovesPos.legalMoves.length; }); + benchmark('makeLegalMoves (with alternate castling moves)', () { + makeLegalMoves(legalMovesPos); + }); + benchmark('parsePgn - kasparov-deep-blue', () { final String data = io.File('./data/kasparov-deep-blue-1997.pgn').readAsStringSync(); diff --git a/lib/src/board.dart b/lib/src/board.dart index dd6a0d0..cb37c78 100644 --- a/lib/src/board.dart +++ b/lib/src/board.dart @@ -1,5 +1,4 @@ import 'package:meta/meta.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import './square_set.dart'; import './models.dart'; import './attacks.dart'; @@ -185,7 +184,7 @@ class Board { } /// Gets the number of pieces of each [Role] for the given [Side]. - IMap materialCount(Side side) => IMap.fromEntries( + ByRole materialCount(Side side) => Map.fromEntries( Role.values.map((role) => MapEntry(role, piecesOf(side, role).size))); /// A [SquareSet] of all the pieces matching this [Side] and [Role]. diff --git a/lib/src/castles.dart b/lib/src/castles.dart index 503e94f..b583e5b 100644 --- a/lib/src/castles.dart +++ b/lib/src/castles.dart @@ -105,30 +105,30 @@ abstract class Castles { /// Gets rooks positions by side and castling side. BySide> get rooksPositions { - return BySide({ - Side.white: ByCastlingSide({ + return { + Side.white: { CastlingSide.queen: _whiteRookQueenSide, CastlingSide.king: _whiteRookKingSide, - }), - Side.black: ByCastlingSide({ + }, + Side.black: { CastlingSide.queen: _blackRookQueenSide, CastlingSide.king: _blackRookKingSide, - }), - }); + }, + }; } /// Gets rooks paths by side and castling side. BySide> get paths { - return BySide({ - Side.white: ByCastlingSide({ + return { + Side.white: { CastlingSide.queen: _whitePathQueenSide, CastlingSide.king: _whitePathKingSide, - }), - Side.black: ByCastlingSide({ + }, + Side.black: { CastlingSide.queen: _blackPathQueenSide, CastlingSide.king: _blackPathKingSide, - }), - }); + }, + }; } /// Gets the rook [Square] by side and castling side. diff --git a/lib/src/models.dart b/lib/src/models.dart index b915f4e..3decf89 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -1,5 +1,4 @@ import 'package:meta/meta.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import './square_set.dart'; /// The chessboard side, white or black. @@ -337,9 +336,9 @@ extension type const Square._(int value) implements int { static const h8 = Square(63); } -typedef BySide = IMap; -typedef ByRole = IMap; -typedef ByCastlingSide = IMap; +typedef BySide = Map; +typedef ByRole = Map; +typedef ByCastlingSide = Map; /// Describes a chess piece kind by its color and role. enum PieceKind { diff --git a/lib/src/pgn.dart b/lib/src/pgn.dart index 5bb6340..ed0eebd 100644 --- a/lib/src/pgn.dart +++ b/lib/src/pgn.dart @@ -1,5 +1,4 @@ import 'dart:math' as math; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:meta/meta.dart'; import './setup.dart'; @@ -493,18 +492,14 @@ class PgnEvaluation { @immutable class PgnComment { const PgnComment( - {this.text, - this.shapes = const IListConst([]), - this.clock, - this.emt, - this.eval}) + {this.text, this.shapes = const [], this.clock, this.emt, this.eval}) : assert(text == null || text != ''); /// Comment string. final String? text; /// List of comment shapes. - final IList shapes; + final List shapes; /// Player's remaining time. final Duration? clock; @@ -571,7 +566,7 @@ class PgnComment { return PgnComment( text: text.isNotEmpty ? text : null, - shapes: IList(shapes), + shapes: shapes, emt: emt, clock: clock, eval: eval); @@ -601,17 +596,24 @@ class PgnComment { @override bool operator ==(Object other) { - return identical(this, other) || - other is PgnComment && - text == other.text && - shapes == other.shapes && - clock == other.clock && - emt == other.emt && - eval == other.eval; + if (identical(this, other)) return true; + if (other is! PgnComment) return false; + if (text != other.text || + clock != other.clock || + emt != other.emt || + eval != other.eval) { + return false; + } + if (shapes.length != other.shapes.length) return false; + for (var i = 0; i < shapes.length; i++) { + if (shapes[i] != other.shapes[i]) return false; + } + return true; } @override - int get hashCode => Object.hash(text, shapes, clock, emt, eval); + int get hashCode => + Object.hash(text, Object.hashAll(shapes), clock, emt, eval); } /// A frame used for parsing a line diff --git a/lib/src/position.dart b/lib/src/position.dart index 8f5a45c..340b0d4 100644 --- a/lib/src/position.dart +++ b/lib/src/position.dart @@ -1,6 +1,5 @@ import 'package:meta/meta.dart'; import 'dart:math' as math; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'attacks.dart'; import 'castles.dart'; import 'models.dart'; @@ -191,13 +190,13 @@ abstract class Position { /// /// Use the [makeLegalMoves] helper to get all the legal moves including alternative /// castling moves. - IMap get legalMoves { + Map get legalMoves { final context = _makeContext(); - if (context.isVariantEnd) return IMap(const {}); - return IMap({ + if (context.isVariantEnd) return const {}; + return { for (final s in board.bySide(turn).squares) s: _legalMovesOf(s, context: context) - }); + }; } /// Gets all the legal drops of this position. diff --git a/lib/src/setup.dart b/lib/src/setup.dart index 826d68f..c0a232e 100644 --- a/lib/src/setup.dart +++ b/lib/src/setup.dart @@ -1,5 +1,4 @@ import 'package:meta/meta.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'dart:math' as math; import './square_set.dart'; import './models.dart'; @@ -214,64 +213,56 @@ class Setup { /// Pockets (captured pieces) in chess variants like [Crazyhouse]. @immutable class Pockets { - /// Creates a new [Pockets] with the provided value. - const Pockets({ - required this.value, - }); + const Pockets._(this._value); - final BySide> value; + // Bitfield: 5 bits per (side, role) slot. + // Offset = side.index * 30 + role.index * 5. Total: 60 bits. + final int _value; /// An empty pocket. - static const empty = Pockets(value: _emptyPocketsBySide); + static const empty = Pockets._(0); - /// Gets the total number of pieces in the pocket. - int get size => value.values - .fold(0, (acc, e) => acc + e.values.fold(0, (acc, e) => acc + e)); + static int _offset(Side side, Role role) => side.index * 30 + role.index * 5; /// Gets the number of pieces of that [Side] and [Role] in the pocket. - int of(Side side, Role role) { - return value[side]![role]!; - } + int of(Side side, Role role) => (_value >> _offset(side, role)) & 0x1F; + + /// Gets the total number of pieces in the pocket. + int get size => Side.values.fold( + 0, + (acc, s) => acc + Role.values.fold(0, (acc2, r) => acc2 + of(s, r)), + ); /// Counts the number of pieces by [Role]. - int count(Role role) { - return value[Side.white]![role]! + value[Side.black]![role]!; - } + int count(Role role) => of(Side.white, role) + of(Side.black, role); /// Checks whether this side has at least 1 quality (any piece but a pawn). - bool hasQuality(Side side) { - final bySide = value[side]!; - return bySide[Role.knight]! > 0 || - bySide[Role.bishop]! > 0 || - bySide[Role.rook]! > 0 || - bySide[Role.queen]! > 0 || - bySide[Role.king]! > 0; - } + bool hasQuality(Side side) => + of(side, Role.knight) > 0 || + of(side, Role.bishop) > 0 || + of(side, Role.rook) > 0 || + of(side, Role.queen) > 0 || + of(side, Role.king) > 0; /// Checks whether this side has at least 1 pawn. - bool hasPawn(Side side) { - return value[side]![Role.pawn]! > 0; - } + bool hasPawn(Side side) => of(side, Role.pawn) > 0; /// Increments the number of pieces in the pocket of that [Side] and [Role]. - Pockets increment(Side side, Role role) { - final newPocket = value[side]!.add(role, of(side, role) + 1); - return Pockets(value: value.add(side, newPocket)); - } + Pockets increment(Side side, Role role) => + Pockets._(_value + (1 << _offset(side, role))); /// Decrements the number of pieces in the pocket of that [Side] and [Role]. - Pockets decrement(Side side, Role role) { - final newPocket = value[side]!.add(role, of(side, role) - 1); - return Pockets(value: value.add(side, newPocket)); - } + Pockets decrement(Side side, Role role) => + Pockets._(_value - (1 << _offset(side, role))); @override bool operator ==(Object other) { - return identical(this, other) || other is Pockets && other.value == value; + if (identical(this, other)) return true; + return other is Pockets && other._value == _value; } @override - int get hashCode => value.hashCode; + int get hashCode => _value.hashCode; } Pockets _parsePockets(String pocketPart) { @@ -397,17 +388,3 @@ int _nthIndexOf(String haystack, String needle, int nth) { } return index; } - -const ByRole _emptyPocket = IMapConst({ - Role.pawn: 0, - Role.knight: 0, - Role.bishop: 0, - Role.rook: 0, - Role.queen: 0, - Role.king: 0, -}); - -const BySide> _emptyPocketsBySide = IMapConst({ - Side.white: _emptyPocket, - Side.black: _emptyPocket, -}); diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 0936601..442bca6 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -1,16 +1,14 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; - import 'models.dart'; import 'position.dart'; /// Returns all the legal moves of the [Position] in a convenient format. /// /// Includes both possible representations of castling moves unless `includeAlternateCastlingMoves` is false. -IMap> makeLegalMoves( +Map> makeLegalMoves( Position pos, { bool includeAlternateCastlingMoves = true, }) { - final Map> result = {}; + final Map> result = {}; for (final entry in pos.legalMoves.entries) { final dests = entry.value.squares; if (dests.isNotEmpty) { @@ -30,8 +28,8 @@ IMap> makeLegalMoves( destSet.add(Square.g8); } } - result[from] = ISet(destSet); + result[from] = destSet; } } - return IMap(result); + return result; } diff --git a/pubspec.yaml b/pubspec.yaml index 54e3973..5157c57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: dartchess description: Provides chess and chess variants rules and operations including chess move generation, read and write FEN, read and write PGN. repository: https://github.com/lichess-org/dartchess -version: 0.12.3 +version: 0.13.0 platforms: android: ios: @@ -15,7 +15,6 @@ environment: sdk: ">=3.3.0 <4.0.0" dependencies: - fast_immutable_collections: ^11.0.0 meta: ^1.8.0 dev_dependencies: diff --git a/test/castles_test.dart b/test/castles_test.dart index 552bf25..fd02531 100644 --- a/test/castles_test.dart +++ b/test/castles_test.dart @@ -1,4 +1,3 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:dartchess/dartchess.dart'; import 'package:test/test.dart'; @@ -40,34 +39,35 @@ void main() { expect(Castles.standard.discardRookAt(Square.a4), Castles.standard); expect( Castles.standard.discardRookAt(Square.h1).rooksPositions[Side.white], - IMap(const {CastlingSide.queen: Square.a1, CastlingSide.king: null})); + const {CastlingSide.queen: Square.a1, CastlingSide.king: null}); }); test('discard side', () { expect( Castles.standard.discardSide(Side.white).rooksPositions, - equals(BySide({ - Side.white: ByCastlingSide( - const {CastlingSide.queen: null, CastlingSide.king: null}, - ), - Side.black: ByCastlingSide( - const { - CastlingSide.queen: Square.a8, - CastlingSide.king: Square.h8, - }, - ) - }))); + equals({ + Side.white: const { + CastlingSide.queen: null, + CastlingSide.king: null + }, + Side.black: const { + CastlingSide.queen: Square.a8, + CastlingSide.king: Square.h8, + }, + })); expect( Castles.standard.discardSide(Side.black).rooksPositions, - equals(BySide({ - Side.white: ByCastlingSide(const { + equals({ + Side.white: const { CastlingSide.queen: Square.a1, CastlingSide.king: Square.h1, - }), - Side.black: ByCastlingSide( - const {CastlingSide.queen: null, CastlingSide.king: null}) - }))); + }, + Side.black: const { + CastlingSide.queen: null, + CastlingSide.king: null + }, + })); }); }); } diff --git a/test/pgn_test.dart b/test/pgn_test.dart index ce9be5b..0e128e2 100644 --- a/test/pgn_test.dart +++ b/test/pgn_test.dart @@ -1,6 +1,5 @@ import 'package:dartchess/dartchess.dart' hide File; import 'package:test/test.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'dart:io'; import 'pgn_fixtures.dart'; @@ -109,26 +108,18 @@ void main() { expect( PgnComment.fromPgn( '[%csl Ya1][%cal Ra1a1,Be1e2]commentary [%csl Gh8]'), - const PgnComment( - text: 'commentary', - shapes: IListConst([ - PgnCommentShape( - color: CommentShapeColor.yellow, - from: Square.a1, - to: Square.a1), - PgnCommentShape( - color: CommentShapeColor.red, - from: Square.a1, - to: Square.a1), - PgnCommentShape( - color: CommentShapeColor.blue, - from: Square.e1, - to: Square.e2), - PgnCommentShape( - color: CommentShapeColor.green, - from: Square.h8, - to: Square.h8) - ]))); + const PgnComment(text: 'commentary', shapes: [ + PgnCommentShape( + color: CommentShapeColor.yellow, + from: Square.a1, + to: Square.a1), + PgnCommentShape( + color: CommentShapeColor.red, from: Square.a1, to: Square.a1), + PgnCommentShape( + color: CommentShapeColor.blue, from: Square.e1, to: Square.e2), + PgnCommentShape( + color: CommentShapeColor.green, from: Square.h8, to: Square.h8) + ])); expect( PgnComment.fromPgn('prefix [%eval .99,23]'), @@ -145,14 +136,10 @@ void main() { expect( PgnComment.fromPgn('[%csl Ga1]foo'), - const PgnComment( - text: 'foo', - shapes: IListConst([ - PgnCommentShape( - color: CommentShapeColor.green, - from: Square.a1, - to: Square.a1) - ]))); + const PgnComment(text: 'foo', shapes: [ + PgnCommentShape( + color: CommentShapeColor.green, from: Square.a1, to: Square.a1) + ])); expect( PgnComment.fromPgn( @@ -169,7 +156,7 @@ void main() { Duration(hours: 1, minutes: 2, seconds: 3, milliseconds: 400), eval: PgnEvaluation.pawns(pawns: 10), clock: Duration(seconds: 1), - shapes: IListConst([ + shapes: [ PgnCommentShape( color: CommentShapeColor.yellow, from: Square.a1, @@ -182,7 +169,7 @@ void main() { color: CommentShapeColor.red, from: Square.a1, to: Square.c1) - ])).makeComment(), + ]).makeComment(), 'text [%csl Ya1] [%cal Ra1b1,Ra1c1] [%eval 10.00] [%emt 1:02:03.4] [%clk 0:00:01]'); expect( @@ -208,6 +195,53 @@ void main() { test('PgnComment implements hashCode/==', () { const comment = '[%csl Ga1][%cal Ra1h1,Gb1b8] foo [%clk 3:25:45]'; expect(PgnComment.fromPgn(comment) == PgnComment.fromPgn(comment), true); + expect(PgnComment.fromPgn(comment).hashCode, + PgnComment.fromPgn(comment).hashCode); + }); + + test('PgnComment == distinguishes shapes content', () { + const withShape = PgnComment(shapes: [ + PgnCommentShape( + color: CommentShapeColor.green, from: Square.a1, to: Square.a1), + ]); + const withDifferentShape = PgnComment(shapes: [ + PgnCommentShape( + color: CommentShapeColor.red, from: Square.a1, to: Square.a1), + ]); + const withoutShape = PgnComment(); + + expect(withShape, withShape); + expect(withShape, isNot(withDifferentShape)); + expect(withShape, isNot(withoutShape)); + expect(withoutShape, withoutShape); + }); + + test('PgnComment == is order-sensitive for shapes', () { + const shapeA = PgnCommentShape( + color: CommentShapeColor.green, from: Square.a1, to: Square.h1); + const shapeB = PgnCommentShape( + color: CommentShapeColor.red, from: Square.b1, to: Square.b8); + + const ab = PgnComment(shapes: [shapeA, shapeB]); + const ba = PgnComment(shapes: [shapeB, shapeA]); + + expect(ab, isNot(ba)); + }); + + test('PgnComment hashCode consistent with ==', () { + const shapeA = PgnCommentShape( + color: CommentShapeColor.green, from: Square.a1, to: Square.h1); + const shapeB = PgnCommentShape( + color: CommentShapeColor.red, from: Square.b1, to: Square.b8); + + const c1 = PgnComment(text: 'hello', shapes: [shapeA, shapeB]); + const c2 = PgnComment(text: 'hello', shapes: [shapeA, shapeB]); + expect(c1, c2); + expect(c1.hashCode, c2.hashCode); + + // Can be used as a map key. + final map = {c1: 42}; + expect(map[c2], 42); }); group('Invalid Pgns', () { diff --git a/test/position_test.dart b/test/position_test.dart index 0cd92cf..717d279 100644 --- a/test/position_test.dart +++ b/test/position_test.dart @@ -1,4 +1,3 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:dartchess/dartchess.dart'; import 'package:test/test.dart'; import 'dart:io' as io; @@ -361,7 +360,7 @@ void main() { }); test('standard position legal moves', () { - final moves = IMap({ + final moves = { Square.a1: SquareSet.empty, Square.b1: const SquareSet.fromSquare(Square.a3).withSquare(Square.c3), Square.c1: SquareSet.empty, @@ -378,7 +377,7 @@ void main() { Square.f2: const SquareSet.fromSquare(Square.f3).withSquare(Square.f4), Square.g2: const SquareSet.fromSquare(Square.g3).withSquare(Square.g4), Square.h2: const SquareSet.fromSquare(Square.h3).withSquare(Square.h4), - }); + }; expect(Chess.initial.legalMoves, equals(moves)); }); @@ -665,10 +664,10 @@ void main() { .play(const NormalMove(from: Square.h1, to: Square.f1)); expect( pos.castles.rooksPositions[Side.white], - equals(IMap(const { + equals(const { CastlingSide.queen: Square.a1, CastlingSide.king: null - }))); + })); expect(pos.castles.castlingRights.has(Square.h1), false); }); diff --git a/test/setup_test.dart b/test/setup_test.dart index 095dd6e..98ca881 100644 --- a/test/setup_test.dart +++ b/test/setup_test.dart @@ -103,10 +103,19 @@ void main() { }); group('Pockets', () { + test('empty', () { + expect(Pockets.empty.size, 0); + for (final side in Side.values) { + for (final role in Role.values) { + expect(Pockets.empty.of(side, role), 0); + } + expect(Pockets.empty.hasPawn(side), false); + expect(Pockets.empty.hasQuality(side), false); + } + }); + test('increment', () { final pockets = Pockets.empty.increment(Side.white, Role.knight); - expect(pockets.hasPawn(Side.white), false); - expect(pockets.hasQuality(Side.white), true); expect(pockets.of(Side.white, Role.knight), 1); expect(pockets.size, 1); expect( @@ -124,5 +133,126 @@ void main() { .of(Side.white, Role.knight), 0); }); + + test('increment/decrement round-trip back to empty', () { + for (final side in Side.values) { + for (final role in Role.values) { + expect( + Pockets.empty.increment(side, role).decrement(side, role), + Pockets.empty, + ); + } + } + }); + + test('of — all roles on both sides are independent (no bitfield overlap)', + () { + for (final side in Side.values) { + for (final role in Role.values) { + final p = Pockets.empty.increment(side, role); + // Only the targeted slot is non-zero. + for (final s in Side.values) { + for (final r in Role.values) { + expect(p.of(s, r), s == side && r == role ? 1 : 0); + } + } + } + } + }); + + test('of — black side is independent from white', () { + final p = Pockets.empty + .increment(Side.white, Role.rook) + .increment(Side.black, Role.queen); + expect(p.of(Side.white, Role.rook), 1); + expect(p.of(Side.black, Role.queen), 1); + expect(p.of(Side.white, Role.queen), 0); + expect(p.of(Side.black, Role.rook), 0); + }); + + test('size counts pieces across both sides', () { + final p = Pockets.empty + .increment(Side.white, Role.knight) + .increment(Side.white, Role.knight) + .increment(Side.black, Role.pawn); + expect(p.size, 3); + }); + + test('count sums both sides for a role', () { + final p = Pockets.empty + .increment(Side.white, Role.rook) + .increment(Side.white, Role.rook) + .increment(Side.black, Role.rook); + expect(p.count(Role.rook), 3); + expect(p.count(Role.pawn), 0); + }); + + test('hasPawn', () { + expect(Pockets.empty.hasPawn(Side.white), false); + expect(Pockets.empty.hasPawn(Side.black), false); + + final p = Pockets.empty.increment(Side.black, Role.pawn); + expect(p.hasPawn(Side.black), true); + expect(p.hasPawn(Side.white), false); + }); + + test('hasQuality', () { + expect(Pockets.empty.hasQuality(Side.white), false); + + // Pawn alone does not count as quality. + expect( + Pockets.empty.increment(Side.white, Role.pawn).hasQuality(Side.white), + false, + ); + + // Each non-pawn role counts as quality. + for (final role in [ + Role.knight, + Role.bishop, + Role.rook, + Role.queen, + Role.king + ]) { + expect( + Pockets.empty.increment(Side.white, role).hasQuality(Side.white), + true, + reason: '$role should count as quality', + ); + } + + // Black side is checked independently. + final p = Pockets.empty.increment(Side.black, Role.bishop); + expect(p.hasQuality(Side.black), true); + expect(p.hasQuality(Side.white), false); + }); + + test('implements ==', () { + expect(Pockets.empty, Pockets.empty); + + final a = Pockets.empty.increment(Side.white, Role.knight); + final b = Pockets.empty.increment(Side.white, Role.knight); + expect(a, b); + + final c = Pockets.empty.increment(Side.black, Role.knight); + expect(a, isNot(c)); + + final d = Pockets.empty.increment(Side.white, Role.pawn); + expect(a, isNot(d)); + }); + + test('implements hashCode', () { + final a = Pockets.empty.increment(Side.white, Role.knight); + final b = Pockets.empty.increment(Side.white, Role.knight); + expect(a.hashCode, b.hashCode); + + expect(Pockets.empty.hashCode, Pockets.empty.hashCode); + + final c = Pockets.empty.increment(Side.black, Role.queen); + expect(a.hashCode, isNot(c.hashCode)); + + // Can be used as a map key. + final map = {a: 'knight'}; + expect(map[b], 'knight'); + }); }); } diff --git a/utils_test.dart b/utils_test.dart index 4fd4cac..ea5c3b9 100644 --- a/utils_test.dart +++ b/utils_test.dart @@ -1,5 +1,4 @@ import 'package:dartchess/dartchess.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:test/test.dart'; void main() { @@ -61,7 +60,7 @@ void main() { ); expect( makeLegalMoves(pos, includeAlternateCastlingMoves: false)[Square.b8], - equals(ISet(const {Square.a8, Square.c8, Square.e8})), + equals(const {Square.a8, Square.c8, Square.e8}), ); }); }