Skip to content

Commit

Permalink
feat: offline games ("over the board")
Browse files Browse the repository at this point in the history
  • Loading branch information
tom-anders committed Aug 21, 2024
1 parent 22503a7 commit 3c70d62
Show file tree
Hide file tree
Showing 10 changed files with 1,390 additions and 62 deletions.
47 changes: 47 additions & 0 deletions lib/src/model/game/over_the_board_game.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/common/eval.dart';
import 'package:lichess_mobile/src/model/common/id.dart';

import 'game.dart';
import 'game_status.dart';
import 'player.dart';

part 'over_the_board_game.freezed.dart';
part 'over_the_board_game.g.dart';

/// An offline game played in real life by two human players on the same device.
///
/// See [PlayableGame] for a game that is played online.
@Freezed(fromJson: true, toJson: true)
abstract class OverTheBoardGame
with _$OverTheBoardGame, BaseGame, IndexableSteps {
const OverTheBoardGame._();

@override
Player get white => const Player();
@override
Player get black => const Player();

@override
IList<ExternalEval>? get evals => null;
@override
IList<Duration>? get clocks => null;

@override
GameId get id => const GameId('--------');

@Assert('steps.isNotEmpty')
factory OverTheBoardGame({
@JsonKey(fromJson: stepsFromJson, toJson: stepsToJson)
required IList<GameStep> steps,
required GameMeta meta,
required String? initialFen,
required GameStatus status,
Side? winner,
bool? isThreefoldRepetition,
}) = _OverTheBoardGame;

bool get finished => status.value >= GameStatus.mate.value;
}
141 changes: 141 additions & 0 deletions lib/src/model/over_the_board/over_the_board_clock.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import 'dart:async';
import 'dart:math';

import 'package:dartchess/dartchess.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/common/time_increment.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'over_the_board_clock.freezed.dart';
part 'over_the_board_clock.g.dart';

@riverpod
class OverTheBoardClock extends _$OverTheBoardClock {
final Stopwatch _stopwatch = Stopwatch();

late Timer _updateTimer;

@override
OverTheBoardClockState build() {
_updateTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (_stopwatch.isRunning) {
final newTime =
state.timeLeft(state.activeClock!)! - _stopwatch.elapsed;

if (state.activeClock == Side.white) {
state = state.copyWith(whiteTimeLeft: newTime);
} else {
state = state.copyWith(blackTimeLeft: newTime);
}

if (newTime <= Duration.zero) {
state = state.copyWith(
flagSide: state.activeClock,
);
}

_stopwatch.reset();
}
});

ref.onDispose(() {
_updateTimer.cancel();
});

return OverTheBoardClockState.fromTimeIncrement(
TimeIncrement(
const Duration(minutes: 5).inSeconds,
const Duration(seconds: 3).inSeconds,
),
);
}

void setupClock(TimeIncrement timeIncrement) {
_stopwatch.stop();
_stopwatch.reset();

state = OverTheBoardClockState.fromTimeIncrement(timeIncrement);
}

void restart() {
setupClock(state.timeIncrement);
}

void switchSide({required Side newSideToMove, required bool addIncrement}) {
if (state.timeIncrement.isInfinite || state.flagSide != null) return;

final increment =
Duration(seconds: addIncrement ? state.timeIncrement.increment : 0);
if (newSideToMove == Side.black) {
state = state.copyWith(
whiteTimeLeft: state.whiteTimeLeft! + increment,
activeClock: Side.black,
);
} else {
state = state.copyWith(
blackTimeLeft: state.blackTimeLeft! + increment,
activeClock: Side.white,
);
}

_stopwatch.reset();
_stopwatch.start();
}

void onMove({required Side newSideToMove}) {
switchSide(newSideToMove: newSideToMove, addIncrement: state.active);
}

void pause() {
if (_stopwatch.isRunning) {
state = state.copyWith(
activeClock: null,
);
_stopwatch.reset();
_stopwatch.stop();
}
}

void resume(Side newSideToMove) {
_stopwatch.reset();
_stopwatch.start();

state = state.copyWith(
activeClock: newSideToMove,
);
}
}

