From 5d84a094a45315966a4ba7b5fb7ce24c6772eb30 Mon Sep 17 00:00:00 2001 From: Seenu K Date: Wed, 12 Aug 2026 01:03:50 +0530 Subject: [PATCH] Update to new server and client api --- CHANGELOG.md | 12 + example/test/board_test.dart | 2 + lib/core/api/game_socket.dart | 76 +-- lib/core/game/game_module.dart | 12 + lib/core/game/game_session.dart | 77 +++ lib/core/game/game_transition.dart | 25 + lib/eigen_flutter.dart | 6 +- lib/features/game/data/game_repository.dart | 235 +++---- .../presentation/screens/game_screen.dart | 150 ++-- .../screens/game_screen_active.dart | 60 +- .../screens/game_screen_pre_game.dart | 34 +- .../screens/game_screen_states.dart | 10 +- .../presentation/screens/replay_screen.dart | 8 +- .../widgets/play_vs_bot_dialog.dart | 5 +- .../game/providers/game_frame_provider.dart | 85 ++- .../game/providers/game_frame_provider.g.dart | 234 ++++--- .../game/providers/game_providers.dart | 137 ++-- .../game/providers/game_providers.g.dart | 642 ++++++++---------- .../game/providers/replay_controller.dart | 22 + .../game/providers/replay_controller.g.dart | 126 ++++ .../presentation/screens/home_screen.dart | 25 +- pubspec.yaml | 2 +- .../data/game_repository_events_test.dart | 275 -------- .../data/game_repository_session_test.dart | 279 ++++++++ .../game/presentation/waiting_room_test.dart | 164 +++++ .../game_wire_compatibility_test.dart | 10 +- 26 files changed, 1527 insertions(+), 1186 deletions(-) create mode 100644 lib/core/game/game_session.dart create mode 100644 lib/core/game/game_transition.dart delete mode 100644 test/features/game/data/game_repository_events_test.dart create mode 100644 test/features/game/data/game_repository_session_test.dart create mode 100644 test/features/game/presentation/waiting_room_test.dart 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 gameConfig(Ref ref, {required String gameId}) async { final rules = await ref.watch(gameRulesProvider(gameId: gameId).future); - final summary = await ref.watch(gameSummaryProvider(gameId: gameId).future); - return rules.parseConfig(summary.config as Map) as Object; + final session = await ref.watch(gameSessionProvider(gameId: gameId).future); + return rules.parseConfig(session.snapshot.config as Map) + as Object; } /// How long the current turn was when it started, in milliseconds. @@ -49,24 +51,30 @@ Future gameConfig(Ref ref, {required String gameId}) async { /// game's configured per-turn length. Null for an untimed game, or when a hook /// set a per-action deadline the client cannot see - both cases fall back to a /// truthful, unmargined countdown. -int? _turnWindowMillis(GameSummary? summary, Frame frame) { +int? _turnWindowMillis(Session snapshot, Frame frame) { final banks = frame.playerTimes; if (banks != null && frame.pendingPlayers.length == 1) { final seat = frame.pendingPlayers.first; if (seat < banks.length) return banks[seat]; } - final turnSeconds = summary?.turnSeconds; + final turnSeconds = snapshot.turnSeconds; return turnSeconds == null ? null : turnSeconds * 1000; } -/// Memoizes [GameRules.parseObservation] so it only runs when the raw payload -/// changes, not on every rebuild of [gameFrame]. -@riverpod -Object? _parsedObservation(Ref ref, {required String gameId}) { - final rules = ref.watch(gameRulesProvider(gameId: gameId)).value; - final frame = ref.watch(gameFrameDataProvider(gameId: gameId)); - if (rules == null || frame == null) return null; - return rules.parseObservation(frame.data as Map) as Object?; +/// Builds the rendered frame for one wire frame, parsing its observation. +GameFrame _frameOf(Ref ref, GameRules rules, Session snapshot, Frame frame) { + return GameFrame( + observation: + rules.parseObservation(frame.data as Map) as Object?, + pendingPlayers: frame.pendingPlayers, + version: frame.version, + timing: TimingContext( + clock: ref.watch(serverClockProvider), + playerTimes: frame.playerTimes, + deadline: frame.deadline, + windowMillis: _turnWindowMillis(snapshot, frame), + ), + ); } /// The per-frame [GameFrame] the game renders from. @@ -76,26 +84,37 @@ Object? _parsedObservation(Ref ref, {required String gameId}) { /// abort. /// /// The parsed config is intentionally not part of this; consume it separately -/// via [gameConfig]. `pendingPlayers`, `version` and `timing` are available as -/// soon as the frame arrives, while `observation` stays null until the rules -/// unit has parsed it. +/// via [gameConfig]. @riverpod GameFrame? gameFrame(Ref ref, {required String gameId}) { - final frame = ref.watch(gameFrameDataProvider(gameId: gameId)); - if (frame == null) return null; + final session = ref.watch(gameSessionProvider(gameId: gameId)).value; + final rules = ref.watch(gameRulesProvider(gameId: gameId)).value; + final frame = session?.frame; + if (session == null || rules == null || frame == null) return null; + return _frameOf(ref, rules, session.snapshot, frame); +} - return GameFrame( - observation: ref.watch(_parsedObservationProvider(gameId: gameId)), - pendingPlayers: frame.pendingPlayers, - version: frame.version, - timing: TimingContext( - clock: ref.watch(serverClockProvider), - playerTimes: frame.playerTimes, - deadline: frame.deadline, - windowMillis: _turnWindowMillis( - ref.watch(gameSummaryProvider(gameId: gameId)).value, - frame, - ), - ), +/// The step from the frame the player last saw to the one on screen now, or +/// null when there is no step to animate. +/// +/// This is the input a game animates from, so that "did I render the +/// predecessor" stops being something every game re-derives in widget state. It +/// is null exactly when animating would be wrong: a cold load, a rejoin, or the +/// opening frame, where the cue is history rather than an event and belongs on +/// screen statically. +@riverpod +GameTransition? gameTransition(Ref ref, {required String gameId}) { + final session = ref.watch(gameSessionProvider(gameId: gameId)).value; + final rules = ref.watch(gameRulesProvider(gameId: gameId)).value; + final from = session?.previousFrame; + final to = session?.frame; + if (session == null || rules == null || from == null || to == null) { + return null; + } + // Only a direct successor is a step the player can have watched happen. + if (to.version != from.version + 1) return null; + return GameTransition( + from: _frameOf(ref, rules, session.snapshot, from), + to: _frameOf(ref, rules, session.snapshot, to), ); } diff --git a/lib/features/game/providers/game_frame_provider.g.dart b/lib/features/game/providers/game_frame_provider.g.dart index da5dd1e..fd3fb19 100644 --- a/lib/features/game/providers/game_frame_provider.g.dart +++ b/lib/features/game/providers/game_frame_provider.g.dart @@ -85,7 +85,7 @@ final class GameRulesProvider } } -String _$gameRulesHash() => r'13a2b31e698ff3b5959d5ea0ac8d381f9c86c082'; +String _$gameRulesHash() => r'edff788cc12ce8f245935aa140a22273380941d2'; /// The [GameRules] version unit for a specific game, resolved once from the /// game's immutable schema version. @@ -189,7 +189,7 @@ final class GameConfigProvider } } -String _$gameConfigHash() => r'0141ca187807433997849c0a3c28be0c5251b3bd'; +String _$gameConfigHash() => r'6be246f6f929069813046eaa21c2730502538a23'; /// The parsed game config, produced once from the immutable config payload. /// @@ -221,63 +221,81 @@ final class GameConfigFamily extends $Family String toString() => r'gameConfigProvider'; } -/// Memoizes [GameRules.parseObservation] so it only runs when the raw payload -/// changes, not on every rebuild of [gameFrame]. +/// The per-frame [GameFrame] the game renders from. +/// +/// Null before the game is under way: frames only exist from v0 of an active +/// game onward, and there is nothing to project in the waiting room or after an +/// abort. +/// +/// The parsed config is intentionally not part of this; consume it separately +/// via [gameConfig]. -@ProviderFor(_parsedObservation) -final _parsedObservationProvider = _ParsedObservationFamily._(); +@ProviderFor(gameFrame) +final gameFrameProvider = GameFrameFamily._(); -/// Memoizes [GameRules.parseObservation] so it only runs when the raw payload -/// changes, not on every rebuild of [gameFrame]. +/// The per-frame [GameFrame] the game renders from. +/// +/// Null before the game is under way: frames only exist from v0 of an active +/// game onward, and there is nothing to project in the waiting room or after an +/// abort. +/// +/// The parsed config is intentionally not part of this; consume it separately +/// via [gameConfig]. -final class _ParsedObservationProvider - extends $FunctionalProvider - with $Provider { - /// Memoizes [GameRules.parseObservation] so it only runs when the raw payload - /// changes, not on every rebuild of [gameFrame]. - _ParsedObservationProvider._({ - required _ParsedObservationFamily super.from, +final class GameFrameProvider + extends $FunctionalProvider + with $Provider { + /// The per-frame [GameFrame] the game renders from. + /// + /// Null before the game is under way: frames only exist from v0 of an active + /// game onward, and there is nothing to project in the waiting room or after an + /// abort. + /// + /// The parsed config is intentionally not part of this; consume it separately + /// via [gameConfig]. + GameFrameProvider._({ + required GameFrameFamily super.from, required String super.argument, }) : super( retry: null, - name: r'_parsedObservationProvider', + name: r'gameFrameProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$_parsedObservationHash(); + String debugGetCreateSourceHash() => _$gameFrameHash(); @override String toString() { - return r'_parsedObservationProvider' + return r'gameFrameProvider' '' '($argument)'; } @$internal @override - $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement $createElement($ProviderPointer pointer) => $ProviderElement(pointer); @override - Object? create(Ref ref) { + GameFrame? create(Ref ref) { final argument = this.argument as String; - return _parsedObservation(ref, gameId: argument); + return gameFrame(ref, gameId: argument); } /// {@macro riverpod.override_with_value} - Override overrideWithValue(Object? value) { + Override overrideWithValue(GameFrame? value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } @override bool operator ==(Object other) { - return other is _ParsedObservationProvider && other.argument == argument; + return other is GameFrameProvider && other.argument == argument; } @override @@ -286,114 +304,120 @@ final class _ParsedObservationProvider } } -String _$_parsedObservationHash() => - r'6c50ea7926c1598b702d9e9b852ae1325cda4477'; +String _$gameFrameHash() => r'2c3bc7b88d6e3f90efbc150c6e975adb2c6d2a16'; -/// Memoizes [GameRules.parseObservation] so it only runs when the raw payload -/// changes, not on every rebuild of [gameFrame]. +/// The per-frame [GameFrame] the game renders from. +/// +/// Null before the game is under way: frames only exist from v0 of an active +/// game onward, and there is nothing to project in the waiting room or after an +/// abort. +/// +/// The parsed config is intentionally not part of this; consume it separately +/// via [gameConfig]. -final class _ParsedObservationFamily extends $Family - with $FunctionalFamilyOverride { - _ParsedObservationFamily._() +final class GameFrameFamily extends $Family + with $FunctionalFamilyOverride { + GameFrameFamily._() : super( retry: null, - name: r'_parsedObservationProvider', + name: r'gameFrameProvider', dependencies: null, $allTransitiveDependencies: null, isAutoDispose: true, ); - /// Memoizes [GameRules.parseObservation] so it only runs when the raw payload - /// changes, not on every rebuild of [gameFrame]. + /// The per-frame [GameFrame] the game renders from. + /// + /// Null before the game is under way: frames only exist from v0 of an active + /// game onward, and there is nothing to project in the waiting room or after an + /// abort. + /// + /// The parsed config is intentionally not part of this; consume it separately + /// via [gameConfig]. - _ParsedObservationProvider call({required String gameId}) => - _ParsedObservationProvider._(argument: gameId, from: this); + GameFrameProvider call({required String gameId}) => + GameFrameProvider._(argument: gameId, from: this); @override - String toString() => r'_parsedObservationProvider'; + String toString() => r'gameFrameProvider'; } -/// The per-frame [GameFrame] the game renders from. +/// The step from the frame the player last saw to the one on screen now, or +/// null when there is no step to animate. /// -/// Null before the game is under way: frames only exist from v0 of an active -/// game onward, and there is nothing to project in the waiting room or after an -/// abort. -/// -/// The parsed config is intentionally not part of this; consume it separately -/// via [gameConfig]. `pendingPlayers`, `version` and `timing` are available as -/// soon as the frame arrives, while `observation` stays null until the rules -/// unit has parsed it. +/// This is the input a game animates from, so that "did I render the +/// predecessor" stops being something every game re-derives in widget state. It +/// is null exactly when animating would be wrong: a cold load, a rejoin, or the +/// opening frame, where the cue is history rather than an event and belongs on +/// screen statically. -@ProviderFor(gameFrame) -final gameFrameProvider = GameFrameFamily._(); +@ProviderFor(gameTransition) +final gameTransitionProvider = GameTransitionFamily._(); -/// The per-frame [GameFrame] the game renders from. -/// -/// Null before the game is under way: frames only exist from v0 of an active -/// game onward, and there is nothing to project in the waiting room or after an -/// abort. +/// The step from the frame the player last saw to the one on screen now, or +/// null when there is no step to animate. /// -/// The parsed config is intentionally not part of this; consume it separately -/// via [gameConfig]. `pendingPlayers`, `version` and `timing` are available as -/// soon as the frame arrives, while `observation` stays null until the rules -/// unit has parsed it. +/// This is the input a game animates from, so that "did I render the +/// predecessor" stops being something every game re-derives in widget state. It +/// is null exactly when animating would be wrong: a cold load, a rejoin, or the +/// opening frame, where the cue is history rather than an event and belongs on +/// screen statically. -final class GameFrameProvider - extends $FunctionalProvider - with $Provider { - /// The per-frame [GameFrame] the game renders from. - /// - /// Null before the game is under way: frames only exist from v0 of an active - /// game onward, and there is nothing to project in the waiting room or after an - /// abort. +final class GameTransitionProvider + extends + $FunctionalProvider + with $Provider { + /// The step from the frame the player last saw to the one on screen now, or + /// null when there is no step to animate. /// - /// The parsed config is intentionally not part of this; consume it separately - /// via [gameConfig]. `pendingPlayers`, `version` and `timing` are available as - /// soon as the frame arrives, while `observation` stays null until the rules - /// unit has parsed it. - GameFrameProvider._({ - required GameFrameFamily super.from, + /// This is the input a game animates from, so that "did I render the + /// predecessor" stops being something every game re-derives in widget state. It + /// is null exactly when animating would be wrong: a cold load, a rejoin, or the + /// opening frame, where the cue is history rather than an event and belongs on + /// screen statically. + GameTransitionProvider._({ + required GameTransitionFamily super.from, required String super.argument, }) : super( retry: null, - name: r'gameFrameProvider', + name: r'gameTransitionProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$gameFrameHash(); + String debugGetCreateSourceHash() => _$gameTransitionHash(); @override String toString() { - return r'gameFrameProvider' + return r'gameTransitionProvider' '' '($argument)'; } @$internal @override - $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement $createElement($ProviderPointer pointer) => $ProviderElement(pointer); @override - GameFrame? create(Ref ref) { + GameTransition? create(Ref ref) { final argument = this.argument as String; - return gameFrame(ref, gameId: argument); + return gameTransition(ref, gameId: argument); } /// {@macro riverpod.override_with_value} - Override overrideWithValue(GameFrame? value) { + Override overrideWithValue(GameTransition? value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } @override bool operator ==(Object other) { - return other is GameFrameProvider && other.argument == argument; + return other is GameTransitionProvider && other.argument == argument; } @override @@ -402,44 +426,40 @@ final class GameFrameProvider } } -String _$gameFrameHash() => r'ab0ae2b9b87118c9d3f2653a1fe2085e683cf5c0'; +String _$gameTransitionHash() => r'93071bc725434be86c94655b2fc5e3b236fe4dc5'; -/// The per-frame [GameFrame] the game renders from. +/// The step from the frame the player last saw to the one on screen now, or +/// null when there is no step to animate. /// -/// Null before the game is under way: frames only exist from v0 of an active -/// game onward, and there is nothing to project in the waiting room or after an -/// abort. -/// -/// The parsed config is intentionally not part of this; consume it separately -/// via [gameConfig]. `pendingPlayers`, `version` and `timing` are available as -/// soon as the frame arrives, while `observation` stays null until the rules -/// unit has parsed it. - -final class GameFrameFamily extends $Family - with $FunctionalFamilyOverride { - GameFrameFamily._() +/// This is the input a game animates from, so that "did I render the +/// predecessor" stops being something every game re-derives in widget state. It +/// is null exactly when animating would be wrong: a cold load, a rejoin, or the +/// opening frame, where the cue is history rather than an event and belongs on +/// screen statically. + +final class GameTransitionFamily extends $Family + with $FunctionalFamilyOverride { + GameTransitionFamily._() : super( retry: null, - name: r'gameFrameProvider', + name: r'gameTransitionProvider', dependencies: null, $allTransitiveDependencies: null, isAutoDispose: true, ); - /// The per-frame [GameFrame] the game renders from. - /// - /// Null before the game is under way: frames only exist from v0 of an active - /// game onward, and there is nothing to project in the waiting room or after an - /// abort. + /// The step from the frame the player last saw to the one on screen now, or + /// null when there is no step to animate. /// - /// The parsed config is intentionally not part of this; consume it separately - /// via [gameConfig]. `pendingPlayers`, `version` and `timing` are available as - /// soon as the frame arrives, while `observation` stays null until the rules - /// unit has parsed it. + /// This is the input a game animates from, so that "did I render the + /// predecessor" stops being something every game re-derives in widget state. It + /// is null exactly when animating would be wrong: a cold load, a rejoin, or the + /// opening frame, where the cue is history rather than an event and belongs on + /// screen statically. - GameFrameProvider call({required String gameId}) => - GameFrameProvider._(argument: gameId, from: this); + GameTransitionProvider call({required String gameId}) => + GameTransitionProvider._(argument: gameId, from: this); @override - String toString() => r'gameFrameProvider'; + String toString() => r'gameTransitionProvider'; } diff --git a/lib/features/game/providers/game_providers.dart b/lib/features/game/providers/game_providers.dart index 8e04122..7911ffc 100644 --- a/lib/features/game/providers/game_providers.dart +++ b/lib/features/game/providers/game_providers.dart @@ -3,10 +3,10 @@ import 'dart:async'; import 'package:eigen_api/eigen_api.dart'; import 'package:eigen_flutter/core/analytics/analytics_provider.dart'; import 'package:eigen_flutter/core/api/engine_api_providers.dart'; -import 'package:eigen_flutter/core/api/game_socket.dart'; import 'package:eigen_flutter/core/game/game_creation_spec.dart'; import 'package:eigen_flutter/core/game/game_module.dart'; import 'package:eigen_flutter/core/game/game_player.dart'; +import 'package:eigen_flutter/core/game/game_session.dart'; import 'package:eigen_flutter/core/game/my_seat.dart'; import 'package:eigen_flutter/core/game/players_context.dart'; import 'package:eigen_flutter/core/storage/storage_provider.dart'; @@ -148,16 +148,37 @@ Future> activeGames(Ref ref) async { }); } -/// One game's metadata: schema version, config, timing, access. +/// One game's live session: the single subscription a game screen needs. /// -/// A plain read rather than a stream. These fields are fixed at creation and -/// never change, so streaming them would be re-delivering constants; what does -/// change - status and roster - arrives on [gameEvents] instead. +/// Every emission is a COMPLETE session, which is what makes `.value` on this +/// sound. The old shape streamed heterogeneous events and had each derived +/// provider type-test the newest one, so a frame arriving after a roster +/// snapshot silently reverted the roster, and nothing on the socket reported a +/// status change at all. +/// +/// One socket serves the whole game. Riverpod's automatic retry covers a failure +/// to establish it; drops after that are handled inside the socket, which +/// reconnects and is answered with the current snapshot, so this stream is never +/// torn down to resync. @riverpod -Future gameSummary(Ref ref, {required String gameId}) { - return ref.watch(gameRepositoryProvider).getGame(gameId); +Stream gameSession(Ref ref, {required String gameId}) { + return ref.watch(gameRepositoryProvider).sessions(gameId); } +/// The game's status, live. +/// +/// A selector, not a fetch. Every screen decision that used to read a summary +/// that nothing refetched reads this instead. +@riverpod +GameStatus? gameStatus(Ref ref, {required String gameId}) => + ref.watch(gameSessionProvider(gameId: gameId)).value?.status; + +/// The seats, live: the roster the server stated with the newest snapshot. +@riverpod +List gameSeats(Ref ref, {required String gameId}) => + ref.watch(gameSessionProvider(gameId: gameId)).value?.snapshot.players ?? + const []; + /// Whether the current wire payload contains gameplay semantics this build /// cannot safely interpret. typedef GameWireCompatibility = ({ @@ -173,41 +194,33 @@ extension GameWireCompatibilityX on GameWireCompatibility { unknownStatus || unknownSeatType || unknownFrameType; } -/// Compatibility verdict across the initial summary and live game payloads. +/// Compatibility verdict over the live session. /// /// Status, seat type, and frame type drive gameplay behavior, so guessing would /// be unsafe. Metadata-only unknowns such as access remain usable with -/// conservative UI. +/// conservative UI. One source now, so there is no second copy of the same field +/// to check. @riverpod GameWireCompatibility gameWireCompatibility(Ref ref, {required String gameId}) { - final summary = ref.watch(gameSummaryProvider(gameId: gameId)).value; - final roster = ref.watch(gameRosterProvider(gameId: gameId)).value; - final frame = ref.watch(gameFrameDataProvider(gameId: gameId)); + final session = ref.watch(gameSessionProvider(gameId: gameId)).value; return evaluateGameWireCompatibility( - summaryStatus: summary?.status, - summarySeats: summary?.participants, - access: summary?.access, - rosterStatus: roster?.status, - rosterSeats: roster?.players, - frameType: frame?.type, + status: session?.status, + seats: session?.snapshot.players, + access: session?.snapshot.access, + frameType: session?.frame?.type, ); } /// Computes compatibility without requiring a provider container. GameWireCompatibility evaluateGameWireCompatibility({ - GameStatus? summaryStatus, - Iterable? summarySeats, + GameStatus? status, + Iterable? seats, GameAccess? access, - GameStatus? rosterStatus, - Iterable? rosterSeats, FrameTypeEnum? frameType, }) { return ( - unknownStatus: - summaryStatus == GameStatus.unknownDefaultOpenApi || - rosterStatus == GameStatus.unknownDefaultOpenApi, - unknownSeatType: - _hasUnknownSeatType(summarySeats) || _hasUnknownSeatType(rosterSeats), + unknownStatus: status == GameStatus.unknownDefaultOpenApi, + unknownSeatType: _hasUnknownSeatType(seats), unknownFrameType: frameType == FrameTypeEnum.unknownDefaultOpenApi, unknownAccess: access == GameAccess.unknownDefaultOpenApi, ); @@ -217,57 +230,21 @@ bool _hasUnknownSeatType(Iterable? seats) => seats?.any((seat) => seat.type == SeatTypeEnum.unknownDefaultOpenApi) ?? false; -/// The game's live feed: roster snapshots pre-game, then ordered frames. -/// -/// One socket serves the whole game, so this is the single subscription a game -/// screen needs. Riverpod's automatic retry covers a failure to establish it; -/// drops after that are handled inside the socket, which reconnects and -/// resyncs without tearing down this stream. -@riverpod -Stream gameEvents(Ref ref, {required String gameId}) { - return ref.watch(gameRepositoryProvider).events(gameId); -} - -/// The newest roster seen for a game, seeded from the summary. -/// -/// The socket delivers a snapshot on open and on every roster change while the -/// game is in the waiting room, but sends none once play starts - so the -/// summary provides the starting value and later snapshots replace it. -@riverpod -Future gameRoster(Ref ref, {required String gameId}) async { - final summary = await ref.watch(gameSummaryProvider(gameId: gameId).future); - final event = ref.watch(gameEventsProvider(gameId: gameId)).value; - if (event is GameSocketRoster) return event.roster; - return Roster( - type: RosterTypeEnum.roster, - status: summary.status, - players: summary.participants, - ); -} - -/// The newest frame seen for a game, or null before the first one arrives. -@riverpod -Frame? gameFrameData(Ref ref, {required String gameId}) { - final event = ref.watch(gameEventsProvider(gameId: gameId)).value; - return event is GameSocketFrame ? event.frame : null; -} - /// The game's seats with their identities resolved, plus which one is mine. /// -/// Seats come from the live roster rather than a separate fetch, so this -/// re-derives as players join and leave. Identities come from the persisted -/// player cache, which covers humans and bots alike. +/// Seats come from the live session, so this re-derives as players join and +/// leave. Identities come from the persisted player cache, which covers humans +/// and bots alike. @riverpod Future gamePlayers(Ref ref, {required String gameId}) async { - final roster = await ref.watch(gameRosterProvider(gameId: gameId).future); + final seats = ref.watch(gameSeatsProvider(gameId: gameId)); final currentUserId = ref.watch(currentUserIdProvider); final entries = await Future.wait([ - for (final seat in roster.players) - _resolveSeat(ref, gameId: gameId, seat: seat), + for (final seat in seats) _resolveSeat(ref, gameId: gameId, seat: seat), ]); - final mySeat = roster.players + final mySeat = seats .where((p) => p.userId == currentUserId) .map((p) => p.playerIndex) .firstOrNull; @@ -323,13 +300,14 @@ Player _deletedPlayer(String gameId, int playerIndex) => Player( isAnonymous: false, ); -/// Joins a game by invite code, returning the game's id and its roster. +/// Joins a game by invite code, answering with the seated session. /// -/// Auto-disposes once the join screen navigates away. The screen uses -/// [ref.listen] to react to the result rather than watching the value -/// directly, so navigation happens exactly once. +/// The session carries the game's id, which is the only place a by-code caller +/// learns it. Auto-disposes once the join screen navigates away. The screen uses +/// [ref.listen] to react to the result rather than watching the value directly, +/// so navigation happens exactly once. @riverpod -Future joinByCode(Ref ref, {required String code}) => ref +Future joinByCode(Ref ref, {required String code}) => ref .read(gameRepositoryProvider) .joinGameByCode( code, @@ -340,12 +318,13 @@ Future joinByCode(Ref ref, {required String code}) => ref /// A finished game's outcomes. /// -/// Immutable once written, and already on the summary - so this is a -/// projection rather than a fetch. Empty while the game is still running. +/// A projection of the live session rather than a fetch: they ride the finishing +/// frame, and a cold open of a finished game gets them on whatever its newest +/// frame is. Empty while the game is still running. @riverpod -Future> gameOutcomes(Ref ref, {required String gameId}) async { - final summary = await ref.watch(gameSummaryProvider(gameId: gameId).future); - final outcomes = summary.outcomes ?? const []; +List gameOutcomes(Ref ref, {required String gameId}) { + final session = ref.watch(gameSessionProvider(gameId: gameId)).value; + final outcomes = session?.frame?.outcomes ?? const []; if (outcomes.any( (outcome) => outcome.result == OutcomeResultEnum.unknownDefaultOpenApi, )) { diff --git a/lib/features/game/providers/game_providers.g.dart b/lib/features/game/providers/game_providers.g.dart index 0f1d9fe..b3ffc29 100644 --- a/lib/features/game/providers/game_providers.g.dart +++ b/lib/features/game/providers/game_providers.g.dart @@ -430,70 +430,91 @@ final class ActiveGamesProvider String _$activeGamesHash() => r'3d2e1b090ecec8292f8d2329cbeb8899ac6b843d'; -/// One game's metadata: schema version, config, timing, access. +/// One game's live session: the single subscription a game screen needs. /// -/// A plain read rather than a stream. These fields are fixed at creation and -/// never change, so streaming them would be re-delivering constants; what does -/// change - status and roster - arrives on [gameEvents] instead. +/// Every emission is a COMPLETE session, which is what makes `.value` on this +/// sound. The old shape streamed heterogeneous events and had each derived +/// provider type-test the newest one, so a frame arriving after a roster +/// snapshot silently reverted the roster, and nothing on the socket reported a +/// status change at all. +/// +/// One socket serves the whole game. Riverpod's automatic retry covers a failure +/// to establish it; drops after that are handled inside the socket, which +/// reconnects and is answered with the current snapshot, so this stream is never +/// torn down to resync. -@ProviderFor(gameSummary) -final gameSummaryProvider = GameSummaryFamily._(); +@ProviderFor(gameSession) +final gameSessionProvider = GameSessionFamily._(); -/// One game's metadata: schema version, config, timing, access. +/// One game's live session: the single subscription a game screen needs. +/// +/// Every emission is a COMPLETE session, which is what makes `.value` on this +/// sound. The old shape streamed heterogeneous events and had each derived +/// provider type-test the newest one, so a frame arriving after a roster +/// snapshot silently reverted the roster, and nothing on the socket reported a +/// status change at all. /// -/// A plain read rather than a stream. These fields are fixed at creation and -/// never change, so streaming them would be re-delivering constants; what does -/// change - status and roster - arrives on [gameEvents] instead. +/// One socket serves the whole game. Riverpod's automatic retry covers a failure +/// to establish it; drops after that are handled inside the socket, which +/// reconnects and is answered with the current snapshot, so this stream is never +/// torn down to resync. -final class GameSummaryProvider +final class GameSessionProvider extends $FunctionalProvider< - AsyncValue, - GameSummary, - FutureOr + AsyncValue, + GameSession, + Stream > - with $FutureModifier, $FutureProvider { - /// One game's metadata: schema version, config, timing, access. + with $FutureModifier, $StreamProvider { + /// One game's live session: the single subscription a game screen needs. + /// + /// Every emission is a COMPLETE session, which is what makes `.value` on this + /// sound. The old shape streamed heterogeneous events and had each derived + /// provider type-test the newest one, so a frame arriving after a roster + /// snapshot silently reverted the roster, and nothing on the socket reported a + /// status change at all. /// - /// A plain read rather than a stream. These fields are fixed at creation and - /// never change, so streaming them would be re-delivering constants; what does - /// change - status and roster - arrives on [gameEvents] instead. - GameSummaryProvider._({ - required GameSummaryFamily super.from, + /// One socket serves the whole game. Riverpod's automatic retry covers a failure + /// to establish it; drops after that are handled inside the socket, which + /// reconnects and is answered with the current snapshot, so this stream is never + /// torn down to resync. + GameSessionProvider._({ + required GameSessionFamily super.from, required String super.argument, }) : super( retry: null, - name: r'gameSummaryProvider', + name: r'gameSessionProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$gameSummaryHash(); + String debugGetCreateSourceHash() => _$gameSessionHash(); @override String toString() { - return r'gameSummaryProvider' + return r'gameSessionProvider' '' '($argument)'; } @$internal @override - $FutureProviderElement $createElement( + $StreamProviderElement $createElement( $ProviderPointer pointer, - ) => $FutureProviderElement(pointer); + ) => $StreamProviderElement(pointer); @override - FutureOr create(Ref ref) { + Stream create(Ref ref) { final argument = this.argument as String; - return gameSummary(ref, gameId: argument); + return gameSession(ref, gameId: argument); } @override bool operator ==(Object other) { - return other is GameSummaryProvider && other.argument == argument; + return other is GameSessionProvider && other.argument == argument; } @override @@ -502,110 +523,115 @@ final class GameSummaryProvider } } -String _$gameSummaryHash() => r'ce8af36d61093b519a70ab8ffdfb8fec4410178b'; +String _$gameSessionHash() => r'75f5c201d1a27ba5ced60417ebcd2100662be89e'; -/// One game's metadata: schema version, config, timing, access. +/// One game's live session: the single subscription a game screen needs. /// -/// A plain read rather than a stream. These fields are fixed at creation and -/// never change, so streaming them would be re-delivering constants; what does -/// change - status and roster - arrives on [gameEvents] instead. - -final class GameSummaryFamily extends $Family - with $FunctionalFamilyOverride, String> { - GameSummaryFamily._() +/// Every emission is a COMPLETE session, which is what makes `.value` on this +/// sound. The old shape streamed heterogeneous events and had each derived +/// provider type-test the newest one, so a frame arriving after a roster +/// snapshot silently reverted the roster, and nothing on the socket reported a +/// status change at all. +/// +/// One socket serves the whole game. Riverpod's automatic retry covers a failure +/// to establish it; drops after that are handled inside the socket, which +/// reconnects and is answered with the current snapshot, so this stream is never +/// torn down to resync. + +final class GameSessionFamily extends $Family + with $FunctionalFamilyOverride, String> { + GameSessionFamily._() : super( retry: null, - name: r'gameSummaryProvider', + name: r'gameSessionProvider', dependencies: null, $allTransitiveDependencies: null, isAutoDispose: true, ); - /// One game's metadata: schema version, config, timing, access. + /// One game's live session: the single subscription a game screen needs. + /// + /// Every emission is a COMPLETE session, which is what makes `.value` on this + /// sound. The old shape streamed heterogeneous events and had each derived + /// provider type-test the newest one, so a frame arriving after a roster + /// snapshot silently reverted the roster, and nothing on the socket reported a + /// status change at all. /// - /// A plain read rather than a stream. These fields are fixed at creation and - /// never change, so streaming them would be re-delivering constants; what does - /// change - status and roster - arrives on [gameEvents] instead. + /// One socket serves the whole game. Riverpod's automatic retry covers a failure + /// to establish it; drops after that are handled inside the socket, which + /// reconnects and is answered with the current snapshot, so this stream is never + /// torn down to resync. - GameSummaryProvider call({required String gameId}) => - GameSummaryProvider._(argument: gameId, from: this); + GameSessionProvider call({required String gameId}) => + GameSessionProvider._(argument: gameId, from: this); @override - String toString() => r'gameSummaryProvider'; + String toString() => r'gameSessionProvider'; } -/// Compatibility verdict across the initial summary and live game payloads. +/// The game's status, live. /// -/// Status, seat type, and frame type drive gameplay behavior, so guessing would -/// be unsafe. Metadata-only unknowns such as access remain usable with -/// conservative UI. +/// A selector, not a fetch. Every screen decision that used to read a summary +/// that nothing refetched reads this instead. -@ProviderFor(gameWireCompatibility) -final gameWireCompatibilityProvider = GameWireCompatibilityFamily._(); +@ProviderFor(gameStatus) +final gameStatusProvider = GameStatusFamily._(); -/// Compatibility verdict across the initial summary and live game payloads. +/// The game's status, live. /// -/// Status, seat type, and frame type drive gameplay behavior, so guessing would -/// be unsafe. Metadata-only unknowns such as access remain usable with -/// conservative UI. +/// A selector, not a fetch. Every screen decision that used to read a summary +/// that nothing refetched reads this instead. -final class GameWireCompatibilityProvider - extends - $FunctionalProvider< - GameWireCompatibility, - GameWireCompatibility, - GameWireCompatibility - > - with $Provider { - /// Compatibility verdict across the initial summary and live game payloads. +final class GameStatusProvider + extends $FunctionalProvider + with $Provider { + /// The game's status, live. /// - /// Status, seat type, and frame type drive gameplay behavior, so guessing would - /// be unsafe. Metadata-only unknowns such as access remain usable with - /// conservative UI. - GameWireCompatibilityProvider._({ - required GameWireCompatibilityFamily super.from, + /// A selector, not a fetch. Every screen decision that used to read a summary + /// that nothing refetched reads this instead. + GameStatusProvider._({ + required GameStatusFamily super.from, required String super.argument, }) : super( retry: null, - name: r'gameWireCompatibilityProvider', + name: r'gameStatusProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$gameWireCompatibilityHash(); + String debugGetCreateSourceHash() => _$gameStatusHash(); @override String toString() { - return r'gameWireCompatibilityProvider' + return r'gameStatusProvider' '' '($argument)'; } @$internal @override - $ProviderElement $createElement( - $ProviderPointer pointer, - ) => $ProviderElement(pointer); + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); @override - GameWireCompatibility create(Ref ref) { + GameStatus? create(Ref ref) { final argument = this.argument as String; - return gameWireCompatibility(ref, gameId: argument); + return gameStatus(ref, gameId: argument); } /// {@macro riverpod.override_with_value} - Override overrideWithValue(GameWireCompatibility value) { + Override overrideWithValue(GameStatus? value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } @override bool operator ==(Object other) { - return other is GameWireCompatibilityProvider && other.argument == argument; + return other is GameStatusProvider && other.argument == argument; } @override @@ -614,206 +640,90 @@ final class GameWireCompatibilityProvider } } -String _$gameWireCompatibilityHash() => - r'069f94aaf1278b1812de48934ce55a9606c4f366'; +String _$gameStatusHash() => r'e1bf882010077cb844d003d9f8bfbb120565d922'; -/// Compatibility verdict across the initial summary and live game payloads. +/// The game's status, live. /// -/// Status, seat type, and frame type drive gameplay behavior, so guessing would -/// be unsafe. Metadata-only unknowns such as access remain usable with -/// conservative UI. +/// A selector, not a fetch. Every screen decision that used to read a summary +/// that nothing refetched reads this instead. -final class GameWireCompatibilityFamily extends $Family - with $FunctionalFamilyOverride { - GameWireCompatibilityFamily._() +final class GameStatusFamily extends $Family + with $FunctionalFamilyOverride { + GameStatusFamily._() : super( retry: null, - name: r'gameWireCompatibilityProvider', + name: r'gameStatusProvider', dependencies: null, $allTransitiveDependencies: null, isAutoDispose: true, ); - /// Compatibility verdict across the initial summary and live game payloads. + /// The game's status, live. /// - /// Status, seat type, and frame type drive gameplay behavior, so guessing would - /// be unsafe. Metadata-only unknowns such as access remain usable with - /// conservative UI. + /// A selector, not a fetch. Every screen decision that used to read a summary + /// that nothing refetched reads this instead. - GameWireCompatibilityProvider call({required String gameId}) => - GameWireCompatibilityProvider._(argument: gameId, from: this); + GameStatusProvider call({required String gameId}) => + GameStatusProvider._(argument: gameId, from: this); @override - String toString() => r'gameWireCompatibilityProvider'; + String toString() => r'gameStatusProvider'; } -/// The game's live feed: roster snapshots pre-game, then ordered frames. -/// -/// One socket serves the whole game, so this is the single subscription a game -/// screen needs. Riverpod's automatic retry covers a failure to establish it; -/// drops after that are handled inside the socket, which reconnects and -/// resyncs without tearing down this stream. +/// The seats, live: the roster the server stated with the newest snapshot. -@ProviderFor(gameEvents) -final gameEventsProvider = GameEventsFamily._(); +@ProviderFor(gameSeats) +final gameSeatsProvider = GameSeatsFamily._(); -/// The game's live feed: roster snapshots pre-game, then ordered frames. -/// -/// One socket serves the whole game, so this is the single subscription a game -/// screen needs. Riverpod's automatic retry covers a failure to establish it; -/// drops after that are handled inside the socket, which reconnects and -/// resyncs without tearing down this stream. +/// The seats, live: the roster the server stated with the newest snapshot. -final class GameEventsProvider - extends - $FunctionalProvider< - AsyncValue, - GameSocketEvent, - Stream - > - with $FutureModifier, $StreamProvider { - /// The game's live feed: roster snapshots pre-game, then ordered frames. - /// - /// One socket serves the whole game, so this is the single subscription a game - /// screen needs. Riverpod's automatic retry covers a failure to establish it; - /// drops after that are handled inside the socket, which reconnects and - /// resyncs without tearing down this stream. - GameEventsProvider._({ - required GameEventsFamily super.from, +final class GameSeatsProvider + extends $FunctionalProvider, List, List> + with $Provider> { + /// The seats, live: the roster the server stated with the newest snapshot. + GameSeatsProvider._({ + required GameSeatsFamily super.from, required String super.argument, }) : super( retry: null, - name: r'gameEventsProvider', + name: r'gameSeatsProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$gameEventsHash(); + String debugGetCreateSourceHash() => _$gameSeatsHash(); @override String toString() { - return r'gameEventsProvider' + return r'gameSeatsProvider' '' '($argument)'; } @$internal @override - $StreamProviderElement $createElement( - $ProviderPointer pointer, - ) => $StreamProviderElement(pointer); + $ProviderElement> $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); @override - Stream create(Ref ref) { + List create(Ref ref) { final argument = this.argument as String; - return gameEvents(ref, gameId: argument); - } - - @override - bool operator ==(Object other) { - return other is GameEventsProvider && other.argument == argument; - } - - @override - int get hashCode { - return argument.hashCode; - } -} - -String _$gameEventsHash() => r'7ab8245a9b0ae956a12f6641a4fcb02c4dc94069'; - -/// The game's live feed: roster snapshots pre-game, then ordered frames. -/// -/// One socket serves the whole game, so this is the single subscription a game -/// screen needs. Riverpod's automatic retry covers a failure to establish it; -/// drops after that are handled inside the socket, which reconnects and -/// resyncs without tearing down this stream. - -final class GameEventsFamily extends $Family - with $FunctionalFamilyOverride, String> { - GameEventsFamily._() - : super( - retry: null, - name: r'gameEventsProvider', - dependencies: null, - $allTransitiveDependencies: null, - isAutoDispose: true, - ); - - /// The game's live feed: roster snapshots pre-game, then ordered frames. - /// - /// One socket serves the whole game, so this is the single subscription a game - /// screen needs. Riverpod's automatic retry covers a failure to establish it; - /// drops after that are handled inside the socket, which reconnects and - /// resyncs without tearing down this stream. - - GameEventsProvider call({required String gameId}) => - GameEventsProvider._(argument: gameId, from: this); - - @override - String toString() => r'gameEventsProvider'; -} - -/// The newest roster seen for a game, seeded from the summary. -/// -/// The socket delivers a snapshot on open and on every roster change while the -/// game is in the waiting room, but sends none once play starts - so the -/// summary provides the starting value and later snapshots replace it. - -@ProviderFor(gameRoster) -final gameRosterProvider = GameRosterFamily._(); - -/// The newest roster seen for a game, seeded from the summary. -/// -/// The socket delivers a snapshot on open and on every roster change while the -/// game is in the waiting room, but sends none once play starts - so the -/// summary provides the starting value and later snapshots replace it. - -final class GameRosterProvider - extends $FunctionalProvider, Roster, FutureOr> - with $FutureModifier, $FutureProvider { - /// The newest roster seen for a game, seeded from the summary. - /// - /// The socket delivers a snapshot on open and on every roster change while the - /// game is in the waiting room, but sends none once play starts - so the - /// summary provides the starting value and later snapshots replace it. - GameRosterProvider._({ - required GameRosterFamily super.from, - required String super.argument, - }) : super( - retry: null, - name: r'gameRosterProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$gameRosterHash(); - - @override - String toString() { - return r'gameRosterProvider' - '' - '($argument)'; + return gameSeats(ref, gameId: argument); } - @$internal - @override - $FutureProviderElement $createElement($ProviderPointer pointer) => - $FutureProviderElement(pointer); - - @override - FutureOr create(Ref ref) { - final argument = this.argument as String; - return gameRoster(ref, gameId: argument); + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); } @override bool operator ==(Object other) { - return other is GameRosterProvider && other.argument == argument; + return other is GameSeatsProvider && other.argument == argument; } @override @@ -822,92 +732,105 @@ final class GameRosterProvider } } -String _$gameRosterHash() => r'e1999de717570cb2f0fc784c3152b8d5e8a74954'; +String _$gameSeatsHash() => r'ea6746154fda7c53de3b4a1ce4e437b1595d1b95'; -/// The newest roster seen for a game, seeded from the summary. -/// -/// The socket delivers a snapshot on open and on every roster change while the -/// game is in the waiting room, but sends none once play starts - so the -/// summary provides the starting value and later snapshots replace it. +/// The seats, live: the roster the server stated with the newest snapshot. -final class GameRosterFamily extends $Family - with $FunctionalFamilyOverride, String> { - GameRosterFamily._() +final class GameSeatsFamily extends $Family + with $FunctionalFamilyOverride, String> { + GameSeatsFamily._() : super( retry: null, - name: r'gameRosterProvider', + name: r'gameSeatsProvider', dependencies: null, $allTransitiveDependencies: null, isAutoDispose: true, ); - /// The newest roster seen for a game, seeded from the summary. - /// - /// The socket delivers a snapshot on open and on every roster change while the - /// game is in the waiting room, but sends none once play starts - so the - /// summary provides the starting value and later snapshots replace it. + /// The seats, live: the roster the server stated with the newest snapshot. - GameRosterProvider call({required String gameId}) => - GameRosterProvider._(argument: gameId, from: this); + GameSeatsProvider call({required String gameId}) => + GameSeatsProvider._(argument: gameId, from: this); @override - String toString() => r'gameRosterProvider'; + String toString() => r'gameSeatsProvider'; } -/// The newest frame seen for a game, or null before the first one arrives. +/// Compatibility verdict over the live session. +/// +/// Status, seat type, and frame type drive gameplay behavior, so guessing would +/// be unsafe. Metadata-only unknowns such as access remain usable with +/// conservative UI. One source now, so there is no second copy of the same field +/// to check. -@ProviderFor(gameFrameData) -final gameFrameDataProvider = GameFrameDataFamily._(); +@ProviderFor(gameWireCompatibility) +final gameWireCompatibilityProvider = GameWireCompatibilityFamily._(); -/// The newest frame seen for a game, or null before the first one arrives. +/// Compatibility verdict over the live session. +/// +/// Status, seat type, and frame type drive gameplay behavior, so guessing would +/// be unsafe. Metadata-only unknowns such as access remain usable with +/// conservative UI. One source now, so there is no second copy of the same field +/// to check. -final class GameFrameDataProvider - extends $FunctionalProvider - with $Provider { - /// The newest frame seen for a game, or null before the first one arrives. - GameFrameDataProvider._({ - required GameFrameDataFamily super.from, +final class GameWireCompatibilityProvider + extends + $FunctionalProvider< + GameWireCompatibility, + GameWireCompatibility, + GameWireCompatibility + > + with $Provider { + /// Compatibility verdict over the live session. + /// + /// Status, seat type, and frame type drive gameplay behavior, so guessing would + /// be unsafe. Metadata-only unknowns such as access remain usable with + /// conservative UI. One source now, so there is no second copy of the same field + /// to check. + GameWireCompatibilityProvider._({ + required GameWireCompatibilityFamily super.from, required String super.argument, }) : super( retry: null, - name: r'gameFrameDataProvider', + name: r'gameWireCompatibilityProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$gameFrameDataHash(); + String debugGetCreateSourceHash() => _$gameWireCompatibilityHash(); @override String toString() { - return r'gameFrameDataProvider' + return r'gameWireCompatibilityProvider' '' '($argument)'; } @$internal @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); @override - Frame? create(Ref ref) { + GameWireCompatibility create(Ref ref) { final argument = this.argument as String; - return gameFrameData(ref, gameId: argument); + return gameWireCompatibility(ref, gameId: argument); } /// {@macro riverpod.override_with_value} - Override overrideWithValue(Frame? value) { + Override overrideWithValue(GameWireCompatibility value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } @override bool operator ==(Object other) { - return other is GameFrameDataProvider && other.argument == argument; + return other is GameWireCompatibilityProvider && other.argument == argument; } @override @@ -916,44 +839,55 @@ final class GameFrameDataProvider } } -String _$gameFrameDataHash() => r'0f72c283d3bef104c0f793c72789b7cf40c040f7'; +String _$gameWireCompatibilityHash() => + r'6406981ada9d8417996f056f7ffb88a11244d27b'; -/// The newest frame seen for a game, or null before the first one arrives. +/// Compatibility verdict over the live session. +/// +/// Status, seat type, and frame type drive gameplay behavior, so guessing would +/// be unsafe. Metadata-only unknowns such as access remain usable with +/// conservative UI. One source now, so there is no second copy of the same field +/// to check. -final class GameFrameDataFamily extends $Family - with $FunctionalFamilyOverride { - GameFrameDataFamily._() +final class GameWireCompatibilityFamily extends $Family + with $FunctionalFamilyOverride { + GameWireCompatibilityFamily._() : super( retry: null, - name: r'gameFrameDataProvider', + name: r'gameWireCompatibilityProvider', dependencies: null, $allTransitiveDependencies: null, isAutoDispose: true, ); - /// The newest frame seen for a game, or null before the first one arrives. + /// Compatibility verdict over the live session. + /// + /// Status, seat type, and frame type drive gameplay behavior, so guessing would + /// be unsafe. Metadata-only unknowns such as access remain usable with + /// conservative UI. One source now, so there is no second copy of the same field + /// to check. - GameFrameDataProvider call({required String gameId}) => - GameFrameDataProvider._(argument: gameId, from: this); + GameWireCompatibilityProvider call({required String gameId}) => + GameWireCompatibilityProvider._(argument: gameId, from: this); @override - String toString() => r'gameFrameDataProvider'; + String toString() => r'gameWireCompatibilityProvider'; } /// The game's seats with their identities resolved, plus which one is mine. /// -/// Seats come from the live roster rather than a separate fetch, so this -/// re-derives as players join and leave. Identities come from the persisted -/// player cache, which covers humans and bots alike. +/// Seats come from the live session, so this re-derives as players join and +/// leave. Identities come from the persisted player cache, which covers humans +/// and bots alike. @ProviderFor(gamePlayers) final gamePlayersProvider = GamePlayersFamily._(); /// The game's seats with their identities resolved, plus which one is mine. /// -/// Seats come from the live roster rather than a separate fetch, so this -/// re-derives as players join and leave. Identities come from the persisted -/// player cache, which covers humans and bots alike. +/// Seats come from the live session, so this re-derives as players join and +/// leave. Identities come from the persisted player cache, which covers humans +/// and bots alike. final class GamePlayersProvider extends @@ -965,9 +899,9 @@ final class GamePlayersProvider with $FutureModifier, $FutureProvider { /// The game's seats with their identities resolved, plus which one is mine. /// - /// Seats come from the live roster rather than a separate fetch, so this - /// re-derives as players join and leave. Identities come from the persisted - /// player cache, which covers humans and bots alike. + /// Seats come from the live session, so this re-derives as players join and + /// leave. Identities come from the persisted player cache, which covers humans + /// and bots alike. GamePlayersProvider._({ required GamePlayersFamily super.from, required String super.argument, @@ -1012,13 +946,13 @@ final class GamePlayersProvider } } -String _$gamePlayersHash() => r'c872020092eec0c5abd91a05e7b249eb2304b8e2'; +String _$gamePlayersHash() => r'1dbcfc8c4276b145fa8cfdb2dc02aaa05eb3b366'; /// The game's seats with their identities resolved, plus which one is mine. /// -/// Seats come from the live roster rather than a separate fetch, so this -/// re-derives as players join and leave. Identities come from the persisted -/// player cache, which covers humans and bots alike. +/// Seats come from the live session, so this re-derives as players join and +/// leave. Identities come from the persisted player cache, which covers humans +/// and bots alike. final class GamePlayersFamily extends $Family with $FunctionalFamilyOverride, String> { @@ -1033,9 +967,9 @@ final class GamePlayersFamily extends $Family /// The game's seats with their identities resolved, plus which one is mine. /// - /// Seats come from the live roster rather than a separate fetch, so this - /// re-derives as players join and leave. Identities come from the persisted - /// player cache, which covers humans and bots alike. + /// Seats come from the live session, so this re-derives as players join and + /// leave. Identities come from the persisted player cache, which covers humans + /// and bots alike. GamePlayersProvider call({required String gameId}) => GamePlayersProvider._(argument: gameId, from: this); @@ -1044,29 +978,32 @@ final class GamePlayersFamily extends $Family String toString() => r'gamePlayersProvider'; } -/// Joins a game by invite code, returning the game's id and its roster. +/// Joins a game by invite code, answering with the seated session. /// -/// Auto-disposes once the join screen navigates away. The screen uses -/// [ref.listen] to react to the result rather than watching the value -/// directly, so navigation happens exactly once. +/// The session carries the game's id, which is the only place a by-code caller +/// learns it. Auto-disposes once the join screen navigates away. The screen uses +/// [ref.listen] to react to the result rather than watching the value directly, +/// so navigation happens exactly once. @ProviderFor(joinByCode) final joinByCodeProvider = JoinByCodeFamily._(); -/// Joins a game by invite code, returning the game's id and its roster. +/// Joins a game by invite code, answering with the seated session. /// -/// Auto-disposes once the join screen navigates away. The screen uses -/// [ref.listen] to react to the result rather than watching the value -/// directly, so navigation happens exactly once. +/// The session carries the game's id, which is the only place a by-code caller +/// learns it. Auto-disposes once the join screen navigates away. The screen uses +/// [ref.listen] to react to the result rather than watching the value directly, +/// so navigation happens exactly once. final class JoinByCodeProvider - extends $FunctionalProvider, Joined, FutureOr> - with $FutureModifier, $FutureProvider { - /// Joins a game by invite code, returning the game's id and its roster. + extends $FunctionalProvider, Session, FutureOr> + with $FutureModifier, $FutureProvider { + /// Joins a game by invite code, answering with the seated session. /// - /// Auto-disposes once the join screen navigates away. The screen uses - /// [ref.listen] to react to the result rather than watching the value - /// directly, so navigation happens exactly once. + /// The session carries the game's id, which is the only place a by-code caller + /// learns it. Auto-disposes once the join screen navigates away. The screen uses + /// [ref.listen] to react to the result rather than watching the value directly, + /// so navigation happens exactly once. JoinByCodeProvider._({ required JoinByCodeFamily super.from, required String super.argument, @@ -1090,11 +1027,11 @@ final class JoinByCodeProvider @$internal @override - $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement $createElement($ProviderPointer pointer) => $FutureProviderElement(pointer); @override - FutureOr create(Ref ref) { + FutureOr create(Ref ref) { final argument = this.argument as String; return joinByCode(ref, code: argument); } @@ -1110,16 +1047,17 @@ final class JoinByCodeProvider } } -String _$joinByCodeHash() => r'9363d23309783281be2e4aadf28d50dcf5640d7e'; +String _$joinByCodeHash() => r'4f697b9f5509bdce2c19ba31d656622a3233c319'; -/// Joins a game by invite code, returning the game's id and its roster. +/// Joins a game by invite code, answering with the seated session. /// -/// Auto-disposes once the join screen navigates away. The screen uses -/// [ref.listen] to react to the result rather than watching the value -/// directly, so navigation happens exactly once. +/// The session carries the game's id, which is the only place a by-code caller +/// learns it. Auto-disposes once the join screen navigates away. The screen uses +/// [ref.listen] to react to the result rather than watching the value directly, +/// so navigation happens exactly once. final class JoinByCodeFamily extends $Family - with $FunctionalFamilyOverride, String> { + with $FunctionalFamilyOverride, String> { JoinByCodeFamily._() : super( retry: null, @@ -1129,11 +1067,12 @@ final class JoinByCodeFamily extends $Family isAutoDispose: true, ); - /// Joins a game by invite code, returning the game's id and its roster. + /// Joins a game by invite code, answering with the seated session. /// - /// Auto-disposes once the join screen navigates away. The screen uses - /// [ref.listen] to react to the result rather than watching the value - /// directly, so navigation happens exactly once. + /// The session carries the game's id, which is the only place a by-code caller + /// learns it. Auto-disposes once the join screen navigates away. The screen uses + /// [ref.listen] to react to the result rather than watching the value directly, + /// so navigation happens exactly once. JoinByCodeProvider call({required String code}) => JoinByCodeProvider._(argument: code, from: this); @@ -1144,29 +1083,27 @@ final class JoinByCodeFamily extends $Family /// A finished game's outcomes. /// -/// Immutable once written, and already on the summary - so this is a -/// projection rather than a fetch. Empty while the game is still running. +/// A projection of the live session rather than a fetch: they ride the finishing +/// frame, and a cold open of a finished game gets them on whatever its newest +/// frame is. Empty while the game is still running. @ProviderFor(gameOutcomes) final gameOutcomesProvider = GameOutcomesFamily._(); /// A finished game's outcomes. /// -/// Immutable once written, and already on the summary - so this is a -/// projection rather than a fetch. Empty while the game is still running. +/// A projection of the live session rather than a fetch: they ride the finishing +/// frame, and a cold open of a finished game gets them on whatever its newest +/// frame is. Empty while the game is still running. final class GameOutcomesProvider - extends - $FunctionalProvider< - AsyncValue>, - List, - FutureOr> - > - with $FutureModifier>, $FutureProvider> { + extends $FunctionalProvider, List, List> + with $Provider> { /// A finished game's outcomes. /// - /// Immutable once written, and already on the summary - so this is a - /// projection rather than a fetch. Empty while the game is still running. + /// A projection of the live session rather than a fetch: they ride the finishing + /// frame, and a cold open of a finished game gets them on whatever its newest + /// frame is. Empty while the game is still running. GameOutcomesProvider._({ required GameOutcomesFamily super.from, required String super.argument, @@ -1190,16 +1127,23 @@ final class GameOutcomesProvider @$internal @override - $FutureProviderElement> $createElement( - $ProviderPointer pointer, - ) => $FutureProviderElement(pointer); + $ProviderElement> $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); @override - FutureOr> create(Ref ref) { + List create(Ref ref) { final argument = this.argument as String; return gameOutcomes(ref, gameId: argument); } + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } + @override bool operator ==(Object other) { return other is GameOutcomesProvider && other.argument == argument; @@ -1211,15 +1155,16 @@ final class GameOutcomesProvider } } -String _$gameOutcomesHash() => r'd572692644faa8b347067d3f25ae8cc9077854fe'; +String _$gameOutcomesHash() => r'bdba89f6c98b2002ac27bb1ad7440ee6b2a5eb9d'; /// A finished game's outcomes. /// -/// Immutable once written, and already on the summary - so this is a -/// projection rather than a fetch. Empty while the game is still running. +/// A projection of the live session rather than a fetch: they ride the finishing +/// frame, and a cold open of a finished game gets them on whatever its newest +/// frame is. Empty while the game is still running. final class GameOutcomesFamily extends $Family - with $FunctionalFamilyOverride>, String> { + with $FunctionalFamilyOverride, String> { GameOutcomesFamily._() : super( retry: null, @@ -1231,8 +1176,9 @@ final class GameOutcomesFamily extends $Family /// A finished game's outcomes. /// - /// Immutable once written, and already on the summary - so this is a - /// projection rather than a fetch. Empty while the game is still running. + /// A projection of the live session rather than a fetch: they ride the finishing + /// frame, and a cold open of a finished game gets them on whatever its newest + /// frame is. Empty while the game is still running. GameOutcomesProvider call({required String gameId}) => GameOutcomesProvider._(argument: gameId, from: this); diff --git a/lib/features/game/providers/replay_controller.dart b/lib/features/game/providers/replay_controller.dart index a90b607..d655f4f 100644 --- a/lib/features/game/providers/replay_controller.dart +++ b/lib/features/game/providers/replay_controller.dart @@ -1,6 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.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_transition.dart'; import 'package:eigen_flutter/core/game/timing_context.dart'; import 'package:eigen_api/eigen_api.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; @@ -81,3 +82,24 @@ GameFrame? replayFrameAt( timing: TimingContext(clock: ref.watch(serverClockProvider)), ); } + +/// The step into the frame at [index], or null on the first frame. +/// +/// Replay animates the same way live play does: it is the transition that +/// carries meaning, so stepping forward hands the game the pair it needs rather +/// than leaving it to remember the previous position. Null at index 0, where +/// there is no predecessor, exactly as a cold load is null live. +@riverpod +GameTransition? replayTransitionAt( + Ref ref, { + required String gameId, + required int index, +}) { + if (index <= 0) return null; + final from = ref.watch( + replayFrameAtProvider(gameId: gameId, index: index - 1), + ); + final to = ref.watch(replayFrameAtProvider(gameId: gameId, index: index)); + if (from == null || to == null) return null; + return GameTransition(from: from, to: to); +} diff --git a/lib/features/game/providers/replay_controller.g.dart b/lib/features/game/providers/replay_controller.g.dart index 04cfbb1..8f319c7 100644 --- a/lib/features/game/providers/replay_controller.g.dart +++ b/lib/features/game/providers/replay_controller.g.dart @@ -413,3 +413,129 @@ final class ReplayFrameAtFamily extends $Family @override String toString() => r'replayFrameAtProvider'; } + +/// The step into the frame at [index], or null on the first frame. +/// +/// Replay animates the same way live play does: it is the transition that +/// carries meaning, so stepping forward hands the game the pair it needs rather +/// than leaving it to remember the previous position. Null at index 0, where +/// there is no predecessor, exactly as a cold load is null live. + +@ProviderFor(replayTransitionAt) +final replayTransitionAtProvider = ReplayTransitionAtFamily._(); + +/// The step into the frame at [index], or null on the first frame. +/// +/// Replay animates the same way live play does: it is the transition that +/// carries meaning, so stepping forward hands the game the pair it needs rather +/// than leaving it to remember the previous position. Null at index 0, where +/// there is no predecessor, exactly as a cold load is null live. + +final class ReplayTransitionAtProvider + extends + $FunctionalProvider + with $Provider { + /// The step into the frame at [index], or null on the first frame. + /// + /// Replay animates the same way live play does: it is the transition that + /// carries meaning, so stepping forward hands the game the pair it needs rather + /// than leaving it to remember the previous position. Null at index 0, where + /// there is no predecessor, exactly as a cold load is null live. + ReplayTransitionAtProvider._({ + required ReplayTransitionAtFamily super.from, + required ({String gameId, int index}) super.argument, + }) : super( + retry: null, + name: r'replayTransitionAtProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$replayTransitionAtHash(); + + @override + String toString() { + return r'replayTransitionAtProvider' + '' + '$argument'; + } + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + GameTransition? create(Ref ref) { + final argument = this.argument as ({String gameId, int index}); + return replayTransitionAt( + ref, + gameId: argument.gameId, + index: argument.index, + ); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GameTransition? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is ReplayTransitionAtProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$replayTransitionAtHash() => + r'b6478ee1ac4465a2ccd7a1789fc1bfffe840fa0f'; + +/// The step into the frame at [index], or null on the first frame. +/// +/// Replay animates the same way live play does: it is the transition that +/// carries meaning, so stepping forward hands the game the pair it needs rather +/// than leaving it to remember the previous position. Null at index 0, where +/// there is no predecessor, exactly as a cold load is null live. + +final class ReplayTransitionAtFamily extends $Family + with + $FunctionalFamilyOverride< + GameTransition?, + ({String gameId, int index}) + > { + ReplayTransitionAtFamily._() + : super( + retry: null, + name: r'replayTransitionAtProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// The step into the frame at [index], or null on the first frame. + /// + /// Replay animates the same way live play does: it is the transition that + /// carries meaning, so stepping forward hands the game the pair it needs rather + /// than leaving it to remember the previous position. Null at index 0, where + /// there is no predecessor, exactly as a cold load is null live. + + ReplayTransitionAtProvider call({ + required String gameId, + required int index, + }) => ReplayTransitionAtProvider._( + argument: (gameId: gameId, index: index), + from: this, + ); + + @override + String toString() => r'replayTransitionAtProvider'; +} diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index 943082a..632616d 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -11,6 +11,7 @@ import 'package:eigen_flutter/features/game/presentation/widgets/turn_countdown. import 'package:eigen_flutter/features/game/providers/game_providers.dart'; import 'package:eigen_flutter/features/game/utils/game_timing.dart'; import 'package:eigen_flutter/features/profile/providers/profile_providers.dart'; +import 'package:eigen_flutter/shared/providers/player_providers.dart'; import 'package:eigen_flutter/shared/widgets/overlapping_avatars.dart'; import 'package:eigen_api/eigen_api.dart'; @@ -417,14 +418,22 @@ class _GameCard extends ConsumerWidget { ? colorScheme.primary : colorScheme.onSurfaceVariant; - // Resolve player identities from the cached game players provider. - final gamePlayersAsync = ref.watch(gamePlayersProvider(gameId: game.id)); - final gamePlayers = - gamePlayersAsync.value?.players.values.toList() ?? const []; - final avatars = [ - for (final gp in gamePlayers) - (avatarUrl: gp.info.avatarUrl, isBot: gp.type == SeatTypeEnum.bot), - ]; + // Identities come from the index row's own roster and the player cache, the + // same way the lobby card resolves them. A list must never reach for a + // game's SESSION: that is a live subscription, and on a screen of cards it + // would open one socket per card for nothing but an avatar. + final avatars = []; + for (final seat in game.participants) { + final playerId = seat.userId ?? seat.botId; + if (playerId == null) continue; + final info = ref.watch(playerInfoCacheProvider(id: playerId)); + if (info.value case final value?) { + avatars.add(( + avatarUrl: value.avatarUrl, + isBot: seat.type == SeatTypeEnum.bot, + )); + } + } return Card( margin: const EdgeInsets.only(bottom: 12), diff --git a/pubspec.yaml b/pubspec.yaml index b8d8c96..33511d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,7 +64,7 @@ dependencies: # Pre-1.0 that resolves to `>=0.1.0 <0.2.0`: the engine announces breakage in # the MINOR position, so 0.2.0 is the release that needs a deliberate bump # here. See https://eigeninteractive.com/docs/reference/compatibility. - eigen_api: ^0.2.0 + eigen_api: ^0.3.0 dio: ^5.10.0 dio_smart_retry: ^7.0.1 firebase_auth: ^6.5.6 diff --git a/test/features/game/data/game_repository_events_test.dart b/test/features/game/data/game_repository_events_test.dart deleted file mode 100644 index 2bdec24..0000000 --- a/test/features/game/data/game_repository_events_test.dart +++ /dev/null @@ -1,275 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:checks/checks.dart'; -import 'package:dio/dio.dart'; -import 'package:eigen_api/eigen_api.dart'; -import 'package:eigen_flutter/core/api/game_socket.dart'; -import 'package:eigen_flutter/features/game/data/game_repository.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// One frame's wire JSON. Only the fields the pipeline reasons about matter. -Map _frameJson(int version) => { - 'type': 'frame', - 'version': version, - 'data': {'v': version}, - 'pendingPlayers': [0], - 'deadline': null, - 'playerTimes': null, -}; - -Frame _frame(int version) => Frame.fromJson(_frameJson(version)); - -/// Serves `GET .../frames?from=&to=` from an in-memory version range, so the -/// repository's gap recovery runs for real against a stubbed wire. -class _FramesAdapter implements HttpClientAdapter { - _FramesAdapter(this.available); - - /// Versions the server would return. - final List available; - - /// Every `[from, to]` the repository asked for, in order. - final requests = <({int from, int? to})>[]; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - final from = int.parse(options.queryParameters['from'].toString()); - final rawTo = options.queryParameters['to']; - final to = rawTo == null ? null : int.parse(rawTo.toString()); - requests.add((from: from, to: to)); - - final frames = available - .where((v) => v >= from && (to == null || v <= to)) - .map(_frameJson) - .toList(); - return ResponseBody.fromString( - jsonEncode({'frames': frames}), - 200, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType], - }, - ); - } - - @override - void close({bool force = false}) {} -} - -/// A socket the test drives by hand. Dart's implicit interfaces make this a -/// stub without needing an abstraction in the production code. -class _ScriptedSocket implements GameSocket { - final _controller = StreamController(); - - void emit(GameSocketEvent event) => _controller.add(event); - - @override - Stream connect(String gameId) => _controller.stream; - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -({GameRepository repo, _ScriptedSocket socket, _FramesAdapter adapter}) _build({ - List available = const [], -}) { - final adapter = _FramesAdapter(available); - final dio = Dio(BaseOptions(baseUrl: 'https://engine.test')) - ..httpClientAdapter = adapter; - final socket = _ScriptedSocket(); - return ( - repo: GameRepository(GamesApi(dio), BotsApi(dio), PlayersApi(dio), socket), - socket: socket, - adapter: adapter, - ); -} - -/// Versions of the frames emitted on [stream], in order. -Future> _versions( - Stream stream, - Future Function() drive, -) async { - final seen = []; - final sub = stream.listen((e) { - if (e is GameSocketFrame) seen.add(e.frame.version); - }); - await drive(); - // Drain the asynchronous stream/Dio pipeline without depending on wall-clock - // timing, which becomes flaky when the test runner executes many suites. - await pumpEventQueue(); - await sub.cancel(); - return seen; -} - -void main() { - test('emits the first frame as-is, without replaying history', () async { - // A cold load snaps to the present: joining at v7 must not replay v0-v6. - final t = _build(available: [0, 1, 2, 3, 4, 5, 6, 7]); - - final seen = await _versions(t.repo.events('g'), () async { - t.socket.emit(GameSocketFrame(_frame(7))); - }); - - check(seen).deepEquals([7]); - check(t.adapter.requests).isEmpty(); - }); - - test('passes consecutive frames straight through', () async { - final t = _build(); - - final seen = await _versions(t.repo.events('g'), () async { - for (var v = 3; v <= 5; v++) { - t.socket.emit(GameSocketFrame(_frame(v))); - } - }); - - check(seen).deepEquals([3, 4, 5]); - check(t.adapter.requests).isEmpty(); - }); - - test('fills a gap in order before the frame that revealed it', () async { - // Every transition must be seen so the game can animate through each one. - final t = _build(available: [1, 2, 3]); - - final seen = await _versions(t.repo.events('g'), () async { - t.socket.emit(GameSocketFrame(_frame(0))); - t.socket.emit(GameSocketFrame(_frame(4))); - }); - - check(seen).deepEquals([0, 1, 2, 3, 4]); - check(t.adapter.requests).deepEquals([(from: 1, to: 3)]); - }); - - test('drops duplicate and stale frames', () async { - // The same frame legitimately arrives twice: over the socket and on the - // action response. Whichever loses the race must be discarded silently. - final t = _build(); - - final seen = await _versions(t.repo.events('g'), () async { - for (final v in [4, 5, 5, 4, 3, 6]) { - t.socket.emit(GameSocketFrame(_frame(v))); - } - }); - - check(seen).deepEquals([4, 5, 6]); - }); - - test('an injected frame renders without waiting for the socket', () async { - // The socket-less paths: a freshly created solo game, and a move made - // while the socket is mid-reconnect. - final t = _build(); - final injected = StreamController(); - - final seen = await _versions( - t.repo.events('g', inject: injected.stream), - () async { - injected.add(_frame(0)); - await Future.delayed(Duration.zero); - injected.add(_frame(1)); - }, - ); - - check(seen).deepEquals([0, 1]); - check(t.adapter.requests).isEmpty(); - await injected.close(); - }); - - test('the socket copy of an injected frame is dropped', () async { - final t = _build(); - final injected = StreamController(); - - final seen = await _versions( - t.repo.events('g', inject: injected.stream), - () async { - injected.add(_frame(2)); - await Future.delayed(Duration.zero); - t.socket.emit(GameSocketFrame(_frame(2))); - }, - ); - - check(seen).deepEquals([2]); - await injected.close(); - }); - - test('catches up to the version reported on reconnect', () async { - final t = _build(available: [3, 4, 5]); - - final seen = await _versions(t.repo.events('g'), () async { - t.socket.emit(GameSocketFrame(_frame(2))); - await Future.delayed(const Duration(milliseconds: 20)); - t.socket.emit(const GameSocketSync(5)); - }); - - check(seen).deepEquals([2, 3, 4, 5]); - check(t.adapter.requests).deepEquals([(from: 3, to: 5)]); - }); - - test('fetches nothing when the reconnect missed nothing', () async { - // The common reconnect on a flaky connection. Without the server stating - // its version this would be an unavoidable no-op round trip every time. - final t = _build(available: [0, 1, 2]); - - final seen = await _versions(t.repo.events('g'), () async { - t.socket.emit(GameSocketFrame(_frame(2))); - await Future.delayed(const Duration(milliseconds: 20)); - t.socket.emit(const GameSocketSync(2)); - }); - - check(seen).deepEquals([2]); - check(t.adapter.requests).isEmpty(); - }); - - test('a cold mid-game open loads only the current frame', () async { - // Joining a game already in progress must show the present position, not - // replay it from v0 - and must show something at all, rather than waiting - // for the next move. - final t = _build(available: [0, 1, 2, 3, 4]); - - final seen = await _versions(t.repo.events('g'), () async { - t.socket.emit(const GameSocketSync(4)); - }); - - check(seen).deepEquals([4]); - check(t.adapter.requests).deepEquals([(from: 4, to: 4)]); - }); - - test('connecting alone reconciles nothing', () async { - // Pre-game there is no version to reconcile against; the roster snapshot - // that follows carries the state instead. - final t = _build(available: [0, 1, 2]); - - final seen = await _versions(t.repo.events('g'), () async { - t.socket.emit(const GameSocketConnected()); - }); - - check(seen).isEmpty(); - check(t.adapter.requests).isEmpty(); - }); - - test('passes roster snapshots through unversioned', () async { - final t = _build(); - final rosters = []; - - final stream = t.repo.events('g'); - final sub = stream.listen((e) { - if (e is GameSocketRoster) rosters.add(e.roster.status.value); - }); - t.socket.emit( - GameSocketRoster( - Roster.fromJson({ - 'type': 'roster', - 'status': 'ready', - 'players': [], - }), - ), - ); - await Future.delayed(Duration.zero); - await sub.cancel(); - - check(rosters).deepEquals(['ready']); - }); -} diff --git a/test/features/game/data/game_repository_session_test.dart b/test/features/game/data/game_repository_session_test.dart new file mode 100644 index 0000000..b08c702 --- /dev/null +++ b/test/features/game/data/game_repository_session_test.dart @@ -0,0 +1,279 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:checks/checks.dart'; +import 'package:dio/dio.dart'; +import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/api/game_socket.dart'; +import 'package:eigen_flutter/core/game/game_session.dart'; +import 'package:eigen_flutter/features/game/data/game_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// One frame's wire JSON. Only the fields the pipeline reasons about matter. +Map _frameJson(int version) => { + 'type': 'frame', + 'version': version, + 'data': {'v': version}, + 'pendingPlayers': [0], + 'deadline': null, + 'playerTimes': null, +}; + +/// One snapshot's wire JSON: the immutable header plus what moves. +Map _sessionJson({ + required int seq, + required String status, + int? version, + bool frame = true, +}) => { + 'type': 'session', + 'seq': seq, + 'gameId': 'g', + 'shortCode': 'ABC123', + 'access': 'public', + 'schemaVersion': 1, + 'config': {}, + 'turnSeconds': null, + 'budgetSeconds': null, + 'incrementSeconds': null, + 'rated': false, + 'ratingPool': null, + 'minPlayers': 2, + 'maxPlayers': 2, + 'createdBy': 'user-a', + 'status': status, + 'players': >[], + 'version': version, + 'frame': version == null || !frame ? null : _frameJson(version), +}; + +Session _session({ + required int seq, + String status = 'active', + int? version, + bool frame = true, +}) => Session.fromJson( + _sessionJson(seq: seq, status: status, version: version, frame: frame), +); + +/// Serves `GET .../frames?from=&to=` from an in-memory version range, so the +/// repository's gap recovery runs for real against a stubbed wire. +class _FramesAdapter implements HttpClientAdapter { + _FramesAdapter(this.available); + + /// Versions the server would return. + final List available; + + /// Every `[from, to]` the repository asked for, in order. + final requests = <({int from, int? to})>[]; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + final from = int.parse(options.queryParameters['from'].toString()); + final rawTo = options.queryParameters['to']; + final to = rawTo == null ? null : int.parse(rawTo.toString()); + requests.add((from: from, to: to)); + + final frames = available + .where((v) => v >= from && (to == null || v <= to)) + .map(_frameJson) + .toList(); + return ResponseBody.fromString( + jsonEncode({'frames': frames}), + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +/// A socket the test drives by hand. Dart's implicit interfaces make this a +/// stub without needing an abstraction in the production code. +class _ScriptedSocket implements GameSocket { + final _controller = StreamController(); + + void emit(GameSocketEvent event) => _controller.add(event); + + @override + Stream connect(String gameId) => _controller.stream; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +({GameRepository repo, _ScriptedSocket socket, _FramesAdapter adapter}) _build({ + List available = const [], +}) { + final adapter = _FramesAdapter(available); + final dio = Dio(BaseOptions(baseUrl: 'https://engine.test')) + ..httpClientAdapter = adapter; + final socket = _ScriptedSocket(); + return ( + repo: GameRepository(GamesApi(dio), BotsApi(dio), PlayersApi(dio), socket), + socket: socket, + adapter: adapter, + ); +} + +/// Everything the stream emitted, in order. +Future> _emitted( + Stream stream, + Future Function() drive, +) async { + final seen = []; + final sub = stream.listen(seen.add); + await drive(); + // Drain the asynchronous stream/Dio pipeline without depending on wall-clock + // timing, which becomes flaky when the test runner executes many suites. + await pumpEventQueue(); + await sub.cancel(); + return seen; +} + +void main() { + test('emits the first snapshot as-is, without replaying history', () async { + // A cold load snaps to the present: joining at v7 must not replay v0-v6. + final t = _build(available: [0, 1, 2, 3, 4, 5, 6, 7]); + + final seen = await _emitted(t.repo.sessions('g'), () async { + t.socket.emit(_session(seq: 8, version: 7)); + }); + + check(seen.map((s) => s.version)).deepEquals([7]); + check(t.adapter.requests).isEmpty(); + }); + + test('passes consecutive snapshots straight through', () async { + final t = _build(); + + final seen = await _emitted(t.repo.sessions('g'), () async { + for (var v = 3; v <= 5; v++) { + t.socket.emit(_session(seq: v, version: v)); + } + }); + + check(seen.map((s) => s.version)).deepEquals([3, 4, 5]); + check(t.adapter.requests).isEmpty(); + }); + + test('fills a gap in order before the snapshot that revealed it', () async { + // Every transition must be seen so the game can animate through each one. + final t = _build(available: [1, 2, 3]); + + final seen = await _emitted(t.repo.sessions('g'), () async { + t.socket.emit(_session(seq: 1, version: 0)); + t.socket.emit(_session(seq: 5, version: 4)); + }); + + check(seen.map((s) => s.version)).deepEquals([0, 1, 2, 3, 4]); + check(t.adapter.requests).deepEquals([(from: 1, to: 3)]); + }); + + test('holds the envelope still while a gap plays out', () async { + // The rule that stops an animation rendering a finished game's banner over + // mid-game moves: only the real snapshot may move the status. + final t = _build(available: [1, 2]); + + final seen = await _emitted(t.repo.sessions('g'), () async { + t.socket.emit(_session(seq: 1, status: 'active', version: 0)); + t.socket.emit(_session(seq: 4, status: 'finished', version: 3)); + }); + + check(seen.map((s) => s.version)).deepEquals([0, 1, 2, 3]); + check( + seen.map((s) => s.status.name), + ).deepEquals(['active', 'active', 'active', 'finished']); + // `seq` holds still too, so a snapshot racing the animation is still + // ordered against the last one the server actually stated. + check(seen.map((s) => s.seq)).deepEquals([1, 1, 1, 4]); + }); + + test('carries the frame each snapshot replaced, for animation', () async { + final t = _build(); + + final seen = await _emitted(t.repo.sessions('g'), () async { + t.socket.emit(_session(seq: 1, version: 0)); + t.socket.emit(_session(seq: 2, version: 1)); + }); + + check(seen[0].previousFrame).isNull(); + check(seen[1].previousFrame?.version).equals(0); + check(seen[1].frame?.version).equals(1); + }); + + test('discards a snapshot whose seq it already holds', () async { + // The same session legitimately arrives twice: over the socket and on the + // command response. Whichever loses the race must be dropped silently, and + // a reconnect that missed nothing must not rebuild anything. + final t = _build(); + + final seen = await _emitted(t.repo.sessions('g'), () async { + for (final seq in [4, 5, 5, 4, 3, 6]) { + t.socket.emit(_session(seq: seq, version: seq)); + } + }); + + check(seen.map((s) => s.seq)).deepEquals([4, 5, 6]); + }); + + test('accepts a terminal snapshot whatever its seq', () async { + // The abort teardown drops the storage `seq` lived in, so a re-initialised + // object legitimately reports a lower one. A terminal status is absorbing, + // so it needs no ordering. + final t = _build(); + + final seen = await _emitted(t.repo.sessions('g'), () async { + t.socket.emit(_session(seq: 9, version: 3)); + t.socket.emit(_session(seq: 0, status: 'aborted', frame: false)); + }); + + check(seen.map((s) => s.status.name)).deepEquals(['active', 'aborted']); + }); + + test('an injected session renders without waiting for the socket', () async { + // The socket-less paths: a freshly created solo game, and a move made + // while the socket is mid-reconnect. + final t = _build(); + final injected = StreamController(); + + final seen = await _emitted( + t.repo.sessions('g', inject: injected.stream), + () async { + injected.add(_session(seq: 1, version: 0)); + await Future.delayed(Duration.zero); + injected.add(_session(seq: 2, version: 1)); + }, + ); + + check(seen.map((s) => s.version)).deepEquals([0, 1]); + check(t.adapter.requests).isEmpty(); + }); + + test('a lobby snapshot has no version and no frame', () async { + final t = _build(); + + final seen = await _emitted(t.repo.sessions('g'), () async { + t.socket.emit(_session(seq: 1, status: 'waiting', frame: false)); + t.socket.emit(_session(seq: 2, status: 'ready', frame: false)); + t.socket.emit(_session(seq: 3, version: 0)); + }); + + check( + seen.map((s) => s.status.name), + ).deepEquals(['waiting', 'ready', 'active']); + check(seen[0].version).isNull(); + check(seen[0].frame).isNull(); + check(seen[2].version).equals(0); + // No range fetch: the first version a client sees needs no predecessor. + check(t.adapter.requests).isEmpty(); + }); +} diff --git a/test/features/game/presentation/waiting_room_test.dart b/test/features/game/presentation/waiting_room_test.dart new file mode 100644 index 0000000..2106ce9 --- /dev/null +++ b/test/features/game/presentation/waiting_room_test.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:eigen_api/eigen_api.dart'; +import 'package:eigen_flutter/core/config/app_config.dart'; +import 'package:eigen_flutter/core/game/game_session.dart'; +import 'package:eigen_flutter/features/auth/data/models/auth_user.dart'; +import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; +import 'package:eigen_flutter/features/game/presentation/screens/game_screen.dart'; +import 'package:eigen_flutter/features/game/providers/game_providers.dart'; +import 'package:eigen_flutter/shared/providers/player_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../helpers/fakes.dart'; + +/// The regression these cover: a creator sitting in the waiting room while the +/// second player joins used to never see Start, because the screen read its +/// status from a one-shot HTTP summary that nothing re-issued. Both assertions +/// here are that the screen follows the SESSION, so neither may touch a summary. + +const _gameId = '11111111-2222-3333-4444-555555555555'; +const _creator = 'user-a'; +const _joiner = 'user-b'; + +Session _session({ + required int seq, + required String status, + required int seats, +}) => Session.fromJson({ + 'type': 'session', + 'seq': seq, + 'gameId': _gameId, + 'shortCode': 'ABC123', + 'access': 'private', + 'schemaVersion': 1, + 'config': {}, + 'turnSeconds': null, + 'budgetSeconds': null, + 'incrementSeconds': null, + 'rated': false, + 'ratingPool': null, + 'minPlayers': 2, + 'maxPlayers': 2, + 'createdBy': _creator, + 'status': status, + 'players': [ + for (var i = 0; i < seats; i++) + { + 'playerIndex': i, + 'userId': i == 0 ? _creator : _joiner, + 'botId': null, + 'type': 'human', + }, + ], + 'version': null, + 'frame': null, +}); + +Player _player(String id) => Player( + id: id, + username: id, + displayName: id, + avatarUrl: null, + isAnonymous: false, +); + +/// Serves one identity without a network or a persisted cache. The seat +/// identities have to resolve for the roster to render, and what is under test +/// is the status wiring, not identity. +class _FakePlayerCache extends PlayerInfoCache { + @override + Future build({required String id}) async => _player(id); +} + +Future _pump( + WidgetTester tester, + Stream sessions, { + required String viewer, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentGameModuleProvider.overrideWithValue(const SampleModule()), + appConfigProvider.overrideWithValue( + const AppConfig( + branding: Branding(appName: 'Test'), + engine: EngineConfig( + apiBaseUrl: 'https://example.test', + googleWebClientId: 'client', + firebaseVapidKey: 'vapid', + ), + ), + ), + currentUserProvider.overrideWithValue( + AuthUser(id: viewer, isAnonymous: false), + ), + currentUserIdProvider.overrideWithValue(viewer), + gameSessionProvider(gameId: _gameId).overrideWith((ref) => sessions), + playerInfoCacheProvider( + id: _creator, + ).overrideWith(_FakePlayerCache.new), + playerInfoCacheProvider(id: _joiner).overrideWith(_FakePlayerCache.new), + ], + child: const MaterialApp(home: GameScreen(gameId: _gameId)), + ), + ); + // Not pumpAndSettle: with no session yet the screen shows a progress + // indicator, which animates forever and never settles. The caller has already + // queued the first snapshot, so a couple of frames deliver it and resolve the + // seat identities behind it. + await _flush(tester); +} + +/// Let the stream deliver and the identity futures resolve, then settle. +Future _flush(WidgetTester tester) async { + await tester.pump(); + await tester.pump(); + await tester.pumpAndSettle(); +} + +GameSession _waiting({ + required int seq, + required String status, + required int seats, +}) => GameSession( + snapshot: _session(seq: seq, status: status, seats: seats), + frame: null, +); + +void main() { + testWidgets('offers Start to the creator the moment the roster fills', ( + tester, + ) async { + final sessions = StreamController(); + addTearDown(sessions.close); + + // Seated alone: waiting, and there is nothing to start. + sessions.add(_waiting(seq: 1, status: 'waiting', seats: 1)); + await _pump(tester, sessions.stream, viewer: _creator); + expect(find.text('Waiting for players...'), findsOne); + expect(find.text('Start Game'), findsNothing); + + // The joiner takes the second seat. No refetch, no navigation, no pull: + // the snapshot says ready and the button is there. + sessions.add(_waiting(seq: 2, status: 'ready', seats: 2)); + await _flush(tester); + expect(find.text('All players ready!'), findsOne); + expect(find.text('Start Game'), findsOne); + }); + + testWidgets('never offers Start to a player who is not the creator', ( + tester, + ) async { + final sessions = StreamController(); + addTearDown(sessions.close); + + sessions.add(_waiting(seq: 2, status: 'ready', seats: 2)); + await _pump(tester, sessions.stream, viewer: _joiner); + expect(find.text('All players ready!'), findsOne); + expect(find.text('Start Game'), findsNothing); + expect(find.text('Leave Game'), findsOne); + }); +} diff --git a/test/features/game/providers/game_wire_compatibility_test.dart b/test/features/game/providers/game_wire_compatibility_test.dart index cd1d6af..fb7267a 100644 --- a/test/features/game/providers/game_wire_compatibility_test.dart +++ b/test/features/game/providers/game_wire_compatibility_test.dart @@ -8,8 +8,8 @@ void main() { test('known gameplay enums remain compatible', () { final compatibility = evaluateGameWireCompatibility( - summaryStatus: GameStatus.active, - summarySeats: [seat(SeatTypeEnum.human)], + status: GameStatus.active, + seats: [seat(SeatTypeEnum.human)], ); expect(compatibility.requiresUpdate, isFalse); @@ -17,7 +17,7 @@ void main() { test('unknown game status requires an update', () { final compatibility = evaluateGameWireCompatibility( - summaryStatus: GameStatus.unknownDefaultOpenApi, + status: GameStatus.unknownDefaultOpenApi, ); expect(compatibility.unknownStatus, isTrue); @@ -26,8 +26,8 @@ void main() { test('unknown seat type requires an update', () { final compatibility = evaluateGameWireCompatibility( - rosterStatus: GameStatus.waiting, - rosterSeats: [seat(SeatTypeEnum.unknownDefaultOpenApi)], + status: GameStatus.waiting, + seats: [seat(SeatTypeEnum.unknownDefaultOpenApi)], ); expect(compatibility.unknownSeatType, isTrue);