Skip to content

Commit 0ccb96f

Browse files
committed
wip
1 parent 1580813 commit 0ccb96f

File tree

14 files changed

+1035
-100
lines changed

14 files changed

+1035
-100
lines changed

lib/src/model/common/game.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'dart:math';
2+
3+
import 'package:dartchess/dartchess.dart';
14
import 'package:lichess_mobile/l10n/l10n.dart';
25

36
/// Represents the choice of a side as a player: white, black or random.
@@ -6,6 +9,15 @@ enum SideChoice {
69
random,
710
black;
811

12+
/// Generate a random side
13+
Side _randomSide() => Side.values[Random().nextInt(Side.values.length)];
14+
15+
Side get generateSide => switch (this) {
16+
SideChoice.white => Side.white,
17+
SideChoice.black => Side.black,
18+
SideChoice.random => _randomSide(),
19+
};
20+
921
String label(AppLocalizations l10n) => switch (this) {
1022
SideChoice.white => l10n.white,
1123
SideChoice.random => l10n.randomColor,
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import 'package:dartchess/dartchess.dart';
2+
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
3+
import 'package:flutter/foundation.dart';
4+
import 'package:freezed_annotation/freezed_annotation.dart';
5+
import 'package:lichess_mobile/src/model/common/chess.dart';
6+
import 'package:lichess_mobile/src/model/common/chess960.dart';
7+
import 'package:lichess_mobile/src/model/common/game.dart';
8+
import 'package:lichess_mobile/src/model/common/perf.dart';
9+
import 'package:lichess_mobile/src/model/common/service/move_feedback.dart';
10+
import 'package:lichess_mobile/src/model/common/speed.dart';
11+
import 'package:lichess_mobile/src/model/computer/computer_game.dart';
12+
import 'package:lichess_mobile/src/model/engine/evaluation_service.dart';
13+
import 'package:lichess_mobile/src/model/engine/work.dart';
14+
import 'package:lichess_mobile/src/model/game/game.dart';
15+
import 'package:lichess_mobile/src/model/game/game_status.dart';
16+
import 'package:lichess_mobile/src/model/game/material_diff.dart';
17+
import 'package:riverpod_annotation/riverpod_annotation.dart';
18+
19+
part 'computer_controller.freezed.dart';
20+
part 'computer_controller.g.dart';
21+
22+
@riverpod
23+
class ComputerGameController extends _$ComputerGameController {
24+
@override
25+
ComputerGameState build() {
26+
ref.onDispose(() {
27+
ref.read(evaluationServiceProvider).disposeEngine();
28+
});
29+
30+
state = ComputerGameState.fromUserChoice(
31+
StockfishLevel.one,
32+
SideChoice.random,
33+
Variant.standard,
34+
);
35+
36+
_onNewGame(firstTime: true);
37+
38+
return state;
39+
}
40+
41+
void _onNewGame({bool firstTime = false}) {
42+
if (!firstTime) {
43+
ref.read(evaluationServiceProvider).newGame(); // should send to engine ucinewgame
44+
}
45+
if (state.game.youAre == Side.black) {
46+
_playComputerMove();
47+
}
48+
}
49+
50+
void _playMove(Move move, {bool isFromComputer = false}) {
51+
final (newPos, newSan) = state.currentPosition.makeSan(move);
52+
final sanMove = SanMove(newSan, move);
53+
final newStep = GameStep(
54+
position: newPos,
55+
sanMove: sanMove,
56+
diff: MaterialDiff.fromBoard(newPos.board),
57+
);
58+
59+
// to fix: if user goes through move list while computer is thinking, state is updated which breaks computer move
60+
if (isFromComputer) {
61+
state = state.copyWith(
62+
game: state.game.copyWith(steps: state.game.steps.add(newStep)),
63+
stepCursor: state.stepCursor + 1,
64+
);
65+
} else {
66+
// In an offline computer game, we support "implicit takebacks":
67+
// When going back one or more steps (i.e. stepCursor < game.steps.length - 1),
68+
// a new move can be made, removing all steps after the current stepCursor.
69+
state = state.copyWith(
70+
game: state.game.copyWith(
71+
steps: state.game.steps
72+
.removeRange(state.stepCursor + 1, state.game.steps.length)
73+
.add(newStep),
74+
),
75+
stepCursor: state.stepCursor + 1,
76+
);
77+
}
78+
79+
_checkGameResults();
80+
_moveFeedback(sanMove);
81+
}
82+
83+
Future<void> _playComputerMove() async {
84+
await ref.read(evaluationServiceProvider).ensureEngineInitialized(state.evaluationContext);
85+
86+
final move = await ref
87+
.read(evaluationServiceProvider)
88+
.startMoveWork(
89+
MoveWork(fen: state.game.lastPosition.fen, level: state.game.level, clock: null),
90+
);
91+
92+
// maybe should show that variant is not supported ?
93+
if (move == null) return;
94+
95+
_playMove(move, isFromComputer: true);
96+
}
97+
98+
void _checkGameResults() {
99+
// check for threefold repetition
100+
if (state.game.steps.count((p) => p.position.board == state.game.lastPosition.board) == 3) {
101+
state = state.copyWith(game: state.game.copyWith(isThreefoldRepetition: true));
102+
} else {
103+
state = state.copyWith(game: state.game.copyWith(isThreefoldRepetition: false));
104+
}
105+
106+
if (state.currentPosition.isCheckmate) {
107+
state = state.copyWith(
108+
game: state.game.copyWith(status: GameStatus.mate, winner: state.turn.opposite),
109+
);
110+
} else if (state.currentPosition.isStalemate) {
111+
state = state.copyWith(game: state.game.copyWith(status: GameStatus.stalemate));
112+
}
113+
}
114+
115+
void startNewGame(StockfishLevel level, SideChoice side, Variant variant) {
116+
state = ComputerGameState.fromUserChoice(level, side, variant);
117+
_onNewGame();
118+
}
119+
120+
void rematch() {
121+
state = ComputerGameState.fromUserChoice(
122+
state.game.level,
123+
state.game.sideChoice,
124+
state.game.meta.variant,
125+
);
126+
_onNewGame();
127+
}
128+
129+
void resign() {
130+
state = state.copyWith(
131+
game: state.game.copyWith(status: GameStatus.resign, winner: state.turn.opposite),
132+
);
133+
}
134+
135+
void draw() {
136+
state = state.copyWith(game: state.game.copyWith(status: GameStatus.draw));
137+
}
138+
139+
Future<void> onUserMove(NormalMove move) async {
140+
if (isPromotionPawnMove(state.currentPosition, move)) {
141+
state = state.copyWith(promotionMove: move);
142+
return;
143+
}
144+
145+
_playMove(move);
146+
147+
if (!state.finished) {
148+
return _playComputerMove();
149+
}
150+
}
151+
152+
void onPromotionSelection(Role? role) {
153+
if (role == null) {
154+
state = state.copyWith(promotionMove: null);
155+
return;
156+
}
157+
final promotionMove = state.promotionMove;
158+
if (promotionMove != null) {
159+
final move = promotionMove.withPromotion(role);
160+
onUserMove(move);
161+
state = state.copyWith(promotionMove: null);
162+
}
163+
}
164+
165+
void onFlag(Side side) {
166+
state = state.copyWith(
167+
game: state.game.copyWith(status: GameStatus.outoftime, winner: side.opposite),
168+
);
169+
}
170+
171+
void goForward() {
172+
if (state.canGoForward) {
173+
state = state.copyWith(stepCursor: state.stepCursor + 1, promotionMove: null);
174+
}
175+
}
176+
177+
void goBack() {
178+
if (state.canGoBack) {
179+
state = state.copyWith(stepCursor: state.stepCursor - 1, promotionMove: null);
180+
}
181+
}
182+
183+
void _moveFeedback(SanMove sanMove) {
184+
final isCheck = sanMove.san.contains('+');
185+
if (sanMove.san.contains('x')) {
186+
ref.read(moveFeedbackServiceProvider).captureFeedback(check: isCheck);
187+
} else {
188+
ref.read(moveFeedbackServiceProvider).moveFeedback(check: isCheck);
189+
}
190+
}
191+
}
192+
193+
@freezed
194+
sealed class ComputerGameState with _$ComputerGameState {
195+
const ComputerGameState._();
196+
197+
const factory ComputerGameState({
198+
required ComputerGame game,
199+
required EvaluationContext evaluationContext,
200+
@Default(0) int stepCursor,
201+
@Default(null) NormalMove? promotionMove,
202+
}) = _ComputerGameState;
203+
204+
factory ComputerGameState.fromUserChoice(
205+
StockfishLevel level,
206+
SideChoice sideChoice,
207+
Variant variant,
208+
) {
209+
// check if we can get rid of speed and perf
210+
// this is not an online game and time is unlimited
211+
const speed = Speed.correspondence;
212+
final position = variant == Variant.chess960
213+
? randomChess960Position()
214+
: variant.initialPosition;
215+
return ComputerGameState(
216+
evaluationContext: EvaluationContext(variant: variant, initialPosition: position),
217+
game: ComputerGame(
218+
steps: [GameStep(position: position)].lock,
219+
status: GameStatus.started,
220+
initialFen: position.fen,
221+
level: level,
222+
sideChoice: sideChoice,
223+
youAre: sideChoice.generateSide,
224+
meta: GameMeta(
225+
createdAt: DateTime.now(),
226+
rated: false,
227+
variant: variant,
228+
speed: speed,
229+
perf: Perf.fromVariantAndSpeed(variant, speed),
230+
),
231+
),
232+
);
233+
}
234+
235+
Position get currentPosition => game.stepAt(stepCursor).position;
236+
Side get turn => currentPosition.turn;
237+
bool get finished => game.finished;
238+
NormalMove? get lastMove =>
239+
stepCursor > 0 ? NormalMove.fromUci(game.steps[stepCursor].sanMove!.move.uci) : null;
240+
241+
MaterialDiffSide? currentMaterialDiff(Side side) {
242+
return game.steps[stepCursor].diff?.bySide(side);
243+
}
244+
245+
List<String> get moves => game.steps.skip(1).map((e) => e.sanMove!.san).toList(growable: false);
246+
247+
bool get canGoForward => stepCursor < game.steps.length - 1;
248+
bool get canGoBack => stepCursor > 0;
249+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import 'package:dartchess/dartchess.dart';
2+
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
3+
import 'package:freezed_annotation/freezed_annotation.dart';
4+
import 'package:lichess_mobile/l10n/l10n.dart';
5+
import 'package:lichess_mobile/src/model/common/eval.dart';
6+
import 'package:lichess_mobile/src/model/common/game.dart';
7+
import 'package:lichess_mobile/src/model/common/id.dart';
8+
import 'package:lichess_mobile/src/model/game/game.dart';
9+
import 'package:lichess_mobile/src/model/game/game_status.dart';
10+
import 'package:lichess_mobile/src/model/game/player.dart';
11+
import 'package:lichess_mobile/src/utils/string.dart';
12+
13+
part 'computer_game.freezed.dart';
14+
part 'computer_game.g.dart';
15+
16+
enum StockfishLevel {
17+
// Values taken from lichobile https://github.com/lichess-org/lichobile/blob/663e69fab10e4267a9b3369febe85d1363816ba2/src/ui/ai/engine.ts#L71-L108
18+
one(1350, 5),
19+
two(1500, 5),
20+
three(1600, 5),
21+
four(1700, 5),
22+
five(2000, 5),
23+
six(2300, 8),
24+
seven(2700, 13),
25+
eight(2850, 22);
26+
27+
const StockfishLevel(this.elo, this.depth);
28+
29+
final int elo;
30+
final int depth;
31+
32+
static const _maxMoveTime = Duration(milliseconds: 5000);
33+
34+
int get value => index + 1;
35+
36+
Duration get moveTime => (_maxMoveTime * value) ~/ 8;
37+
38+
String label(AppLocalizations l10n) => l10n.aiNameLevelAiLevel('Stockfish', value.toString());
39+
}
40+
41+
/// An offline game played against Sockfish.
42+
@Freezed(fromJson: true, toJson: true)
43+
abstract class ComputerGame with _$ComputerGame, BaseGame, IndexableSteps {
44+
const ComputerGame._();
45+
46+
@Assert('steps.isNotEmpty')
47+
factory ComputerGame({
48+
@JsonKey(fromJson: stepsFromJson, toJson: stepsToJson) required IList<GameStep> steps,
49+
required GameMeta meta,
50+
required String? initialFen,
51+
required GameStatus status,
52+
required StockfishLevel level,
53+
required SideChoice sideChoice,
54+
// can't be null for a computer game but I don't know how to make the type not nullable
55+
Side? youAre,
56+
Side? winner,
57+
bool? isThreefoldRepetition,
58+
}) = _ComputerGame;
59+
60+
@override
61+
Player get white => _playerFromSide(Side.white);
62+
63+
@override
64+
Player get black => _playerFromSide(Side.black);
65+
66+
Player _playerFromSide(Side side) =>
67+
(youAre == side) ? Player(name: side.name.capitalize()) : Player(aiLevel: level.value);
68+
69+
@override
70+
IList<ExternalEval>? get evals => null;
71+
72+
@override
73+
IList<Duration>? get clocks => null;
74+
75+
@override
76+
GameId get id => const GameId('--------');
77+
78+
bool get abortable => playable && lastPosition.fullmoves <= 1;
79+
80+
bool get resignable => playable && !abortable;
81+
bool get drawable => playable && lastPosition.fullmoves >= 2;
82+
}

0 commit comments

Comments
 (0)