Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions lib/src/model/analysis/analysis_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -710,15 +710,12 @@ class AnalysisState with _$AnalysisState implements EvaluationMixinState {
IList<PgnComment>? pgnRootComments,
}) = _AnalysisState;

@override
bool get alwaysRequestCloudEval => currentPosition.ply < 15;

/// Whether the analysis is for a lichess game.
bool get isLichessGameAnalysis => gameId != null;

/// Whether to delay the local engine evaluation.
///
/// Cloud evaluations are most likely available for the opening moves.
@override
bool get delayLocalEngine => variant != Variant.fromPosition && currentPosition.ply < 20;

/// Whether the user can request server analysis.
///
/// It must be a lichess game, which is finished and not already analyzed.
Expand Down
5 changes: 2 additions & 3 deletions lib/src/model/broadcast/broadcast_analysis_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -590,10 +590,9 @@ class BroadcastAnalysisState with _$BroadcastAnalysisState implements Evaluation
/// If the game is new the path will be empty.
UciPath? get broadcastLivePath => isNewOrOngoing ? broadcastPath : null;

/// In a broadcast analysis, the cloud evals are most likely available, so we always want to delay
/// the local engine evaluation to save battery.
/// In a broadcast analysis, the cloud evals are most likely available.
@override
bool get delayLocalEngine => true;
bool get alwaysRequestCloudEval => true;

/// Whether an evaluation can be available
bool hasAvailableEval(EngineEvaluationPrefState prefs) =>
Expand Down
13 changes: 13 additions & 0 deletions lib/src/model/common/chess.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class SanMove with _$SanMove {

bool get isCheck => san.contains('+');
bool get isCapture => san.contains('x');

bool isIrreversible(Variant variant) {
if (isCheck) return true;
if (variant == Variant.crazyhouse) return false;
if (isCapture) return true;
if (san[0].toLowerCase() == san[0]) return true; // pawn move
return variant == Variant.threeCheck && isCheck;
}
}

class MoveConverter implements JsonConverter<Move, String> {
Expand All @@ -45,6 +53,11 @@ bool isPromotionPawnMove(Position position, NormalMove move) {
(move.to.rank == Rank.eighth && position.turn == Side.white));
}

String fenToEpd(String fen) {
// EPD is FEN without the halfmove clock and fullmove number
return fen.split(' ').take(4).join(' ');
}