@freezed
class OverTheBoardClockState with _$OverTheBoardClockState {
const OverTheBoardClockState._();

const factory OverTheBoardClockState({
required TimeIncrement timeIncrement,
required Duration? whiteTimeLeft,
required Duration? blackTimeLeft,
required Side? activeClock,
required Side? flagSide,
}) = _OverTheBoardClockState;

factory OverTheBoardClockState.fromTimeIncrement(
TimeIncrement timeIncrement,
) {
final initialTime = timeIncrement.isInfinite
? null
: Duration(seconds: max(timeIncrement.time, timeIncrement.increment));

return OverTheBoardClockState(
timeIncrement: timeIncrement,
whiteTimeLeft: initialTime,
blackTimeLeft: initialTime,
activeClock: null,
flagSide: null,
);
}

bool get active => activeClock != null || flagSide != null;

Duration? timeLeft(Side side) =>
side == Side.white ? whiteTimeLeft : blackTimeLeft;
}
166 changes: 166 additions & 0 deletions lib/src/model/over_the_board/over_the_board_game_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import 'package:dartchess/dartchess.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';
import 'package:lichess_mobile/src/model/common/chess960.dart';
import 'package:lichess_mobile/src/model/common/perf.dart';
import 'package:lichess_mobile/src/model/common/service/move_feedback.dart';
import 'package:lichess_mobile/src/model/common/speed.dart';
import 'package:lichess_mobile/src/model/common/time_increment.dart';
import 'package:lichess_mobile/src/model/game/game.dart';
import 'package:lichess_mobile/src/model/game/game_status.dart';
import 'package:lichess_mobile/src/model/game/material_diff.dart';
import 'package:lichess_mobile/src/model/game/over_the_board_game.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'over_the_board_game_controller.freezed.dart';
part 'over_the_board_game_controller.g.dart';

@riverpod
class OverTheBoardGameController extends _$OverTheBoardGameController {
@override
OverTheBoardGameState build() => OverTheBoardGameState.fromVariant(
Variant.standard,
Speed.fromTimeIncrement(const TimeIncrement(0, 0)),
);

void startNewGame(Variant variant, TimeIncrement timeIncrement) {
state = OverTheBoardGameState.fromVariant(
variant,
Speed.fromTimeIncrement(timeIncrement),
);
}

void rematch() {
state = OverTheBoardGameState.fromVariant(
state.game.meta.variant,
state.game.meta.speed,
);
}

void makeMove(Move move) {
final (newPos, newSan) =
state.currentPosition.makeSan(Move.parse(move.uci)!);
final sanMove = SanMove(newSan, move);
final newStep = GameStep(
position: newPos,
sanMove: sanMove,
diff: MaterialDiff.fromBoard(newPos.board),
);

// In an over-the-board game, we support "implicit takebacks":
// When going back one or more steps (i.e. stepCursor < game.steps.length - 1),
// a new move can be made, removing all steps after the current stepCursor.
state = state.copyWith(
game: state.game.copyWith(
steps: state.game.steps
.removeRange(state.stepCursor + 1, state.game.steps.length)
.add(newStep),
),
stepCursor: state.stepCursor + 1,
);

if (state.currentPosition.isCheckmate) {
state = state.copyWith(
game: state.game.copyWith(
status: GameStatus.mate,
winner: state.turn.opposite,
),
);
} else if (state.currentPosition.isStalemate) {
state = state.copyWith(
game: state.game.copyWith(
status: GameStatus.stalemate,
),
);
}

_moveFeedback(sanMove);
}

void onFlag(Side side) {
state = state.copyWith(
game: state.game.copyWith(
status: GameStatus.outoftime,
winner: side.opposite,
),
);
}

void goForward() {
if (state.canGoForward) {
state = state.copyWith(stepCursor: state.stepCursor + 1);
}
}

void goBack() {
if (state.canGoBack) {
state = state.copyWith(
stepCursor: state.stepCursor - 1,
);
}
}

void _moveFeedback(SanMove sanMove) {
final isCheck = sanMove.san.contains('+');
if (sanMove.san.contains('x')) {
ref.read(moveFeedbackServiceProvider).captureFeedback(check: isCheck);
} else {
ref.read(moveFeedbackServiceProvider).moveFeedback(check: isCheck);
}
}
}

@freezed
class OverTheBoardGameState with _$OverTheBoardGameState {
const OverTheBoardGameState._();

const factory OverTheBoardGameState({
required OverTheBoardGame game,
@Default(0) int stepCursor,
}) = _OverTheBoardGameState;

factory OverTheBoardGameState.fromVariant(
Variant variant,
Speed speed,
) {
final position = variant == Variant.chess960
? randomChess960Position()
: variant.initialPosition;
return OverTheBoardGameState(
game: OverTheBoardGame(
steps: [
GameStep(
position: position,
),
].lock,
status: GameStatus.started,
initialFen: position.fen,
meta: GameMeta(
createdAt: DateTime.now(),
rated: false,
variant: variant,
speed: speed,
perf: Perf.fromVariantAndSpeed(variant, speed),
),
),
);
}

Position get currentPosition => game.stepAt(stepCursor).position;
Side get turn => currentPosition.turn;
bool get finished => game.finished;
NormalMove? get lastMove => stepCursor > 0
? NormalMove.fromUci(game.steps[stepCursor].sanMove!.move.uci)
: null;

MaterialDiffSide? currentMaterialDiff(Side side) {
return game.steps[stepCursor].diff?.bySide(side);
}

List<String> get moves =>
game.steps.skip(1).map((e) => e.sanMove!.san).toList(growable: false);

bool get canGoForward => stepCursor < game.steps.length - 1;
bool get canGoBack => stepCursor > 0;
}
Loading

0 comments on commit 3c70d62

Please sign in to comment.