Skip to content
24 changes: 23 additions & 1 deletion lib/src/model/common/chess.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ sealed class SanMove with _$SanMove {
// It's sufficient to check for O-O here, because that of course also covers O-O-O.
bool get isCastles => san.startsWith('O-O');

/// Normalize UCI to a "king takes rook" UCI notation.
///
/// Returns the original notation chess960 variant where this notation is already forced and
/// where the normalized notation could conflict with the actual move.
UCIMove normalizeUci(Variant variant) {
if (variant == Variant.chess960) {
return move.uci;
}
Comment on lines 29 to 36
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring grammar/clarity: “Returns the original notation chess960 variant …” is missing a preposition and is hard to parse. Consider rephrasing to something like “Returns the original notation in chess960, where this notation is already forced …” to make the behavior unambiguous.

Copilot uses AI. Check for mistakes.
if (isCastles) {
return kingTakesRookCastles[move.uci] ?? move.uci;
} else {
return move.uci;
}
}

bool isIrreversible(Variant variant) {
if (isCheck) return true;
if (variant == Variant.crazyhouse) return false;
Expand All @@ -46,9 +61,16 @@ class MoveConverter implements JsonConverter<Move, String> {
String toJson(Move object) => object.uci;
}

/// Alternative castling uci notations.
/// Get alternate castling notations from king takes rook notation, e.g. e1c1 for O-O-O and e1g1 for O-O.
const altCastles = {'e1a1': 'e1c1', 'e1h1': 'e1g1', 'e8a8': 'e8c8', 'e8h8': 'e8g8'};

/// Get king takes rook castling notations from alternate notation, e.g. e1a1 for O-O-O and e1h1 for O-O.
const kingTakesRookCastles = {'e1c1': 'e1a1', 'e1g1': 'e1h1', 'e8c8': 'e8a8', 'e8g8': 'e8h8'};

/// Normalizes a UCI move string for comparison by converting alternate castling notations to
/// standard notation.
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normalizeUci doc says it converts “alternate castling notations to standard notation”, but the implementation uses kingTakesRookCastles (standard → king-takes-rook). Please update the comment to match what the function actually does, or rename the helper to reflect the direction of conversion to avoid future misuse.

Suggested change
/// standard notation.
/// "king takes rook" notation (e.g. e1c1 → e1a1).

Copilot uses AI. Check for mistakes.
String normalizeUci(String uci) => kingTakesRookCastles[uci] ?? uci;

/// Returns `true` if the move is a pawn promotion move that is not yet promoted.
bool isPromotionPawnMove(Position position, NormalMove move) {
return move.promotion == null &&
Expand Down
25 changes: 12 additions & 13 deletions lib/src/model/common/node.dart
Original file line number Diff line number Diff line change
Expand Up @@ -215,16 +215,11 @@ abstract class Node {
}) {
final pos = nodeAt(path).position;

final potentialAltCastlingMove =
move is NormalMove &&
pos.board.roleAt(move.from) == Role.king &&
pos.board.roleAt(move.to) != Role.rook;

final convertedMove = potentialAltCastlingMove ? convertAltCastlingMove(move) ?? move : move;
final normalizedMove = normalizeMove(pos, move);

final (newPos, newSan) = pos.makeSan(convertedMove);
final (newPos, newSan) = pos.makeSan(normalizedMove);
final newNode = Branch(
sanMove: SanMove(newSan, convertedMove),
sanMove: SanMove(newSan, normalizedMove),
position: newPos,
comments: (clock != null) ? [PgnComment(clock: clock)] : null,
);
Expand All @@ -239,11 +234,15 @@ abstract class Node {
return newPath != null ? addMovesAt(newPath, moves.skip(1), prepend: prepend) : null;
}

/// The function `convertAltCastlingMove` checks if a move is an alternative
/// castling move and converts it to the corresponding standard castling move if so.
Move? convertAltCastlingMove(Move move) {
return altCastles.containsValue(move.uci)
? Move.parse(altCastles.entries.firstWhere((e) => e.value == move.uci).key)
/// Normalizes the move to the king takes rook castling notation if it is an alternate castling move.
Move normalizeMove(Position pos, Move move) {
final potentialAltCastlingMove =
move is NormalMove &&
pos.board.roleAt(move.from) == Role.king &&
pos.board.roleAt(move.to) != Role.rook;
if (!potentialAltCastlingMove) return move;
return kingTakesRookCastles[move.uci] != null
? NormalMove.fromUci(kingTakesRookCastles[move.uci]!)
: move;
}

Expand Down
47 changes: 24 additions & 23 deletions lib/src/model/engine/evaluation_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,35 +212,36 @@ class EvaluationService {
/// Returns a [Future] that completes with the evaluation, or `null` if no evaluation
/// could be obtained (e.g. the engine fails).
///
/// The [work] must have a [EvalWork.searchTime] set.
///
/// If [minDepth] is provided, the evaluation will stop early once this depth is reached.
/// Otherwise, it will run for the full [EvalWork.searchTime].
Future<LocalEval?> findEval(EvalWork work, {int? minDepth}) async {
assert(work.searchTime != Duration.zero, 'searchTime must be set for findEval');

final stream = evaluate(work);
if (stream == null) {
// do we have a cached eval?
switch (work.evalCache) {
case final LocalEval localEval:
return localEval;
case CloudEval _:
return null;
case _:
return null;
}
}
/// If provided, the evaluation will stop at [depthThreshold], if the [minSearchTime] has passed.
/// This allows for better evaluations in high end devices while still providing quick responses in low end devices.
/// Even if [depthThreshold] is not reached, the evaluation will still stop at [EvalWork.searchTime].
Comment on lines +215 to +217
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The findEval doc mentions overriding EvalWork.searchTime with a provided maxSearchTime, but findEval no longer has a maxSearchTime parameter and uses work.searchTime directly. Please update/remove this sentence so the API contract matches the implementation.

Suggested change
/// If provided, the evaluation will stop at [depthThreshold], if the [minSearchTime] has passed.
/// This allows for better evaluations in high end devices while still providing quick responses in low end devices.
/// Even if [depthThreshold] is not reached, the evaluation will still stop at [EvalWork.searchTime].
/// If [depthThreshold] is provided, the evaluation may stop once that depth is
/// reached, provided that [minSearchTime] (if given) has already elapsed.
/// This allows for better evaluations in high end devices while still providing
/// quick responses in low end devices. Regardless of these parameters, the
/// evaluation will not run longer than [EvalWork.searchTime].

Copilot uses AI. Check for mistakes.
Future<LocalEval?> findEval(EvalWork work, {int? depthThreshold, Duration? minSearchTime}) async {
_setEval(null);

_logger.info(
'Finding evaluation at ply ${work.position.ply} with options: '
'flavor=${work.stockfishFlavor}, multiPv=${work.multiPv}, cores=${work.threads}, '
'searchTime=${work.searchTime.inMilliseconds}ms, threatMode=${work.threatMode}',
);

_startWork(work);

LocalEval? finalEval;

try {
await for (final (_, eval) in stream.timeout(
work.searchTime + const Duration(milliseconds: 500),
)) {
await for (final (_, eval)
in evalStream
.where((result) => result.$1 == work)
.timeout(work.searchTime + const Duration(milliseconds: 500))) {
finalEval = eval;
if (minDepth != null && eval.depth >= minDepth) {
// if depth threshold is reached quickly, let's still wait min search time (but skip for
// higher depths)
if ((eval.depth >= 25 || minSearchTime == null || eval.searchTime >= minSearchTime) &&
(depthThreshold != null && eval.depth >= depthThreshold)) {
stop();
break;
} else if (eval.searchTime >= work.searchTime) {
break;
}
}
} on TimeoutException {
Expand Down
7 changes: 2 additions & 5 deletions lib/src/model/engine/uci_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import 'dart:math' as math;
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/common/eval.dart';
import 'package:lichess_mobile/src/model/engine/engine.dart';
import 'package:lichess_mobile/src/model/engine/work.dart';
Expand Down Expand Up @@ -241,16 +240,14 @@ class UCIProtocol {
setOption('Skill Level', '20');
}

final positionCommand = switch (_work) {
final positionCommand = switch (_work!) {
final EvalWork evalWork when evalWork.threatMode =>
'position fen ${evalWork.threatModePosition.fen}',
_ => [
'position fen',
_work!.initialPosition.fen,
'moves',
..._work!.steps.map(
(s) => _work!.variant == Variant.chess960 ? s.sanMove.move.uci : s.castleSafeUCI,
),
..._work!.steps.map((s) => s.sanMove.normalizeUci(_work!.variant)),
].join(' '),
};

Expand Down
16 changes: 0 additions & 16 deletions lib/src/model/engine/work.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,4 @@ sealed class Step with _$Step {
factory Step.fromNode(Branch node) {
return Step(position: node.position, sanMove: node.sanMove, eval: node.eval);
}

/// Stockfish in chess960 mode always needs a "king takes rook" UCI notation.
///
/// Cannot be used in chess960 variant where this notation is already forced and
/// where it would conflict with the actual move.
UCIMove get castleSafeUCI {
if (sanMove.isCastles) {
return _castleMoves.containsKey(sanMove.move.uci)
? _castleMoves[sanMove.move.uci]!
: sanMove.move.uci;
} else {
return sanMove.move.uci;
}
}
}

const _castleMoves = {'e1c1': 'e1a1', 'e1g1': 'e1h1', 'e8c8': 'e8a8', 'e8g8': 'e8h8'};
15 changes: 15 additions & 0 deletions lib/src/model/explorer/tablebase.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import 'package:dartchess/dartchess.dart';
import 'package:deep_pick/deep_pick.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/common/chess.dart' show Variant;

part 'tablebase.freezed.dart';

int _tablebasePieces(Variant variant) {
return switch (variant) {
Variant.standard || Variant.fromPosition || Variant.chess960 => 8,
Variant.atomic || Variant.antichess => 6,
_ => 0,
};
}

bool isTablebaseRelevant(Position pos) {
final pieceCount = pos.board.pieces.length;
return pieceCount <= _tablebasePieces(Variant.fromRule(pos.rule));
}

enum TablebaseCategory {
win,
unknown,
Expand Down
Loading