/// Set of supported variants for reading a game (not playing).
const ISet<Variant> readSupportedVariants = ISetConst({
Variant.standard,
Expand Down
81 changes: 47 additions & 34 deletions lib/src/model/engine/evaluation_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:dartchess/dartchess.dart';
import 'package:deep_pick/deep_pick.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/common/node.dart';
import 'package:lichess_mobile/src/model/common/socket.dart';
Expand All @@ -23,13 +24,11 @@ import 'package:meta/meta.dart';
const kRequestEvalDebounceDelay = Duration(milliseconds: 250);

/// The debounce delay for starting the local engine evaluation in case we assume the cloud eval
/// will be available.
/// will be available (broadcasts).
///
/// This is superior to the `kRequestEvalDebounceDelay` to avoid running the local engine
/// when the cloud evaluation is available. The delay is thus increased to ensure that the socket
/// 'evalGet/evalHit' round trip gets a chance to complete before starting the local engine, even
/// with reasonably high network latency.
const kStartLocalEngineDebounceDelay = Duration(milliseconds: 600);
/// This is superior to the `kRequestEvalDebounceDelay` to avoid running the local engine too soon
/// to get a chance to get the cloud eval first.
const kLocalEngineAfterCloudEvalDelay = Duration(milliseconds: 500);

/// Interface for Notifiers's State that uses [EngineEvaluationMixin].
abstract class EvaluationMixinState {
Expand All @@ -48,20 +47,8 @@ abstract class EvaluationMixinState {
/// found in studies.
Position? get currentPosition;

/// Whether to delay the local engine evaluation to give time to get the cloud eval.
///
/// By default, this should be `false`, which means the local engine evaluation is started at the
/// same time as the cloud evaluation is requested, after a debounce delay.
///
/// This is the most common use case, because most of the time the cloud eval is not available
/// except on opening positions.
///
/// However, on some cases, we know the cloud evaluations are available, as for instance in a broadcast.
/// So this can be set to `true` to delay the local engine evaluation in order to save battery.
/// The local engine will be started with a higher delay which gives time to get the cloud
/// eval during a socket round trip. If the cloud eval is available the [EvaluationService] will
/// detect it and not run the local engine.
bool get delayLocalEngine;
/// Whether to always request a cloud evaluation, regardless of the current ply.
bool get alwaysRequestCloudEval;
}

/// A mixin to provide engine evaluation functionality to an [AsyncNotifier].
Expand Down Expand Up @@ -95,7 +82,7 @@ mixin EngineEvaluationMixin {
Node get positionTree;

final _cloudEvalGetDebounce = Debouncer(kRequestEvalDebounceDelay);
final _engineEvalDebounce = Debouncer(kStartLocalEngineDebounceDelay);
final _engineEvalDebounce = Debouncer(kLocalEngineAfterCloudEvalDelay);

StreamSubscription<SocketEvent>? _subscription;

Expand Down Expand Up @@ -177,7 +164,7 @@ mixin EngineEvaluationMixin {
/// This sends an `evalGet` event to the server to get the cloud evaluation and starts the local
/// engine evaluation.
///
/// If [EvaluationMixinState.delayLocalEngine] is `true`, the local engine evaluation will be
/// If [EvaluationMixinState.alwaysRequestCloudEval] is `true`, the local engine evaluation will be
/// delayed to give time to get the cloud eval.
///
/// The evaluation will not be requested if the engine is not available by the context or the
Expand All @@ -191,12 +178,12 @@ mixin EngineEvaluationMixin {

_cloudEvalGetDebounce(() {
_sendEvalGetEvent();
if (!evaluationState.delayLocalEngine) {
if (!evaluationState.alwaysRequestCloudEval) {
_startEngineEval();
}
});

if (evaluationState.delayLocalEngine) {
if (evaluationState.alwaysRequestCloudEval) {
_engineEvalDebounce(() {
_startEngineEval();
});
Expand All @@ -214,6 +201,7 @@ mixin EngineEvaluationMixin {
void _handleEvalHitEvent(SocketEvent event) {
final path = pick(event.data, 'path').asUciPathOrThrow();
final depth = pick(event.data, 'depth').asIntOrThrow();

final pvs =
pick(event.data, 'pvs')
.asListOrThrow(
Expand All @@ -228,6 +216,11 @@ mixin EngineEvaluationMixin {
bool isSameEvalString = true;
positionTree.updateAt(path, (node) {
final eval = CloudEval(depth: depth, pvs: pvs, position: node.position);
final nodeDepth = node.eval?.depth;
if (nodeDepth != null && nodeDepth >= depth) {
// don't override the local eval if it's deeper than the cloud eval
return;
}
isSameEvalString = eval.evalString == node.eval?.evalString;
node.eval = eval;
});
Expand All @@ -237,11 +230,33 @@ mixin EngineEvaluationMixin {
}
}

bool _canCloudEval() {
if (evaluationState.currentPosition!.ply >= 15 && !evaluationState.alwaysRequestCloudEval) {
return false;
}
if (positionTree.nodeAt(evaluationState.currentPath).eval is CloudEval) return false;

// cloud eval does not support threefold repetition
final Set<String> fens = <String>{};
final nodeList = positionTree.branchesOn(evaluationState.currentPath).toList();
for (var i = nodeList.length - 1; i >= 0; i--) {
final node = nodeList[i];
final epd = fenToEpd(node.position.fen);
if (fens.contains(epd)) return false;
if (node.sanMove.isIrreversible(evaluationState.evaluationContext.variant)) {
return true;
}
fens.add(epd);
}

return true;
}

void _sendEvalGetEvent() {
if (!evaluationState.isEngineAvailable(evaluationPrefs)) return;
if (!_canCloudEval()) return;
final curPosition = evaluationState.currentPosition;
if (curPosition == null) return;

final numEvalLines = evaluationPrefs.numEvalLines;

socketClient?.send('evalGet', {
Expand Down Expand Up @@ -270,11 +285,13 @@ mixin EngineEvaluationMixin {
final (work, eval) = tuple;
bool isSameEvalString = true;
positionTree.updateAt(work.path, (node) {
if (node.eval is CloudEval) {
// in case of high network latency, the cloud eval may arrive after the local eval
// in this case, we should not override the cloud eval with the local eval
_stopEngineEval();
return;
// don't override the cloud eval if it's deeper than the local eval
// unless the engineSearchTime pref is set to kMaxEngineSearchTime (infinity)
if (node.eval is CloudEval &&
evaluationPrefs.engineSearchTime != kMaxEngineSearchTime) {
if (node.eval?.depth != null && node.eval!.depth >= eval.depth) {
return;
}
}
isSameEvalString = eval.evalString == node.eval?.evalString;
node.eval = eval;
Expand All @@ -284,8 +301,4 @@ mixin EngineEvaluationMixin {
}
});
}

void _stopEngineEval() {
_evaluationService?.stop();
}
}
4 changes: 3 additions & 1 deletion lib/src/model/engine/evaluation_preferences.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,7 @@ const kAvailableEngineSearchTimes = [
Duration(seconds: 30),

/// Displayed as infinity in the UI.
Duration(hours: 1),
kMaxEngineSearchTime,
];

const kMaxEngineSearchTime = Duration(hours: 1);
2 changes: 1 addition & 1 deletion lib/src/model/puzzle/puzzle_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ class PuzzleState with _$PuzzleState implements EvaluationMixinState {
}) = _PuzzleState;

@override
bool get delayLocalEngine => false;
bool get alwaysRequestCloudEval => false;

@override
bool isEngineAvailable(EngineEvaluationPrefState _) =>
Expand Down
2 changes: 1 addition & 1 deletion lib/src/model/study/study_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ class StudyState with _$StudyState implements EvaluationMixinState {
}) = _StudyState;

@override
bool get delayLocalEngine => false;
bool get alwaysRequestCloudEval => false;

/// Whether the engine is available for evaluation
@override
Expand Down
4 changes: 2 additions & 2 deletions lib/src/view/analysis/analysis_settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/analysis/stockfish_settings.dart';
import 'package:lichess_mobile/src/view/analysis/engine_settings_widget.dart';
import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_settings.dart';
import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
import 'package:lichess_mobile/src/widgets/feedback.dart';
Expand Down Expand Up @@ -119,7 +119,7 @@ class AnalysisSettingsScreen extends ConsumerWidget {
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SizedBox.shrink(),
secondChild: StockfishSettingsWidget(
secondChild: EngineSettingsWidget(
onSetEngineSearchTime: (value) {
ref.read(ctrlProvider.notifier).setEngineSearchTime(value);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import 'package:lichess_mobile/src/widgets/list.dart';
import 'package:lichess_mobile/src/widgets/non_linear_slider.dart';
import 'package:lichess_mobile/src/widgets/settings.dart';

class StockfishSettingsWidget extends ConsumerWidget {
const StockfishSettingsWidget({
class EngineSettingsWidget extends ConsumerWidget {
const EngineSettingsWidget({
this.onToggleLocalEvaluation,
required this.onSetEngineSearchTime,
required this.onSetNumEvalLines,
Expand Down
4 changes: 2 additions & 2 deletions lib/src/view/broadcast/broadcast_game_settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:lichess_mobile/src/model/broadcast/broadcast_analysis_controller
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/analysis/stockfish_settings.dart';
import 'package:lichess_mobile/src/view/analysis/engine_settings_widget.dart';
import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_settings.dart';
import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
import 'package:lichess_mobile/src/widgets/list.dart';
Expand Down Expand Up @@ -114,7 +114,7 @@ class BroadcastGameSettingsScreen extends ConsumerWidget {
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SizedBox.shrink(),
secondChild: StockfishSettingsWidget(
secondChild: EngineSettingsWidget(
onSetEngineSearchTime:
(value) => ref.read(controller.notifier).setEngineSearchTime(value),
onSetNumEvalLines: (value) => ref.read(controller.notifier).setNumEvalLines(value),
Expand Down
4 changes: 2 additions & 2 deletions lib/src/view/settings/engine_settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/model/engine/evaluation_preferences.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/analysis/stockfish_settings.dart';
import 'package:lichess_mobile/src/view/analysis/engine_settings_widget.dart';

class EngineSettingsScreen extends StatelessWidget {
const EngineSettingsScreen({super.key});
Expand All @@ -22,7 +22,7 @@ class _Body extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
StockfishSettingsWidget(
EngineSettingsWidget(
onSetEngineSearchTime: (value) {
ref.read(engineEvaluationPreferencesProvider.notifier).setEngineSearchTime(value);
},
Expand Down
4 changes: 2 additions & 2 deletions lib/src/view/study/study_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'package:lichess_mobile/src/model/study/study_controller.dart';
import 'package:lichess_mobile/src/model/study/study_preferences.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/analysis/stockfish_settings.dart';
import 'package:lichess_mobile/src/view/analysis/engine_settings_widget.dart';
import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_settings.dart';
import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart';
import 'package:lichess_mobile/src/widgets/list.dart';
Expand Down Expand Up @@ -72,7 +72,7 @@ class StudySettingsScreen extends ConsumerWidget {
],
),
if (isComputerAnalysisAllowed)
StockfishSettingsWidget(
EngineSettingsWidget(
onToggleLocalEvaluation: () => ref.read(studyController.notifier).toggleEngine(),
onSetEngineSearchTime:
(value) => ref.read(studyController.notifier).setEngineSearchTime(value),
Expand Down
4 changes: 2 additions & 2 deletions test/view/analysis/analysis_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -475,10 +475,10 @@ void main() {
await makeEngineTestApp(tester, isCloudEvalEnabled: false);
await playMove(tester, 'e2', 'e4');
expect(find.byType(InlineMove), findsOne);
await tester.pump(kStartLocalEngineDebounceDelay + kEngineEvalEmissionThrottleDelay);
await tester.pump(kLocalEngineAfterCloudEvalDelay + kEngineEvalEmissionThrottleDelay);
expect(find.widgetWithText(InlineMove, '+0.2'), findsOne);
await playMove(tester, 'e7', 'e5');
await tester.pump(kStartLocalEngineDebounceDelay + kEngineEvalEmissionThrottleDelay);
await tester.pump(kLocalEngineAfterCloudEvalDelay + kEngineEvalEmissionThrottleDelay);
expect(find.widgetWithText(InlineMove, '+0.2'), findsNWidgets(2));
});
});
Expand Down
14 changes: 7 additions & 7 deletions test/view/engine/engine_depth_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ void main() {
expect(find.widgetWithText(EngineDepth, '36'), findsOne);

// Wait for local engine delay
await tester.pump(kStartLocalEngineDebounceDelay + kEngineEvalEmissionThrottleDelay);
await tester.pump(kLocalEngineAfterCloudEvalDelay + kEngineEvalEmissionThrottleDelay);
// Local engine has not even started
expect(find.widgetWithText(EngineDepth, '36'), findsOne);
});
Expand All @@ -66,20 +66,20 @@ void main() {
expect(isCloudEvalDisplayed(), isFalse);

// Now wait for local engine
await tester.pump(kStartLocalEngineDebounceDelay + kEngineEvalEmissionThrottleDelay);
await tester.pump(kLocalEngineAfterCloudEvalDelay + kEngineEvalEmissionThrottleDelay);
expect(find.widgetWithText(EngineDepth, '16'), findsOne);
});

testWidgets('Cloud eval will override local engine eval', (tester) async {
// Simulates a connection lag that will make the cloud eval come 300ms after the local engine
final connectionLag =
kStartLocalEngineDebounceDelay -
kLocalEngineAfterCloudEvalDelay -
kRequestEvalDebounceDelay +
const Duration(milliseconds: 300);
await makeEngineTestApp(tester, connectionLag: connectionLag);

// Wait for local engine eval
await tester.pump(kStartLocalEngineDebounceDelay);
await tester.pump(kLocalEngineAfterCloudEvalDelay);
expect(find.widgetWithText(EngineDepth, '15'), findsOne);

// cloud eval will be available 300ms after the local engine eval
Expand All @@ -88,16 +88,16 @@ void main() {
expect(find.widgetWithText(EngineDepth, '36'), findsOne);
});

testWidgets('Local engine will not override cloud eval', (tester) async {
testWidgets('Local engine will not override cloud eval with greater depth', (tester) async {
// Simulates a connection lag that will make the local engine come 100ms after the cloud eval
final connectionLag =
kStartLocalEngineDebounceDelay -
kLocalEngineAfterCloudEvalDelay -
kRequestEvalDebounceDelay +
const Duration(milliseconds: 100);
await makeEngineTestApp(tester, connectionLag: connectionLag);

// Wait for local engine eval
await tester.pump(kStartLocalEngineDebounceDelay);
await tester.pump(kLocalEngineAfterCloudEvalDelay);
expect(find.widgetWithText(EngineDepth, '15'), findsOne);

// Cloud eval will be available 100ms after the first local engine eval emission
Expand Down