Files
mobile/lib/src/model/analysis/analysis_controller.dart
T
Vincent Velociter 96aef2d48e Save standalone analysis session in memory (#2462)
* Save standalone analysis session in memory

Closes #2423

* Upgrade riverpod_lint
2025-12-27 11:36:26 +01:00

1057 lines
34 KiB
Dart

import 'dart:async';
import 'package:collection/collection.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:intl/intl.dart';
import 'package:lichess_mobile/src/model/account/account_service.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart';
import 'package:lichess_mobile/src/model/analysis/common_analysis_state.dart';
import 'package:lichess_mobile/src/model/analysis/forecast.dart';
import 'package:lichess_mobile/src/model/analysis/opening_service.dart';
import 'package:lichess_mobile/src/model/analysis/server_analysis_service.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/id.dart';
import 'package:lichess_mobile/src/model/common/node.dart';
import 'package:lichess_mobile/src/model/common/service/move_feedback.dart';
import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
import 'package:lichess_mobile/src/model/common/socket.dart';
import 'package:lichess_mobile/src/model/common/uci.dart';
import 'package:lichess_mobile/src/model/engine/evaluation_mixin.dart';
import 'package:lichess_mobile/src/model/engine/evaluation_preferences.dart';
import 'package:lichess_mobile/src/model/engine/evaluation_service.dart';
import 'package:lichess_mobile/src/model/game/exported_game.dart';
import 'package:lichess_mobile/src/model/game/game_repository.dart';
import 'package:lichess_mobile/src/model/game/game_repository_providers.dart';
import 'package:lichess_mobile/src/model/game/playable_game.dart';
import 'package:lichess_mobile/src/model/game/player.dart';
import 'package:lichess_mobile/src/model/tv/tv_socket_events.dart';
import 'package:lichess_mobile/src/network/connectivity.dart';
import 'package:lichess_mobile/src/network/http.dart';
import 'package:lichess_mobile/src/network/socket.dart';
import 'package:lichess_mobile/src/view/engine/engine_gauge.dart';
import 'package:lichess_mobile/src/widgets/pgn.dart';
part 'analysis_controller.freezed.dart';
final _dateFormat = DateFormat('yyyy.MM.dd');
@freezed
sealed class AnalysisOptions with _$AnalysisOptions {
const AnalysisOptions._();
const factory AnalysisOptions.standalone({
required Side orientation,
int? initialMoveCursor,
required String pgn,
required Variant variant,
required bool isComputerAnalysisAllowed,
}) = Standalone;
const factory AnalysisOptions.archivedGame({
required Side orientation,
int? initialMoveCursor,
required GameId gameId,
}) = ArchivedGame;
const factory AnalysisOptions.activeCorrespondenceGame({
required Side orientation,
int? initialMoveCursor,
required GameFullId gameFullId,
}) = ActiveCorrespondenceGame;
bool get isLichessGameAnalysis => this is ArchivedGame;
GameId? get gameId => switch (this) {
ArchivedGame(:final gameId) => gameId,
Standalone() => null,
ActiveCorrespondenceGame(:final gameFullId) => gameFullId.gameId,
};
}
class UnsupportedVariantException implements Exception {
final Variant variant;
final GameId gameId;
const UnsupportedVariantException(this.variant, this.gameId);
}
enum AnalysisGameResult {
whiteWins,
blackWins,
draw,
other;
static AnalysisGameResult resultFromPgnResult(String? result) {
return switch (result) {
'1-0' => AnalysisGameResult.whiteWins,
'0-1' => AnalysisGameResult.blackWins,
'½-½' => AnalysisGameResult.draw,
_ => AnalysisGameResult.other,
};
}
String? resultToString(Side side) => switch (this) {
whiteWins => side == Side.white ? '1' : '0',
blackWins => side == Side.white ? '0' : '1',
draw => '½',
other => null,
};
}
/// A provider for [AnalysisController].
final analysisControllerProvider = AsyncNotifierProvider.autoDispose
.family<AnalysisController, AnalysisState, AnalysisOptions>(
AnalysisController.new,
name: 'AnalysisControllerProvider',
);
Root? _savedStandaloneRoot;
void clearSavedStandaloneAnalysis() {
_savedStandaloneRoot = null;
}
class AnalysisController extends AsyncNotifier<AnalysisState>
with EngineEvaluationMixin
implements PgnTreeNotifier {
AnalysisController(this.options);
final AnalysisOptions options;
static final Uri socketUri = Uri(path: '/analysis/socket/v5');
StreamSubscription<SocketEvent>? _socketSubscription;
late Root _root;
late Variant _variant;
@override
@protected
late SocketClient socketClient;
@override
@protected
Root get positionTree => _root;
GameRepository get _gameRepository => ref.read(gameRepositoryProvider);
@override
Future<AnalysisState> build() async {
final serverAnalysisService = ref.watch(serverAnalysisServiceProvider);
ref.onDispose(() {
_socketSubscription?.cancel();
serverAnalysisService.lastAnalysisEvent.removeListener(_listenToServerAnalysisEvents);
});
socketClient = ref.watch(socketPoolProvider).open(AnalysisController.socketUri);
_socketSubscription?.cancel();
_socketSubscription = socketClient.stream.listen(_handleSocketEvent);
isOnline(ref.read(defaultClientProvider)).then((online) {
if (!online) {
socketClient.close();
}
});
late final String pgn;
late final LightOpening? opening;
late final ({PlayerAnalysis white, PlayerAnalysis black})? serverAnalysis;
late final Division? division;
late final PlayableGame? activeCorrespondenceGame;
ExportedGame? archivedGame;
switch (options) {
case ArchivedGame(:final gameId):
{
archivedGame = await ref.read(archivedGameProvider(gameId).future);
_variant = archivedGame!.meta.variant;
if (!_variant.isReadSupported) {
throw UnsupportedVariantException(_variant, gameId);
}
pgn = archivedGame.makePgn();
opening = archivedGame.data.opening;
serverAnalysis = archivedGame.serverAnalysis;
division = archivedGame.meta.division;
activeCorrespondenceGame = null;
}
case Standalone(:final variant, pgn: final gamePgn):
{
_variant = variant;
pgn = gamePgn;
opening = null;
serverAnalysis = null;
division = null;
activeCorrespondenceGame = null;
// We want to keep the standalone analysis session alive even if the user navigates away
ref.onCancel(() {
if (_root.mainline.isNotEmpty) {
_savedStandaloneRoot = _root;
}
});
}
case ActiveCorrespondenceGame(:final gameFullId):
{
final game = await _gameRepository.getActiveCorrespondenceGame(gameFullId);
_variant = game.meta.variant;
pgn = game.makePgn();
opening = game.meta.opening;
serverAnalysis = null;
division = null;
activeCorrespondenceGame = game;
}
}
UciPath path = UciPath.empty;
Move? lastMove;
final game = PgnGame.parsePgn(
pgn,
initHeaders: () => options.isLichessGameAnalysis
? {}
: {
'Event': '?',
'Site': '?',
'Date': _dateFormat.format(DateTime.now()),
'Round': '?',
'White': '?',
'Black': '?',
'Result': '*',
'WhiteElo': '?',
'BlackElo': '?',
},
);
final pgnHeaders = IMap(game.headers);
final rootComments = IList(game.comments.map((c) => PgnComment.fromPgn(c)));
final isGameFinished = pgnHeaders['Result'] != '*';
final isComputerAnalysisAllowed = switch (options) {
Standalone(:final isComputerAnalysisAllowed) => isComputerAnalysisAllowed,
ArchivedGame() => isGameFinished,
ActiveCorrespondenceGame() => false,
};
final List<Future<(UciPath, FullOpening)?>> openingFutures = [];
_root = switch (options) {
Standalone() when _savedStandaloneRoot != null => _savedStandaloneRoot!,
_ => Root.fromPgnGame(
game,
isLichessAnalysis: options.isLichessGameAnalysis,
onVisitNode: (root, branch, isMainline) {
if (isMainline &&
options.initialMoveCursor != null &&
branch.position.ply <= root.position.ply + options.initialMoveCursor!) {
path = path + branch.id;
lastMove = branch.sanMove.move;
}
if (isMainline && opening == null && branch.position.ply <= 10) {
openingFutures.add(_fetchOpening(branch.position.fen, path));
}
},
),
};
// wait for the opening to be fetched to recompute the branch opening
Future.wait(openingFutures)
.then((list) {
bool hasOpening = false;
for (final updated in list) {
if (updated != null) {
hasOpening = true;
final (path, opening) = updated;
_root.updateAt(path, (node) => node.opening = opening);
}
}
return hasOpening;
})
.then((hasOpening) {
if (hasOpening) {
scheduleMicrotask(() {
_setPath(state.requireValue.currentPath);
});
}
});
final currentPath = options.initialMoveCursor == null ? _root.mainlinePath : path;
final currentNode = _root.nodeAt(currentPath);
late final Forecast? forecast;
if (activeCorrespondenceGame != null) {
// Premove paths are saved on the server, so if the user has already added some premoves on web,
// we need to add them to our tree here as well.
final lastMainlineNode = _root.mainline.lastOrNull ?? _root;
// Adding nodes in the loop below changes _root.mainline, so save the current mainline path here
final gameMainlinePath = _root.mainlinePath;
final paths = <UciPath>[];
for (final line
in (activeCorrespondenceGame.correspondenceForecast ??
const IList<IList<SanMove>>.empty())) {
var position = lastMainlineNode.position;
final nodes = <Branch>[];
for (final sanMove in line) {
position = position.playUnchecked(sanMove.move);
nodes.add(Branch(position: position, sanMove: sanMove));
}
_root.addNodesAt(gameMainlinePath, nodes);
paths.add(UciPath.fromUciMoves(line.map((sanMove) => sanMove.move.uci)));
}
forecast = Forecast(onMyTurn: activeCorrespondenceGame.isMyTurn, lines: paths.lock);
} else {
forecast = null;
}
// don't use ref.watch here: we don't want to invalidate state when the
// analysis preferences change
final prefs = ref.read(analysisPreferencesProvider);
serverAnalysisService.lastAnalysisEvent.addListener(_listenToServerAnalysisEvents);
final analysisState = AnalysisState(
variant: _variant,
gameId: options.gameId,
archivedGame: archivedGame,
currentPath: currentPath,
pathToLiveMove: isGameFinished ? null : currentPath,
forecast: forecast,
isOnMainline: _root.isOnMainline(currentPath),
root: _root.view,
currentNode: AnalysisCurrentNode.fromNode(currentNode),
pgnHeaders: pgnHeaders,
pgnRootComments: rootComments,
lastMove: lastMove,
pov: options.orientation,
contextOpening: opening,
isComputerAnalysisAllowed: isComputerAnalysisAllowed,
isServerAnalysisEnabled: prefs.enableServerAnalysis,
evaluationContext: EvaluationContext(variant: _variant, initialPosition: _root.position),
playersAnalysis: serverAnalysis,
acplChartData: serverAnalysis != null ? _makeAcplChartData() : null,
division: division,
);
// We need to define the state value in the build method because `requestEval` require the state
// to have a value.
state = AsyncData(analysisState);
socketClient.firstConnection
.timeout(const Duration(seconds: 3))
.onError((_, _) {})
.whenComplete(() {
if (state.requireValue.isEngineAvailable(evaluationPrefs)) {
requestEval();
} else if (options case ActiveCorrespondenceGame(:final gameFullId)) {
socketClient.send('startWatching', gameFullId.gameId.value);
}
});
return analysisState;
}
void clearSavedStandaloneAnalysis() {
_savedStandaloneRoot = null;
ref.invalidateSelf();
}
Future<void> onFocusRegained() async {
if (options case ActiveCorrespondenceGame(:final gameFullId)) {
final updatedGame = await _gameRepository.getActiveCorrespondenceGame(gameFullId);
_addNewLiveMoves(
updatedGame.steps
// Skip one more step, since the first one is the initial position
.skip(1 + state.requireValue.pathToLiveMove!.size)
.map((step) => step.sanMove!.move),
);
}
}
void _addNewLiveMoves(Iterable<Move> moves) {
final newLiveMovePath = _root.addMovesAt(state.requireValue.pathToLiveMove!, moves);
_root.promoteAt(newLiveMovePath!, toMainline: true);
_setPath(
// If the move cursor was on the live move, follow the game and move to the new live move.
// Otherwise, stay on the current path.
state.requireValue.pathToLiveMove == state.requireValue.currentPath
? newLiveMovePath
: state.requireValue.currentPath,
shouldRecomputeRootView: true,
shouldForceShowVariation: true,
);
state = AsyncData(
state.requireValue.copyWith(
forecast: moves.fold(
state.requireValue.forecast,
(forecast, move) => forecast!.playMove(move),
),
pathToLiveMove: newLiveMovePath,
),
);
}
void _handleSocketEvent(SocketEvent event) {
if (!state.hasValue) {
return;
}
switch (event.topic) {
case 'fen':
_handleFenEvent(FenSocketEvent.fromJson(event.data as Map<String, dynamic>));
}
}
void _handleFenEvent(FenSocketEvent fenEvent) {
final isNewMove =
fenEvent.id == options.gameId &&
state.requireValue.liveMoveNode != null &&
// The server sends an initial FEN event on connection, ignore that here
!state.requireValue.liveMoveNode!.position.fen.startsWith(fenEvent.fen);
if (!isNewMove) {
return;
}
_addNewLiveMoves([fenEvent.lastMove]);
}
@override
void onCurrentPathEvalChanged(bool isSameEvalString) {
_refreshCurrentNode(recomputeRootView: !isSameEvalString);
}
void _refreshCurrentNode({bool recomputeRootView = false}) {
state = AsyncData(
state.requireValue.copyWith(
root: recomputeRootView ? _root.view : state.requireValue.root,
currentNode: AnalysisCurrentNode.fromNode(_root.nodeAt(state.requireValue.currentPath)),
),
);
}
void onUserMove(NormalMove move, {bool shouldReplace = false}) {
if (!state.requireValue.currentPosition.isLegal(move)) return;
if (isPromotionPawnMove(state.requireValue.currentPosition, move)) {
state = AsyncValue.data(state.requireValue.copyWith(promotionMove: move));
return;
}
final (newPath, isNewNode) = _root.addMoveAt(
state.requireValue.currentPath,
move,
replace: shouldReplace,
);
if (newPath != null) {
_setPath(newPath, shouldRecomputeRootView: isNewNode, shouldForceShowVariation: true);
}
}
void onPromotionSelection(Role? role) {
if (role == null) {
state = AsyncData(state.requireValue.copyWith(promotionMove: null));
return;
}
final promotionMove = state.requireValue.promotionMove;
if (promotionMove != null) {
final promotion = promotionMove.withPromotion(role);
onUserMove(promotion);
}
}
void userPrevious() {
_setPath(state.requireValue.currentPath.penultimate, isNavigating: true);
}
void userNext() {
final curState = state.requireValue;
if (!curState.currentNode.hasChild) return;
_setPath(
curState.currentPath + _root.nodeAt(curState.currentPath).children.first.id,
isNavigating: true,
);
}
void jumpToNthNodeOnMainline(int n) {
UciPath path = _root.mainlinePath;
while (!path.penultimate.isEmpty) {
path = path.penultimate;
}
Node? node = _root.nodeAt(path);
int count = 0;
while (node != null && count < n) {
if (node.children.isNotEmpty) {
path = path + node.children.first.id;
node = _root.nodeAt(path);
count++;
} else {
break;
}
}
if (node != null) {
userJump(path);
}
}
void toggleBoard() {
final curState = state.requireValue;
state = AsyncData(curState.copyWith(pov: curState.pov.opposite));
}
@override
void userJump(UciPath path) {
_setPath(path);
}
@override
void expandVariations(UciPath path) {
final node = _root.nodeAt(path);
final childrenToShow = _root.isOnMainline(path) ? node.children.skip(1) : node.children;
for (final child in childrenToShow) {
child.isCollapsed = false;
for (final grandChild in child.children) {
grandChild.isCollapsed = false;
}
}
state = AsyncData(state.requireValue.copyWith(root: _root.view));
}
@override
void collapseVariations(UciPath path) {
final node = _root.nodeAt(path);
for (final child in node.children) {
child.isCollapsed = true;
}
state = AsyncData(state.requireValue.copyWith(root: _root.view));
}
@override
void promoteVariation(UciPath path, bool toMainline) {
_root.promoteAt(path, toMainline: toMainline);
final curState = state.requireValue;
state = AsyncData(
curState.copyWith(isOnMainline: _root.isOnMainline(curState.currentPath), root: _root.view),
);
}
@override
void deleteFromHere(UciPath path) {
_root.deleteAt(path);
_setPath(path.penultimate, shouldRecomputeRootView: true);
}
void addCurrentPathAsPremove() {
state = AsyncData(
state.requireValue.copyWith(
forecast: state.requireValue.forecast!.add(
state.requireValue.currentPath.stripPrefix(state.requireValue.pathToLiveMove!),
),
),
);
if (!state.requireValue.forecast!.onMyTurn) {
_syncForecast();
}
}
void playPendingMoveAndSaveForecast() {
if (!state.hasValue || state.requireValue.pendingMove == null) return;
final moveToPlay = state.requireValue.pendingMove!.move;
final newForecast = state.requireValue.forecast!.playMove(moveToPlay);
final newLiveMovePath = UciPath.join(
state.requireValue.pathToLiveMove!,
UciPath.fromId(UciCharPair.fromUci(moveToPlay.uci)),
);
_gameRepository.saveForecast(
gameId: (options as ActiveCorrespondenceGame).gameFullId,
forecast: newForecast.toApiForecast(_root.branchAt(newLiveMovePath)!.view),
moveToPlay: moveToPlay,
);
state = AsyncData(
state.requireValue.copyWith(
forecast: state.requireValue.forecast!.playMove(moveToPlay),
pathToLiveMove: newLiveMovePath,
),
);
}
void removePremovePath(UciPath path) {
state = AsyncData(
state.requireValue.copyWith(forecast: state.requireValue.forecast!.remove(path)),
);
_syncForecast();
}
void _syncForecast() {
final pathToLiveMove = state.requireValue.pathToLiveMove!;
_gameRepository.saveForecast(
gameId: (options as ActiveCorrespondenceGame).gameFullId,
forecast: state.requireValue.forecast!.toApiForecast(
pathToLiveMove.isEmpty ? _root.view : _root.branchAt(pathToLiveMove)!.view,
),
);
}
void updatePgnHeader(String key, String value) {
final headers = state.requireValue.pgnHeaders.add(key, value);
state = AsyncData(state.requireValue.copyWith(pgnHeaders: headers));
}
Future<void> requestServerAnalysis() {
if (state.requireValue.canRequestServerAnalysis) {
final service = ref.read(serverAnalysisServiceProvider);
return service.requestAnalysis(options.gameId!, options.orientation);
}
return Future.error('Cannot request server analysis');
}
/// Gets the node and maybe the associated branch opening at the given path.
(Node, Opening?) _nodeOpeningAt(Node node, UciPath path, [Opening? opening]) {
if (path.isEmpty) return (node, opening);
final child = node.childById(path.head!);
if (child != null) {
return _nodeOpeningAt(child, path.tail, child.opening ?? opening);
} else {
return (node, opening);
}
}
/// Makes a full PGN string (including headers and comments) of the current game state.
String makeExportPgn() {
final curState = state.requireValue;
return _root.makePgn(curState.pgnHeaders, curState.pgnRootComments);
}
/// Makes a PGN string up to the current node only.
String makeCurrentNodePgn() {
final nodes = _root.branchesOn(state.requireValue.currentPath);
return nodes.map((node) => node.sanMove.san).join(' ');
}
Future<void> toggleBookmark() async {
if (state.hasValue) {
final game = state.requireValue.archivedGame;
if (game == null) return;
final toggledBookmark = !(game.data.bookmarked ?? false);
await ref.read(accountServiceProvider).setGameBookmark(game.id, bookmark: toggledBookmark);
state = AsyncValue.data(
state.requireValue.copyWith(
archivedGame: state.requireValue.archivedGame?.copyWith(
data: game.data.copyWith(bookmarked: toggledBookmark),
),
),
);
}
}
Future<void> toggleEngineThreatMode() async {
if (state.hasValue) {
state = AsyncData(
state.requireValue.copyWith(engineInThreatMode: !state.requireValue.engineInThreatMode),
);
requestEval();
}
}
void _setPath(
UciPath path, {
bool shouldForceShowVariation = false,
bool shouldRecomputeRootView = false,
/// Whether the user is navigating through the moves (as opposed to playing a move).
bool isNavigating = false,
}) {
final curState = state.requireValue;
final pathChange = curState.currentPath != path;
final (currentNode, opening) = _nodeOpeningAt(_root, path);
// always show variation if the user plays a move
if (shouldForceShowVariation && currentNode is Branch && currentNode.isCollapsed) {
_root.updateAt(path, (node) {
if (node is Branch) node.isCollapsed = false;
});
}
// root view is only used to display move list, so we need to
// recompute the root view only when the nodelist length changes
// or a variation is hidden/shown
final rootView = shouldForceShowVariation || shouldRecomputeRootView
? _root.view
: curState.root;
final isForward = path.size > curState.currentPath.size;
if (currentNode is Branch) {
// normal move feedback
if (!isNavigating && isForward) {
final isCheck = currentNode.sanMove.isCheck;
if (currentNode.sanMove.isCapture) {
ref.read(moveFeedbackServiceProvider).captureFeedback(check: isCheck);
} else {
ref.read(moveFeedbackServiceProvider).moveFeedback(check: isCheck);
}
}
// if navigating, only sound feedback
else {
final soundService = ref.read(soundServiceProvider);
if (currentNode.sanMove.isCapture) {
soundService.play(Sound.capture);
} else {
soundService.play(Sound.move);
}
}
if (currentNode.opening == null && currentNode.position.ply <= 30) {
_fetchOpening(currentNode.position.fen, path).then((value) {
if (value != null) {
final (path, opening) = value;
_updateOpening(path, opening);
}
});
}
state = AsyncData(
curState.copyWith(
currentPath: path,
isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening,
lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView,
),
);
} else {
state = AsyncData(
curState.copyWith(
currentPath: path,
isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening,
lastMove: null,
promotionMove: null,
root: rootView,
),
);
}
if (pathChange) {
state = AsyncData(state.requireValue.copyWith(engineInThreatMode: false));
requestEval();
}
}
Future<(UciPath, FullOpening)?> _fetchOpening(String fen, UciPath path) async {
if (!kOpeningAllowedVariants.contains(_variant)) return null;
final opening = await ref.read(openingServiceProvider).fetchFromFen(fen);
if (opening != null) {
return (path, opening);
}
return null;
}
void _updateOpening(UciPath path, FullOpening opening) {
_root.updateAt(path, (node) => node.opening = opening);
final curState = state.requireValue;
if (curState.currentPath == path) {
_refreshCurrentNode();
}
}
void _listenToServerAnalysisEvents() {
final event = ref.read(serverAnalysisServiceProvider).lastAnalysisEvent.value;
if (event != null && event.$1 == state.requireValue.gameId) {
ServerAnalysisService.mergeOngoingAnalysis(_root, event.$2.tree);
state = AsyncData(
state.requireValue.copyWith(
acplChartData: _makeAcplChartData(),
playersAnalysis: event.$2.analysis != null
? (white: event.$2.analysis!.white, black: event.$2.analysis!.black)
: null,
root: _root.view,
),
);
}
}
IList<ExternalEval>? _makeAcplChartData() {
if (!_root.mainline.any((node) => node.lichessAnalysisComments != null)) {
return null;
}
final list = _root.mainline
.map(
(node) => (
node.position.isCheckmate,
node.position.turn,
node.lichessAnalysisComments?.firstWhereOrNull((c) => c.eval != null)?.eval,
),
)
.map((el) {
final (isCheckmate, side, eval) = el;
return eval != null
? ExternalEval(
cp: eval.pawns != null ? cpFromPawns(eval.pawns!) : null,
mate: eval.mate,
depth: eval.depth,
)
: ExternalEval(
cp: null,
// hack to display checkmate as the max eval
mate: isCheckmate
? side == Side.white
? -1
: 1
: null,
);
})
.toList(growable: false);
return list.isEmpty ? null : IList(list);
}
}
@freezed
sealed class AnalysisState
with _$AnalysisState
implements EvaluationMixinState, CommonAnalysisState {
const AnalysisState._();
const factory AnalysisState({
/// The ID of the game if it's a lichess game.
required GameId? gameId,
/// The archived game if it's a finished lichess game.
ExportedGame? archivedGame,
/// The variant of the analysis.
required Variant variant,
/// Immutable view of the whole tree
required ViewRoot root,
/// The current node in the analysis view.
///
/// This is an immutable copy of the actual [Node] at the `currentPath`.
/// We don't want to use [Node.view] here because it'd copy the whole tree
/// under the current node and it's expensive.
required AnalysisCurrentNode currentNode,
/// The path to the current node in the analysis view.
required UciPath currentPath,
/// If this is a correspondence game, the path to the last move that has been played.
required UciPath? pathToLiveMove,
/// If this is a correspondence game, stores the current set of conditional premoves.
required Forecast? forecast,
/// Whether the current path is on the mainline.
required bool isOnMainline,
/// The side to display the board from.
required Side pov,
/// Whether computer evaluation is allowed for this analysis.
///
/// Acts on both local and server analysis.
required bool isComputerAnalysisAllowed,
/// Whether the user has enabled server analysis.
required bool isServerAnalysisEnabled,
/// The context that the local engine is initialized with.
required EvaluationContext evaluationContext,
/// The last move played.
Move? lastMove,
/// Possible promotion move to be played.
NormalMove? promotionMove,
/// Opening of the analysis context (from lichess archived games).
Opening? contextOpening,
/// The opening of the current branch.
Opening? currentBranchOpening,
/// Optional server analysis to display player stats.
({PlayerAnalysis white, PlayerAnalysis black})? playersAnalysis,
/// Optional game division data, given by server analysis.
Division? division,
/// Optional ACPL chart data of the game, coming from lichess server analysis.
IList<Eval>? acplChartData,
/// The PGN headers of the game.
required IMap<String, String> pgnHeaders,
/// The PGN comments of the game.
///
/// This field is only used with user submitted PGNS.
IList<PgnComment>? pgnRootComments,
@Default(false) bool engineInThreatMode,
}) = _AnalysisState;
@override
bool get alwaysRequestCloudEval => false;
/// Whether the analysis is for a lichess game.
bool get isLichessGameAnalysis => gameId != null;
/// Whether the user can request server analysis.
///
/// It must be a lichess game, which is finished and not already analyzed.
bool get canRequestServerAnalysis =>
gameId != null && !hasServerAnalysis && pgnHeaders['Result'] != '*';
/// Whether the server analysis is available.
bool get hasServerAnalysis => playersAnalysis != null;
bool get canShowGameSummary => hasServerAnalysis || canRequestServerAnalysis;
/// Whether an evaluation can be available
bool hasAvailableEval(EngineEvaluationPrefState prefs) =>
isEngineAvailable(prefs) ||
(isComputerAnalysisAllowed && isServerAnalysisEnabled && hasServerAnalysis);
/// Whether the engine is allowed for this analysis and variant.
bool get isEngineAllowed =>
isComputerAnalysisAllowed && engineSupportedVariants.contains(variant);
ViewNode? get liveMoveNode => pathToLiveMove != null
? pathToLiveMove!.isEmpty
? root
: root.branchesOn(pathToLiveMove!).last
: null;
bool get branchedOffFromLiveMove =>
forecast != null &&
pathToLiveMove != null &&
currentPath != pathToLiveMove &&
currentPath.contains(pathToLiveMove!);
/// If the current node branches off from the live move and is not yet saved as a premove,
/// the part of [AnalysisState.currentPath] that would be saved as a premove line. null otherwise.
UciPath? get currentPremoveCandidate {
if (!branchedOffFromLiveMove) {
return null;
}
final candidate = currentPath.stripPrefix(pathToLiveMove!);
return forecast!.isCandidate(candidate) ? candidate : null;
}
/// If it's our turn and we have branched off from the main line, this is the move we would play
/// if we to save the entire current path as a premove line.
SanMove? get pendingMove => forecast?.onMyTurn == true && branchedOffFromLiveMove
? liveMoveNode!.childById(currentPath.stripPrefix(pathToLiveMove!).head!)?.sanMove
: null;
IList<UciPath>? get linesForPendingMove =>
pendingMove != null ? forecast?.linesStartingWith(pendingMove!.move) : null;
@override
bool isEngineAvailable(EngineEvaluationPrefState prefs) => isEngineAllowed && prefs.isEnabled;
@override
Position get currentPosition => currentNode.position;
bool get canGoNext => currentNode.hasChild;
bool get canGoBack => currentPath.size > UciPath.empty.size;
EngineGaugeParams engineGaugeParams(EngineEvaluationPrefState prefs) => (
isLocalEngineAvailable: isEngineAvailable(prefs),
orientation: pov,
position: currentPosition,
savedEval: currentNode.eval,
serverEval: currentNode.serverEval,
);
}
@freezed
sealed class AnalysisCurrentNode
with _$AnalysisCurrentNode
implements AnalysisCurrentNodeInterface {
const AnalysisCurrentNode._();
const factory AnalysisCurrentNode({
required Position position,
required bool hasChild,
required bool isRoot,
SanMove? sanMove,
Opening? opening,
ClientEval? eval,
IList<PgnComment>? lichessAnalysisComments,
IList<PgnComment>? startingComments,
IList<PgnComment>? comments,
IList<int>? nags,
}) = _AnalysisCurrentNode;
factory AnalysisCurrentNode.fromNode(Node node) {
if (node is Branch) {
return AnalysisCurrentNode(
sanMove: node.sanMove,
position: node.position,
isRoot: node is Root,
hasChild: node.children.isNotEmpty,
opening: node.opening,
eval: node.eval,
lichessAnalysisComments: IList(node.lichessAnalysisComments),
startingComments: IList(node.startingComments),
comments: IList(node.comments),
nags: IList(node.nags),
);
} else {
return AnalysisCurrentNode(
position: node.position,
hasChild: node.children.isNotEmpty,
isRoot: node is Root,
opening: node.opening,
eval: node.eval,
);
}
}
/// The evaluation from the PGN comments.
///
/// For now we only trust the eval coming from lichess analysis.
ExternalEval? get serverEval {
final pgnEval = lichessAnalysisComments?.firstWhereOrNull((c) => c.eval != null)?.eval;
return pgnEval != null
? ExternalEval(
cp: pgnEval.pawns != null ? cpFromPawns(pgnEval.pawns!) : null,
mate: pgnEval.mate,
depth: pgnEval.depth,
)
: null;
}
}