diff --git a/CHANGELOG.md b/CHANGELOG.md
index a832516..9016806 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,17 @@ Pre-1.0, breaking changes land in a **MINOR** bump: `^0.1.0` resolves to
[Versions and compatibility](https://eigeninteractive.com/docs/reference/compatibility)
for how this package, the engine and the generated `eigen_api` client pair up.
+## [Unreleased]
+### Added
+- GameContentContext.transition gives a game the step into the current frame (from, to), or null when there is nothing to animate: a cold load, a rejoin, or the opening frame. Animate only when it is non-null, which replaces tracking the last rendered version in widget state. Replay supplies it too, so one animation path serves live play and replay.
+
+### Changed
+- The game screen renders from one live session instead of assembling one from four sources. gameSessionProvider is the single subscription, and status, roster, frame, outcomes and the wire-compatibility verdict are all pure selectors over it; no game screen reads gameSummaryProvider any more, which stays what it always was, the index behind lists. This fixes a creator's waiting room never offering Start once the roster filled, every seat staying in the waiting room after the game began, and a socket reconnect replacing the board with a spinner until somebody moved.
+- eigen\_api pin raised to ^0.3.0: the engine's session-snapshot wire is a new release line, and a shell speaking 0.2.x cannot read it.
+
+### Removed
+- Roster, Joined and LobbyAccepted are gone from the wire, along with the roster, sync and frame socket message kinds. The socket carries one message, a complete per-seat session snapshot, and every accepted command answers with the same value. A list screen must never subscribe to a session; the home card now resolves its avatars from the index row, as the lobby card already did, instead of opening one socket per row.
+
## [0.3.7] - 2026-08-09
### Added
- `configure_firebase` now fills in the deployment values that exist only once a Firebase project does: `GOOGLE_WEB_CLIENT_ID` in `app-config.json`, from the OAuth client Firebase created, and with the new `--worker
` flag, `FIREBASE_PROJECT_ID` in the Cloudflare Worker's `wrangler.jsonc`. The Worker edit rewrites one assignment in place, so that file's comments survive, and refuses when the key does not appear exactly once. An app-only repository omits the flag and keeps the app half.
@@ -151,6 +162,7 @@ server-side concern now live in the engine.
- `google_fonts`, which fetched Inter at runtime, replaced by the bundled
package font above.
+[Unreleased]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.7...HEAD
[0.3.7]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.4...v0.3.5
diff --git a/example/test/board_test.dart b/example/test/board_test.dart
index 4eb7b81..25f1215 100644
--- a/example/test/board_test.dart
+++ b/example/test/board_test.dart
@@ -169,6 +169,7 @@ GameContentContext _context({
required List pendingPlayers,
GameStatus gameStatus = GameStatus.active,
List outcomes = const [],
+ GameTransition? transition,
Future Function(Map)? onAction,
}) {
return GameContentContext(
@@ -179,6 +180,7 @@ GameContentContext _context({
version: 1,
timing: TimingContext(clock: ServerClock()),
),
+ transition: transition,
gameStatus: gameStatus,
outcomes: outcomes,
actionPending: false,
diff --git a/lib/core/api/game_socket.dart b/lib/core/api/game_socket.dart
index 36b038e..450e90a 100644
--- a/lib/core/api/game_socket.dart
+++ b/lib/core/api/game_socket.dart
@@ -33,58 +33,21 @@ Uri buildGameSocketUri({
);
}
-/// One message from a game's socket, or the signal that the socket (re)opened.
+/// The socket carries exactly one message: the complete live truth about the
+/// game as this seat sees it.
///
-/// The server never reads from this socket, since every client-to-server
-/// command rides HTTP, so this is a one-way feed of the message kinds the protocol
-/// defines, plus the connection signal the ordering pipeline needs.
-sealed class GameSocketEvent {
- const GameSocketEvent();
-}
-
-/// The socket just (re)opened.
-///
-/// Emitted on the first connection and on every reconnect. Nothing is
-/// reconciled from this alone: the server states where the game is in the
-/// [GameSocketRoster] or [GameSocketSync] that follows, so the pipeline acts on
-/// that rather than guessing from the fact of connecting.
-final class GameSocketConnected extends GameSocketEvent {
- const GameSocketConnected();
-}
-
-/// A pre-game roster snapshot: who is seated and what the game's status is.
-///
-/// Unversioned and idempotent: the server pushes one on every roster change
-/// and one on socket open while the game is still in the waiting room, so a
-/// reconnect simply gets the current one.
-final class GameSocketRoster extends GameSocketEvent {
- const GameSocketRoster(this.roster);
-
- final Roster roster;
-}
-
-/// Where the game currently is, sent once on a mid-game socket open.
-///
-/// Hand-parsed rather than generated: unlike [Roster] and [Frame], which appear
-/// in HTTP responses and so exist in the OpenAPI document, this message is
-/// socket-only and has no generated counterpart.
-final class GameSocketSync extends GameSocketEvent {
- const GameSocketSync(this.version);
-
- /// The newest committed version at the moment the socket opened.
- final int version;
-}
-
-/// One versioned frame for the receiving seat.
+/// The server never reads from this socket, since every client-to-server command
+/// rides HTTP, so this is a one-way feed of [Session] snapshots. There is
+/// deliberately nothing else on it, and no "connected" signal to reason about:
+/// an open always answers with a snapshot, so there is never a window in which
+/// a client holds a frame without the status it belongs to, and never anything
+/// to infer from the bare fact of connecting.
///
-/// Only ever this socket's own seat's view; the server resolves each frame's
-/// owner against the roster before sending, so another player's hidden
-/// information never crosses the wire.
-final class GameSocketFrame extends GameSocketEvent {
- const GameSocketFrame(this.frame);
-
- final Frame frame;
-}
+/// `frame` is only ever this socket's own seat's view. The server resolves the
+/// seat from the connection's authenticated principal against the roster before
+/// sending, so another player's hidden information never crosses the wire, and a
+/// client holding no seat receives the envelope with no frame at all.
+typedef GameSocketEvent = Session;
/// Opens a game's socket and keeps it open for the screen's lifetime.
///
@@ -118,6 +81,11 @@ class GameSocket {
/// and the recovery for it is the reconnect this already performs. Errors
/// that are *not* recoverable that way, such as a malformed message, are logged and
/// skipped rather than tearing down a working connection.
+ ///
+ /// A reconnect needs no announcement, because the server's first message on
+ /// the new connection is the current snapshot. When nothing was missed it
+ /// carries a `seq` the caller already holds and is discarded there, so the
+ /// common case on a flaky connection costs one message and no rebuild.
Stream connect(String gameId) async* {
var backoff = _initialBackoff;
@@ -134,7 +102,6 @@ class GameSocket {
channel = WebSocketChannel.connect(_socketUri(gameId, token));
await channel.ready;
backoff = _initialBackoff;
- yield const GameSocketConnected();
await for (final message in channel.stream) {
final event = _decode(message);
@@ -174,12 +141,7 @@ class GameSocket {
GameSocketEvent? _decode(dynamic message) {
try {
final json = jsonDecode(message as String) as Map;
- return switch (json['type']) {
- 'roster' => GameSocketRoster(Roster.fromJson(json)),
- 'frame' => GameSocketFrame(Frame.fromJson(json)),
- 'sync' => GameSocketSync(json['version'] as int),
- _ => null,
- };
+ return json['type'] == 'session' ? Session.fromJson(json) : null;
} catch (error, stack) {
developer.log(
'unreadable game socket message',
diff --git a/lib/core/game/game_module.dart b/lib/core/game/game_module.dart
index ac33063..4e3342a 100644
--- a/lib/core/game/game_module.dart
+++ b/lib/core/game/game_module.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:eigen_flutter/core/game/game_creation_spec.dart';
import 'package:eigen_flutter/core/game/game_frame.dart';
+import 'package:eigen_flutter/core/game/game_transition.dart';
import 'package:eigen_flutter/core/game/my_seat.dart';
import 'package:eigen_flutter/core/game/players_context.dart';
import 'package:eigen_flutter/core/game/timing_context.dart';
@@ -48,6 +49,7 @@ class GameContentContext {
const GameContentContext({
required this.config,
required this.frame,
+ required this.transition,
required this.gameStatus,
required this.outcomes,
required this.actionPending,
@@ -65,6 +67,16 @@ class GameContentContext {
/// players and timing. Rebuilt on every observation event.
final GameFrame frame;
+ /// The step from the frame the player last saw to [frame], or null when there
+ /// is none to animate.
+ ///
+ /// Animate when this is non-null; render statically when it is null, which is
+ /// a cold load, a rejoin, or the opening frame. Read what happened from
+ /// [GameTransition.to]'s observation rather than diffing the two frames: the
+ /// cues are embedded there by `computeObservation`, and a diff cannot recover
+ /// causality.
+ final GameTransition? transition;
+
/// Current lifecycle status of the game.
final GameStatus gameStatus;
diff --git a/lib/core/game/game_session.dart b/lib/core/game/game_session.dart
new file mode 100644
index 0000000..a357c4b
--- /dev/null
+++ b/lib/core/game/game_session.dart
@@ -0,0 +1,77 @@
+import 'package:eigen_api/eigen_api.dart';
+
+/// One game's live session, as the client holds it: the newest snapshot the
+/// server stated, plus the frame being rendered and the one it replaced.
+///
+/// This is the whole of a game screen's state. Nothing else holds a piece of it,
+/// which is the point: the server states the complete truth for this seat in one
+/// value, so status, roster, version and observation can never disagree with
+/// each other, and there is no second source for any of them to drift from.
+///
+/// [frame] is not always [Session.frame]. While a gap is animating, the frames
+/// the client missed are emitted one at a time against the *previous* envelope,
+/// so a client that missed a finish plays out the moves and only then shows the
+/// outcome. That is why the frame is carried alongside the snapshot rather than
+/// read out of it.
+class GameSession {
+ const GameSession({
+ required this.snapshot,
+ required this.frame,
+ this.previousFrame,
+ });
+
+ /// The envelope: status, roster, timing, config, and the immutable header.
+ final Session snapshot;
+
+ /// The frame to render, or null when there is nothing to render: the waiting
+ /// room, a game aborted before it started, or a viewer holding no seat.
+ final Frame? frame;
+
+ /// The frame [frame] replaced, or null when none was rendered before it.
+ ///
+ /// Null on a cold load and on the first frame of a game, which is exactly when
+ /// a game must *not* animate: there is no predecessor the player saw, so the
+ /// cue is history rather than an event. Games read this through
+ /// `GameContentContext.transition`.
+ final Frame? previousFrame;
+
+ /// Monotonic per game, incremented by every commit. Totally orders snapshots
+ /// where `version` cannot, because a lobby change has none.
+ int get seq => snapshot.seq;
+
+ GameStatus get status => snapshot.status;
+
+ /// The version being rendered, which during a gap animation is behind
+ /// [Session.version]. Null in the lobby.
+ int? get version => frame?.version ?? snapshot.version;
+
+ /// Whether the game has reached a status nothing can move it out of.
+ bool get isTerminal =>
+ status == GameStatus.finished || status == GameStatus.aborted;
+
+ /// Advance to a newly stated snapshot.
+ GameSession applySnapshot(Session next) =>
+ GameSession(snapshot: next, frame: next.frame, previousFrame: frame);
+
+ /// Advance one recovered frame without touching the envelope.
+ ///
+ /// Status, roster and `seq` hold still while a missed span plays out, so an
+ /// animation cannot render a finished game's banner over a mid-game move. The
+ /// authoritative snapshot lands last and supplies them.
+ GameSession applyGapFrame(Frame gap) =>
+ GameSession(snapshot: snapshot, frame: gap, previousFrame: frame);
+
+ /// Whether [next] should replace this session.
+ ///
+ /// Ordinarily that is simply a higher `seq`, which resolves a command response
+ /// racing its own socket push, a duplicate delivery, and a reconnect that
+ /// missed nothing. The terminal clause is not a special case bolted on: a
+ /// finished or aborted game is absorbing, so such a snapshot needs no
+ /// ordering, and the abort teardown drops the storage `seq` lived in, so a
+ /// re-initialised object legitimately reports a lower one.
+ bool supersededBy(Session next) =>
+ next.seq > seq ||
+ (!isTerminal &&
+ (next.status == GameStatus.finished ||
+ next.status == GameStatus.aborted));
+}
diff --git a/lib/core/game/game_transition.dart b/lib/core/game/game_transition.dart
new file mode 100644
index 0000000..1ebd09c
--- /dev/null
+++ b/lib/core/game/game_transition.dart
@@ -0,0 +1,25 @@
+import 'package:eigen_flutter/core/game/game_frame.dart';
+
+/// The step from one frame to the next: the unit a game animates.
+///
+/// Animation is the presentation of a frame *transition*, not of a state, and
+/// not of a diff. Two frames are given rather than one because the step is what
+/// carries meaning; what happened during it is read from [to]'s observation,
+/// where `computeObservation` embedded the cues this seat is permitted to see.
+/// Never diff [from] against [to] to work out the cause: a hidden move leaves no
+/// visible footprint, two different causes can leave the same one, and a
+/// composite resolution collapses into a single diff.
+///
+/// A game receives this as `GameContentContext.transition`, and it is null
+/// exactly when animating would be wrong: a cold load, a rejoin, or the opening
+/// frame. In those cases the newest frame is still correct and complete, so
+/// render it statically and treat its cues as history rather than as events.
+class GameTransition {
+ const GameTransition({required this.from, required this.to});
+
+ /// The frame the player was looking at.
+ final GameFrame from;
+
+ /// The frame now on screen, one version later.
+ final GameFrame to;
+}
diff --git a/lib/eigen_flutter.dart b/lib/eigen_flutter.dart
index b809011..e56071d 100644
--- a/lib/eigen_flutter.dart
+++ b/lib/eigen_flutter.dart
@@ -57,9 +57,9 @@ export 'package:eigen_api/eigen_api.dart'
Rating,
RatingDelta,
RatingIdentity,
- Roster,
Seat,
- SeatTypeEnum;
+ SeatTypeEnum,
+ Session;
export 'app_runner.dart' show runEngineApp, MyApp;
@@ -72,6 +72,8 @@ export 'core/config/app_config.dart'
export 'core/errors/engine_exception.dart';
export 'core/game/game_creation_spec.dart';
export 'core/game/game_frame.dart';
+export 'core/game/game_session.dart';
+export 'core/game/game_transition.dart';
export 'core/game/game_module.dart';
export 'core/game/game_player.dart';
export 'core/game/my_seat.dart';
diff --git a/lib/features/game/data/game_repository.dart b/lib/features/game/data/game_repository.dart
index b22fc66..06bc97c 100644
--- a/lib/features/game/data/game_repository.dart
+++ b/lib/features/game/data/game_repository.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:eigen_api/eigen_api.dart';
import 'package:eigen_flutter/core/api/engine_call.dart';
import 'package:eigen_flutter/core/api/game_socket.dart';
+import 'package:eigen_flutter/core/game/game_session.dart';
/// Number of games fetched per lobby page.
const lobbyPageSize = 50;
@@ -70,15 +71,24 @@ class GameRepository {
return body.games;
}
- /// One game's metadata and roster.
+ /// One game's index entry: what discovery and history show.
///
- /// Carries the immutable facts a game screen needs once - schema version,
- /// config, timing - so they are fetched rather than streamed. The mutable
- /// parts (status, seats) also arrive live over the socket.
+ /// This is the D1 read-model, which is a mirror of the game's own session and
+ /// can lag it. It backs lists and cards. A screen bound to one game reads
+ /// [getSession] or the live stream instead, never this.
Future getGame(String gameId) async {
return engineData(() => _api.getGame(gameId: gameId));
}
+ /// One game's current session, from the game itself rather than the index.
+ ///
+ /// The socket delivers the same value and keeps delivering it, so a screen
+ /// with a socket never needs this. It serves the paths without one: a
+ /// deep-link preview, and a replay of a finished game.
+ Future getSession(String gameId) async {
+ return engineData(() => _api.getGameSession(gameId: gameId));
+ }
+
/// A player's finished public games - the replay list on their profile.
///
/// Any player, human or bot. Public and finished only, so this exposes
@@ -137,9 +147,9 @@ class GameRepository {
/// Creates a private game seated with the caller plus [botIds] and starts it,
/// in one call.
///
- /// Returns the caller's committed v0 frame alongside the ids: the game is
- /// already running before any socket exists, so this response is the only
- /// place that first frame can come from.
+ /// Answers with the caller's session at version 0: the game is already running
+ /// before any socket exists, so this response is the only place its opening
+ /// frame can come from.
Future createSoloGame({
required int schemaVersion,
required Object config,
@@ -172,15 +182,15 @@ class GameRepository {
/// rules for - the server refuses rather than let an old build mis-parse a
/// newer game.
///
- /// Answers with the same [Joined] shape as [joinGameByCode]: they are one
- /// operation differing only in how the game was named, so a caller handles
- /// either result identically.
- Future joinGame(
+ /// Answers with the same session [joinGameByCode] does: they are one operation
+ /// differing only in how the game was named, so a caller handles either result
+ /// identically.
+ Future joinGame(
String gameId, {
required int clientSchemaVersion,
String? commandId,
}) async {
- return engineData(
+ final body = await engineData(
() => _api.joinGame(
gameId: gameId,
join: Join(
@@ -189,18 +199,19 @@ class GameRepository {
),
),
);
+ return body.session;
}
/// Takes a seat using a shared short code rather than a game id.
///
- /// Returns the game's id alongside the roster - the caller supplied a code,
- /// so this is the only place they learn which game they are now in.
- Future joinGameByCode(
+ /// The session carries the game's id, which the by-code caller never had: this
+ /// is the only place they learn which game they are now in.
+ Future joinGameByCode(
String shortCode, {
required int clientSchemaVersion,
String? commandId,
}) async {
- return engineData(
+ final body = await engineData(
() => _api.joinGameByCode(
joinByCode: JoinByCode(
shortCode: shortCode,
@@ -209,32 +220,33 @@ class GameRepository {
),
),
);
+ return body.session;
}
/// Gives up a seat before the game starts. The creator cancels instead.
- Future leaveGame(String gameId, {String? commandId}) async {
+ Future leaveGame(String gameId, {String? commandId}) async {
final body = await engineData(
() => _api.leaveGame(
gameId: gameId,
lobbyCommand: LobbyCommand(commandId: commandId),
),
);
- return body.roster;
+ return body.session;
}
/// Abandons a game that has not started. Creator only.
- Future cancelGame(String gameId, {String? commandId}) async {
+ Future cancelGame(String gameId, {String? commandId}) async {
final body = await engineData(
() => _api.cancelGame(
gameId: gameId,
lobbyCommand: LobbyCommand(commandId: commandId),
),
);
- return body.roster;
+ return body.session;
}
/// Seats a bot alongside the humans. Creator only, pre-start.
- Future addBot(
+ Future addBot(
String gameId, {
required String botId,
String? commandId,
@@ -245,21 +257,22 @@ class GameRepository {
addBot: AddBot(botId: botId, commandId: commandId),
),
);
- return body.roster;
+ return body.session;
}
/// Starts a ready game. Creator only.
///
- /// Returns the committed version; the opening frames reach every seat over
- /// their own socket, since a start has no single acting seat.
- Future startGame(String gameId, {String? commandId}) async {
+ /// Answers with the caller's own session at version 0; every other seat gets
+ /// the same transition over its own socket, since a start has no single acting
+ /// seat.
+ Future startGame(String gameId, {String? commandId}) async {
final body = await engineData(
() => _api.startGame(
gameId: gameId,
lobbyCommand: LobbyCommand(commandId: commandId),
),
);
- return body.version;
+ return body.session;
}
// ── Playing ────────────────────────────────────────────────────────────────
@@ -274,9 +287,9 @@ class GameRepository {
/// Reusing a [commandId] replays the stored response instead of re-executing,
/// so a resubmitted move cannot land twice.
///
- /// The returned [CommandAccepted.frame] is this seat's own committed view.
- /// Feed it to [frames] so the move renders without waiting on the socket -
- /// see that method for why both paths carry it.
+ /// The returned session is this seat's own committed view. Feed it to
+ /// [sessions] so the move renders without waiting on the socket; see that
+ /// method for why both paths carry it.
Future submitAction({
required String gameId,
required int seat,
@@ -324,132 +337,86 @@ class GameRepository {
// ── The live feed ──────────────────────────────────────────────────────────
- /// The game's live events: roster snapshots pre-game, then frames in strict
- /// version order with any gaps filled.
+ /// The game's live session: one complete value per change, in order, with any
+ /// missed frames played through first.
+ ///
+ /// Every snapshot states the whole truth for this seat, so a client that holds
+ /// the newest one is correct however many it missed. That is what makes this
+ /// recoverable without reconstruction: staleness is one `seq` comparison, and
+ /// a socket drop needs no special handling because the reconnect's first
+ /// message is the current snapshot.
///
- /// Frames are append-only server-side, one per seat per version, which is
- /// what makes this recoverable: a socket may drop or a reconnect may miss
- /// events, but a version jump is detected here and the missing frames are
- /// fetched and emitted in order first. A game therefore always animates
- /// through every transition rather than snapping past one.
+ /// Two things are still worth work here, and they are the only two.
///
- /// Duplicates and stale frames are dropped, which is what lets the same frame
- /// arrive twice safely. That matters because it does: a move's own frame
- /// rides the [submitAction] response *and* fans out over the socket. Pass the
- /// response's frame to [inject] and whichever copy arrives second is
- /// discarded by version. On the socket-less paths - a freshly created solo
- /// game, or a move made while the socket is mid-reconnect - the injected copy
- /// is the only one, which is why the response carries it at all.
+ /// **Gaps animate.** Frames are append-only server-side, one per seat per
+ /// version, so a version jump means the client missed transitions it would
+ /// rather show than skip. The missing span is fetched and emitted first, one
+ /// session at a time, each carrying the *previous* envelope so the status
+ /// cannot run ahead of the moves. Then the real snapshot lands and supplies
+ /// the envelope. A cold open does not animate: with no predecessor rendered
+ /// there is nothing to animate from, so it snaps to the present.
///
- /// The first frame seen is emitted as-is: a cold load snaps to the present
- /// rather than replaying the whole game. Opening mid-game is exactly that
- /// case - the server reports its current version on connect, and only that
- /// frame is fetched.
- Stream events(String gameId, {Stream? inject}) {
- late StreamController controller;
- int? lastVersion;
- StreamSubscription? socketSub;
- StreamSubscription? injectSub;
-
- // Serialises handling so emissions stay in version order however bursts of
- // socket events interleave with the async gap fetches they trigger.
+ /// **Duplicates are dropped.** A move's own snapshot rides the
+ /// [submitAction] response *and* fans out over the socket; pass the response
+ /// to [inject] and whichever copy arrives second is discarded by `seq`. On the
+ /// socket-less paths (a freshly created solo game, a move made while the
+ /// socket is mid-reconnect) the injected copy is the only one, which is why
+ /// the response carries a full session at all.
+ Stream sessions(String gameId, {Stream? inject}) {
+ late StreamController controller;
+ GameSession? held;
+ StreamSubscription? socketSub;
+ StreamSubscription? injectSub;
+
+ // Serialises handling so emissions stay in order however bursts of
+ // snapshots interleave with the async gap fetches they trigger.
var pipeline = Future.value();
- void emit(Frame frame) {
- lastVersion = frame.version;
- controller.add(GameSocketFrame(frame));
+ void emit(GameSession session) {
+ held = session;
+ controller.add(session);
}
- Future handleFrame(Frame frame) async {
+ /// Play through the frames between what is held and [next], then apply it.
+ ///
+ /// Only a game that is already under way can have a gap: the client must
+ /// have rendered a version to be behind one.
+ Future apply(Session next) async {
if (controller.isClosed) return;
- final last = lastVersion;
- if (last == null) {
- emit(frame);
+ final current = held;
+ if (current == null) {
+ emit(GameSession(snapshot: next, frame: next.frame));
return;
}
- if (frame.version <= last) return;
- if (frame.version > last + 1) {
- final missed = await getFrames(
- gameId,
- from: last + 1,
- to: frame.version - 1,
- );
- for (final gap in missed) {
- if (controller.isClosed) return;
- if (gap.version > lastVersion!) emit(gap);
- }
- }
- if (controller.isClosed) return;
- if (frame.version > lastVersion!) emit(frame);
- }
+ if (!current.supersededBy(next)) return;
- /// Reconcile against the version the server reports on open.
- ///
- /// Three cases, and only one of them costs a request:
- ///
- /// - **Nothing seen yet** (a cold open, mid-game): fetch just [version].
- /// A cold load snaps to the present rather than replaying the game.
- /// - **Already current**: no request at all. This is the common reconnect
- /// on a flaky connection, and is the reason the server reports its
- /// version instead of leaving the client to guess.
- /// - **Behind**: fetch exactly the missing span, emitted in order, so the
- /// game animates through every transition it missed.
- Future syncTo(int version) async {
- if (controller.isClosed) return;
- final cursor = lastVersion;
-
- if (cursor == null) {
- for (final frame in await getFrames(
- gameId,
- from: version,
- to: version,
- )) {
+ final from = current.version;
+ final to = next.version;
+ if (from != null && to != null && to > from + 1) {
+ for (final gap in await getFrames(gameId, from: from + 1, to: to - 1)) {
if (controller.isClosed) return;
- emit(frame);
+ final latest = held;
+ if (latest == null || gap.version <= (latest.version ?? -1)) continue;
+ emit(latest.applyGapFrame(gap));
}
- return;
- }
-
- if (version <= cursor) return;
- for (final frame in await getFrames(
- gameId,
- from: cursor + 1,
- to: version,
- )) {
- if (controller.isClosed) return;
- if (frame.version > lastVersion!) emit(frame);
}
+ if (controller.isClosed) return;
+ final latest = held;
+ if (latest != null) emit(latest.applySnapshot(next));
}
- void enqueue(Future Function() work) {
- pipeline = pipeline.then((_) => work()).catchError((Object error) {
+ void enqueue(Session next) {
+ pipeline = pipeline.then((_) => apply(next)).catchError((Object error) {
if (!controller.isClosed) controller.addError(error);
});
}
- controller = StreamController(
+ controller = StreamController(
onListen: () {
- socketSub = _socket.connect(gameId).listen((event) {
- switch (event) {
- case GameSocketConnected():
- // Nothing to reconcile yet - the server reports where the game is
- // in the sync that follows, and pre-game there is nothing to
- // reconcile at all.
- controller.add(event);
- case GameSocketSync(:final version):
- enqueue(() => syncTo(version));
- case GameSocketRoster():
- // Unversioned and idempotent - pass straight through.
- controller.add(event);
- case GameSocketFrame(:final frame):
- enqueue(() => handleFrame(frame));
- }
- }, onError: controller.addError);
-
- injectSub = inject?.listen(
- (frame) => enqueue(() => handleFrame(frame)),
- onError: controller.addError,
- );
+ socketSub = _socket
+ .connect(gameId)
+ .listen(enqueue, onError: controller.addError);
+ injectSub = inject?.listen(enqueue, onError: controller.addError);
},
onCancel: () async {
await socketSub?.cancel();
diff --git a/lib/features/game/presentation/screens/game_screen.dart b/lib/features/game/presentation/screens/game_screen.dart
index b410f81..646649e 100644
--- a/lib/features/game/presentation/screens/game_screen.dart
+++ b/lib/features/game/presentation/screens/game_screen.dart
@@ -16,6 +16,7 @@ import 'package:go_router/go_router.dart';
import 'package:eigen_flutter/core/errors/engine_exception.dart';
import 'package:eigen_flutter/core/errors/error_messages.dart';
import 'package:eigen_flutter/core/game/game_module.dart';
+import 'package:eigen_flutter/core/game/game_session.dart';
import 'package:eigen_flutter/core/game/players_context.dart';
import 'package:eigen_flutter/core/game/my_seat.dart';
import 'package:eigen_flutter/core/game/timing_context.dart';
@@ -31,7 +32,6 @@ import 'package:eigen_flutter/features/game/providers/game_frame_provider.dart';
import 'package:eigen_flutter/features/social/presentation/widgets/player_profile_sheet.dart';
import 'package:eigen_flutter/shared/widgets/player_avatar.dart';
import 'package:eigen_flutter/shared/widgets/player_tags.dart';
-import 'package:eigen_flutter/core/api/game_socket.dart';
part 'game_screen_pre_game.dart';
part 'game_screen_active.dart';
@@ -69,35 +69,28 @@ class _GameScreenState extends ConsumerState {
bool _errorSnackBarShown = false;
final Set _reportedCompatibility = {};
late final AppLifecycleListener _lifecycleListener;
- late final ProviderSubscription> _gameStatusSub;
- late final ProviderSubscription>> _outcomesSub;
+ late final ProviderSubscription> _sessionSub;
+ late final ProviderSubscription> _outcomesSub;
late final ProviderSubscription _offlineSub;
- late final ProviderSubscription> _eventsSub;
late final ProviderSubscription _compatibilitySub;
@override
void initState() {
super.initState();
- _lifecycleListener = AppLifecycleListener(
- onResume: _invalidateAllGameProviders,
- );
+ _lifecycleListener = AppLifecycleListener(onResume: _invalidateStreams);
// Analytics listeners registered once here so they are independent of the
// build cycle and don't mix side-effects into the build method.
// Registered before the build-cycle listeners so they read player count
// before gamePlayersProvider is invalidated on status change.
- _gameStatusSub = ref.listenManual(
- gameRosterProvider(gameId: widget.gameId),
- _onGameStatusChange,
+ _sessionSub = ref.listenManual(
+ gameSessionProvider(gameId: widget.gameId),
+ _onSession,
);
_outcomesSub = ref.listenManual(
gameOutcomesProvider(gameId: widget.gameId),
_onOutcomes,
);
_offlineSub = ref.listenManual(isOfflineProvider, _onConnectivityChange);
- _eventsSub = ref.listenManual(
- gameEventsProvider(gameId: widget.gameId),
- _onGameEvent,
- );
_compatibilitySub = ref.listenManual(
gameWireCompatibilityProvider(gameId: widget.gameId),
_onCompatibilityChange,
@@ -105,9 +98,22 @@ class _GameScreenState extends ConsumerState {
);
}
- void _onGameStatusChange(AsyncValue? prev, AsyncValue next) {
- final prevStatus = prev?.value?.status;
- final status = next.value?.status;
+ /// One listener for the one stream: analytics on a witnessed transition, and
+ /// clearing the in-flight marker when a move's own session lands.
+ void _onSession(AsyncValue? prev, AsyncValue next) {
+ if (!mounted) return;
+ _onSessionError(prev, next);
+ final prevVersion = prev?.value?.version;
+ final version = next.value?.version;
+ if (version != null &&
+ version != prevVersion &&
+ _pendingAction == _PendingAction.submittingAction) {
+ setState(() => _pendingAction = null);
+ }
+ _onGameStatusChange(prev?.value?.status, next.value?.status);
+ }
+
+ void _onGameStatusChange(GameStatus? prevStatus, GameStatus? status) {
if (prevStatus == status) return;
// Fire game_started only on a witnessed pre-game → active transition.
@@ -131,30 +137,20 @@ class _GameScreenState extends ConsumerState {
);
}
- // Refresh player list on any status transition so the waiting room
- // immediately reflects new joiners without requiring a manual pull.
- ref.invalidate(gamePlayersProvider(gameId: widget.gameId));
-
- if (status == GameStatus.finished) {
- ref.invalidate(gameOutcomesProvider(gameId: widget.gameId));
- }
+ // Nothing to invalidate: the roster and the outcomes are projections of
+ // the session that just changed, so they have already re-derived.
}
- void _onOutcomes(
- AsyncValue>? prev,
- AsyncValue> next,
- ) {
- // Side effects fire only on a witnessed empty → non-empty transition.
- // prev?.value is null on first load (re-opening a finished game must not
- // re-fire) and non-empty during reloads (AsyncLoading carries previous
- // data in Riverpod 3.x, guarding against app-resume re-firing).
- if (prev?.value?.isEmpty != true) return;
- if (next.value?.isEmpty ?? true) return;
+ void _onOutcomes(List? prev, List next) {
+ // Side effects fire only on a witnessed empty → non-empty transition, so
+ // re-opening a finished game does not re-fire them. `prev` is null on the
+ // first read, which is that case.
+ if (prev?.isEmpty != true || next.isEmpty) return;
unawaited(
ref.read(analyticsServiceProvider).gameFinished(gameId: widget.gameId),
);
- _maybeRequestReview(next.value!);
- _maybeTriggerWinHaptic(next.value!);
+ _maybeRequestReview(next);
+ _maybeTriggerWinHaptic(next);
}
void _maybeTriggerWinHaptic(List outcomes) {
@@ -169,39 +165,33 @@ class _GameScreenState extends ConsumerState {
if (didWin) unawaited(HapticFeedback.heavyImpact());
}
- void _onGameEvent(
- AsyncValue? prev,
- AsyncValue next,
+ /// Snackbar handling for the session stream's error state.
+ ///
+ /// Split out of [_onSession] only for length: it is the same stream. Riverpod
+ /// 3.x carries the previous value through AsyncLoading and AsyncError, so the
+ /// in-flight marker is cleared off the version changing rather than off the
+ /// state, which a pull-to-refresh or an app resume would otherwise clear
+ /// prematurely.
+ void _onSessionError(
+ AsyncValue? prev,
+ AsyncValue next,
) {
- if (!mounted) return;
- // Use AsyncData pattern: next.value returns previous data even during
- // AsyncLoading/AsyncError in Riverpod 3.x, which would cause premature
- // action-pending reset during pull-to-refresh or app resume.
switch (next) {
- case AsyncData(:final value):
+ case AsyncData():
if (_errorSnackBarShown) {
_errorSnackBarShown = false;
ScaffoldMessenger.of(context).clearSnackBars();
}
- if (value is GameSocketFrame &&
- _pendingAction == _PendingAction.submittingAction) {
- setState(() => _pendingAction = null);
- }
case AsyncError():
if (_pendingAction == _PendingAction.submittingAction) {
setState(() => _pendingAction = null);
}
// One snackbar per error episode, terminal or not, since Riverpod's retry
// cycle would otherwise re-show it on every failed attempt. The flag
- // resets on the next successful observation.
+ // resets on the next successful snapshot.
if (_errorSnackBarShown) return;
_errorSnackBarShown = true;
- final status = ref
- .read(gameRosterProvider(gameId: widget.gameId))
- .value
- ?.status;
- final isTerminal =
- status == GameStatus.finished || status == GameStatus.aborted;
+ final isTerminal = next.value?.isTerminal ?? false;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -214,12 +204,7 @@ class _GameScreenState extends ConsumerState {
label: 'Back to Home',
onPressed: () => context.go('/home'),
)
- : SnackBarAction(
- label: 'Retry',
- onPressed: () => ref.invalidate(
- gameEventsProvider(gameId: widget.gameId),
- ),
- ),
+ : SnackBarAction(label: 'Retry', onPressed: _invalidateStreams),
duration: const Duration(seconds: 10),
),
);
@@ -235,18 +220,14 @@ class _GameScreenState extends ConsumerState {
_invalidateStreams();
}
- /// Re-subscribes both live streams immediately, bypassing Riverpod's retry
+ /// Re-subscribes the live session immediately, bypassing Riverpod's retry
/// backoff.
+ ///
+ /// One stream to re-establish, and the snapshot it opens with restates
+ /// everything the screen shows, so there is nothing else to refresh: status,
+ /// roster, frame and outcomes are all projections of it.
void _invalidateStreams() {
- ref.invalidate(gameRosterProvider(gameId: widget.gameId));
- ref.invalidate(gameEventsProvider(gameId: widget.gameId));
- }
-
- /// Full refresh of every per-game provider this screen depends on.
- void _invalidateAllGameProviders() {
- _invalidateStreams();
- ref.invalidate(gamePlayersProvider(gameId: widget.gameId));
- ref.invalidate(gameOutcomesProvider(gameId: widget.gameId));
+ ref.invalidate(gameSessionProvider(gameId: widget.gameId));
}
void _maybeRequestReview(List outcomes) {
@@ -266,10 +247,9 @@ class _GameScreenState extends ConsumerState {
@override
void dispose() {
- _gameStatusSub.close();
+ _sessionSub.close();
_outcomesSub.close();
_offlineSub.close();
- _eventsSub.close();
_compatibilitySub.close();
_lifecycleListener.dispose();
super.dispose();
@@ -277,7 +257,7 @@ class _GameScreenState extends ConsumerState {
@override
Widget build(BuildContext context) {
- final gameAsync = ref.watch(gameSummaryProvider(gameId: widget.gameId));
+ final sessionAsync = ref.watch(gameSessionProvider(gameId: widget.gameId));
final compatibility = ref.watch(
gameWireCompatibilityProvider(gameId: widget.gameId),
);
@@ -287,16 +267,16 @@ class _GameScreenState extends ConsumerState {
_ReconnectingBannerSlot(gameId: widget.gameId),
Expanded(
child: SafeArea(
- child: switch (gameAsync) {
+ child: switch (sessionAsync) {
// value is non-null for AsyncData and for AsyncError with
// stale data: keep showing the game while the banner
// communicates the reconnecting state.
- _ when gameAsync.value != null => RefreshIndicator(
+ _ when sessionAsync.value != null => RefreshIndicator(
onRefresh: _onRefresh,
child: compatibility.requiresUpdate
? const _UpdateRequiredScroll()
: _GameBody(
- game: gameAsync.value!,
+ session: sessionAsync.value!,
pendingAction: _pendingAction,
onStartGame: _startGame,
onCancelGame: _cancelGame,
@@ -340,8 +320,8 @@ class _GameScreenState extends ConsumerState {
void _retryConnection() => _invalidateStreams();
Future _onRefresh() async {
- _invalidateAllGameProviders();
- await ref.read(gamePlayersProvider(gameId: widget.gameId).future);
+ _invalidateStreams();
+ await ref.read(gameSessionProvider(gameId: widget.gameId).future);
}
/// The caller's own seat, or null when they are only watching.
@@ -481,7 +461,7 @@ class _GameScreenState extends ConsumerState {
/// only rebuild the subtree that needs them.
class _GameBody extends StatelessWidget {
const _GameBody({
- required this.game,
+ required this.session,
required this.pendingAction,
required this.onStartGame,
required this.onCancelGame,
@@ -490,7 +470,9 @@ class _GameBody extends StatelessWidget {
required this.onForfeit,
});
- final GameSummary game;
+ /// The live session. Its status is what routes below, so the board and the
+ /// waiting room can never disagree about which one the game is in.
+ final GameSession session;
/// The in-flight user operation, if any. Leaf widgets receive the specific
/// booleans they need, derived here.
@@ -503,7 +485,7 @@ class _GameBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
- switch (game.status) {
+ switch (session.status) {
case GameStatus.waiting:
case GameStatus.ready:
return CustomScrollView(
@@ -512,7 +494,7 @@ class _GameBody extends StatelessWidget {
SliverFillRemaining(
hasScrollBody: false,
child: _PreGameContent(
- game: game,
+ session: session,
isStartingGame: pendingAction == _PendingAction.starting,
isCancelling: pendingAction == _PendingAction.cancelling,
isLeaving: pendingAction == _PendingAction.leaving,
@@ -543,7 +525,7 @@ class _GameBody extends StatelessWidget {
SliverFillRemaining(
hasScrollBody: false,
child: _ActiveGameContent(
- game: game,
+ session: session,
isSubmittingAction:
pendingAction == _PendingAction.submittingAction,
isForfeiting: pendingAction == _PendingAction.forfeiting,
diff --git a/lib/features/game/presentation/screens/game_screen_active.dart b/lib/features/game/presentation/screens/game_screen_active.dart
index a5343ff..6e49a4b 100644
--- a/lib/features/game/presentation/screens/game_screen_active.dart
+++ b/lib/features/game/presentation/screens/game_screen_active.dart
@@ -10,14 +10,14 @@ part of 'game_screen.dart';
/// observation updates rebuild only this widget, not the parent [_GameBody].
class _ActiveGameContent extends ConsumerWidget {
const _ActiveGameContent({
- required this.game,
+ required this.session,
required this.isSubmittingAction,
required this.isForfeiting,
required this.onAction,
required this.onForfeit,
});
- final GameSummary game;
+ final GameSession session;
final bool isSubmittingAction;
final bool isForfeiting;
final Future Function(Map, int) onAction;
@@ -28,7 +28,9 @@ class _ActiveGameContent extends ConsumerWidget {
// gameConfigProvider (data layer) is the single authority on whether this
// build can load the game; here we only render its verdict. A game from
// a newer build surfaces as UnsupportedGameSchemaException.
- final configAsync = ref.watch(gameConfigProvider(gameId: game.id));
+ final configAsync = ref.watch(
+ gameConfigProvider(gameId: session.snapshot.gameId),
+ );
if (configAsync.error is UnsupportedGameSchemaException) {
return const _UpdateRequiredContent();
}
@@ -36,8 +38,12 @@ class _ActiveGameContent extends ConsumerWidget {
final config = configAsync.value;
// Resolved before the config (the config provider awaits it), so a
// non-null config implies non-null rules.
- final rules = ref.watch(gameRulesProvider(gameId: game.id)).value;
- final gamePlayersAsync = ref.watch(gamePlayersProvider(gameId: game.id));
+ final rules = ref
+ .watch(gameRulesProvider(gameId: session.snapshot.gameId))
+ .value;
+ final gamePlayersAsync = ref.watch(
+ gamePlayersProvider(gameId: session.snapshot.gameId),
+ );
// Essentials that don't depend on the live observation stream come first,
// so a non-participant (who has no frame stream) can be handled before we
@@ -53,31 +59,28 @@ class _ActiveGameContent extends ConsumerWidget {
// Offer the replay (finished) or a wait message (active) instead.
final mySeat = gamePlayers.mySeat;
if (mySeat is! Seated) {
- return _NonParticipantContent(game: game);
+ return _NonParticipantContent(game: session.snapshot);
}
final mySeatIndex = mySeat.index;
- final frame = ref.watch(gameFrameProvider(gameId: game.id));
+ final frame = ref.watch(gameFrameProvider(gameId: session.snapshot.gameId));
if (frame == null || frame.observation == null) {
return const Center(child: CircularProgressIndicator());
}
- // Outcomes are fetched once when the game finishes (invalidated by the
- // gameRosterProvider listener in _GameScreenState). Empty list for active
- // games, where no outcome rows exist yet.
- final outcomes =
- ref
- .watch(gameOutcomesProvider(gameId: game.id))
- .whenOrNull(data: (o) => o) ??
- const [];
+ // A projection of the session, so it needs no fetch and no invalidation:
+ // outcomes ride the finishing frame. Empty while the game is running.
+ final outcomes = ref.watch(
+ gameOutcomesProvider(gameId: session.snapshot.gameId),
+ );
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
- if (game.status == GameStatus.active)
+ if (session.status == GameStatus.active)
_TimingHeader(
- game: game,
+ game: session.snapshot,
timing: frame.timing,
pendingPlayers: frame.pendingPlayers,
myPlayerIndex: mySeatIndex,
@@ -87,7 +90,10 @@ class _ActiveGameContent extends ConsumerWidget {
GameContentContext(
config: config,
frame: frame,
- gameStatus: game.status,
+ transition: ref.watch(
+ gameTransitionProvider(gameId: session.snapshot.gameId),
+ ),
+ gameStatus: session.status,
outcomes: outcomes,
actionPending: isSubmittingAction,
onAction: (actionJson) => onAction(actionJson, frame.version),
@@ -97,7 +103,7 @@ class _ActiveGameContent extends ConsumerWidget {
),
),
),
- if (game.status == GameStatus.active)
+ if (session.status == GameStatus.active)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _ForfeitButton(
@@ -105,7 +111,7 @@ class _ActiveGameContent extends ConsumerWidget {
onForfeit: onForfeit,
),
),
- if (game.status == GameStatus.finished)
+ if (session.status == GameStatus.finished)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
@@ -118,12 +124,12 @@ class _ActiveGameContent extends ConsumerWidget {
FilledButton.tonalIcon(
onPressed: () => context.pushNamed(
'replay',
- pathParameters: {'gameId': game.id},
+ pathParameters: {'gameId': session.snapshot.gameId},
),
icon: const Icon(Icons.play_circle_outline),
label: const Text('Watch replay'),
),
- _ShareReplayButton(game: game),
+ _ShareReplayButton(game: session.snapshot),
],
),
const SizedBox(height: 12),
@@ -146,13 +152,13 @@ class _ActiveGameContent extends ConsumerWidget {
class _ShareReplayButton extends ConsumerWidget {
const _ShareReplayButton({required this.game});
- final GameSummary game;
+ final Session game;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (game.access != GameAccess.public) return const SizedBox.shrink();
final appHost = ref.watch(appConfigProvider).engine.appHost;
- final link = gameReplayLink(game.id, appHost: appHost);
+ final link = gameReplayLink(game.gameId, appHost: appHost);
if (link == null) return const SizedBox.shrink();
return OutlinedButton.icon(
@@ -172,7 +178,7 @@ class _ShareReplayButton extends ConsumerWidget {
class _NonParticipantContent extends StatelessWidget {
const _NonParticipantContent({required this.game});
- final GameSummary game;
+ final Session game;
@override
Widget build(BuildContext context) {
@@ -203,7 +209,7 @@ class _NonParticipantContent extends StatelessWidget {
FilledButton.icon(
onPressed: () => context.pushNamed(
'replay',
- pathParameters: {'gameId': game.id},
+ pathParameters: {'gameId': game.gameId},
),
icon: const Icon(Icons.play_circle_outline),
label: const Text('Watch replay'),
@@ -233,7 +239,7 @@ class _TimingHeader extends StatelessWidget {
required this.myPlayerIndex,
});
- final GameSummary game;
+ final Session game;
final TimingContext timing;
final List pendingPlayers;
final int myPlayerIndex;
diff --git a/lib/features/game/presentation/screens/game_screen_pre_game.dart b/lib/features/game/presentation/screens/game_screen_pre_game.dart
index a24dde4..96297d2 100644
--- a/lib/features/game/presentation/screens/game_screen_pre_game.dart
+++ b/lib/features/game/presentation/screens/game_screen_pre_game.dart
@@ -6,7 +6,7 @@ part of 'game_screen.dart';
/// created at [GameStatus.active] transition.
class _PreGameContent extends ConsumerWidget {
const _PreGameContent({
- required this.game,
+ required this.session,
required this.isStartingGame,
required this.isCancelling,
required this.isLeaving,
@@ -15,7 +15,7 @@ class _PreGameContent extends ConsumerWidget {
required this.onLeaveGame,
});
- final GameSummary game;
+ final GameSession session;
final bool isStartingGame;
final bool isCancelling;
final bool isLeaving;
@@ -25,13 +25,15 @@ class _PreGameContent extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final gamePlayersAsync = ref.watch(gamePlayersProvider(gameId: game.id));
+ final gamePlayersAsync = ref.watch(
+ gamePlayersProvider(gameId: session.snapshot.gameId),
+ );
final currentUser = ref.watch(currentUserProvider);
final appHost = ref.watch(appConfigProvider).engine.appHost;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
- final isCreator = game.createdBy == currentUser?.id;
- final isReady = game.status == GameStatus.ready;
+ final isCreator = session.snapshot.createdBy == currentUser?.id;
+ final isReady = session.status == GameStatus.ready;
return Padding(
padding: const EdgeInsets.all(24),
@@ -60,7 +62,7 @@ class _PreGameContent extends ConsumerWidget {
),
const SizedBox(height: 8),
Text(
- 'Game #${game.id.substring(0, 8)}',
+ 'Game #${session.snapshot.gameId.substring(0, 8)}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -83,7 +85,7 @@ class _PreGameContent extends ConsumerWidget {
const Icon(Icons.key, size: 20),
const SizedBox(width: 8),
Text(
- game.shortCode,
+ session.snapshot.shortCode,
style: textTheme.titleLarge?.copyWith(
letterSpacing: 2,
fontWeight: FontWeight.bold,
@@ -92,7 +94,7 @@ class _PreGameContent extends ConsumerWidget {
],
),
),
- if (gameInviteLink(game.shortCode, appHost: appHost)
+ if (gameInviteLink(session.snapshot.shortCode, appHost: appHost)
case final link?) ...[
const SizedBox(height: 16),
// QR modules must be dark-on-light for scanner compatibility,
@@ -120,7 +122,9 @@ class _PreGameContent extends ConsumerWidget {
children: [
OutlinedButton.icon(
onPressed: () {
- Clipboard.setData(ClipboardData(text: game.shortCode));
+ Clipboard.setData(
+ ClipboardData(text: session.snapshot.shortCode),
+ );
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Code copied')),
);
@@ -128,7 +132,7 @@ class _PreGameContent extends ConsumerWidget {
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copy code'),
),
- if (gameInviteLink(game.shortCode, appHost: appHost)
+ if (gameInviteLink(session.snapshot.shortCode, appHost: appHost)
case final link?) ...[
const SizedBox(width: 8),
FilledButton.icon(
@@ -152,16 +156,16 @@ class _PreGameContent extends ConsumerWidget {
// Host can fill an open seat with a server bot (multiplayer fill).
if (isCreator &&
(gamePlayersAsync.value?.players.length ?? 0) <
- game.maxPlayers) ...[
+ session.snapshot.maxPlayers) ...[
OutlinedButton.icon(
onPressed: () => showDialog(
context: context,
useSafeArea: true,
builder: (_) => _AddBotDialog(
- gameId: game.id,
- rated: game.rated,
- config: game.config,
- schemaVersion: game.schemaVersion,
+ gameId: session.snapshot.gameId,
+ rated: session.snapshot.rated,
+ config: session.snapshot.config,
+ schemaVersion: session.snapshot.schemaVersion,
),
),
icon: const Icon(Icons.smart_toy_outlined),
diff --git a/lib/features/game/presentation/screens/game_screen_states.dart b/lib/features/game/presentation/screens/game_screen_states.dart
index ce9075f..9a5163b 100644
--- a/lib/features/game/presentation/screens/game_screen_states.dart
+++ b/lib/features/game/presentation/screens/game_screen_states.dart
@@ -132,13 +132,11 @@ class _ReconnectingBannerSlot extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOffline = ref.watch(isOfflineProvider);
- final obsAsync = ref.watch(gameEventsProvider(gameId: gameId));
- final gameAsync = ref.watch(gameRosterProvider(gameId: gameId));
- final isDisconnected =
- isOffline || obsAsync is AsyncError || gameAsync is AsyncError;
+ final sessionAsync = ref.watch(gameSessionProvider(gameId: gameId));
+ final isDisconnected = isOffline || sessionAsync is AsyncError;
- // Use .value to read stale status when stream is in AsyncError.
- final status = gameAsync.value?.status;
+ // Use .value to read the stale session when the stream is in AsyncError.
+ final status = sessionAsync.value?.status;
final isInGame = switch (status) {
GameStatus.waiting || GameStatus.ready || GameStatus.active => true,
_ => false,
diff --git a/lib/features/game/presentation/screens/replay_screen.dart b/lib/features/game/presentation/screens/replay_screen.dart
index f5a4263..32635cd 100644
--- a/lib/features/game/presentation/screens/replay_screen.dart
+++ b/lib/features/game/presentation/screens/replay_screen.dart
@@ -92,10 +92,7 @@ class _ReplayBody extends ConsumerWidget {
// final position, so it is only handed over on the last frame.
final isLastFrame = index == frameCount - 1;
final outcomes = isLastFrame
- ? ref
- .watch(gameOutcomesProvider(gameId: gameId))
- .whenOrNull(data: (o) => o) ??
- const []
+ ? ref.watch(gameOutcomesProvider(gameId: gameId))
: const [];
return Padding(
@@ -107,6 +104,9 @@ class _ReplayBody extends ConsumerWidget {
GameContentContext(
config: config,
frame: frame,
+ transition: ref.watch(
+ replayTransitionAtProvider(gameId: gameId, index: index),
+ ),
// Finished for every frame so the game keeps input inert; a
// live status would re-enable the board on the actor's turn.
gameStatus: GameStatus.finished,
diff --git a/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart b/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart
index f2624e8..f25f2b7 100644
--- a/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart
+++ b/lib/features/game/presentation/widgets/play_vs_bot_dialog.dart
@@ -263,7 +263,10 @@ class _PlayVsBotDialogState extends ConsumerState {
);
if (!mounted) return;
Navigator.pop(context);
- context.pushNamed('game', pathParameters: {'gameId': started.gameId});
+ context.pushNamed(
+ 'game',
+ pathParameters: {'gameId': started.session.gameId},
+ );
} catch (e) {
if (!mounted) return;
setState(() => _creating = false);
diff --git a/lib/features/game/providers/game_frame_provider.dart b/lib/features/game/providers/game_frame_provider.dart
index bad9f78..fe42823 100644
--- a/lib/features/game/providers/game_frame_provider.dart
+++ b/lib/features/game/providers/game_frame_provider.dart
@@ -2,6 +2,7 @@ import 'package:eigen_api/eigen_api.dart';
import 'package:eigen_flutter/core/api/engine_api_providers.dart';
import 'package:eigen_flutter/core/game/game_frame.dart';
import 'package:eigen_flutter/core/game/game_module.dart';
+import 'package:eigen_flutter/core/game/game_transition.dart';
import 'package:eigen_flutter/core/game/timing_context.dart';
import 'package:eigen_flutter/features/game/providers/game_providers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -16,14 +17,14 @@ part 'game_frame_provider.g.dart';
/// and never branches on version.
@riverpod
Future gameRules(Ref ref, {required String gameId}) async {
+ final session = await ref.watch(gameSessionProvider(gameId: gameId).future);
final module = ref.watch(currentGameModuleProvider);
- final summary = await ref.watch(gameSummaryProvider(gameId: gameId).future);
- final rules = module.versions[summary.schemaVersion];
+ final rules = module.versions[session.snapshot.schemaVersion];
if (rules == null) {
// Created by a newer build (or a retired version) - refuse rather than
// mis-parse with the wrong generation of code.
throw UnsupportedGameSchemaException(
- gameSchema: summary.schemaVersion,
+ gameSchema: session.snapshot.schemaVersion,
supportedSchema: module.latestSchemaVersion,
);
}
@@ -38,8 +39,9 @@ Future gameRules(Ref ref, {required String gameId}) async {
@riverpod
Future