diff --git a/.changeset/inspect-the-local-stores.md b/.changeset/inspect-the-local-stores.md new file mode 100644 index 0000000..fb6cdac --- /dev/null +++ b/.changeset/inspect-the-local-stores.md @@ -0,0 +1,34 @@ +--- +"@eigeninteractive/testkit": patch +--- + +New `eigen-inspect` command, for reading a Worker's local `.wrangler` state from the terminal while you develop a game. + +A game's truth is split across two stores on purpose. D1 is the index: discovery, history, ratings, identity. The game's own Durable Object is the session: the authoritative status, the roster, and the append-only transition log. That split is what makes the engine's read paths cheap, and it is also why a question like "the lobby filled, so why is nothing happening" cannot be answered by looking at one table. The D1 `games` row is a fire-and-forget display mirror, so it can lag; the DO is the only authority. + +`eigen-inspect game ` joins both, decodes every JSON column, prints the transition log as a timeline, and ends with the sentence you actually wanted: + +``` +Diagnosis ready and not started: 2/2 seats filled. Nothing happens until the + CREATOR calls POST /api/engine/games/{id}/start. A start is explicit + and creator-only; filling the lobby does not start a game. +``` + +When the two stores disagree it says so as `Mirror drift`, naming the DO as authoritative, so a lost mirror write reads as a lost mirror write rather than as a mystery. A finished game with surviving `outbox` rows is called out the same way, since that means the D1 finish apply has not landed and ratings are not published. + +The other commands are `games` (the index), `do` (every local Durable Object mapped back to its game id, which the hashed filename cannot tell you), `players`, `tables`, and `sql` for anything unmodelled. `--game ` points `tables` and `sql` at one game's Durable Object instead of D1; `--json` emits everything for a script or an agent. + +This complements rather than replaces Wrangler's own local browser, which is `e` in `wrangler dev` (or `/cdn-cgi/explorer`) and is the better tool for generic table browsing. `eigen-inspect` is the one that knows what a game is, and it needs no dev server, browser, or account. + +The reader is also exported as a library at `@eigeninteractive/testkit/local-state`, so a test or a script can assert against local state directly: + +```ts +import { LocalStore } from "@eigeninteractive/testkit/local-state"; + +const store = LocalStore.open(); +const game = store.game("ABC123"); +expect(game?.meta?.status).toBe("active"); +store.close(); +``` + +Every database is opened read-only, so all of this is safe to run against a live `wrangler dev`. It reads them through Node's built-in `node:sqlite`, so it adds no dependency, needs no native build, and nothing new reaches your Worker's runtime dependencies. It needs Node 24, which the engine already requires. diff --git a/.changeset/one-session-snapshot.md b/.changeset/one-session-snapshot.md new file mode 100644 index 0000000..1f6f0a9 --- /dev/null +++ b/.changeset/one-session-snapshot.md @@ -0,0 +1,41 @@ +--- +"@eigeninteractive/server": minor +--- + +The socket now carries one message, a complete per-seat **session snapshot**, and every accepted command answers with the same value. `roster`, `sync` and `frame` as separate message kinds are gone. + +This is a breaking wire change, and it fixes a class of bug rather than an instance. The old shape asked a client to assemble one session out of four sources, each carrying a different slice of the truth under a different versioning scheme: an HTTP summary that carried status, unversioned `roster` messages that stopped at the lobby, a `sync` on mid-game open that carried only a version, and versioned `frame` messages that carried neither. Read that list for "how does a client learn the game became `active`" and the answer is that it cannot. Nothing on the live channel said so. A creator sitting in a full waiting room never saw a Start button, and every seat stayed in the waiting room after the game began, until the screen was disposed and re-entered. + +```jsonc +{ + "type": "session", + "seq": 7, // monotonic per game, incremented by EVERY commit + "gameId": "…", "shortCode": "ABC123", "access": "private", + "schemaVersion": 1, "config": { … }, + "turnSeconds": null, "budgetSeconds": null, "incrementSeconds": null, + "rated": false, "ratingPool": null, + "minPlayers": 2, "maxPlayers": 2, "createdBy": "…", + "status": "active", // what moves + "players": [ … ], + "version": 3, // null in the lobby + "frame": { … } // THIS seat's observation; null in the lobby +} +``` + +It is sent on socket open at **every** status, and after every committed change, lobby or state. Being complete makes it idempotent: a client that applies the newest one it has seen is correct however many it missed, so there is nothing to reconstruct and no second channel to correlate against. It carries the immutable header as well as the moving parts because a game screen must not need a second source; the D1 summary stays what it always was, the index behind lists and discovery. + +Hidden information is safe by construction rather than by vigilance. The envelope is projected per seat before it is sent, `frame` is only ever the receiving principal's own seat's view resolved against the roster at send time, and a socket holding no seat gets the envelope with `frame: null`, which is how a viewer learns the game started at all. + +**`seq` is the new ordering key**, because `version` cannot order a lobby change that has none. Apply a snapshot when `seq` exceeds the one you hold; that single comparison resolves a command response racing its own socket push, a duplicate delivery, and a reconnect that missed nothing. One clause is added, and it is a property of the state machine rather than an exception: a `finished` or `aborted` snapshot applies whatever its `seq`, because those statuses are absorbing and the abort teardown drops the storage the counter lives in. + +Gap recovery is unchanged and still animates. `GET /games/{id}/frames?from=&to=` still fills a version jump, and the missing span is played through **under the previous envelope**, so only the real snapshot may move `status`, `players` and `seq`. A client that missed a finish therefore animates the moves and then shows the outcome, instead of displaying a finished game while mid-game moves play. + +New and changed surface: + +- `GET /games/{id}/session` returns the snapshot over HTTP, for the paths that have no socket. +- `LobbyAccepted`, `Joined` and `Roster` are gone. `CommandAccepted` and `SoloStarted` are now `{ session }`. +- `RosterSnapshot` and `SyncMessage` are removed from the exported protocol types; `SessionSnapshot` replaces them. `FrameMessage` remains, as the payload inside a snapshot and the element type of the range fetch. +- `GameStub` gains `session(gameId, userId)`. +- The Durable Object's `meta` table gains `seq`, `short_code` and `outcomes`. Pre-1.0 and with no games depending on the engine, these are edited into the init migration in place rather than added as a second one, so **local `.wrangler` state predating this must be deleted**. Outcomes are retained rather than drained by the finish compaction, because they are kernel output that no transition row holds, so a cold open of a finished game could not otherwise be answered from the DO alone. + +One incidental fix rides along: `frame` was typed non-nullable in the generated Dart client, because a nullable `$ref` lost its null branch on the way into the OpenAPI document. The schema now spells it as a union, so it emits `anyOf: [$ref, {type: null}]` and generates as `Frame?`. A lobby session would have crashed the old typing on arrival. diff --git a/.changeset/scaffold-ships-inspect.md b/.changeset/scaffold-ships-inspect.md new file mode 100644 index 0000000..a3f9bfa --- /dev/null +++ b/.changeset/scaffold-ships-inspect.md @@ -0,0 +1,7 @@ +--- +"create-eigen-game": patch +--- + +A scaffolded game now ships the local inspector wired up: `pnpm inspect games`, `pnpm inspect game `, `pnpm inspect do`. + +One line makes it work. The `inspect` script points at `eigen-inspect`, which the testkit already installs as a `devDependency`, and which reads the local `.wrangler` databases through Node's built-in `node:sqlite`, so there is nothing to install and no native build to approve. diff --git a/.changeset/watch-the-fixtures.md b/.changeset/watch-the-fixtures.md new file mode 100644 index 0000000..0167902 --- /dev/null +++ b/.changeset/watch-the-fixtures.md @@ -0,0 +1,17 @@ +--- +"create-eigen-game": patch +--- + +`test:watch` now reruns when you edit only a fixture. It did not before, and the failure was silent: you saved a case, nothing happened, and the suite you were watching kept reporting the last result. + +The cause is that `twinFixtureTests` reads fixtures with `readFileSync` at collect time, so they are not in Vite's module graph. Vitest looks a changed path up in that graph, finds nothing, checks whether the file is itself a test file, and reruns nothing. Fixture JSON was never even watched, since Vitest only adds paths outside the graph to its watcher when a trigger glob names them. + +A scaffold therefore now ships `server/vitest.config.mts` carrying one option: + +```ts +forceRerunTriggers: [...configDefaults.forceRerunTriggers, "**/src/module/fixtures/**/*.json"], +``` + +This is what `forceRerunTriggers` is for. Vitest's own defaults use it for `package.json` and the config files, which are read outside the graph for the same reason, and it appends `setupFiles` and `snapshotSerializers` to the same list. + +Two things worth keeping if you edit the file. Spread the defaults rather than replacing them, because config resolution is a shallow merge and a bare array quietly stops manifest and config edits from triggering a rerun. And leave the trigger off any `@cloudflare/vitest-pool-workers` project you add later: a match reruns the whole suite, which costs nothing for pure-Node tests and is not free for workerd, where a fixture cannot change the result anyway. diff --git a/clients/dart/.openapi-generator/FILES b/clients/dart/.openapi-generator/FILES index bf415c2..264488b 100644 --- a/clients/dart/.openapi-generator/FILES +++ b/clients/dart/.openapi-generator/FILES @@ -35,9 +35,7 @@ doc/Health.md doc/HealthApi.md doc/Join.md doc/JoinByCode.md -doc/Joined.md doc/Lobby.md -doc/LobbyAccepted.md doc/LobbyCommand.md doc/MeApi.md doc/MyGames.md @@ -53,8 +51,8 @@ doc/RatingHistory.md doc/RatingHistoryEntry.md doc/RatingIdentity.md doc/Ratings.md -doc/Roster.md doc/Seat.md +doc/Session.md doc/SocialApi.md doc/SoloStarted.md doc/UserSearch.md @@ -105,9 +103,7 @@ lib/src/model/game_summary.dart lib/src/model/health.dart lib/src/model/join.dart lib/src/model/join_by_code.dart -lib/src/model/joined.dart lib/src/model/lobby.dart -lib/src/model/lobby_accepted.dart lib/src/model/lobby_command.dart lib/src/model/my_games.dart lib/src/model/outcome.dart @@ -121,8 +117,8 @@ lib/src/model/rating_history.dart lib/src/model/rating_history_entry.dart lib/src/model/rating_identity.dart lib/src/model/ratings.dart -lib/src/model/roster.dart lib/src/model/seat.dart +lib/src/model/session.dart lib/src/model/solo_started.dart lib/src/model/user_search.dart lib/src/model/username_update.dart diff --git a/clients/dart/README.md b/clients/dart/README.md index e718776..22cf343 100644 --- a/clients/dart/README.md +++ b/clients/dart/README.md @@ -87,6 +87,7 @@ Class | Method | HTTP request | Description [*GamesApi*](doc/GamesApi.md) | [**forfeitGame**](doc/GamesApi.md#forfeitgame) | **POST** /api/engine/games/{gameId}/forfeit | [*GamesApi*](doc/GamesApi.md) | [**getFrames**](doc/GamesApi.md#getframes) | **GET** /api/engine/games/{gameId}/frames | [*GamesApi*](doc/GamesApi.md) | [**getGame**](doc/GamesApi.md#getgame) | **GET** /api/engine/games/{gameId} | +[*GamesApi*](doc/GamesApi.md) | [**getGameSession**](doc/GamesApi.md#getgamesession) | **GET** /api/engine/games/{gameId}/session | [*GamesApi*](doc/GamesApi.md) | [**getLobby**](doc/GamesApi.md#getlobby) | **GET** /api/engine/lobby | [*GamesApi*](doc/GamesApi.md) | [**getMyGames**](doc/GamesApi.md#getmygames) | **GET** /api/engine/games/mine | [*GamesApi*](doc/GamesApi.md) | [**joinGame**](doc/GamesApi.md#joingame) | **POST** /api/engine/games/{gameId}/join | @@ -149,9 +150,7 @@ Class | Method | HTTP request | Description - [Health](doc/Health.md) - [Join](doc/Join.md) - [JoinByCode](doc/JoinByCode.md) - - [Joined](doc/Joined.md) - [Lobby](doc/Lobby.md) - - [LobbyAccepted](doc/LobbyAccepted.md) - [LobbyCommand](doc/LobbyCommand.md) - [MyGames](doc/MyGames.md) - [Outcome](doc/Outcome.md) @@ -165,8 +164,8 @@ Class | Method | HTTP request | Description - [RatingHistoryEntry](doc/RatingHistoryEntry.md) - [RatingIdentity](doc/RatingIdentity.md) - [Ratings](doc/Ratings.md) - - [Roster](doc/Roster.md) - [Seat](doc/Seat.md) + - [Session](doc/Session.md) - [SoloStarted](doc/SoloStarted.md) - [UserSearch](doc/UserSearch.md) - [UsernameUpdate](doc/UsernameUpdate.md) diff --git a/clients/dart/doc/CommandAccepted.md b/clients/dart/doc/CommandAccepted.md index b058adc..40975e5 100644 --- a/clients/dart/doc/CommandAccepted.md +++ b/clients/dart/doc/CommandAccepted.md @@ -8,8 +8,7 @@ import 'package:eigen_api/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**version** | **int** | | -**frame** | [**Frame**](Frame.md) | | +**session** | [**Session**](Session.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/clients/dart/doc/GamesApi.md b/clients/dart/doc/GamesApi.md index 00e377e..fec5d9d 100644 --- a/clients/dart/doc/GamesApi.md +++ b/clients/dart/doc/GamesApi.md @@ -16,6 +16,7 @@ Method | HTTP request | Description [**forfeitGame**](GamesApi.md#forfeitgame) | **POST** /api/engine/games/{gameId}/forfeit | [**getFrames**](GamesApi.md#getframes) | **GET** /api/engine/games/{gameId}/frames | [**getGame**](GamesApi.md#getgame) | **GET** /api/engine/games/{gameId} | +[**getGameSession**](GamesApi.md#getgamesession) | **GET** /api/engine/games/{gameId}/session | [**getLobby**](GamesApi.md#getlobby) | **GET** /api/engine/lobby | [**getMyGames**](GamesApi.md#getmygames) | **GET** /api/engine/games/mine | [**joinGame**](GamesApi.md#joingame) | **POST** /api/engine/games/{gameId}/join | @@ -26,7 +27,7 @@ Method | HTTP request | Description # **addBot** -> LobbyAccepted addBot(gameId, addBot) +> CommandAccepted addBot(gameId, addBot) @@ -55,7 +56,7 @@ Name | Type | Description | Notes ### Return type -[**LobbyAccepted**](LobbyAccepted.md) +[**CommandAccepted**](CommandAccepted.md) ### Authorization @@ -69,7 +70,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **cancelGame** -> LobbyAccepted cancelGame(gameId, lobbyCommand) +> CommandAccepted cancelGame(gameId, lobbyCommand) @@ -98,7 +99,7 @@ Name | Type | Description | Notes ### Return type -[**LobbyAccepted**](LobbyAccepted.md) +[**CommandAccepted**](CommandAccepted.md) ### Authorization @@ -322,6 +323,47 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getGameSession** +> Session getGameSession(gameId) + + + +### Example +```dart +import 'package:eigen_api/api.dart'; + +final api = EigenApi().getGamesApi(); +final String gameId = gameId_example; // String | + +try { + final response = api.getGameSession(gameId); + print(response); +} catch on DioException (e) { + print('Exception when calling GamesApi->getGameSession: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **gameId** | **String**| | + +### Return type + +[**Session**](Session.md) + +### Authorization + +[firebase](../README.md#firebase) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getLobby** > Lobby getLobby(limit, cursor) @@ -411,7 +453,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **joinGame** -> Joined joinGame(gameId, join) +> CommandAccepted joinGame(gameId, join) @@ -440,7 +482,7 @@ Name | Type | Description | Notes ### Return type -[**Joined**](Joined.md) +[**CommandAccepted**](CommandAccepted.md) ### Authorization @@ -454,7 +496,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **joinGameByCode** -> Joined joinGameByCode(joinByCode) +> CommandAccepted joinGameByCode(joinByCode) @@ -481,7 +523,7 @@ Name | Type | Description | Notes ### Return type -[**Joined**](Joined.md) +[**CommandAccepted**](CommandAccepted.md) ### Authorization @@ -495,7 +537,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **leaveGame** -> LobbyAccepted leaveGame(gameId, lobbyCommand) +> CommandAccepted leaveGame(gameId, lobbyCommand) @@ -524,7 +566,7 @@ Name | Type | Description | Notes ### Return type -[**LobbyAccepted**](LobbyAccepted.md) +[**CommandAccepted**](CommandAccepted.md) ### Authorization diff --git a/clients/dart/doc/Joined.md b/clients/dart/doc/Joined.md deleted file mode 100644 index e06b2a3..0000000 --- a/clients/dart/doc/Joined.md +++ /dev/null @@ -1,16 +0,0 @@ -# eigen_api.model.Joined - -## Load the model package -```dart -import 'package:eigen_api/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**gameId** | **String** | | -**roster** | [**Roster**](Roster.md) | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/clients/dart/doc/LobbyAccepted.md b/clients/dart/doc/LobbyAccepted.md deleted file mode 100644 index 7295749..0000000 --- a/clients/dart/doc/LobbyAccepted.md +++ /dev/null @@ -1,15 +0,0 @@ -# eigen_api.model.LobbyAccepted - -## Load the model package -```dart -import 'package:eigen_api/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**roster** | [**Roster**](Roster.md) | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/clients/dart/doc/Roster.md b/clients/dart/doc/Roster.md deleted file mode 100644 index ea8e28f..0000000 --- a/clients/dart/doc/Roster.md +++ /dev/null @@ -1,17 +0,0 @@ -# eigen_api.model.Roster - -## Load the model package -```dart -import 'package:eigen_api/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**type** | **String** | | -**status** | [**GameStatus**](GameStatus.md) | | -**players** | [**List<Seat>**](Seat.md) | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/clients/dart/doc/Session.md b/clients/dart/doc/Session.md new file mode 100644 index 0000000..9762db9 --- /dev/null +++ b/clients/dart/doc/Session.md @@ -0,0 +1,33 @@ +# eigen_api.model.Session + +## Load the model package +```dart +import 'package:eigen_api/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**type** | **String** | | +**seq** | **int** | | +**gameId** | **String** | | +**shortCode** | **String** | | +**access** | [**GameAccess**](GameAccess.md) | | +**schemaVersion** | **int** | | +**config** | **Object** | | +**turnSeconds** | **int** | | +**budgetSeconds** | **int** | | +**incrementSeconds** | **int** | | +**rated** | **bool** | | +**ratingPool** | **String** | | +**minPlayers** | **int** | | +**maxPlayers** | **int** | | +**createdBy** | **String** | | +**status** | [**GameStatus**](GameStatus.md) | | +**players** | [**List<Seat>**](Seat.md) | | +**version** | **int** | | +**frame** | [**Frame**](Frame.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/clients/dart/doc/SoloStarted.md b/clients/dart/doc/SoloStarted.md index f9a82bf..ed49043 100644 --- a/clients/dart/doc/SoloStarted.md +++ b/clients/dart/doc/SoloStarted.md @@ -8,10 +8,7 @@ import 'package:eigen_api/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**gameId** | **String** | | -**shortCode** | **String** | | -**version** | **int** | | -**frame** | [**Frame**](Frame.md) | | +**session** | [**Session**](Session.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/clients/dart/lib/eigen_api.dart b/clients/dart/lib/eigen_api.dart index 3cf6607..123431f 100644 --- a/clients/dart/lib/eigen_api.dart +++ b/clients/dart/lib/eigen_api.dart @@ -46,9 +46,7 @@ export 'package:eigen_api/src/model/game_summary.dart'; export 'package:eigen_api/src/model/health.dart'; export 'package:eigen_api/src/model/join.dart'; export 'package:eigen_api/src/model/join_by_code.dart'; -export 'package:eigen_api/src/model/joined.dart'; export 'package:eigen_api/src/model/lobby.dart'; -export 'package:eigen_api/src/model/lobby_accepted.dart'; export 'package:eigen_api/src/model/lobby_command.dart'; export 'package:eigen_api/src/model/my_games.dart'; export 'package:eigen_api/src/model/outcome.dart'; @@ -62,8 +60,8 @@ export 'package:eigen_api/src/model/rating_history.dart'; export 'package:eigen_api/src/model/rating_history_entry.dart'; export 'package:eigen_api/src/model/rating_identity.dart'; export 'package:eigen_api/src/model/ratings.dart'; -export 'package:eigen_api/src/model/roster.dart'; export 'package:eigen_api/src/model/seat.dart'; +export 'package:eigen_api/src/model/session.dart'; export 'package:eigen_api/src/model/solo_started.dart'; export 'package:eigen_api/src/model/user_search.dart'; export 'package:eigen_api/src/model/username_update.dart'; diff --git a/clients/dart/lib/src/api/games_api.dart b/clients/dart/lib/src/api/games_api.dart index e864e99..a95c0c0 100644 --- a/clients/dart/lib/src/api/games_api.dart +++ b/clients/dart/lib/src/api/games_api.dart @@ -21,11 +21,10 @@ import 'package:eigen_api/src/model/frames.dart'; import 'package:eigen_api/src/model/game_summary.dart'; import 'package:eigen_api/src/model/join.dart'; import 'package:eigen_api/src/model/join_by_code.dart'; -import 'package:eigen_api/src/model/joined.dart'; import 'package:eigen_api/src/model/lobby.dart'; -import 'package:eigen_api/src/model/lobby_accepted.dart'; import 'package:eigen_api/src/model/lobby_command.dart'; import 'package:eigen_api/src/model/my_games.dart'; +import 'package:eigen_api/src/model/session.dart'; import 'package:eigen_api/src/model/solo_started.dart'; class GamesApi { @@ -46,9 +45,9 @@ class GamesApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [LobbyAccepted] as data + /// Returns a [Future] containing a [Response] with a [CommandAccepted] as data /// Throws [DioException] if API call or serialization fails - Future> addBot({ + Future> addBot({ required String gameId, required AddBot addBot, CancelToken? cancelToken, @@ -99,15 +98,15 @@ class GamesApi { onReceiveProgress: onReceiveProgress, ); - LobbyAccepted? _responseData; + CommandAccepted? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize( + : deserialize( rawData, - 'LobbyAccepted', + 'CommandAccepted', growable: true, ); } catch (error, stackTrace) { @@ -120,7 +119,7 @@ class GamesApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, @@ -145,9 +144,9 @@ class GamesApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [LobbyAccepted] as data + /// Returns a [Future] containing a [Response] with a [CommandAccepted] as data /// Throws [DioException] if API call or serialization fails - Future> cancelGame({ + Future> cancelGame({ required String gameId, required LobbyCommand lobbyCommand, CancelToken? cancelToken, @@ -198,15 +197,15 @@ class GamesApi { onReceiveProgress: onReceiveProgress, ); - LobbyAccepted? _responseData; + CommandAccepted? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize( + : deserialize( rawData, - 'LobbyAccepted', + 'CommandAccepted', growable: true, ); } catch (error, stackTrace) { @@ -219,7 +218,7 @@ class GamesApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, @@ -677,6 +676,84 @@ class GamesApi { ); } + /// getGameSession + /// + /// + /// Parameters: + /// * [gameId] + /// * [cancelToken] - A [CancelToken] that can be used to cancel the operation + /// * [headers] - Can be used to add additional headers to the request + /// * [extras] - Can be used to add flags to the request + /// * [validateStatus] - A [ValidateStatus] callback that can be used to determine request success based on the HTTP status of the response + /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress + /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress + /// + /// Returns a [Future] containing a [Response] with a [Session] as data + /// Throws [DioException] if API call or serialization fails + Future> getGameSession({ + required String gameId, + CancelToken? cancelToken, + Map? headers, + Map? extra, + ValidateStatus? validateStatus, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + }) async { + final _path = r'/api/engine/games/{gameId}/session'.replaceAll( + '{' + r'gameId' + '}', + gameId.toString(), + ); + final _options = Options( + method: r'GET', + headers: {...?headers}, + extra: { + 'secure': >[ + {'type': 'http', 'scheme': 'bearer', 'name': 'firebase'}, + ], + ...?extra, + }, + validateStatus: validateStatus, + ); + + final _response = await _dio.request( + _path, + options: _options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + ); + + Session? _responseData; + + try { + final rawData = _response.data; + _responseData = rawData == null + ? null + : deserialize(rawData, 'Session', growable: true); + } catch (error, stackTrace) { + throw DioException( + requestOptions: _response.requestOptions, + response: _response, + type: DioExceptionType.unknown, + error: error, + stackTrace: stackTrace, + ); + } + + return Response( + data: _responseData, + headers: _response.headers, + isRedirect: _response.isRedirect, + requestOptions: _response.requestOptions, + redirects: _response.redirects, + statusCode: _response.statusCode, + statusMessage: _response.statusMessage, + extra: _response.extra, + ); + } + /// getLobby /// /// @@ -855,9 +932,9 @@ class GamesApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [Joined] as data + /// Returns a [Future] containing a [Response] with a [CommandAccepted] as data /// Throws [DioException] if API call or serialization fails - Future> joinGame({ + Future> joinGame({ required String gameId, required Join join, CancelToken? cancelToken, @@ -908,13 +985,17 @@ class GamesApi { onReceiveProgress: onReceiveProgress, ); - Joined? _responseData; + CommandAccepted? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize(rawData, 'Joined', growable: true); + : deserialize( + rawData, + 'CommandAccepted', + growable: true, + ); } catch (error, stackTrace) { throw DioException( requestOptions: _response.requestOptions, @@ -925,7 +1006,7 @@ class GamesApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, @@ -949,9 +1030,9 @@ class GamesApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [Joined] as data + /// Returns a [Future] containing a [Response] with a [CommandAccepted] as data /// Throws [DioException] if API call or serialization fails - Future> joinGameByCode({ + Future> joinGameByCode({ required JoinByCode joinByCode, CancelToken? cancelToken, Map? headers, @@ -996,13 +1077,17 @@ class GamesApi { onReceiveProgress: onReceiveProgress, ); - Joined? _responseData; + CommandAccepted? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize(rawData, 'Joined', growable: true); + : deserialize( + rawData, + 'CommandAccepted', + growable: true, + ); } catch (error, stackTrace) { throw DioException( requestOptions: _response.requestOptions, @@ -1013,7 +1098,7 @@ class GamesApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, @@ -1038,9 +1123,9 @@ class GamesApi { /// * [onSendProgress] - A [ProgressCallback] that can be used to get the send progress /// * [onReceiveProgress] - A [ProgressCallback] that can be used to get the receive progress /// - /// Returns a [Future] containing a [Response] with a [LobbyAccepted] as data + /// Returns a [Future] containing a [Response] with a [CommandAccepted] as data /// Throws [DioException] if API call or serialization fails - Future> leaveGame({ + Future> leaveGame({ required String gameId, required LobbyCommand lobbyCommand, CancelToken? cancelToken, @@ -1091,15 +1176,15 @@ class GamesApi { onReceiveProgress: onReceiveProgress, ); - LobbyAccepted? _responseData; + CommandAccepted? _responseData; try { final rawData = _response.data; _responseData = rawData == null ? null - : deserialize( + : deserialize( rawData, - 'LobbyAccepted', + 'CommandAccepted', growable: true, ); } catch (error, stackTrace) { @@ -1112,7 +1197,7 @@ class GamesApi { ); } - return Response( + return Response( data: _responseData, headers: _response.headers, isRedirect: _response.isRedirect, diff --git a/clients/dart/lib/src/deserialize.dart b/clients/dart/lib/src/deserialize.dart index b08d25d..fc8badb 100644 --- a/clients/dart/lib/src/deserialize.dart +++ b/clients/dart/lib/src/deserialize.dart @@ -25,9 +25,7 @@ import 'package:eigen_api/src/model/game_summary.dart'; import 'package:eigen_api/src/model/health.dart'; import 'package:eigen_api/src/model/join.dart'; import 'package:eigen_api/src/model/join_by_code.dart'; -import 'package:eigen_api/src/model/joined.dart'; import 'package:eigen_api/src/model/lobby.dart'; -import 'package:eigen_api/src/model/lobby_accepted.dart'; import 'package:eigen_api/src/model/lobby_command.dart'; import 'package:eigen_api/src/model/my_games.dart'; import 'package:eigen_api/src/model/outcome.dart'; @@ -41,8 +39,8 @@ import 'package:eigen_api/src/model/rating_history.dart'; import 'package:eigen_api/src/model/rating_history_entry.dart'; import 'package:eigen_api/src/model/rating_identity.dart'; import 'package:eigen_api/src/model/ratings.dart'; -import 'package:eigen_api/src/model/roster.dart'; import 'package:eigen_api/src/model/seat.dart'; +import 'package:eigen_api/src/model/session.dart'; import 'package:eigen_api/src/model/solo_started.dart'; import 'package:eigen_api/src/model/user_search.dart'; import 'package:eigen_api/src/model/username_update.dart'; @@ -135,13 +133,8 @@ ReturnType deserialize( return Join.fromJson(value as Map) as ReturnType; case 'JoinByCode': return JoinByCode.fromJson(value as Map) as ReturnType; - case 'Joined': - return Joined.fromJson(value as Map) as ReturnType; case 'Lobby': return Lobby.fromJson(value as Map) as ReturnType; - case 'LobbyAccepted': - return LobbyAccepted.fromJson(value as Map) - as ReturnType; case 'LobbyCommand': return LobbyCommand.fromJson(value as Map) as ReturnType; case 'MyGames': @@ -171,10 +164,10 @@ ReturnType deserialize( as ReturnType; case 'Ratings': return Ratings.fromJson(value as Map) as ReturnType; - case 'Roster': - return Roster.fromJson(value as Map) as ReturnType; case 'Seat': return Seat.fromJson(value as Map) as ReturnType; + case 'Session': + return Session.fromJson(value as Map) as ReturnType; case 'SoloStarted': return SoloStarted.fromJson(value as Map) as ReturnType; case 'UserSearch': diff --git a/clients/dart/lib/src/model/command_accepted.dart b/clients/dart/lib/src/model/command_accepted.dart index dacf451..ce3e81c 100644 --- a/clients/dart/lib/src/model/command_accepted.dart +++ b/clients/dart/lib/src/model/command_accepted.dart @@ -3,7 +3,7 @@ // // ignore_for_file: unused_element -import 'package:eigen_api/src/model/frame.dart'; +import 'package:eigen_api/src/model/session.dart'; import 'package:json_annotation/json_annotation.dart'; part 'command_accepted.g.dart'; @@ -16,23 +16,18 @@ part 'command_accepted.g.dart'; ) class CommandAccepted { /// Returns a new [CommandAccepted] instance. - CommandAccepted({required this.version, required this.frame}); + CommandAccepted({required this.session}); - @JsonKey(name: r'version', required: true, includeIfNull: false) - final int version; - - @JsonKey(name: r'frame', required: true, includeIfNull: false) - final Frame frame; + @JsonKey(name: r'session', required: true, includeIfNull: false) + final Session session; @override bool operator ==(Object other) => identical(this, other) || - other is CommandAccepted && - other.version == version && - other.frame == frame; + other is CommandAccepted && other.session == session; @override - int get hashCode => version.hashCode + frame.hashCode; + int get hashCode => session.hashCode; factory CommandAccepted.fromJson(Map json) => _$CommandAcceptedFromJson(json); diff --git a/clients/dart/lib/src/model/command_accepted.g.dart b/clients/dart/lib/src/model/command_accepted.g.dart index 09e37b1..02cca4c 100644 --- a/clients/dart/lib/src/model/command_accepted.g.dart +++ b/clients/dart/lib/src/model/command_accepted.g.dart @@ -8,19 +8,15 @@ part of 'command_accepted.dart'; CommandAccepted _$CommandAcceptedFromJson(Map json) => $checkedCreate('CommandAccepted', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['version', 'frame']); + $checkKeys(json, requiredKeys: const ['session']); final val = CommandAccepted( - version: $checkedConvert('version', (v) => (v as num).toInt()), - frame: $checkedConvert( - 'frame', - (v) => Frame.fromJson(v as Map), + session: $checkedConvert( + 'session', + (v) => Session.fromJson(v as Map), ), ); return val; }); Map _$CommandAcceptedToJson(CommandAccepted instance) => - { - 'version': instance.version, - 'frame': instance.frame.toJson(), - }; + {'session': instance.session.toJson()}; diff --git a/clients/dart/lib/src/model/joined.dart b/clients/dart/lib/src/model/joined.dart deleted file mode 100644 index f2025b2..0000000 --- a/clients/dart/lib/src/model/joined.dart +++ /dev/null @@ -1,43 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// - -// ignore_for_file: unused_element -import 'package:eigen_api/src/model/roster.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'joined.g.dart'; - -@JsonSerializable( - checked: true, - createToJson: true, - disallowUnrecognizedKeys: false, - explicitToJson: true, -) -class Joined { - /// Returns a new [Joined] instance. - Joined({required this.gameId, required this.roster}); - - @JsonKey(name: r'gameId', required: true, includeIfNull: false) - final String gameId; - - @JsonKey(name: r'roster', required: true, includeIfNull: false) - final Roster roster; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Joined && other.gameId == gameId && other.roster == roster; - - @override - int get hashCode => gameId.hashCode + roster.hashCode; - - factory Joined.fromJson(Map json) => _$JoinedFromJson(json); - - Map toJson() => _$JoinedToJson(this); - - @override - String toString() { - return toJson().toString(); - } -} diff --git a/clients/dart/lib/src/model/joined.g.dart b/clients/dart/lib/src/model/joined.g.dart deleted file mode 100644 index 0dedf3b..0000000 --- a/clients/dart/lib/src/model/joined.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'joined.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Joined _$JoinedFromJson(Map json) => - $checkedCreate('Joined', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['gameId', 'roster']); - final val = Joined( - gameId: $checkedConvert('gameId', (v) => v as String), - roster: $checkedConvert( - 'roster', - (v) => Roster.fromJson(v as Map), - ), - ); - return val; - }); - -Map _$JoinedToJson(Joined instance) => { - 'gameId': instance.gameId, - 'roster': instance.roster.toJson(), -}; diff --git a/clients/dart/lib/src/model/lobby_accepted.dart b/clients/dart/lib/src/model/lobby_accepted.dart deleted file mode 100644 index ea33124..0000000 --- a/clients/dart/lib/src/model/lobby_accepted.dart +++ /dev/null @@ -1,41 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// - -// ignore_for_file: unused_element -import 'package:eigen_api/src/model/roster.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'lobby_accepted.g.dart'; - -@JsonSerializable( - checked: true, - createToJson: true, - disallowUnrecognizedKeys: false, - explicitToJson: true, -) -class LobbyAccepted { - /// Returns a new [LobbyAccepted] instance. - LobbyAccepted({required this.roster}); - - @JsonKey(name: r'roster', required: true, includeIfNull: false) - final Roster roster; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is LobbyAccepted && other.roster == roster; - - @override - int get hashCode => roster.hashCode; - - factory LobbyAccepted.fromJson(Map json) => - _$LobbyAcceptedFromJson(json); - - Map toJson() => _$LobbyAcceptedToJson(this); - - @override - String toString() { - return toJson().toString(); - } -} diff --git a/clients/dart/lib/src/model/lobby_accepted.g.dart b/clients/dart/lib/src/model/lobby_accepted.g.dart deleted file mode 100644 index 9cdf0a0..0000000 --- a/clients/dart/lib/src/model/lobby_accepted.g.dart +++ /dev/null @@ -1,22 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'lobby_accepted.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LobbyAccepted _$LobbyAcceptedFromJson(Map json) => - $checkedCreate('LobbyAccepted', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['roster']); - final val = LobbyAccepted( - roster: $checkedConvert( - 'roster', - (v) => Roster.fromJson(v as Map), - ), - ); - return val; - }); - -Map _$LobbyAcceptedToJson(LobbyAccepted instance) => - {'roster': instance.roster.toJson()}; diff --git a/clients/dart/lib/src/model/roster.dart b/clients/dart/lib/src/model/roster.dart deleted file mode 100644 index 3da6454..0000000 --- a/clients/dart/lib/src/model/roster.dart +++ /dev/null @@ -1,74 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// - -// ignore_for_file: unused_element -import 'package:eigen_api/src/model/seat.dart'; -import 'package:eigen_api/src/model/game_status.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'roster.g.dart'; - -@JsonSerializable( - checked: true, - createToJson: true, - disallowUnrecognizedKeys: false, - explicitToJson: true, -) -class Roster { - /// Returns a new [Roster] instance. - Roster({required this.type, required this.status, required this.players}); - - @JsonKey( - name: r'type', - required: true, - includeIfNull: false, - unknownEnumValue: RosterTypeEnum.unknownDefaultOpenApi, - ) - final RosterTypeEnum type; - - @JsonKey( - name: r'status', - required: true, - includeIfNull: false, - unknownEnumValue: GameStatus.unknownDefaultOpenApi, - ) - final GameStatus status; - - @JsonKey(name: r'players', required: true, includeIfNull: false) - final List players; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is Roster && - other.type == type && - other.status == status && - other.players == players; - - @override - int get hashCode => type.hashCode + status.hashCode + players.hashCode; - - factory Roster.fromJson(Map json) => _$RosterFromJson(json); - - Map toJson() => _$RosterToJson(this); - - @override - String toString() { - return toJson().toString(); - } -} - -enum RosterTypeEnum { - @JsonValue(r'roster') - roster(r'roster'), - @JsonValue(r'unknown_default_open_api') - unknownDefaultOpenApi(r'unknown_default_open_api'); - - const RosterTypeEnum(this.value); - - final String value; - - @override - String toString() => value; -} diff --git a/clients/dart/lib/src/model/roster.g.dart b/clients/dart/lib/src/model/roster.g.dart deleted file mode 100644 index 20eedd6..0000000 --- a/clients/dart/lib/src/model/roster.g.dart +++ /dev/null @@ -1,57 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'roster.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Roster _$RosterFromJson(Map json) => - $checkedCreate('Roster', json, ($checkedConvert) { - $checkKeys(json, requiredKeys: const ['type', 'status', 'players']); - final val = Roster( - type: $checkedConvert( - 'type', - (v) => $enumDecode( - _$RosterTypeEnumEnumMap, - v, - unknownValue: RosterTypeEnum.unknownDefaultOpenApi, - ), - ), - status: $checkedConvert( - 'status', - (v) => $enumDecode( - _$GameStatusEnumMap, - v, - unknownValue: GameStatus.unknownDefaultOpenApi, - ), - ), - players: $checkedConvert( - 'players', - (v) => (v as List) - .map((e) => Seat.fromJson(e as Map)) - .toList(), - ), - ); - return val; - }); - -Map _$RosterToJson(Roster instance) => { - 'type': _$RosterTypeEnumEnumMap[instance.type]!, - 'status': _$GameStatusEnumMap[instance.status]!, - 'players': instance.players.map((e) => e.toJson()).toList(), -}; - -const _$RosterTypeEnumEnumMap = { - RosterTypeEnum.roster: 'roster', - RosterTypeEnum.unknownDefaultOpenApi: 'unknown_default_open_api', -}; - -const _$GameStatusEnumMap = { - GameStatus.waiting: 'waiting', - GameStatus.ready: 'ready', - GameStatus.active: 'active', - GameStatus.finished: 'finished', - GameStatus.aborted: 'aborted', - GameStatus.unknownDefaultOpenApi: 'unknown_default_open_api', -}; diff --git a/clients/dart/lib/src/model/session.dart b/clients/dart/lib/src/model/session.dart new file mode 100644 index 0000000..caa2643 --- /dev/null +++ b/clients/dart/lib/src/model/session.dart @@ -0,0 +1,203 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// + +// ignore_for_file: unused_element +import 'package:eigen_api/src/model/frame.dart'; +import 'package:eigen_api/src/model/seat.dart'; +import 'package:eigen_api/src/model/game_status.dart'; +import 'package:eigen_api/src/model/game_access.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'session.g.dart'; + +@JsonSerializable( + checked: true, + createToJson: true, + disallowUnrecognizedKeys: false, + explicitToJson: true, +) +class Session { + /// Returns a new [Session] instance. + Session({ + required this.type, + + required this.seq, + + required this.gameId, + + required this.shortCode, + + required this.access, + + required this.schemaVersion, + + required this.config, + + required this.turnSeconds, + + required this.budgetSeconds, + + required this.incrementSeconds, + + required this.rated, + + required this.ratingPool, + + required this.minPlayers, + + required this.maxPlayers, + + required this.createdBy, + + required this.status, + + required this.players, + + required this.version, + + required this.frame, + }); + + @JsonKey( + name: r'type', + required: true, + includeIfNull: false, + unknownEnumValue: SessionTypeEnum.unknownDefaultOpenApi, + ) + final SessionTypeEnum type; + + @JsonKey(name: r'seq', required: true, includeIfNull: false) + final int seq; + + @JsonKey(name: r'gameId', required: true, includeIfNull: false) + final String gameId; + + @JsonKey(name: r'shortCode', required: true, includeIfNull: false) + final String shortCode; + + @JsonKey( + name: r'access', + required: true, + includeIfNull: false, + unknownEnumValue: GameAccess.unknownDefaultOpenApi, + ) + final GameAccess access; + + @JsonKey(name: r'schemaVersion', required: true, includeIfNull: false) + final int schemaVersion; + + @JsonKey(name: r'config', required: true, includeIfNull: false) + final Object config; + + @JsonKey(name: r'turnSeconds', required: true, includeIfNull: true) + final int? turnSeconds; + + @JsonKey(name: r'budgetSeconds', required: true, includeIfNull: true) + final int? budgetSeconds; + + @JsonKey(name: r'incrementSeconds', required: true, includeIfNull: true) + final int? incrementSeconds; + + @JsonKey(name: r'rated', required: true, includeIfNull: false) + final bool rated; + + @JsonKey(name: r'ratingPool', required: true, includeIfNull: true) + final String? ratingPool; + + @JsonKey(name: r'minPlayers', required: true, includeIfNull: false) + final int minPlayers; + + @JsonKey(name: r'maxPlayers', required: true, includeIfNull: false) + final int maxPlayers; + + @JsonKey(name: r'createdBy', required: true, includeIfNull: true) + final String? createdBy; + + @JsonKey( + name: r'status', + required: true, + includeIfNull: false, + unknownEnumValue: GameStatus.unknownDefaultOpenApi, + ) + final GameStatus status; + + @JsonKey(name: r'players', required: true, includeIfNull: false) + final List players; + + @JsonKey(name: r'version', required: true, includeIfNull: true) + final int? version; + + @JsonKey(name: r'frame', required: true, includeIfNull: true) + final Frame? frame; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Session && + other.type == type && + other.seq == seq && + other.gameId == gameId && + other.shortCode == shortCode && + other.access == access && + other.schemaVersion == schemaVersion && + other.config == config && + other.turnSeconds == turnSeconds && + other.budgetSeconds == budgetSeconds && + other.incrementSeconds == incrementSeconds && + other.rated == rated && + other.ratingPool == ratingPool && + other.minPlayers == minPlayers && + other.maxPlayers == maxPlayers && + other.createdBy == createdBy && + other.status == status && + other.players == players && + other.version == version && + other.frame == frame; + + @override + int get hashCode => + type.hashCode + + seq.hashCode + + gameId.hashCode + + shortCode.hashCode + + access.hashCode + + schemaVersion.hashCode + + config.hashCode + + (turnSeconds == null ? 0 : turnSeconds.hashCode) + + (budgetSeconds == null ? 0 : budgetSeconds.hashCode) + + (incrementSeconds == null ? 0 : incrementSeconds.hashCode) + + rated.hashCode + + (ratingPool == null ? 0 : ratingPool.hashCode) + + minPlayers.hashCode + + maxPlayers.hashCode + + (createdBy == null ? 0 : createdBy.hashCode) + + status.hashCode + + players.hashCode + + (version == null ? 0 : version.hashCode) + + (frame == null ? 0 : frame.hashCode); + + factory Session.fromJson(Map json) => + _$SessionFromJson(json); + + Map toJson() => _$SessionToJson(this); + + @override + String toString() { + return toJson().toString(); + } +} + +enum SessionTypeEnum { + @JsonValue(r'session') + session(r'session'), + @JsonValue(r'unknown_default_open_api') + unknownDefaultOpenApi(r'unknown_default_open_api'); + + const SessionTypeEnum(this.value); + + final String value; + + @override + String toString() => value; +} diff --git a/clients/dart/lib/src/model/session.g.dart b/clients/dart/lib/src/model/session.g.dart new file mode 100644 index 0000000..d44138b --- /dev/null +++ b/clients/dart/lib/src/model/session.g.dart @@ -0,0 +1,136 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Session _$SessionFromJson( + Map json, +) => $checkedCreate('Session', json, ($checkedConvert) { + $checkKeys( + json, + requiredKeys: const [ + 'type', + 'seq', + 'gameId', + 'shortCode', + 'access', + 'schemaVersion', + 'config', + 'turnSeconds', + 'budgetSeconds', + 'incrementSeconds', + 'rated', + 'ratingPool', + 'minPlayers', + 'maxPlayers', + 'createdBy', + 'status', + 'players', + 'version', + 'frame', + ], + ); + final val = Session( + type: $checkedConvert( + 'type', + (v) => $enumDecode( + _$SessionTypeEnumEnumMap, + v, + unknownValue: SessionTypeEnum.unknownDefaultOpenApi, + ), + ), + seq: $checkedConvert('seq', (v) => (v as num).toInt()), + gameId: $checkedConvert('gameId', (v) => v as String), + shortCode: $checkedConvert('shortCode', (v) => v as String), + access: $checkedConvert( + 'access', + (v) => $enumDecode( + _$GameAccessEnumMap, + v, + unknownValue: GameAccess.unknownDefaultOpenApi, + ), + ), + schemaVersion: $checkedConvert('schemaVersion', (v) => (v as num).toInt()), + config: $checkedConvert('config', (v) => v as Object), + turnSeconds: $checkedConvert('turnSeconds', (v) => (v as num?)?.toInt()), + budgetSeconds: $checkedConvert( + 'budgetSeconds', + (v) => (v as num?)?.toInt(), + ), + incrementSeconds: $checkedConvert( + 'incrementSeconds', + (v) => (v as num?)?.toInt(), + ), + rated: $checkedConvert('rated', (v) => v as bool), + ratingPool: $checkedConvert('ratingPool', (v) => v as String?), + minPlayers: $checkedConvert('minPlayers', (v) => (v as num).toInt()), + maxPlayers: $checkedConvert('maxPlayers', (v) => (v as num).toInt()), + createdBy: $checkedConvert('createdBy', (v) => v as String?), + status: $checkedConvert( + 'status', + (v) => $enumDecode( + _$GameStatusEnumMap, + v, + unknownValue: GameStatus.unknownDefaultOpenApi, + ), + ), + players: $checkedConvert( + 'players', + (v) => (v as List) + .map((e) => Seat.fromJson(e as Map)) + .toList(), + ), + version: $checkedConvert('version', (v) => (v as num?)?.toInt()), + frame: $checkedConvert( + 'frame', + (v) => v == null ? null : Frame.fromJson(v as Map), + ), + ); + return val; +}); + +Map _$SessionToJson(Session instance) => { + 'type': _$SessionTypeEnumEnumMap[instance.type]!, + 'seq': instance.seq, + 'gameId': instance.gameId, + 'shortCode': instance.shortCode, + 'access': _$GameAccessEnumMap[instance.access]!, + 'schemaVersion': instance.schemaVersion, + 'config': instance.config, + 'turnSeconds': instance.turnSeconds, + 'budgetSeconds': instance.budgetSeconds, + 'incrementSeconds': instance.incrementSeconds, + 'rated': instance.rated, + 'ratingPool': instance.ratingPool, + 'minPlayers': instance.minPlayers, + 'maxPlayers': instance.maxPlayers, + 'createdBy': instance.createdBy, + 'status': _$GameStatusEnumMap[instance.status]!, + 'players': instance.players.map((e) => e.toJson()).toList(), + 'version': instance.version, + 'frame': instance.frame?.toJson(), +}; + +const _$SessionTypeEnumEnumMap = { + SessionTypeEnum.session: 'session', + SessionTypeEnum.unknownDefaultOpenApi: 'unknown_default_open_api', +}; + +const _$GameAccessEnumMap = { + GameAccess.public: 'public', + GameAccess.private: 'private', + GameAccess.friends: 'friends', + GameAccess.unknownDefaultOpenApi: 'unknown_default_open_api', +}; + +const _$GameStatusEnumMap = { + GameStatus.waiting: 'waiting', + GameStatus.ready: 'ready', + GameStatus.active: 'active', + GameStatus.finished: 'finished', + GameStatus.aborted: 'aborted', + GameStatus.unknownDefaultOpenApi: 'unknown_default_open_api', +}; diff --git a/clients/dart/lib/src/model/solo_started.dart b/clients/dart/lib/src/model/solo_started.dart index bc3e920..0bde9a7 100644 --- a/clients/dart/lib/src/model/solo_started.dart +++ b/clients/dart/lib/src/model/solo_started.dart @@ -3,7 +3,7 @@ // // ignore_for_file: unused_element -import 'package:eigen_api/src/model/frame.dart'; +import 'package:eigen_api/src/model/session.dart'; import 'package:json_annotation/json_annotation.dart'; part 'solo_started.g.dart'; @@ -16,40 +16,18 @@ part 'solo_started.g.dart'; ) class SoloStarted { /// Returns a new [SoloStarted] instance. - SoloStarted({ - required this.gameId, + SoloStarted({required this.session}); - required this.shortCode, - - required this.version, - - required this.frame, - }); - - @JsonKey(name: r'gameId', required: true, includeIfNull: false) - final String gameId; - - @JsonKey(name: r'shortCode', required: true, includeIfNull: false) - final String shortCode; - - @JsonKey(name: r'version', required: true, includeIfNull: false) - final int version; - - @JsonKey(name: r'frame', required: true, includeIfNull: false) - final Frame frame; + @JsonKey(name: r'session', required: true, includeIfNull: false) + final Session session; @override bool operator ==(Object other) => identical(this, other) || - other is SoloStarted && - other.gameId == gameId && - other.shortCode == shortCode && - other.version == version && - other.frame == frame; + other is SoloStarted && other.session == session; @override - int get hashCode => - gameId.hashCode + shortCode.hashCode + version.hashCode + frame.hashCode; + int get hashCode => session.hashCode; factory SoloStarted.fromJson(Map json) => _$SoloStartedFromJson(json); diff --git a/clients/dart/lib/src/model/solo_started.g.dart b/clients/dart/lib/src/model/solo_started.g.dart index ca94867..b820d31 100644 --- a/clients/dart/lib/src/model/solo_started.g.dart +++ b/clients/dart/lib/src/model/solo_started.g.dart @@ -8,26 +8,15 @@ part of 'solo_started.dart'; SoloStarted _$SoloStartedFromJson(Map json) => $checkedCreate('SoloStarted', json, ($checkedConvert) { - $checkKeys( - json, - requiredKeys: const ['gameId', 'shortCode', 'version', 'frame'], - ); + $checkKeys(json, requiredKeys: const ['session']); final val = SoloStarted( - gameId: $checkedConvert('gameId', (v) => v as String), - shortCode: $checkedConvert('shortCode', (v) => v as String), - version: $checkedConvert('version', (v) => (v as num).toInt()), - frame: $checkedConvert( - 'frame', - (v) => Frame.fromJson(v as Map), + session: $checkedConvert( + 'session', + (v) => Session.fromJson(v as Map), ), ); return val; }); Map _$SoloStartedToJson(SoloStarted instance) => - { - 'gameId': instance.gameId, - 'shortCode': instance.shortCode, - 'version': instance.version, - 'frame': instance.frame.toJson(), - }; + {'session': instance.session.toJson()}; diff --git a/examples/rps/test/index.spec.ts b/examples/rps/test/index.spec.ts index 8960bec..1c16f87 100644 --- a/examples/rps/test/index.spec.ts +++ b/examples/rps/test/index.spec.ts @@ -38,23 +38,25 @@ it("plays a full game: waiting room, same-view simultaneous commits, finish, rep const { gameId } = (await created.json()) as { gameId: string }; const joined = await api(BOB, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 }); - expect((await joined.json()) as object).toMatchObject({ roster: { status: "ready" } }); + // Every accepted command answers with the caller's own session, so a join, a + // start and a move are all read the same way. + expect((await joined.json()) as object).toMatchObject({ session: { status: "ready", version: null } }); // No mirror wait: the DO seats Bob on the join command and verifies the // seat each client sends against its own roster, so Bob can act the // moment his join returns. const started = await api(ALICE, "POST", `/games/${gameId}/start`, {}); - expect(await started.json()).toMatchObject({ version: 0 }); + expect(await started.json()).toMatchObject({ session: { status: "active", version: 0 } }); // Both seats commit against v0. The second arrives stale, and lands, // because RPS masks the opponent's hidden commit (same-view rule). const rock = await api(ALICE, "POST", `/games/${gameId}/action`, { seat: 0, data: { move: "rock" }, expectedVersion: 0 }); - expect(await rock.json()).toMatchObject({ version: 1 }); + expect(await rock.json()).toMatchObject({ session: { version: 1 } }); const scissors = await api(BOB, "POST", `/games/${gameId}/action`, { seat: 1, data: { move: "scissors" }, expectedVersion: 0 }); - const resolved = (await scissors.json()) as { version: number; frame: { outcomes?: unknown[] } }; - expect(resolved).toMatchObject({ version: 2 }); - expect(resolved.frame.outcomes).toHaveLength(2); + const resolved = (await scissors.json()) as { session: { version: number; status: string; frame: { outcomes?: unknown[] } } }; + expect(resolved).toMatchObject({ session: { version: 2, status: "finished" } }); + expect(resolved.session.frame.outcomes).toHaveLength(2); // The finish apply lands in D1 (single attempt, post-commit). `readGameRow` // is the engine's own accessor; the D1 table definitions are internal. diff --git a/examples/rps/vitest.unit.config.mts b/examples/rps/vitest.unit.config.mts index 16b8015..90d3de8 100644 --- a/examples/rps/vitest.unit.config.mts +++ b/examples/rps/vitest.unit.config.mts @@ -1,9 +1,18 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; // Plain-Node vitest for the pure side of the game: the twin-fixture suite and // kernel-level scenarios over the rules unit. No workerd, no bindings. +// +// `forceRerunTriggers` carries the fixtures, which `twinFixtureTests` reads +// with `readFileSync` at collect time and Vite therefore never sees. Without +// it a fixture-only edit reruns nothing in watch mode. Spread the defaults: +// config resolution is a shallow merge, so a bare array drops them. +// +// Deliberately not set on vitest.config.mts, the workers-pool project: a +// fixture cannot change a workerd result, and the rebuild is not free. export default defineConfig({ test: { include: ["test/unit/**/*.spec.ts"], + forceRerunTriggers: [...configDefaults.forceRerunTriggers, "**/src/module/fixtures/**/*.json"], }, }); diff --git a/packages/create-eigen-game/templates/worker/package.json b/packages/create-eigen-game/templates/worker/package.json index e66104d..0a7fd39 100644 --- a/packages/create-eigen-game/templates/worker/package.json +++ b/packages/create-eigen-game/templates/worker/package.json @@ -31,6 +31,7 @@ "contract:check": "eigen-contract --check", "deploy": "wrangler d1 migrations apply GAME_DB --remote && wrangler deploy", "dev": "{{PACKAGE_MANAGER}} run db:migrate:local && wrangler dev", + "inspect": "eigen-inspect", "start": "{{PACKAGE_MANAGER}} run db:migrate:local && wrangler dev", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/create-eigen-game/templates/worker/tsconfig.json b/packages/create-eigen-game/templates/worker/tsconfig.json index aa6b653..35f64c8 100644 --- a/packages/create-eigen-game/templates/worker/tsconfig.json +++ b/packages/create-eigen-game/templates/worker/tsconfig.json @@ -11,5 +11,5 @@ "skipLibCheck": true, "types": ["./worker-configuration.d.ts", "node"] }, - "include": ["src", "test", "worker-configuration.d.ts"] + "include": ["src", "test", "worker-configuration.d.ts", "vitest.config.mts"] } diff --git a/packages/create-eigen-game/templates/worker/vitest.config.mts b/packages/create-eigen-game/templates/worker/vitest.config.mts new file mode 100644 index 0000000..31cf5d7 --- /dev/null +++ b/packages/create-eigen-game/templates/worker/vitest.config.mts @@ -0,0 +1,24 @@ +import { configDefaults, defineConfig } from "vitest/config"; + +/** + * The twin fixtures are read with `readFileSync` at collect time, so they are + * not in Vite's module graph, and `test:watch` would ignore a fixture-only + * edit: Vitest looks a changed path up in the graph, finds nothing, and reruns + * nothing. `forceRerunTriggers` is the supported way to watch a file consumed + * outside the graph, and Vitest's own defaults use it for `package.json` and + * the config files for exactly that reason. + * + * Spread the defaults rather than replacing them. Config resolution is a + * shallow merge, so a bare array silently stops manifest and config edits from + * triggering a rerun. + * + * A match reruns the whole suite rather than one file. That is cheap here + * because these tests are pure Node. Keep the trigger off any + * `@cloudflare/vitest-pool-workers` project added later, where a fixture + * cannot change the result and the rebuild is not free. + */ +export default defineConfig({ + test: { + forceRerunTriggers: [...configDefaults.forceRerunTriggers, "**/src/module/fixtures/**/*.json"], + }, +}); diff --git a/packages/server/openapi.json b/packages/server/openapi.json index f31b2c1..742c02b 100644 --- a/packages/server/openapi.json +++ b/packages/server/openapi.json @@ -420,6 +420,178 @@ "games" ] }, + "Session": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "session" + ] + }, + "seq": { + "type": "integer" + }, + "gameId": { + "type": "string" + }, + "shortCode": { + "type": "string" + }, + "access": { + "$ref": "#/components/schemas/GameAccess" + }, + "schemaVersion": { + "type": "integer" + }, + "config": { + "type": "object" + }, + "turnSeconds": { + "type": [ + "integer", + "null" + ] + }, + "budgetSeconds": { + "type": [ + "integer", + "null" + ] + }, + "incrementSeconds": { + "type": [ + "integer", + "null" + ] + }, + "rated": { + "type": "boolean" + }, + "ratingPool": { + "type": [ + "string", + "null" + ] + }, + "minPlayers": { + "type": "integer" + }, + "maxPlayers": { + "type": "integer" + }, + "createdBy": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/GameStatus" + }, + "players": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Seat" + } + }, + "version": { + "type": [ + "integer", + "null" + ] + }, + "frame": { + "anyOf": [ + { + "$ref": "#/components/schemas/Frame" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "seq", + "gameId", + "shortCode", + "access", + "schemaVersion", + "config", + "turnSeconds", + "budgetSeconds", + "incrementSeconds", + "rated", + "ratingPool", + "minPlayers", + "maxPlayers", + "createdBy", + "status", + "players", + "version", + "frame" + ] + }, + "Frame": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "frame" + ] + }, + "version": { + "type": "integer" + }, + "data": { + "type": "object" + }, + "pendingPlayers": { + "type": "array", + "items": { + "type": "integer" + } + }, + "deadline": { + "type": [ + "integer", + "null" + ] + }, + "playerTimes": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer" + } + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Outcome" + } + }, + "ratings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RatingDelta" + } + } + }, + "required": [ + "type", + "version", + "data", + "pendingPlayers", + "deadline", + "playerTimes" + ] + }, "Players": { "type": "object", "properties": { @@ -715,85 +887,12 @@ "SoloStarted": { "type": "object", "properties": { - "gameId": { - "type": "string" - }, - "shortCode": { - "type": "string" - }, - "version": { - "type": "integer" - }, - "frame": { - "$ref": "#/components/schemas/Frame" + "session": { + "$ref": "#/components/schemas/Session" } }, "required": [ - "gameId", - "shortCode", - "version", - "frame" - ] - }, - "Frame": { - "type": [ - "object", - "null" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "frame" - ] - }, - "version": { - "type": "integer" - }, - "data": { - "type": "object" - }, - "pendingPlayers": { - "type": "array", - "items": { - "type": "integer" - } - }, - "deadline": { - "type": [ - "integer", - "null" - ] - }, - "playerTimes": { - "type": [ - "array", - "null" - ], - "items": { - "type": "integer" - } - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Outcome" - } - }, - "ratings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RatingDelta" - } - } - }, - "required": [ - "type", - "version", - "data", - "pendingPlayers", - "deadline", - "playerTimes" + "session" ] }, "CreateSolo": { @@ -856,44 +955,15 @@ "botIds" ] }, - "Joined": { - "type": "object", - "properties": { - "gameId": { - "type": "string" - }, - "roster": { - "$ref": "#/components/schemas/Roster" - } - }, - "required": [ - "gameId", - "roster" - ] - }, - "Roster": { + "CommandAccepted": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": [ - "roster" - ] - }, - "status": { - "$ref": "#/components/schemas/GameStatus" - }, - "players": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Seat" - } + "session": { + "$ref": "#/components/schemas/Session" } }, "required": [ - "type", - "status", - "players" + "session" ] }, "Join": { @@ -931,17 +1001,6 @@ } ] }, - "LobbyAccepted": { - "type": "object", - "properties": { - "roster": { - "$ref": "#/components/schemas/Roster" - } - }, - "required": [ - "roster" - ] - }, "LobbyCommand": { "type": "object", "properties": { @@ -968,21 +1027,6 @@ "botId" ] }, - "CommandAccepted": { - "type": "object", - "properties": { - "version": { - "type": "integer" - }, - "frame": { - "$ref": "#/components/schemas/Frame" - } - }, - "required": [ - "version", - "frame" - ] - }, "Action": { "type": "object", "properties": { @@ -1573,6 +1617,77 @@ } } }, + "/api/engine/games/{gameId}/session": { + "get": { + "operationId": "getGameSession", + "tags": [ + "Games" + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "gameId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The caller's current session snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/engine/players": { "get": { "operationId": "getPlayers", @@ -2309,11 +2424,11 @@ }, "responses": { "200": { - "description": "Seated: the game's id and the post-join roster", + "description": "Seated: the post-join session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Joined" + "$ref": "#/components/schemas/CommandAccepted" } } } @@ -2399,11 +2514,11 @@ }, "responses": { "200": { - "description": "Seated: the game's id and the post-join roster", + "description": "Seated: the post-join session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Joined" + "$ref": "#/components/schemas/CommandAccepted" } } } @@ -2500,11 +2615,11 @@ }, "responses": { "200": { - "description": "Left: the post-leave roster", + "description": "Left: the post-leave session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LobbyAccepted" + "$ref": "#/components/schemas/CommandAccepted" } } } @@ -2605,7 +2720,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LobbyAccepted" + "$ref": "#/components/schemas/CommandAccepted" } } } @@ -2702,11 +2817,11 @@ }, "responses": { "200": { - "description": "Bot seated: the new roster", + "description": "Bot seated: the post-commit session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LobbyAccepted" + "$ref": "#/components/schemas/CommandAccepted" } } } @@ -2803,7 +2918,7 @@ }, "responses": { "200": { - "description": "Started: version 0 committed", + "description": "Started: the session at version 0", "content": { "application/json": { "schema": { @@ -2904,7 +3019,7 @@ }, "responses": { "200": { - "description": "Committed: the acting seat's frame", + "description": "Committed: the acting seat's session", "content": { "application/json": { "schema": { diff --git a/packages/server/src/do/game-do.ts b/packages/server/src/do/game-do.ts index aa419ff..619033f 100644 --- a/packages/server/src/do/game-do.ts +++ b/packages/server/src/do/game-do.ts @@ -58,7 +58,7 @@ import { type Bot, readBot } from "../d1/reads.js"; import { withRetry } from "../d1/retry.js"; import { type FirebaseAdminEffects, firebaseAdminFromEnv } from "../firebase/admin-effects.js"; import { finishPush, readyPush, turnPush } from "../notify/push.js"; -import type { Command, CommandResult, FrameMessage, GameStub, Principal, RosterSnapshot, SyncMessage } from "../protocol.js"; +import type { Command, CommandResult, FrameMessage, GameStub, Principal, SessionSnapshot } from "../protocol.js"; import migrations from "./migrations/migrations.js"; import * as t from "./schema.js"; @@ -201,21 +201,25 @@ export abstract class BaseGameDO extends DurableObject implements Ga } const status: GameStatus = nextRoster.length >= meta.minPlayers ? "ready" : "waiting"; - const snapshot: RosterSnapshot = { type: "roster", status, players: nextRoster }; - const response: CommandResult = { ok: true, roster: snapshot }; + const seq = meta.seq + 1; + // A lobby command has no version and no frame, so every seat's snapshot is + // the same value; the actor's copy is also the command response. + const header = this.#header(meta, { status, players: nextRoster, seq, version: null }); + const response: CommandResult = { ok: true, session: { ...header, frame: null } }; this.#db.transaction((tx) => { tx.delete(t.roster).run(); for (const seat of nextRoster) { tx.insert(t.roster).values({ playerIndex: seat.playerIndex, userId: seat.userId, botId: seat.botId, type: seat.type }).run(); } - if (status !== meta.status) { - tx.update(t.meta).set({ status }).where(eq(t.meta.id, 1)).run(); - } + tx.update(t.meta) + .set({ seq, ...(status !== meta.status ? { status } : {}) }) + .where(eq(t.meta.id, 1)) + .run(); tx.insert(t.commands).values({ commandId: cmd.commandId, response, createdAt: now }).run(); }); // ── post-commit ── - this.#broadcast(snapshot); + this.#broadcast(header, new Map(), nextRoster); const gameId = meta.gameId; // Background D1 mirror, off the response path. No ctx.waitUntil: a Durable // Object stays alive while a promise is pending, so an unawaited (but @@ -248,10 +252,15 @@ export abstract class BaseGameDO extends DurableObject implements Ga // Terminal status lands in storage first: anything interleaving with the // awaits below already sees an aborted game. if (meta.status !== "aborted") { - this.#db.update(t.meta).set({ status: "aborted" }).where(eq(t.meta.id, 1)).run(); + this.#db + .update(t.meta) + .set({ status: "aborted", seq: meta.seq + 1 }) + .where(eq(t.meta.id, 1)) + .run(); } - await this.#tearDownAborted(meta.gameId, "Game cancelled"); - return { ok: true, roster: { type: "roster", status: "aborted", players: [] } }; + const session = { ...this.#header(meta, { status: "aborted", players: [], seq: meta.seq + 1, version: null }), frame: null }; + await this.#tearDownAborted(meta.gameId, "Game cancelled", session); + return { ok: true, session }; } /** Unconditional teardown (cron reap): mark the game aborted in D1 and @@ -264,18 +273,28 @@ export abstract class BaseGameDO extends DurableObject implements Ga // command interleaving with the teardown's awaits sees an aborted game. const meta = this.#loadMeta(); if (meta !== undefined && meta.status !== "aborted") { - this.#db.update(t.meta).set({ status: "aborted" }).where(eq(t.meta.id, 1)).run(); + this.#db + .update(t.meta) + .set({ status: "aborted", seq: meta.seq + 1 }) + .where(eq(t.meta.id, 1)) + .run(); } - await this.#tearDownAborted(gameId, "Game aborted"); + // A never-touched lobby has no meta row, so there is no header to build and + // no socket to tell: the aborted D1 row is the whole outcome. + const session = meta === undefined ? null : { ...this.#header(meta, { status: "aborted", players: [], seq: meta.seq + 1, version: null }), frame: null }; + await this.#tearDownAborted(gameId, "Game aborted", session); } /** The shared abort teardown: mirror the aborted status to D1 (the only * survivor, awaited so a failure surfaces), notify and close any sockets, * then drop the alarm and all storage. The schema goes with the storage, so * restore it; a later poke lazy-re-inits from the aborted D1 row. */ - async #tearDownAborted(gameId: string, closeReason: string): Promise { + async #tearDownAborted(gameId: string, closeReason: string, session: SessionSnapshot | null): Promise { await mirrorRoster(this.d1(this.env), { gameId, status: "aborted", seats: [], now: Date.now() }); - this.#broadcast({ type: "roster", status: "aborted", players: [] }); + // Told before the sockets close, and `seq` cannot help a client that misses + // it, since the teardown drops the storage the counter lived in. That is why + // the client's rule accepts a terminal status whatever its `seq`. + if (session !== null) this.#sendToAll(session); for (const ws of this.ctx.getWebSockets()) { try { ws.close(1000, closeReason); @@ -379,12 +398,13 @@ export abstract class BaseGameDO extends DurableObject implements Ga const next = plan.nextState; const finish = plan.outcomes === null ? null : { outcomes: plan.outcomes, finishId: crypto.randomUUID() }; const status: GameStatus = finish === null ? "active" : "finished"; - const ownFrame = actingSeat === null ? null : (plan.frames.find((f) => f.playerIndex === actingSeat) ?? null); - const response: CommandResult = { - ok: true, - version: next.version, - frame: ownFrame === null ? null : this.#wireFrame(ownFrame, next, plan.outcomes), - }; + const seq = meta.seq + 1; + const header = this.#header(meta, { status, players: roster, seq, version: next.version }); + const wireFrames = this.#wireFrames(plan.frames, next, plan.outcomes); + // The actor's own copy rides the response, which is what lets a move render + // before the socket delivers it, and is the only delivery on the paths that + // have no socket yet (a freshly created solo game, a move made mid-reconnect). + const response: CommandResult = { ok: true, session: { ...header, frame: actingSeat === null ? null : (wireFrames.get(actingSeat) ?? null) } }; this.#db.transaction((tx) => { tx.insert(t.transitions) @@ -405,18 +425,24 @@ export abstract class BaseGameDO extends DurableObject implements Ga tx.insert(t.frames).values(this.#frameRows(next.version, plan.frames)).run(); } tx.insert(t.commands).values({ commandId: cmd.commandId, response, createdAt: now }).run(); - if (cmd.kind === "start") { - tx.update(t.meta).set({ status, rngSeed: next.rngSeed }).where(eq(t.meta.id, 1)).run(); - } else if (status !== meta.status) { - tx.update(t.meta).set({ status }).where(eq(t.meta.id, 1)).run(); - } + // `seq` advances on every commit; status and the seed only when they move. + // `outcomes` is retained here, unlike the outbox row below that the + // compaction drains, so a cold open of a finished game is answerable. + tx.update(t.meta) + .set({ + seq, + ...(cmd.kind === "start" ? { status, rngSeed: next.rngSeed } : status !== meta.status ? { status } : {}), + ...(finish !== null ? { outcomes: finish.outcomes } : {}), + }) + .where(eq(t.meta.id, 1)) + .run(); if (finish !== null) { tx.insert(t.outbox).values({ finishId: finish.finishId, outcomes: finish.outcomes, createdAt: now }).run(); } }); // ── post-commit ── - this.#fanOut(plan.frames, next, plan.outcomes, roster); + this.#broadcast(header, wireFrames, roster); if (plan.alarm !== null) { await this.ctx.storage.setAlarm(plan.alarm); } else { @@ -663,7 +689,9 @@ export abstract class BaseGameDO extends DurableObject implements Ga // Engine-owned action variant; no game hook produces or ever sees it. const action: TransitionAction = { type: "system", kind: "ratings", data: { deltas }, playerIndex: null }; const frames = this.#project(meta, roster, finalState.state, [], null, false); + const seq = this.#meta().seq + 1; this.#db.transaction((tx) => { + tx.update(t.meta).set({ seq }).where(eq(t.meta.id, 1)).run(); tx.insert(t.transitions).values({ version, state: finalState.state, action, pending: [], deadline: null, playerTimes: null, turnStartedAt: null }).run(); // Uniform like every transition, and drained one statement later by // the compaction, kept ceremony-free on purpose: zero special cases @@ -675,18 +703,11 @@ export abstract class BaseGameDO extends DurableObject implements Ga tx.delete(t.commands).run(); tx.delete(t.outbox).run(); }); - for (const frame of frames) { - const message: FrameMessage = { - type: "frame", - version, - data: frame.data, - pendingPlayers: frame.pendingPlayers, - deadline: null, - playerTimes: null, - ratings: deltas, - }; - this.#send(roster, frame.playerIndex, message); - } + // An ordinary snapshot, no longer a special frame with a `ratings` field + // bolted on: the status is already `finished`, so what this delivers is the + // deltas, riding the same envelope every other commit uses. + const wireFrames = new Map(frames.map((frame) => [frame.playerIndex, { type: "frame", version, data: frame.data, pendingPlayers: frame.pendingPlayers, deadline: null, playerTimes: null, ratings: deltas }])); + this.#broadcast(this.#header(meta, { status: "finished", players: roster, seq, version }), wireFrames, roster); } /** The gated admin re-poke (step 4): re-runs the D1 apply for a @@ -725,10 +746,10 @@ export abstract class BaseGameDO extends DurableObject implements Ga /** The worker routes the upgrade here after authenticating; the principal * header is worker-set (never client-supplied; the worker strips inbound - * headers when forwarding). One socket serves the game's whole lifetime: - * unversioned roster snapshots pre-game, versioned frames from v0. - * A not-yet-seated user's socket simply receives no frames until the - * roster contains them. */ + * headers when forwarding). One socket serves the game's whole lifetime and + * carries one message kind, the per-seat {@link SessionSnapshot}. A + * not-yet-seated user's socket receives the envelope with no frame until the + * roster contains them, which is how it learns the game started at all. */ async fetch(request: Request): Promise { if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { return new Response("Expected a WebSocket upgrade", { status: 426 }); @@ -741,23 +762,22 @@ export abstract class BaseGameDO extends DurableObject implements Ga const pair = new WebSocketPair(); pair[1].serializeAttachment(attachment); this.ctx.acceptWebSocket(pair[1]); - // Either way the open says where the game is, so a client never has to - // guess. Pre-game that is the roster snapshot (idempotent, so a reconnect - // just gets it again); from v0 the roster is frozen and what moves is the - // version, so that is what a sync carries. Both are cheap and neither - // replays history: the client decides what, if anything, to fetch. - const meta = this.#meta(); - if (meta.status === "waiting" || meta.status === "ready") { - pair[1].send(JSON.stringify({ type: "roster", status: meta.status, players: this.#roster() } satisfies RosterSnapshot)); - } else { - const latest = this.#latestTransition(); - if (latest !== null) { - pair[1].send(JSON.stringify({ type: "sync", version: latest.version } satisfies SyncMessage)); - } - } + // The open always states where the game is, at every status, so a client + // never has to guess and never has a window in which it holds a frame + // without the status it belongs to. It replays no history: the snapshot + // carries the newest version and the client decides what, if anything, to + // fetch to fill a gap. A reconnect that missed nothing therefore costs one + // message that the client discards by `seq`. + pair[1].send(JSON.stringify(this.#sessionFor(attachment.userId))); return new Response(null, { status: 101, webSocket: pair[0] }); } + /** The snapshot over RPC, for the HTTP paths that have no socket. */ + async session(gameId: string, userId: string | null): Promise { + if (!(await this.#ensureInit(gameId))) return null; + return this.#sessionFor(userId); + } + async webSocketMessage(): Promise { // Client → server traffic rides HTTP commands; ping/pong is handled by // the auto-responder without waking the DO. Anything else is ignored. @@ -774,56 +794,136 @@ export abstract class BaseGameDO extends DurableObject implements Ga console.error("game socket errored", error); } - #fanOut(frames: ObservationFrame[], next: StateRow, outcomes: OutcomeEntry[] | null, roster: Seat[]): void { + /** The seat-independent part of a snapshot: the immutable header from `meta` + * plus the moving parts every seat sees identically. One builder so a socket + * open, a command response and a fan-out cannot disagree. */ + #header(meta: MetaRow, live: { status: GameStatus; players: Seat[]; seq: number; version: number | null }): Omit { + return { + type: "session", + seq: live.seq, + gameId: meta.gameId, + shortCode: meta.shortCode, + access: meta.access, + schemaVersion: meta.schemaVersion, + config: meta.config, + turnSeconds: meta.turnSeconds, + budgetSeconds: meta.budgetSeconds, + incrementSeconds: meta.incrementSeconds, + rated: meta.rated, + ratingPool: meta.ratingPool, + minPlayers: meta.minPlayers, + maxPlayers: meta.maxPlayers, + createdBy: meta.createdBy, + status: live.status, + players: live.players, + version: live.version, + }; + } + + /** The current snapshot for one principal, read from storage. + * + * Serves the socket open and the HTTP session read, so both answer with + * exactly what a fan-out would have sent. The frame comes from the seat's + * stored row where there is one, and is re-projected otherwise (a finished + * game whose `frames` table the compaction drained), which is the same + * fallback the range fetch uses. */ + #sessionFor(userId: string | null): SessionSnapshot { + const meta = this.#meta(); + const roster = this.#roster(); + const latest = this.#latestTransition(); + const header = this.#header(meta, { status: meta.status, players: roster, seq: meta.seq, version: latest?.version ?? null }); + if (latest === null) return { ...header, frame: null }; + const seat = this.#seatOf(userId, roster); + const view = seat === null ? null : (this.#storedView(latest.version, seat) ?? this.#viewFor(meta, roster, latest, seat, false)); + if (view === null) return { ...header, frame: null }; + const ratings = latest.action !== null && latest.action.kind === "ratings" ? latest.action.data.deltas : null; + // A finished game attaches its outcomes to whatever its newest frame is, + // because that is the only frame a cold-opening client will see. On the live + // path they rode the finishing frame and the ratings transition N+1 carried + // only the deltas; here both arrive together. Either way the client ends up + // holding the same thing. + const outcomes = meta.status === "finished" ? meta.outcomes : null; + return { + ...header, + frame: { + type: "frame", + version: latest.version, + data: view.data, + pendingPlayers: view.pendingPlayers, + deadline: latest.deadline, + playerTimes: latest.playerTimes, + ...(outcomes !== null ? { outcomes } : {}), + ...(ratings !== null ? { ratings } : {}), + }, + }; + } + + /** The seat a principal holds, or null. `join` rejects a duplicate user, so a + * user holds at most one seat; a bot holds seats but never a socket. */ + #seatOf(userId: string | null, roster: Seat[]): number | null { + if (userId === null) return null; + return roster.find((s) => s.userId === userId)?.playerIndex ?? null; + } + + /** Project a commit's frames onto the wire, keyed by seat. */ + #wireFrames(frames: ObservationFrame[], next: StateRow, outcomes: OutcomeEntry[] | null): Map { + const out = new Map(); for (const frame of frames) { - this.#send(roster, frame.playerIndex, this.#wireFrame(frame, next, outcomes)); + out.set(frame.playerIndex, { + type: "frame", + version: next.version, + data: frame.data, + pendingPlayers: frame.pendingPlayers, + deadline: next.deadline, + playerTimes: next.playerTimes, + ...(outcomes !== null ? { outcomes } : {}), + }); } + return out; } - /** Deliver one seat's frame to every socket whose authenticated principal - * owns that seat, resolved against the roster at send time. */ - #send(roster: Seat[], seat: number, message: FrameMessage): void { - const owner = roster.find((s) => s.playerIndex === seat); - if (owner === undefined || owner.userId === null) return; - const payload = JSON.stringify(message); + /** Push the post-commit snapshot to every socket, each getting the frame of + * the seat its own principal holds and nothing else. + * + * This is where hidden information is enforced on the live path: the seat is + * resolved from the socket's authenticated attachment against the roster at + * send time, so a socket opened before its user was seated starts receiving + * that seat's view the moment it is, with no re-tagging, and a socket holding + * no seat receives the envelope with `frame: null`. */ + #broadcast(header: Omit, frames: Map, roster: Seat[]): void { + const unseated = JSON.stringify({ ...header, frame: null } satisfies SessionSnapshot); + const bySeat = new Map(); for (const ws of this.ctx.getWebSockets()) { const attachment = ws.deserializeAttachment() as SocketAttachment | null; - if (attachment?.userId !== null && attachment?.userId === owner.userId) { - try { - ws.send(payload); - } catch { - // A dead socket is the client's reconnect problem; frames are - // recoverable by range fetch. - } + const seat = this.#seatOf(attachment?.userId ?? null, roster); + const frame = seat === null ? undefined : frames.get(seat); + let payload = unseated; + if (seat !== null && frame !== undefined) { + payload = bySeat.get(seat) ?? JSON.stringify({ ...header, frame } satisfies SessionSnapshot); + bySeat.set(seat, payload); + } + try { + ws.send(payload); + } catch { + // A dead socket is the client's reconnect problem: the open snapshot + // states the truth again, and frames are recoverable by range fetch. } } } - /** Push an unversioned roster snapshot to EVERY socket, seated or - * not, it is public lobby information and idempotent by construction. */ - #broadcast(snapshot: RosterSnapshot): void { - const payload = JSON.stringify(snapshot); + /** Push one already-built snapshot to every socket. Only correct where the + * value carries no per-seat frame, which is the abort teardown. */ + #sendToAll(session: SessionSnapshot): void { + const payload = JSON.stringify(session); for (const ws of this.ctx.getWebSockets()) { try { ws.send(payload); } catch { - // Dead socket; reconnect gets the current snapshot on open. + // Dead socket; nothing to recover, the game is over. } } } - #wireFrame(frame: ObservationFrame, next: StateRow, outcomes: OutcomeEntry[] | null): FrameMessage { - return { - type: "frame", - version: next.version, - data: frame.data, - pendingPlayers: frame.pendingPlayers, - deadline: next.deadline, - playerTimes: next.playerTimes, - ...(outcomes !== null ? { outcomes } : {}), - }; - } - // ── Range fetch: live gap recovery AND finished-game replay ──────── /** Project a version range for one seat (null = public viewer, replay @@ -948,6 +1048,9 @@ export abstract class BaseGameDO extends DurableObject implements Ga maxPlayers: row.maxPlayers, createdBy: row.createdBy, rngSeed: null, + shortCode: row.shortCode, + outcomes: row.outcomes, + seq: 0, }) .run(); for (const seat of row.participants) { diff --git a/packages/server/src/do/migrations/0000_init.sql b/packages/server/src/do/migrations/0000_init.sql index b0ed50c..7effbcf 100644 --- a/packages/server/src/do/migrations/0000_init.sql +++ b/packages/server/src/do/migrations/0000_init.sql @@ -27,7 +27,10 @@ CREATE TABLE `meta` ( `min_players` integer NOT NULL, `max_players` integer NOT NULL, `created_by` text, - `rng_seed` text + `rng_seed` text, + `short_code` text NOT NULL, + `outcomes` text, + `seq` integer NOT NULL ); --> statement-breakpoint CREATE TABLE `outbox` ( diff --git a/packages/server/src/do/migrations/meta/0000_snapshot.json b/packages/server/src/do/migrations/meta/0000_snapshot.json index 1a8364a..fbb35dd 100644 --- a/packages/server/src/do/migrations/meta/0000_snapshot.json +++ b/packages/server/src/do/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "29e891f4-ac71-49ed-8528-482498b03f5d", + "id": "b9609cf9-4d3a-4f2d-a6e1-1bcb2f00599d", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "commands": { @@ -188,6 +188,27 @@ "primaryKey": false, "notNull": false, "autoincrement": false + }, + "short_code": { + "name": "short_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcomes": { + "name": "outcomes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, "indexes": {}, diff --git a/packages/server/src/do/schema.ts b/packages/server/src/do/schema.ts index e7935bc..0844554 100644 --- a/packages/server/src/do/schema.ts +++ b/packages/server/src/do/schema.ts @@ -37,6 +37,30 @@ export const meta = sqliteTable("meta", { createdBy: text(), /** Generated by the start command (`randomSeed()`); null in the lobby. */ rngSeed: text(), + /** Copied from D1 so a session snapshot can carry it: the waiting room needs + * the invite code, and the DO is the only thing a game screen talks to. */ + shortCode: text().notNull(), + /** The final per-seat results, written with the finish and RETAINED, unlike + * the `outbox` row that the compaction drains. + * + * Retained because a cold open of a finished game has to be answerable from + * the DO alone: outcomes are kernel output rather than game state, so they are + * in no transition row, and the live finishing frame that carried them is long + * gone by then. Null until the game finishes. */ + outcomes: text({ mode: "json" }).$type(), + /** Monotonic session counter, incremented by EVERY commit, lobby or state. + * + * It totally orders {@link SessionSnapshot}s, which `version` cannot: a lobby + * change has no version, so two roster snapshots and a lobby command's + * response would otherwise be mutually unorderable. A client applies a + * snapshot only when its `seq` exceeds the one it holds, which makes a + * command response racing its own socket push, a duplicate delivery, and a + * reconnect that missed nothing all resolve by comparing one integer. + * + * Reset to 0 by the abort teardown, which drops all storage. Sound because + * abort is terminal and terminal statuses are absorbing, so the client's rule + * accepts a terminal snapshot whatever its `seq`. */ + seq: integer().notNull(), }); /** Seats. Both ids null ⇒ account purged mid-game (plays on as "Deleted diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6dbba34..39d332d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,4 +23,4 @@ export { BaseGameDO, DEADLINE_GRACE_MS } from "./do/game-do.js"; export { createEngine, DEFAULT_CREDIT, type EngineConfig, type LegalConfig, type OperatorConfig, openApiDocument, type SiteConfig } from "./engine.js"; export type { FirebaseAdminEffects } from "./firebase/admin-effects.js"; export { HttpError } from "./http.js"; -export type { Command, CommandResult, FrameMessage, LobbyRejectCode, Principal, RosterSnapshot } from "./protocol.js"; +export type { Command, CommandResult, FrameMessage, LobbyRejectCode, Principal, SessionSnapshot } from "./protocol.js"; diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 8f38207..ed77474 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -7,7 +7,7 @@ */ import type { GameStatus, RatingDelta, RejectCode, Seat } from "@eigeninteractive/kernel"; -import type { JsonObject, LifecycleType, OutcomeEntry } from "@eigeninteractive/rules"; +import type { GameAccess, JsonObject, LifecycleType, OutcomeEntry } from "@eigeninteractive/rules"; /** Re-exported from the kernel, where rating math and its shapes live. */ export type { RatingDelta }; @@ -76,13 +76,59 @@ export type LobbyRejectCode = /** The creator cannot leave; they cancel instead. */ | "creatorCannotLeave"; -/** The unversioned pre-game snapshot: pushed to every socket on any - * roster change, idempotent, so a reconnect just gets the current one. Also the - * response body of an accepted waiting-room command. */ -export interface RosterSnapshot { - type: "roster"; +/** + * The complete live truth about one game, as ONE SEAT sees it: the only message + * the socket carries, and the body of every accepted command. + * + * Sent on socket open whatever the status, and after every committed change, + * lobby or state. Self-describing and idempotent: a client that applies the + * newest one it has seen is correct, having missed any number of earlier ones, + * so there is no state for it to reconstruct and no channel for it to correlate + * against another. + * + * It carries the immutable header as well as the moving parts because a game + * screen must not need a second source. That is what the old split cost: status + * lived only in a D1 read nothing re-issued, so a client could observe a frame + * without the status it belonged to, and never learned a game had started. + * + * Hidden information is safe by construction: the envelope is projected per seat + * before it is sent, and `frame` is only ever the receiving principal's own + * seat's view. + */ +export interface SessionSnapshot { + type: "session"; + /** Monotonic per game, incremented by every commit. Totally orders snapshots + * across every path they arrive by, which `version` cannot do because a lobby + * change has none. Apply a snapshot when `seq` exceeds the held one, OR when + * it reports a terminal status the held state does not: `finished` and + * `aborted` are absorbing, so they need no ordering, and the abort teardown + * drops the storage `seq` lived in. */ + seq: number; + + /** Fixed at creation; carried so this is sufficient on its own. */ + gameId: string; + shortCode: string; + access: GameAccess; + schemaVersion: number; + config: JsonObject; + turnSeconds: number | null; + budgetSeconds: number | null; + incrementSeconds: number | null; + rated: boolean; + ratingPool: string | null; + minPlayers: number; + maxPlayers: number; + createdBy: string | null; + + /** What moves. */ status: GameStatus; players: Seat[]; + /** The newest committed version, or null while the game is in the lobby. */ + version: number | null; + /** The receiving seat's observation at `version`. Null in the lobby, and null + * for a principal holding no seat, which is how an unseated client still + * learns that the game started. */ + frame: FrameMessage | null; } /** One seat's versioned frame on the wire: the socket fan-out payload, and @@ -100,28 +146,16 @@ export interface FrameMessage { ratings?: RatingDelta[]; } -/** Sent once, on a mid-game socket open, saying where the game currently is. - * - * The pre-game equivalent is the {@link RosterSnapshot} that rides the open; - * from v0 onward the roster is frozen, so this carries the one thing that does - * still move: the newest committed version. +/** What `GameDO.handle()` returns: one accepted shape for every command kind, + * the caller's own post-commit {@link SessionSnapshot}, so a lobby command and a + * move answer with the same value and the client feeds both into one path. * - * It exists so a client can reconcile in one step instead of guessing. A cold - * open learns which version to load without replaying the whole game, and a - * reconnect can compare against its own cursor and skip the catch-up fetch - * entirely when nothing was missed, the common case on a flaky connection, - * where reconnects are frequent but usually miss nothing. */ -export interface SyncMessage { - type: "sync"; - version: number; -} - -/** What `GameDO.handle()` returns; accepted results are stored for commandId - * dedupe and replayed verbatim to a retry. Rejections are computed - * fresh each time, since re-evaluating one is always sound. State-transitioning - * commands answer with a version (+ the acting seat's frame); waiting-room - * commands answer with the post-commit roster snapshot. */ -export type CommandResult = { ok: true; version: number; frame: FrameMessage | null } | { ok: true; roster: RosterSnapshot } | { ok: false; code: RejectCode | LobbyRejectCode; message: string }; + * Accepted results are stored for commandId dedupe and replayed verbatim to a + * retry, which means a retry receives the snapshot as it was at first execution. + * That is harmless rather than stale: `seq` orders it against whatever the + * client now holds, so an older one is simply discarded. Rejections are computed + * fresh each time, since re-evaluating one is always sound. */ +export type CommandResult = { ok: true; session: SessionSnapshot } | { ok: false; code: RejectCode | LobbyRejectCode; message: string }; /** The DO surface the worker calls: structurally the RPC stub of any * `BaseGameDO` subclass. Lives here (not in `engine.ts`) so the lifecycle @@ -129,6 +163,10 @@ export type CommandResult = { ok: true; version: number; frame: FrameMessage | n * factory. */ export interface GameStub { handle(cmd: Command): Promise; + /** The current snapshot for one principal, for the paths with no socket: a + * cold HTTP read, a deep-link preview, a spectator. Null when no such game + * exists. `userId` null means "no seat", which yields `frame: null`. */ + session(gameId: string, userId: string | null): Promise; frames(args: { seat: number | null; from: number; to: number; isReplay?: boolean }): Promise; repokeFinish(): Promise; /** Unconditional teardown: mark the game aborted, drop DO storage. diff --git a/packages/server/src/routes/games.ts b/packages/server/src/routes/games.ts index e26b48b..56dd698 100644 --- a/packages/server/src/routes/games.ts +++ b/packages/server/src/routes/games.ts @@ -18,7 +18,7 @@ import { HttpError, unwrap } from "../http.js"; import { gameInvitePush } from "../notify/push.js"; import type { Command, CommandResult } from "../protocol.js"; import { enforceRateLimit } from "../rate-limit.js"; -import { actionBody, addBotBody, commandAcceptedShape, createdShape, createGameBody, createSoloBody, errorShape, forfeitBody, frameShape, joinBody, joinByCodeBody, joinedShape, lobbyAcceptedShape, lobbyCommandBody, soloStartedShape } from "./wire.js"; +import { actionBody, addBotBody, commandAcceptedShape, createdShape, createGameBody, createSoloBody, errorShape, forfeitBody, frameShape, joinBody, joinByCodeBody, lobbyCommandBody, soloStartedShape } from "./wire.js"; // ── Route plumbing ──────────────────────────────────────────────────────────── @@ -48,20 +48,11 @@ function createdResponses(schema: T, description: string) { const gameIdParam = z.object({ gameId: z.string().min(1) }); -/** Narrow an accepted result to the lobby (roster) variant, stripping the - * internal `ok` discriminator; success is the HTTP 200, not a body field. */ -function lobbyResult(result: CommandResult) { - const value = unwrap(result); - if (!("roster" in value)) throw new HttpError(500, "engine bug: expected a roster response"); - return { roster: value.roster }; -} - -/** Narrow an accepted result to the versioned (frame) variant, stripping the - * internal `ok` discriminator; success is the HTTP 200, not a body field. */ +/** Strip the internal `ok` discriminator from an accepted result; success is the + * HTTP 200, not a body field. One helper for every command kind, because they + * all answer with the caller's session now. */ function commandResult(result: CommandResult) { - const value = unwrap(result); - if (!("version" in value)) throw new HttpError(500, "engine bug: expected a versioned response"); - return { version: value.version, frame: value.frame }; + return { session: unwrap(result).session }; } function rulesFor(ctx: RouteContext, schemaVersion: number): GameRules { @@ -316,13 +307,15 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { // Start immediately: the DO lazy-inits from D1 (bots included) and // commits v0; a bot due to open plays via its in-DO brain post-commit - // (arriving over the socket). A start has no single acting seat, so its - // response carries no frame, so read the creator's opening projection back - // so the client has the initial board without a round trip. + // (arriving over the socket). A start has no single acting seat, so the + // committed response carries no frame; read the creator's session back so + // the client has the opening board without a round trip. The game is + // already running before any socket exists, so this is its only delivery. const stub = ctx.stub(c.env, gameId); - const started = commandResult(await stub.handle(mint(auth, "start", gameId, undefined))); - const [frame] = await stub.frames({ seat: 0, from: started.version, to: started.version }); - return c.json({ gameId, shortCode, version: started.version, frame: frame ?? null }, 201); + unwrap(await stub.handle(mint(auth, "start", gameId, undefined))); + const session = await stub.session(gameId, auth.user.id); + if (session === null) throw new HttpError(500, "engine bug: started game has no session"); + return c.json({ session }, 201); }, ); @@ -350,7 +343,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { if (await isBlockedAmong(ctx.d1(c.env), auth.user.id, seatedUserIds)) { throw new HttpError(404, "Unknown game", "unknownGame"); } - return lobbyResult(await ctx.stub(c.env, game.id).handle(mint(c.var.auth, "join", game.id, commandId))); + return commandResult(await ctx.stub(c.env, game.id).handle(mint(c.var.auth, "join", game.id, commandId))); }; app.openapi( @@ -360,13 +353,13 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "joinGame", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(joinBody) }, - responses: responses(joinedShape, "Seated: the game's id and the post-join roster"), + responses: responses(commandAcceptedShape, "Seated: the post-join session"), }), async (c) => { const body = c.req.valid("json"); const game = await loadGame(ctx, c.env, c.req.valid("param").gameId); const seated = await join(c, game, body.clientSchemaVersion, body.commandId); - return c.json({ gameId: game.id, ...seated }, 200); + return c.json(seated, 200); }, ); @@ -377,14 +370,14 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "joinGameByCode", tags: ["Games"], request: { body: jsonBody(joinByCodeBody) }, - responses: responses(joinedShape, "Seated: the game's id and the post-join roster"), + responses: responses(commandAcceptedShape, "Seated: the post-join session"), }), async (c) => { const body = c.req.valid("json"); const game = await readGameByCode(ctx.d1(c.env), body.shortCode.toUpperCase()); if (game === undefined) throw new HttpError(404, "No game with that code", "unknownGame"); const seated = await join(c, game, body.clientSchemaVersion, body.commandId); - return c.json({ gameId: game.id, ...seated }, 200); + return c.json(seated, 200); }, ); @@ -395,12 +388,12 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "leaveGame", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(lobbyCommandBody) }, - responses: responses(lobbyAcceptedShape, "Left: the post-leave roster"), + responses: responses(commandAcceptedShape, "Left: the post-leave session"), }), async (c) => { const { gameId } = c.req.valid("param"); const result = await ctx.stub(c.env, gameId).handle(mint(c.var.auth, "leave", gameId, c.req.valid("json").commandId)); - return c.json(lobbyResult(result), 200); + return c.json(commandResult(result), 200); }, ); @@ -411,12 +404,12 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "cancelGame", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(lobbyCommandBody) }, - responses: responses(lobbyAcceptedShape, "Cancelled"), + responses: responses(commandAcceptedShape, "Cancelled"), }), async (c) => { const { gameId } = c.req.valid("param"); const result = await ctx.stub(c.env, gameId).handle(mint(c.var.auth, "cancel", gameId, c.req.valid("json").commandId)); - return c.json(lobbyResult(result), 200); + return c.json(commandResult(result), 200); }, ); @@ -431,7 +424,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "addBot", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(addBotBody) }, - responses: responses(lobbyAcceptedShape, "Bot seated: the new roster"), + responses: responses(commandAcceptedShape, "Bot seated: the post-commit session"), }), async (c) => { const auth = c.var.auth; @@ -442,7 +435,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { if (bot === undefined) throw new HttpError(404, "Bot not found"); assertBotSeatable(ctx, game, bot); const cmd: Command = { kind: "add-bot", gameId, commandId: body.commandId ?? crypto.randomUUID(), actor: { userId: auth.user.id, botId: null }, botId: bot.id }; - return c.json(lobbyResult(await ctx.stub(c.env, gameId).handle(cmd)), 200); + return c.json(commandResult(await ctx.stub(c.env, gameId).handle(cmd)), 200); }, ); @@ -453,7 +446,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "startGame", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(lobbyCommandBody) }, - responses: responses(commandAcceptedShape, "Started: version 0 committed"), + responses: responses(commandAcceptedShape, "Started: the session at version 0"), }), async (c) => { const { gameId } = c.req.valid("param"); @@ -473,7 +466,7 @@ export function registerGameRoutes(app: EngineApp, ctx: RouteContext): void { operationId: "submitAction", tags: ["Games"], request: { params: gameIdParam, body: jsonBody(actionBody) }, - responses: responses(commandAcceptedShape, "Committed: the acting seat's frame"), + responses: responses(commandAcceptedShape, "Committed: the acting seat's session"), }), async (c) => { const body = c.req.valid("json"); diff --git a/packages/server/src/routes/reads.ts b/packages/server/src/routes/reads.ts index 9bb56eb..a34ae60 100644 --- a/packages/server/src/routes/reads.ts +++ b/packages/server/src/routes/reads.ts @@ -8,7 +8,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { clampIds, readBots, readGame, readLobby, readMyGames, readPlayerPublicGames, readPlayers, readRatingHistory, readRatings } from "../d1/reads.js"; import type { EngineApp, RouteContext } from "../engine.js"; import { HttpError } from "../http.js"; -import { botOf, botShape, errorShape, gameSummaryOf, gameSummaryShape, playerOf, playerShape, profileShape } from "./wire.js"; +import { botOf, botShape, errorShape, gameSummaryOf, gameSummaryShape, playerOf, playerShape, profileShape, sessionShape } from "./wire.js"; function okResponse(schema: T, description: string) { const error = (what: string) => ({ content: { "application/json": { schema: errorShape } }, description: what }); @@ -78,6 +78,28 @@ export function registerReadRoutes(app: EngineApp, ctx: RouteContext): void { }, ); + // The one read that goes to the DO rather than D1, because it asks the + // question only the DO can answer: where is this game NOW, as I see it. The + // socket delivers the same value and keeps delivering it, so a client with a + // socket never needs this; it serves the paths without one, and a cold read + // that wants the truth rather than the index's mirror of it. + app.openapi( + createRoute({ + method: "get", + path: "/games/{gameId}/session", + operationId: "getGameSession", + tags: ["Games"], + request: { params: z.object({ gameId: z.string().min(1) }) }, + responses: okResponse(sessionShape, "The caller's current session snapshot"), + }), + async (c) => { + const { gameId } = c.req.valid("param"); + const session = await ctx.stub(c.env, gameId).session(gameId, c.var.auth.user.id); + if (session === null) throw new HttpError(404, "Unknown game", "unknownGame"); + return c.json(session, 200); + }, + ); + // The batch identity endpoint, and why games rows carry no denormalized // identity: a renamed user is correct everywhere on the next fetch, with no // history rewrite. The client's persisted cache keeps this warm. diff --git a/packages/server/src/routes/wire.ts b/packages/server/src/routes/wire.ts index 780e7bd..29e1f68 100644 --- a/packages/server/src/routes/wire.ts +++ b/packages/server/src/routes/wire.ts @@ -131,44 +131,55 @@ export const frameShape = z }) .openapi("Frame"); -export const rosterShape = z +/** The complete live truth about one game as one seat sees it: the only message + * the socket carries, and the body of every accepted command. + * + * One shape for every path a client can learn about a game, which is the point. + * It carries the immutable header as well as the moving parts so a game screen + * needs no second source, and `frame` is only ever the receiving seat's own view, + * projected before send. Ordered by `seq` rather than `version`, because a lobby + * change has no version. */ +export const sessionShape = z .object({ - type: z.literal("roster"), + type: z.literal("session"), + seq: z.number().int(), + gameId: z.string(), + shortCode: z.string(), + access: gameAccessShape, + schemaVersion: z.number().int(), + config: jsonObjectShape, + turnSeconds: z.number().int().nullable(), + budgetSeconds: z.number().int().nullable(), + incrementSeconds: z.number().int().nullable(), + rated: z.boolean(), + ratingPool: z.string().nullable(), + minPlayers: z.number().int(), + maxPlayers: z.number().int(), + createdBy: z.string().nullable(), status: gameStatusShape, players: z.array(seatShape), + /** Null while the game is in the lobby. */ + version: z.number().int().nullable(), + /** Null in the lobby, and for a caller holding no seat. + * + * Spelled as a union with `z.null()` rather than `.nullable()` because a + * nullable `$ref` loses its null branch on the way into the OpenAPI + * document: the emitter resolves it to a bare `$ref`, and the generated Dart + * then types the field non-nullable and throws parsing a lobby session, + * which is the common case. A union emits `anyOf: [$ref, {type: null}]`, + * which survives. */ + frame: z.union([frameShape, z.null()]), }) - .openapi("Roster"); + .openapi("Session"); -/** An accepted state-transitioning command: the committed version, plus the - * acting seat's own frame riding the response. */ +/** Every accepted command answers with the caller's own post-commit session, so + * a join, a start and a move are one client path rather than three. */ export const commandAcceptedShape = z .object({ - version: z.number().int(), - frame: frameShape.nullable(), + session: sessionShape, }) .openapi("CommandAccepted"); -/** An accepted waiting-room command: the post-commit roster snapshot. */ -export const lobbyAcceptedShape = z - .object({ - roster: rosterShape, - }) - .openapi("LobbyAccepted"); - -/** An accepted join, by id or by short code. - * - * Both forms answer identically; they are the same operation from the caller's - * side ("seat me in this game"), and only differ in how the game was named. The - * id is echoed rather than assumed because the by-code caller never had it, and - * a single shape means one client path from either entry point instead of two - * that happen to agree. */ -export const joinedShape = z - .object({ - gameId: z.string(), - roster: rosterShape, - }) - .openapi("Joined"); - export const gameSummaryShape = z .object({ id: z.string(), @@ -311,7 +322,9 @@ export const createSoloBody = z /** The started solo game: its ids plus the caller's committed v0 frame (the * same ride-along an action response carries). */ -export const soloStartedShape = z.object({ gameId: z.string(), shortCode: z.string(), version: z.number().int(), frame: frameShape.nullable() }).openapi("SoloStarted"); +/** A created-and-started solo game. The session is the only delivery of its + * opening frame: the game is already running before any socket exists. */ +export const soloStartedShape = z.object({ session: sessionShape }).openapi("SoloStarted"); /** Client retries reuse the same commandId, so the DO replays the stored * response instead of re-executing. */ diff --git a/packages/server/test/blocks.spec.ts b/packages/server/test/blocks.spec.ts index 149dbc5..3b23fbb 100644 --- a/packages/server/test/blocks.spec.ts +++ b/packages/server/test/blocks.spec.ts @@ -119,8 +119,8 @@ describe("blocking: the seating boundary", () => { const game = await createGame(a); await block(a, b); // C is blocked with nobody: the seat is open to them. - const joined = await json<{ roster: { players: unknown[] } }>(await api(c, "POST", `/games/${game.gameId}/join`, { clientSchemaVersion: 1 })); - expect(joined.roster.players.length).toBe(2); + const joined = await json<{ session: { players: unknown[] } }>(await api(c, "POST", `/games/${game.gameId}/join`, { clientSchemaVersion: 1 })); + expect(joined.session.players.length).toBe(2); }); }); diff --git a/packages/server/test/engine.spec.ts b/packages/server/test/engine.spec.ts index 2d9ab57..92635b7 100644 --- a/packages/server/test/engine.spec.ts +++ b/packages/server/test/engine.spec.ts @@ -42,12 +42,20 @@ interface Created { gameId: string; shortCode: string; } -interface LobbyOk { - roster: { type: "roster"; status: string; players: { playerIndex: number; userId: string | null; botId: string | null; type: string }[] }; +interface Session { + type: "session"; + seq: number; + gameId: string; + status: string; + version: number | null; + players: { playerIndex: number; userId: string | null; botId: string | null; type: string }[]; + shortCode: string; + frame: { version: number; data: Record; pendingPlayers: number[]; outcomes?: unknown[]; ratings?: unknown[] } | null; } +/** Every accepted command answers with the caller's own session, so the lobby + * and the play paths share one alias where they used to need two. */ interface CommandOk { - version: number; - frame: { version: number; data: Record; pendingPlayers: number[]; outcomes?: unknown[] } | null; + session: Session; } const createBody = { access: "public" as const, schemaVersion: 1, config: { target: 3 }, minPlayers: 2, maxPlayers: 2 }; @@ -129,9 +137,10 @@ describe("waiting room", () => { const u = makeUsers(); const { gameId } = await createGame(u.a, { rated: false }); - const joined = await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); - expect(joined.roster.status).toBe("ready"); - expect(joined.roster.players.map((p) => p.userId)).toEqual([u.a, u.b]); + const joined = await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); + expect(joined.session.status).toBe("ready"); + expect(joined.session.players.map((p) => p.userId)).toEqual([u.a, u.b]); + expect(joined.session.version).toBeNull(); const dup = await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 }); expect(dup.status).toBe(409); @@ -149,8 +158,8 @@ describe("waiting room", () => { expect(old.status).toBe(409); expect(((await old.json()) as { code: string }).code).toBe("schemaUnsupported"); - const joined = await json(await api(u.b, "POST", "/games/join-by-code", { shortCode: shortCode.toLowerCase(), clientSchemaVersion: 1 })); - expect(joined.roster.players).toHaveLength(2); + const joined = await json(await api(u.b, "POST", "/games/join-by-code", { shortCode: shortCode.toLowerCase(), clientSchemaVersion: 1 })); + expect(joined.session.players).toHaveLength(2); expect((await db.select().from(participants).where(eq(participants.gameId, gameId)).all()).length).toBeGreaterThanOrEqual(1); }); @@ -163,14 +172,14 @@ describe("waiting room", () => { it("leave compacts and demotes below minPlayers; the creator cannot leave", async () => { const u = makeUsers(); const { gameId } = await createGame(u.a, { rated: false }); - await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); + await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); const creator = await api(u.a, "POST", `/games/${gameId}/leave`, {}); expect(((await creator.json()) as { code: string }).code).toBe("creatorCannotLeave"); - const left = await json(await api(u.b, "POST", `/games/${gameId}/leave`, {})); - expect(left.roster.status).toBe("waiting"); - expect(left.roster.players).toEqual([{ playerIndex: 0, userId: u.a, botId: null, type: "human" }]); + const left = await json(await api(u.b, "POST", `/games/${gameId}/leave`, {})); + expect(left.session.status).toBe("waiting"); + expect(left.session.players).toEqual([{ playerIndex: 0, userId: u.a, botId: null, type: "human" }]); await vi.waitFor(async () => { const rows = await db.select().from(participants).where(eq(participants.gameId, gameId)).all(); @@ -183,8 +192,8 @@ describe("waiting room", () => { const { gameId } = await createGame(u.a, { rated: false }); expect((await api(u.b, "POST", `/games/${gameId}/cancel`, {})).status).toBe(403); - const cancelled = await json(await api(u.a, "POST", `/games/${gameId}/cancel`, {})); - expect(cancelled.roster.status).toBe("aborted"); + const cancelled = await json(await api(u.a, "POST", `/games/${gameId}/cancel`, {})); + expect(cancelled.session.status).toBe("aborted"); const detail = await json<{ status: string; participants: unknown[] }>(await api(u.a, "GET", `/games/${gameId}`)); expect(detail.status).toBe("aborted"); @@ -208,18 +217,19 @@ describe("waiting room", () => { const early = await api(u.a, "POST", `/games/${gameId}/start`, {}); expect(early.status).toBe(409); // waiting, not ready - await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); + await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); expect((await api(u.b, "POST", `/games/${gameId}/start`, {})).status).toBe(403); const started = await json(await api(u.a, "POST", `/games/${gameId}/start`, {})); - expect(started.version).toBe(0); + expect(started.session.version).toBe(0); + expect(started.session.status).toBe("active"); }); }); describe("active play & frames", () => { async function readyGame(u: ReturnType, overrides: Record = {}) { const { gameId } = await createGame(u.a, overrides); - await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); + await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); // No mirror wait: the DO resolves seats from its own roster, so the // joiner can act the moment the join response lands. await json(await api(u.a, "POST", `/games/${gameId}/start`, {})); @@ -231,8 +241,8 @@ describe("active play & frames", () => { const gameId = await readyGame(u, { rated: false }); const a1 = await json(await api(u.a, "POST", `/games/${gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); - expect(a1.version).toBe(1); - expect(a1.frame?.data).toEqual({ count: 1 }); + expect(a1.session.version).toBe(1); + expect(a1.session.frame?.data).toEqual({ count: 1 }); // A non-participant naming a seat they don't hold is a clean 403. expect((await api(u.c, "POST", `/games/${gameId}/action`, { seat: 1, data: { add: 1 }, expectedVersion: 1 })).status).toBe(403); @@ -247,7 +257,8 @@ describe("active play & frames", () => { await json(await api(u.a, "POST", `/games/${gameId}/action`, { seat: 0, data: { add: 2 }, expectedVersion: 0 })); const finish = await json(await api(u.b, "POST", `/games/${gameId}/action`, { seat: 1, data: { add: 2 }, expectedVersion: 1 })); - expect(finish.frame?.outcomes).toBeDefined(); + expect(finish.session.frame?.outcomes).toBeDefined(); + expect(finish.session.status).toBe("finished"); await vi.waitFor(async () => { const detail = await json<{ status: string; outcomes: unknown[] | null }>(await api(u.a, "GET", `/games/${gameId}`)); @@ -281,7 +292,7 @@ describe("active play & frames", () => { const u = makeUsers(); const gameId = await readyGame(u, { rated: false }); const result = await json(await api(u.b, "POST", `/games/${gameId}/forfeit`, { seat: 1 })); - expect(result.frame?.outcomes).toBeDefined(); + expect(result.session.frame?.outcomes).toBeDefined(); }); it("keeps frames private: a non-participant cannot read an active game", async () => { @@ -291,7 +302,7 @@ describe("active play & frames", () => { }); }); -describe("socket (roster snapshots → frames)", () => { +describe("socket (session snapshots)", () => { it("accepts the configured browser origin and rejects an unknown one", async () => { const u = makeUsers(); const { gameId } = await createGame(u.a, { rated: false }); @@ -326,7 +337,7 @@ describe("socket (roster snapshots → frames)", () => { expect(res.status).toBe(101); const ws = res.webSocket; if (!ws) throw new Error("no websocket on the 101 response"); - const messages: { type: string; status?: string; players?: unknown[]; version?: number }[] = []; + const messages: Session[] = []; ws.addEventListener("message", (event: MessageEvent) => { messages.push(JSON.parse(event.data as string)); }); @@ -334,19 +345,22 @@ describe("socket (roster snapshots → frames)", () => { // Pre-join, pre-start: the current snapshot rides the open. await vi.waitFor(() => expect(messages).toHaveLength(1)); - expect(messages[0]).toMatchObject({ type: "roster", status: "waiting" }); + expect(messages[0]).toMatchObject({ type: "session", status: "waiting", version: null, frame: null }); expect(messages[0]?.players).toHaveLength(1); // Joining pushes the new snapshot to every socket, including this one, // whose principal just became seat 1. - await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); + await json(await api(u.b, "POST", `/games/${gameId}/join`, { clientSchemaVersion: 1 })); await vi.waitFor(() => expect(messages).toHaveLength(2)); - expect(messages[1]).toMatchObject({ type: "roster", status: "ready" }); + expect(messages[1]).toMatchObject({ type: "session", status: "ready" }); // Start: v0 fan-out reaches the seat through the SAME socket. await json(await api(u.a, "POST", `/games/${gameId}/start`, {})); await vi.waitFor(() => expect(messages).toHaveLength(3)); - expect(messages[2]).toMatchObject({ type: "frame", version: 0 }); + // The status and the opening frame arrive as one value, which is what makes + // a waiting room that never learns the game started impossible. + expect(messages[2]).toMatchObject({ type: "session", status: "active", version: 0 }); + expect(messages[2]?.frame).toMatchObject({ version: 0 }); ws.close(); }); }); @@ -395,15 +409,16 @@ describe("bots", () => { it("plays a human-vs-bot solo game through the in-DO brain", async () => { const u = makeUsers(); - const solo = await json<{ gameId: string; version: number; frame: { data: { count: number } } | null }>(await api(u.a, "POST", "/games/solo", soloBody(ENGINE)), 201); - expect(solo.version).toBe(0); - expect(solo.frame?.data.count).toBe(0); + const solo = await json(await api(u.a, "POST", "/games/solo", soloBody(ENGINE)), 201); + expect(solo.session.version).toBe(0); + expect(solo.session.status).toBe("active"); + expect(solo.session.frame?.data.count).toBe(0); // The human opens (v1); the bot answers via its in-DO brain (add 1), // committing v2 with no second client involved. - await json(await api(u.a, "POST", `/games/${solo.gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); + await json(await api(u.a, "POST", `/games/${solo.session.gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); await vi.waitFor(async () => { - const frames = await json<{ frames: { version: number; data: { count: number } }[] }>(await api(u.a, "GET", `/games/${solo.gameId}/frames?from=0&to=10`)); + const frames = await json<{ frames: { version: number; data: { count: number } }[] }>(await api(u.a, "GET", `/games/${solo.session.gameId}/frames?from=0&to=10`)); expect(frames.frames.find((f) => f.version === 2)?.data.count).toBe(2); }); }); @@ -433,13 +448,13 @@ describe("bots", () => { // An external bot: its webhook_url means the DO wakes it instead of running // an in-DO brain. The wake is intercepted (202) in beforeAll; the move // arrives here on bot/action. - const solo = await json<{ gameId: string }>(await api(u.a, "POST", "/games/solo", soloBody(EXTERNAL)), 201); + const solo = await json(await api(u.a, "POST", "/games/solo", soloBody(EXTERNAL)), 201); // Human opens (v1); now seat 1 (the external bot) is due. - await json(await api(u.a, "POST", `/games/${solo.gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); + await json(await api(u.a, "POST", `/games/${solo.session.gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); // The bot signs the EXACT body bytes it sends and carries the signature in // the Eigen-Signature header, bound to the `action` domain. - const body = JSON.stringify({ botId: EXTERNAL, gameId: solo.gameId, playerIndex: 1, version: 1, data: { add: 1 } }); + const body = JSON.stringify({ botId: EXTERNAL, gameId: solo.session.gameId, playerIndex: 1, version: 1, data: { add: 1 } }); const good = await exports.default.fetch("https://x/api/bot/action", { method: "POST", headers: { "content-type": "application/json", "eigen-signature": await signForBot(BOT_SECRET, EXTERNAL, "action", body) }, @@ -447,7 +462,7 @@ describe("bots", () => { }); expect(good.status).toBe(204); await vi.waitFor(async () => { - const frames = await json<{ frames: { version: number; data: { count: number } }[] }>(await api(u.a, "GET", `/games/${solo.gameId}/frames?from=0&to=10`)); + const frames = await json<{ frames: { version: number; data: { count: number } }[] }>(await api(u.a, "GET", `/games/${solo.session.gameId}/frames?from=0&to=10`)); expect(frames.frames.find((f) => f.version === 2)?.data.count).toBe(2); }); @@ -475,10 +490,10 @@ describe("bots", () => { // Sign a body with the derived key alone, no master secret in sight. const u = makeUsers(); - const solo = await json<{ gameId: string }>(await api(u.a, "POST", "/games/solo", soloBody(EXTERNAL)), 201); - await json(await api(u.a, "POST", `/games/${solo.gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); + const solo = await json(await api(u.a, "POST", "/games/solo", soloBody(EXTERNAL)), 201); + await json(await api(u.a, "POST", `/games/${solo.session.gameId}/action`, { seat: 0, data: { add: 1 }, expectedVersion: 0 })); - const body = JSON.stringify({ botId: EXTERNAL, gameId: solo.gameId, playerIndex: 1, version: 1, data: { add: 1 } }); + const body = JSON.stringify({ botId: EXTERNAL, gameId: solo.session.gameId, playerIndex: 1, version: 1, data: { add: 1 } }); const ownerKey = await crypto.subtle.importKey("raw", Uint8Array.fromBase64(derived) as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", ownerKey, new TextEncoder().encode(`action:${body}`))).toBase64(); diff --git a/packages/server/test/game-do.spec.ts b/packages/server/test/game-do.spec.ts index 7e215d1..a75611f 100644 --- a/packages/server/test/game-do.spec.ts +++ b/packages/server/test/game-do.spec.ts @@ -15,8 +15,8 @@ import { eq } from "drizzle-orm"; import { describe, expect, it, vi } from "vitest"; import { orm } from "../src/d1/orm.js"; import { games, playerRatings, ratingHistory, users } from "../src/d1/schema.js"; -import { type Command, createGame, type FrameMessage } from "../src/index.js"; -import type { SyncMessage } from "../src/protocol.js"; +import { type Command, createGame } from "../src/index.js"; +import type { SessionSnapshot } from "../src/protocol.js"; import { userRow } from "./factories.js"; /** Typed D1 access for seeds and assertions. The DO's own SQLite is @@ -28,6 +28,9 @@ interface SeedOptions { rated?: boolean; turnSeconds?: number | null; status?: Extract; + /** How many of the two seats are already taken. One leaves room for a join, + * which is the only way to exercise a waiting-to-ready transition. */ + seats?: 1 | 2; } let gameCounter = 0; @@ -58,10 +61,7 @@ async function seedGame(opts: SeedOptions = {}): Promise { minPlayers: 2, maxPlayers: 2, shortCode: gameId.slice(0, 6) + gameCounter, - seats: [ - { playerIndex: 0, userId: "user-a", botId: null, type: "human" }, - { playerIndex: 1, userId: "user-b", botId: null, type: "human" }, - ], + seats: [{ playerIndex: 0, userId: "user-a", botId: null, type: "human" }, ...((opts.seats ?? 2) === 2 ? [{ playerIndex: 1, userId: "user-b", botId: null, type: "human" as const }] : [])], now: Date.now(), }); return gameId; @@ -80,7 +80,7 @@ async function startGame(opts: SeedOptions = {}) { const gameId = await seedGame(opts); const stub = stubFor(gameId); const started = await stub.handle(cmd("start", gameId)); - expect(started).toMatchObject({ ok: true, version: 0 }); + expect(started).toMatchObject({ ok: true, session: { status: "active", version: 0 } }); return { gameId, stub }; } @@ -100,11 +100,11 @@ function action(gameId: string, seat: number, add: number, expectedVersion: numb * on version 3... target 3 reached at second move; keep it explicit instead. */ async function playToFinish(gameId: string, stub: ReturnType) { const a1 = await stub.handle(action(gameId, 0, 2, 0, "user-a")); - expect(a1).toMatchObject({ ok: true, version: 1 }); + expect(a1).toMatchObject({ ok: true, session: { version: 1 } }); const a2 = await stub.handle(action(gameId, 1, 2, 1, "user-b")); - expect(a2).toMatchObject({ ok: true, version: 2 }); - if (!a2.ok || !("frame" in a2)) throw new Error("unreachable"); - expect(a2.frame?.outcomes).toBeDefined(); + expect(a2).toMatchObject({ ok: true, session: { version: 2, status: "finished" } }); + if (!a2.ok) throw new Error("unreachable"); + expect(a2.session.frame?.outcomes).toBeDefined(); return a2; } @@ -143,10 +143,10 @@ describe("actions & dedupe", () => { it("alternates turns, versions strictly serial, own frame rides the response", async () => { const { gameId, stub } = await startGame(); const a1 = await stub.handle(action(gameId, 0, 1, 0, "user-a")); - expect(a1).toMatchObject({ ok: true, version: 1 }); - if (!a1.ok || !("frame" in a1)) throw new Error("unreachable"); - expect(a1.frame?.data).toEqual({ count: 1 }); - expect(a1.frame?.pendingPlayers).toEqual([1]); + expect(a1).toMatchObject({ ok: true, session: { version: 1, status: "active" } }); + if (!a1.ok) throw new Error("unreachable"); + expect(a1.session.frame?.data).toEqual({ count: 1 }); + expect(a1.session.frame?.pendingPlayers).toEqual([1]); }); it("replays the stored response for a duplicate commandId instead of double-applying", async () => { @@ -299,51 +299,165 @@ describe("finish sequence", () => { }); describe("hibernating sockets", () => { - it("accepts an upgrade and fans versioned frames out to the seat", async () => { - const { gameId, stub } = await startGame(); + /** Open a socket as `userId` and collect every snapshot it receives. */ + async function openSocket(gameId: string, stub: ReturnType, userId: string) { const res = await stub.fetch("https://do/socket", { - headers: { Upgrade: "websocket", "x-eigen-game": gameId, "x-eigen-user": "user-b" }, + headers: { Upgrade: "websocket", "x-eigen-game": gameId, "x-eigen-user": userId }, }); expect(res.status).toBe(101); const ws = res.webSocket; if (!ws) throw new Error("no websocket on 101 response"); ws.accept(); - const messages: (FrameMessage | SyncMessage)[] = []; + const messages: SessionSnapshot[] = []; ws.addEventListener("message", (event: MessageEvent) => { - messages.push(JSON.parse(event.data as string) as FrameMessage | SyncMessage); + messages.push(JSON.parse(event.data as string) as SessionSnapshot); }); + return { ws, messages }; + } + + it("states where the game is on open, at every status, and fans the seat's own view out", async () => { + const { gameId, stub } = await startGame(); + const { ws, messages } = await openSocket(gameId, stub, "user-b"); - // A mid-game open states where the game is, so a client knows what (if - // anything) to fetch rather than waiting for the next transition. + // The open always answers, so a client never holds a frame without the + // status it belongs to, and never has to infer one from connecting. await vi.waitFor(() => expect(messages).toHaveLength(1)); - expect(messages[0]).toEqual({ type: "sync", version: 0 }); + expect(messages[0]).toMatchObject({ type: "session", status: "active", version: 0, seq: 1, minPlayers: 2, maxPlayers: 2, config: { target: 3 } }); + expect(messages[0].frame).toMatchObject({ version: 0, data: { count: 0 } }); await stub.handle(action(gameId, 0, 1, 0, "user-a")); await vi.waitFor(() => expect(messages).toHaveLength(2)); - expect(messages[1]).toMatchObject({ type: "frame", version: 1, data: { count: 1 } }); + expect(messages[1]).toMatchObject({ type: "session", status: "active", version: 1, seq: 2 }); + expect(messages[1].frame).toMatchObject({ version: 1, data: { count: 1 }, pendingPlayers: [1] }); ws.close(); }); - it("sends the roster, not a sync, while the game is still in the lobby", async () => { - // Pre-game there is no version to report; what moves is the roster. - const gameId = await seedGame(); + it("tells a lobby socket the game became ready, then active", async () => { + // The transition this protocol exists for: the creator sits in the waiting + // room, somebody joins, and the status moves under them. + const gameId = await seedGame({ status: "waiting", seats: 1 }); const stub = stubFor(gameId); - const res = await stub.fetch("https://do/socket", { - headers: { Upgrade: "websocket", "x-eigen-game": gameId, "x-eigen-user": "user-a" }, + const { ws, messages } = await openSocket(gameId, stub, "user-a"); + + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(messages[0]).toMatchObject({ type: "session", status: "waiting", version: null, frame: null }); + + await stub.handle({ ...cmd("join", gameId), actor: { userId: "user-b", botId: null } }); + await vi.waitFor(() => expect(messages).toHaveLength(2)); + expect(messages[1]).toMatchObject({ type: "session", status: "ready", version: null }); + expect(messages[1].players).toHaveLength(2); + expect(messages[1].seq).toBeGreaterThan(messages[0].seq); + + await stub.handle(cmd("start", gameId)); + await vi.waitFor(() => expect(messages).toHaveLength(3)); + expect(messages[2]).toMatchObject({ type: "session", status: "active", version: 0 }); + ws.close(); + }); + + it("gives an unseated socket the envelope with no frame", async () => { + // How a viewer learns the game started without ever seeing a seat's view. + const { gameId, stub } = await startGame(); + const { ws, messages } = await openSocket(gameId, stub, "user-c"); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + expect(messages[0]).toMatchObject({ type: "session", status: "active", version: 0, frame: null }); + + await stub.handle(action(gameId, 0, 1, 0, "user-a")); + await vi.waitFor(() => expect(messages).toHaveLength(2)); + expect(messages[1]).toMatchObject({ version: 1, frame: null }); + ws.close(); + }); + + it("never sends a seat's view to another seat's socket", async () => { + const { gameId, stub } = await startGame(); + const a = await openSocket(gameId, stub, "user-a"); + const b = await openSocket(gameId, stub, "user-b"); + await vi.waitFor(() => { + expect(a.messages).toHaveLength(1); + expect(b.messages).toHaveLength(1); }); - const ws = res.webSocket; - if (!ws) throw new Error("no websocket on 101 response"); - ws.accept(); - const messages: { type: string }[] = []; - ws.addEventListener("message", (event: MessageEvent) => { - messages.push(JSON.parse(event.data as string) as { type: string }); + // The counter game reveals everything, so the payloads agree; what is + // asserted is that each socket is served through its OWN seat resolution, + // which is what keeps a hidden-information game safe on this path. + await stub.handle(action(gameId, 0, 1, 0, "user-a")); + await vi.waitFor(() => { + expect(a.messages).toHaveLength(2); + expect(b.messages).toHaveLength(2); }); + expect(a.messages[1].frame?.version).toBe(1); + expect(b.messages[1].frame?.version).toBe(1); + a.ws.close(); + b.ws.close(); + }); + it("delivers the finish and then the ratings deltas as ordinary snapshots", async () => { + const { gameId, stub } = await startGame({ rated: true }); + const { ws, messages } = await openSocket(gameId, stub, "user-b"); await vi.waitFor(() => expect(messages).toHaveLength(1)); - expect(messages[0]).toMatchObject({ type: "roster" }); + + await playToFinish(gameId, stub); + // Wait for the ratings snapshot specifically: it rides the post-commit + // finish effects, so it lands after the finishing frame rather than with it. + await vi.waitFor(() => expect(messages.some((m) => m.frame?.ratings !== undefined)).toBe(true)); + const finished = messages.filter((m) => m.status === "finished"); + expect(finished[0].frame?.outcomes).toBeDefined(); + // The ratings transition N+1 is a snapshot like any other, not a special + // frame with a field bolted on. + const withRatings = messages.find((m) => m.frame?.ratings !== undefined); + expect(withRatings).toMatchObject({ type: "session", status: "finished" }); + expect(withRatings?.version).toBe(3); + // Strictly increasing across lobby and state commits alike. + const seqs = messages.map((m) => m.seq); + expect(seqs).toEqual([...seqs].sort((x, y) => x - y)); + expect(new Set(seqs).size).toBe(seqs.length); ws.close(); }); + it("tells every socket about an abort before closing them", async () => { + const gameId = await seedGame(); + const stub = stubFor(gameId); + const { messages } = await openSocket(gameId, stub, "user-b"); + await vi.waitFor(() => expect(messages).toHaveLength(1)); + await stub.handle(cmd("cancel", gameId)); + await vi.waitFor(() => expect(messages).toHaveLength(2)); + expect(messages[1]).toMatchObject({ type: "session", status: "aborted", players: [], frame: null }); + }); +}); + +describe("the session read", () => { + it("answers with the same value a fan-out would have sent", async () => { + const { gameId, stub } = await startGame(); + await stub.handle(action(gameId, 0, 1, 0, "user-a")); + const session = await stub.session(gameId, "user-b"); + expect(session).toMatchObject({ type: "session", status: "active", version: 1, shortCode: expect.any(String) }); + expect(session?.frame).toMatchObject({ version: 1, data: { count: 1 }, pendingPlayers: [1] }); + }); + + it("carries the outcomes of a finished game whose frames were compacted away", async () => { + // The reason `outcomes` is retained on `meta`: they are kernel output, so no + // transition row holds them, and the frame that carried them is long gone. + const { gameId, stub } = await startGame(); + await playToFinish(gameId, stub); + await vi.waitFor(async () => { + const row = await db.select({ status: games.status }).from(games).where(eq(games.id, gameId)).get(); + expect(row?.status).toBe("finished"); + }); + const session = await stub.session(gameId, "user-a"); + expect(session).toMatchObject({ status: "finished" }); + expect(session?.frame?.outcomes).toHaveLength(2); + }); + + it("gives a non-participant the envelope with no frame", async () => { + const { gameId, stub } = await startGame(); + const session = await stub.session(gameId, "user-c"); + expect(session).toMatchObject({ status: "active", version: 0, frame: null }); + }); + + it("answers null for a game that does not exist", async () => { + expect(await stubFor("no-such-game").session("no-such-game", "user-a")).toBeNull(); + }); +}); + +describe("gap recovery", () => { it("serves live gap recovery through the frames range fetch", async () => { const { gameId, stub } = await startGame(); await stub.handle(action(gameId, 0, 1, 0, "user-a")); diff --git a/packages/testkit/bin/eigen-inspect.js b/packages/testkit/bin/eigen-inspect.js new file mode 100755 index 0000000..771ad1b --- /dev/null +++ b/packages/testkit/bin/eigen-inspect.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/inspect-cli.js"; diff --git a/packages/testkit/package.json b/packages/testkit/package.json index 4fc7302..2709fbf 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -6,12 +6,17 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "eigen-contract": "./bin/eigen-contract.js" + "eigen-contract": "./bin/eigen-contract.js", + "eigen-inspect": "./bin/eigen-inspect.js" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./local-state": { + "types": "./dist/local-state.d.ts", + "import": "./dist/local-state.js" } }, "files": [ diff --git a/packages/testkit/src/inspect-cli.ts b/packages/testkit/src/inspect-cli.ts new file mode 100644 index 0000000..7a4bcf7 --- /dev/null +++ b/packages/testkit/src/inspect-cli.ts @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * `eigen-inspect`: read a Worker's local `.wrangler` state from the terminal. + * + * Deliberately narrow. Wrangler already ships a first-party browser for local + * resources (press `e` in `wrangler dev`, or open `/cdn-cgi/explorer`), and + * generic table browsing is its job, not this tool's. What that view cannot do + * is answer a question in the engine's own terms, because a game's truth is + * split across two stores on purpose: the D1 index and the game's own Durable + * Object. This reads both, decodes the JSON columns, prints the transition log + * as a timeline, and says what the game is waiting for. + * + * It also runs with no dev server, no browser and no account, and emits + * `--json` for a script or an agent. + */ + +import { parseArgs } from "node:util"; +import type { LocalGameRow, LocalGameView, LocalSeat } from "./local-state.js"; +import { LocalStore } from "./local-state.js"; + +const HELP = `Usage: eigen-inspect [options] + +Read the local .wrangler state of an EigenInteractive Worker: the D1 index and +each game's own Durable Object database. + +Commands: + games The D1 index, newest first + game One game across both stores, with its timeline + do Every local Durable Object, mapped back to its game + players Local users and bots, by id + tables [--game ] Table names and row counts + sql [--game ] + Run a read-only statement against D1 or one game's DO + +Options: + --dir Start the search for .wrangler/state here (default: cwd) + --status games: filter by status + --limit games: row cap (default 20) + --game tables/sql: target one game's Durable Object instead of D1 + --json Emit JSON instead of a table + -h, --help Show this help + +Every database is opened read-only, so this is safe to run while the Worker is +running. Wrangler's own browser for the same files is \`e\` in \`wrangler dev\`.`; + +function relative(ms: number | null): string { + if (ms === null) return "never"; + const delta = Date.now() - ms; + const future = delta < 0; + const seconds = Math.round(Math.abs(delta) / 1000); + const [value, unit] = seconds < 60 ? [seconds, "s"] : seconds < 3600 ? [Math.round(seconds / 60), "m"] : seconds < 86400 ? [Math.round(seconds / 3600), "h"] : [Math.round(seconds / 86400), "d"]; + return future ? `in ${value}${unit}` : `${value}${unit} ago`; +} + +/** Render rows as an aligned table. Values are stringified defensively because + * SQLite hands back whatever the column holds, including blobs and nulls. */ +function table(rows: Record[]): string { + if (rows.length === 0) return "(no rows)"; + const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]; + const cell = (value: unknown): string => (value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value)); + const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length))); + const line = (values: string[]): string => + values + .map((value, i) => value.padEnd(widths[i])) + .join(" ") + .trimEnd(); + return [line(columns), line(widths.map((w) => "-".repeat(w))), ...rows.map((row) => line(columns.map((column, i) => cell(row[column]).padEnd(widths[i]))))].join("\n"); +} + +function short(id: string | null, length = 8): string { + return id === null ? "" : id.length <= length ? id : `${id.slice(0, length)}…`; +} + +function seatLabel(seat: LocalSeat): string { + const identity = seat.username ?? short(seat.userId ?? seat.botId); + return `${seat.playerIndex}:${identity || "(empty)"}${seat.type === "bot" ? " (bot)" : ""}`; +} + +function timingLabel(row: { turnSeconds: number | null; budgetSeconds: number | null; incrementSeconds: number | null }): string { + if (row.turnSeconds !== null) return `${row.turnSeconds}s per turn`; + if (row.budgetSeconds !== null) return `${row.budgetSeconds}s budget${row.incrementSeconds === null ? "" : ` +${row.incrementSeconds}s`}`; + return "untimed"; +} + +function gamesTable(rows: LocalGameRow[]): Record[] { + return rows.map((row) => ({ + id: short(row.id, 8), + code: row.shortCode, + status: row.status, + access: row.access, + seats: `${row.minPlayers}-${row.maxPlayers}`, + rated: row.rated ? "yes" : "no", + timing: timingLabel(row), + pending: row.pendingPlayers === null ? "" : JSON.stringify(row.pendingPlayers), + updated: relative(row.updatedAt), + })); +} + +/** The `game` report: the whole session in reading order, index first, then the + * authoritative session, then the log, then the one sentence that explains it. */ +function renderGame(view: LocalGameView): string { + const out: string[] = []; + const index = view.index; + out.push(`Game ${view.gameId}${index === null ? "" : ` ${index.shortCode}`}`); + + if (index === null) { + out.push("", "D1 index (no games row)"); + } else { + out.push( + "", + `D1 index status=${index.status} access=${index.access} rated=${index.rated ? "yes" : "no"} schema=v${index.schemaVersion}`, + ` seats ${index.minPlayers}-${index.maxPlayers} ${timingLabel(index)} created ${relative(index.createdAt)} updated ${relative(index.updatedAt)}`, + `config ${JSON.stringify(index.config)}`, + `participants ${view.indexSeats.length === 0 ? "(none)" : view.indexSeats.map(seatLabel).join(" ")}`, + ); + if (index.finishedAt !== null) out.push(`finished ${relative(index.finishedAt)} outcomes ${JSON.stringify(index.outcomes)}`); + } + + out.push(""); + if (view.durableObject === null) { + out.push("Durable Object (none: no command or socket has touched this game yet)"); + } else { + out.push(`Durable Object ${short(view.durableObject.id, 12)} ${view.durableObject.file}`); + const meta = view.meta; + if (meta === null) { + out.push("DO meta (no meta row: the object exists but never initialised)"); + } else { + out.push(`DO meta status=${meta.status} seats ${meta.minPlayers}-${meta.maxPlayers} createdBy=${short(meta.createdBy)} rngSeed=${meta.rngSeed === null ? "(unset, so no start has committed)" : short(meta.rngSeed, 12)}`); + } + out.push(`roster ${view.roster.length === 0 ? "(empty)" : view.roster.map(seatLabel).join(" ")}`); + out.push(`alarm ${view.alarm === null || view.alarm.scheduledTime === null ? "none armed" : `${new Date(view.alarm.scheduledTime).toISOString()} (${relative(view.alarm.scheduledTime)})`}`); + out.push(`commands ${view.commands.length} recorded`); + if (view.outbox.length > 0) out.push(`outbox ${view.outbox.length} UNAPPLIED finish row(s): ${view.outbox.map((row) => short(row.finishId)).join(", ")}`); + } + + out.push("", "Timeline"); + if (view.transitions.length === 0) { + out.push(" (no transitions: the game has not started, so there is no v0)"); + } else { + for (const transition of view.transitions) { + const action = transition.action; + const cause = action === null ? "start" : `${action.kind}/${action.type}${action.playerIndex === null ? "" : ` seat ${action.playerIndex}`}`; + const payload = action === null || action.data === null || action.data === undefined ? "" : ` ${JSON.stringify(action.data)}`; + out.push(` v${String(transition.version).padEnd(3)} ${cause}${payload}`); + out.push(` state ${JSON.stringify(transition.state)}`); + out.push(` pending ${JSON.stringify(transition.pending)} deadline ${transition.deadline === null ? "none" : relative(transition.deadline)} frames ${transition.frameSeats.length === 0 ? "(compacted)" : JSON.stringify(transition.frameSeats)}`); + } + } + + out.push("", `Diagnosis ${view.diagnosis.summary}`); + for (const drift of view.diagnosis.mirrorDrift) out.push(`Mirror drift ${drift}`); + return out.join("\n"); +} + +function main(): void { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + dir: { type: "string" }, + status: { type: "string" }, + limit: { type: "string" }, + game: { type: "string" }, + json: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + }); + + const [command, ...rest] = positionals; + if (values.help === true || command === undefined) { + console.log(HELP); + return; + } + + const emit = (value: unknown, text: () => string): void => { + console.log(values.json === true ? JSON.stringify(value, null, 2) : text()); + }; + + const store = LocalStore.open(values.dir); + try { + switch (command) { + case "games": { + const rows = store.games({ status: values.status, limit: values.limit === undefined ? 20 : Number(values.limit) }); + emit(rows, () => (rows.length === 0 ? "no games in the local D1 index" : table(gamesTable(rows)))); + return; + } + case "game": { + const ref = rest[0]; + if (ref === undefined) throw new Error("game needs an id, short code, or id prefix"); + const view = store.game(ref); + if (view === null) throw new Error(`no game matching "${ref}" in the local D1 index or in any local Durable Object`); + emit(view, () => renderGame(view)); + return; + } + case "do": { + // Paths print relative to the state root: the absolute prefix is the + // same on every row and pushes the useful columns off the terminal. + const objects = store.durableObjects().map((object) => ({ class: object.className, game: object.name ?? "(unnamed)", doId: short(object.id, 12), file: object.file.startsWith(store.root) ? object.file.slice(store.root.length + 1) : object.file })); + emit(objects, () => (objects.length === 0 ? "no local Durable Objects: nothing has created a game session yet" : table(objects))); + return; + } + case "players": { + const users = store.query("select id, username, display_name, is_anonymous, created_at from users order by created_at desc"); + const bots = store.query("select id, username, display_name, type, schema_version from bots order by username"); + emit({ users, bots }, () => `Users\n${table(users)}\n\nBots\n${table(bots)}`); + return; + } + case "tables": { + const rows = store.tables({ game: values.game }); + emit(rows, () => table(rows)); + return; + } + case "sql": { + const sql = rest.join(" "); + if (sql === "") throw new Error("sql needs a statement"); + const rows = store.query(sql, { game: values.game }); + emit(rows, () => table(rows)); + return; + } + default: + throw new Error(`unknown command "${command}". Run eigen-inspect --help`); + } + } finally { + store.close(); + } +} + +try { + main(); +} catch (error) { + console.error(`eigen-inspect: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +} diff --git a/packages/testkit/src/local-state.ts b/packages/testkit/src/local-state.ts new file mode 100644 index 0000000..14ff06e --- /dev/null +++ b/packages/testkit/src/local-state.ts @@ -0,0 +1,646 @@ +/** + * Read a Worker's LOCAL `.wrangler` state directly: the D1 index and the + * per-game Durable Object databases, opened as plain SQLite files. + * + * This is a development and debugging surface, never a runtime one. Nothing here + * runs on Workers: it is Node reading files that `wrangler dev` left on disk, + * which is why it lives in the testkit (a `devDependency`) behind its own + * subpath, and why it reads them through Node's built-in `node:sqlite` rather + * than a driver package. `better-sqlite3` would work and was tried first, but + * `drizzle-orm` declares it as an OPTIONAL PEER: installing it anywhere in the + * workspace re-resolves drizzle's peer set for every importer, which changed the + * types `packages/server` compiles against and broke its `dts` build. A + * debugging tool is not worth perturbing the engine's own dependency graph, and + * the built-in module costs nothing: no native build, no `allowBuilds` opt-in, + * and no dependency at all. + * + * Two stores, two different questions, and the reason a game-shaped reader + * exists at all: + * + * - **D1 is the index.** Discovery, history, ratings, identity. Its `games` row + * is a display read-model written by the DO as a fire-and-forget mirror. + * - **The game's DO is the session.** One SQLite database per game, holding the + * authoritative status, roster and the append-only transition log. + * + * A question like "why is this game not starting" therefore cannot be answered + * from either store alone, and answering it from a generic table browser means + * knowing which of the two to distrust. {@link LocalStore.game} joins them and + * reports the disagreement as a value, so a stale mirror reads as a stale + * mirror rather than as a mystery. + * + * Every open is read-only, so this is safe to run against a live `wrangler dev`. + * + * @module @eigeninteractive/testkit/local-state + */ + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +/** Where `wrangler dev` persists local resources, relative to a Worker root. */ +const STATE_SUFFIX = join(".wrangler", "state", "v3"); + +/** Miniflare's own bookkeeping database, present in every resource directory + * alongside the real ones. It holds `_cf_ALARM`, not user data. */ +const METADATA_FILE = "metadata.sqlite"; + +/** The table Miniflare writes into every Durable Object database, holding the + * `idFromName` name the object was addressed by. It is what makes the hashed + * filename reversible: the engine names a game's DO by its `gameId`, and the + * hash is one-way, so this row is the only local file-to-game map there is. */ +const DO_NAME_TABLE = "__miniflare_do_name"; + +/** A local Durable Object database: one game's whole session. */ +export interface LocalDurableObject { + /** The Durable Object class, parsed from the directory Miniflare names + * `-`. */ + className: string; + /** The hex `DurableObjectId`, which is the filename: `idFromName(name)` + * hashed. Not the game id, and not reversible without {@link name}. */ + id: string; + /** The name the object was addressed by, which for a game DO is its + * `gameId`. Null only if the object was created by unique id rather than by + * name, which the engine never does. */ + name: string | null; + /** Absolute path to the SQLite file. */ + file: string; +} + +/** What a bound parameter may be: SQLite's own value domain, spelled out here + * rather than re-exported from `node:sqlite` so the public signature does not + * depend on a built-in module's type names. */ +export type LocalQueryParam = string | number | bigint | null | Uint8Array; + +/** A row of Miniflare's alarm table: the DO's armed alarm, which for a game is + * its turn deadline plus grace. An absent row means no timer is armed, which is + * correct for an untimed game and a bug for a timed one mid-turn. */ +export interface LocalAlarm { + actorId: string; + actorName: string | null; + scheduledTime: number | null; +} + +/** The engine's `games` row: the D1 index entry, a display mirror of the DO. */ +export interface LocalGameRow { + id: string; + createdBy: string | null; + status: string; + access: string; + schemaVersion: number; + config: unknown; + turnSeconds: number | null; + budgetSeconds: number | null; + incrementSeconds: number | null; + rated: boolean; + ratingPool: string | null; + minPlayers: number; + maxPlayers: number; + shortCode: string; + pendingPlayers: number[] | null; + turnDeadline: number | null; + outcomes: unknown; + finishId: string | null; + finishedAt: number | null; + archivedAt: number | null; + createdAt: number; + updatedAt: number; +} + +/** One seat, from either store. `username` is filled in from D1's `users` and + * `bots` tables when the reader has an index to join against. */ +export interface LocalSeat { + playerIndex: number; + userId: string | null; + botId: string | null; + type: string; + username: string | null; + displayName: string | null; +} + +/** The DO's single `meta` row: the authoritative session header. */ +export interface LocalGameMeta { + gameId: string; + status: string; + access: string; + schemaVersion: number; + config: unknown; + turnSeconds: number | null; + budgetSeconds: number | null; + incrementSeconds: number | null; + rated: boolean; + ratingPool: string | null; + minPlayers: number; + maxPlayers: number; + createdBy: string | null; + rngSeed: string | null; +} + +/** One committed transition, decoded. `action` is null at v0 (the start). */ +export interface LocalTransition { + version: number; + state: unknown; + action: { type: string; kind: string; playerIndex: number | null; data: unknown } | null; + pending: number[]; + deadline: number | null; + playerTimes: number[] | null; + turnStartedAt: number | null; + /** Which seats hold a stored frame at this version. Empty after the finish + * compaction, which empties `frames` and leaves replay to re-projection. */ + frameSeats: number[]; +} + +/** Why the game is where it is, and what would move it. Derived rather than + * stored: this is the sentence a developer actually wants, and every input to + * it is already on screen above it. */ +export interface LocalGameDiagnosis { + /** One line naming the next expected event, or the reason there is none. */ + summary: string; + /** Disagreements between the D1 mirror and the DO's own truth. A non-empty + * list means a mirror write was lost, since the DO is authoritative. */ + mirrorDrift: string[]; +} + +/** Everything both stores know about one game. */ +export interface LocalGameView { + gameId: string; + /** The D1 index row, or null when D1 has no such game: either the id came + * from a DO name and the index was wiped, or the create write never landed. */ + index: LocalGameRow | null; + /** D1's `participants`, which mirror the DO's roster for display. */ + indexSeats: LocalSeat[]; + /** The DO file, or null when no Durable Object exists for this game yet. + * That is the normal state of a created-but-never-touched lobby: the DO is + * created lazily by the first command or socket. */ + durableObject: LocalDurableObject | null; + meta: LocalGameMeta | null; + roster: LocalSeat[]; + transitions: LocalTransition[]; + /** Recorded `commandId` dedupe entries, newest first. */ + commands: { commandId: string; createdAt: number; result: unknown }[]; + /** Unapplied finish rows: a surviving row means the D1 finish apply has not + * succeeded and the admin re-poke is the recovery. */ + outbox: { finishId: string; createdAt: number; outcomes: unknown }[]; + alarm: LocalAlarm | null; + diagnosis: LocalGameDiagnosis; +} + +/** + * Walk up from `from` to the nearest directory holding `.wrangler/state/v3`. + * + * Walking rather than requiring the Worker root means this works from anywhere + * inside a game repository, including the app half of a two-package layout, + * where the state lives a directory or two above the shell's cwd. + */ +export function findWranglerState(from: string = process.cwd()): string | null { + let dir = resolve(from); + for (;;) { + const candidate = join(dir, STATE_SUFFIX); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Open a SQLite file for reading only. + * + * Read-only is what makes this safe to point at a running `wrangler dev`: no + * writer lock is taken and no journal is touched. The fallback matters because + * these databases are in WAL mode, and a read-only connection cannot recover a + * hot journal or create the `-shm` file it needs; when `wrangler dev` is not + * running and left one behind, the first open fails and a writable handle is + * the only way in. Nothing here issues anything but `SELECT`, so the widened + * handle stays as harmless as the narrow one. The existence check comes first + * because that fallback would otherwise CREATE a database rather than report a + * missing one. + */ +export function openSqlite(file: string): DatabaseSync { + if (!existsSync(file)) throw new Error(`no SQLite database at ${file}`); + try { + return new DatabaseSync(file, { readOnly: true }); + } catch { + return new DatabaseSync(file); + } +} + +function sqliteFilesIn(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((entry) => entry.endsWith(".sqlite") && entry !== METADATA_FILE) + .map((entry) => join(dir, entry)); +} + +function tableExists(db: DatabaseSync, table: string): boolean { + const row = db.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table); + return row !== undefined; +} + +/** Parse a JSON text column, tolerating a value some other writer left + * un-encoded. A debugging reader that throws on one malformed cell is useless + * precisely when it is needed. */ +function json(value: unknown): unknown { + if (typeof value !== "string") return value ?? null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function numbers(value: unknown): number[] { + const parsed = json(value); + return Array.isArray(parsed) ? parsed.filter((n): n is number => typeof n === "number") : []; +} + +function bool(value: unknown): boolean { + return value === 1 || value === true; +} + +/** + * A Worker's local state, with its databases opened lazily and held open. + * + * Stateful because it owns file handles: construct one, ask it questions, and + * {@link close} it. A single instance is the unit both the CLI and a test use, + * so neither has to know which file backs which resource. + */ +export class LocalStore { + /** Absolute path to `.wrangler/state/v3`. */ + readonly root: string; + + #d1: { file: string; db: DatabaseSync } | null | undefined; + readonly #open = new Map(); + + private constructor(root: string) { + this.root = root; + } + + /** + * Locate and open the state under `from`, or throw with the one thing the + * caller can act on: that no Worker has been run here yet. + */ + static open(from?: string): LocalStore { + const root = findWranglerState(from); + if (root === null) { + throw new Error(`no .wrangler/state/v3 found at or above ${resolve(from ?? process.cwd())}. Run the Worker once (\`pnpm dev\`) so it has local state to read.`); + } + return new LocalStore(root); + } + + #handle(file: string): DatabaseSync { + const existing = this.#open.get(file); + if (existing !== undefined) return existing; + const db = openSqlite(file); + this.#open.set(file, db); + return db; + } + + /** + * The engine's D1 database, identified by its schema rather than its name. + * + * Miniflare names the file after a hash of the database, so there is no + * mapping back to the binding. Recognising the engine's own `games` table + * instead is both simpler and more honest about what the caller wants: the + * database that has games in it, whatever the binding was called. + */ + d1(): { file: string; db: DatabaseSync } | null { + if (this.#d1 !== undefined) return this.#d1; + for (const file of sqliteFilesIn(join(this.root, "d1", "miniflare-D1DatabaseObject"))) { + const db = this.#handle(file); + if (tableExists(db, "games")) { + this.#d1 = { file, db }; + return this.#d1; + } + } + this.#d1 = null; + return null; + } + + /** + * Every local Durable Object database, newest-modified first. + * + * The directory is `-`, and a worker name may itself + * contain a dash, so the class is everything after the LAST one. Ordering by + * mtime puts the game you just touched at the top, which is nearly always the + * one being debugged. + */ + durableObjects(): LocalDurableObject[] { + const base = join(this.root, "do"); + if (!existsSync(base)) return []; + const found: LocalDurableObject[] = []; + for (const namespace of readdirSync(base)) { + const dir = join(base, namespace); + if (!statSync(dir).isDirectory()) continue; + const className = namespace.slice(namespace.lastIndexOf("-") + 1); + for (const file of sqliteFilesIn(dir)) { + const db = this.#handle(file); + const name = tableExists(db, DO_NAME_TABLE) ? ((db.prepare(`select name from ${DO_NAME_TABLE} limit 1`).get() as { name?: string } | undefined)?.name ?? null) : null; + found.push({ className, id: file.slice(file.lastIndexOf("/") + 1, -".sqlite".length), name, file }); + } + } + return found.sort((a, b) => statSync(b.file).mtimeMs - statSync(a.file).mtimeMs); + } + + /** The armed alarm for a Durable Object, from its namespace's metadata + * database. Matched on either column because the id is what Miniflare keys + * by and the name is what a human recognises. */ + alarm(target: LocalDurableObject): LocalAlarm | null { + const file = join(dirname(target.file), METADATA_FILE); + if (!existsSync(file)) return null; + const db = this.#handle(file); + if (!tableExists(db, "_cf_ALARM")) return null; + const row = db.prepare("select actor_id, actor_name, scheduled_time from _cf_ALARM where actor_id = ? or actor_name = ?").get(target.id, target.name ?? target.id) as { actor_id: string; actor_name: string | null; scheduled_time: number | null } | undefined; + return row === undefined ? null : { actorId: row.actor_id, actorName: row.actor_name, scheduledTime: row.scheduled_time }; + } + + /** The D1 index rows, newest-updated first. */ + games(options: { status?: string; limit?: number } = {}): LocalGameRow[] { + const d1 = this.d1(); + if (d1 === null) return []; + const where = options.status === undefined ? "" : "where status = ?"; + const params = options.status === undefined ? [] : [options.status]; + const rows = d1.db.prepare(`select * from games ${where} order by updated_at desc limit ?`).all(...params, options.limit ?? 50) as Record[]; + return rows.map(toGameRow); + } + + /** + * Resolve a game reference the way a developer will type it: a full id, a + * six-character short code, or an id prefix long enough to be unique. + * + * D1 is tried first because it can match a short code, then the DO names, so + * a game whose index row was wiped is still reachable by id. + */ + resolveGameId(ref: string): string | null { + const d1 = this.d1(); + if (d1 !== null) { + const exact = d1.db.prepare("select id from games where id = ? or short_code = ?").get(ref, ref.toUpperCase()) as { id: string } | undefined; + if (exact !== undefined) return exact.id; + const prefixed = d1.db.prepare("select id from games where id like ? limit 2").all(`${ref}%`) as { id: string }[]; + if (prefixed.length === 1) return prefixed[0].id; + if (prefixed.length > 1) throw new Error(`"${ref}" matches more than one game; use a longer prefix`); + } + const named = this.durableObjects().filter((o) => o.name !== null && (o.name === ref || o.name.startsWith(ref))); + if (named.length === 1) return named[0].name; + if (named.length > 1) throw new Error(`"${ref}" matches more than one game; use a longer prefix`); + return null; + } + + /** The Durable Object holding a game's session, or null if none exists yet. */ + gameDurableObject(gameId: string): LocalDurableObject | null { + return this.durableObjects().find((o) => o.name === gameId) ?? null; + } + + /** Identity for a set of user and bot ids, for rendering seats as names. */ + #identities(userIds: string[], botIds: string[]): Map { + const out = new Map(); + const d1 = this.d1(); + if (d1 === null) return out; + for (const [table, ids] of [ + ["users", userIds], + ["bots", botIds], + ] as const) { + if (ids.length === 0) continue; + const placeholders = ids.map(() => "?").join(","); + const rows = d1.db.prepare(`select id, username, display_name from ${table} where id in (${placeholders})`).all(...ids) as { id: string; username: string; display_name: string }[]; + for (const row of rows) out.set(row.id, { username: row.username, displayName: row.display_name }); + } + return out; + } + + #seats(rows: { player_index: number; user_id: string | null; bot_id: string | null; type: string }[]): LocalSeat[] { + const identities = this.#identities( + rows.map((r) => r.user_id).filter((id): id is string => id !== null), + rows.map((r) => r.bot_id).filter((id): id is string => id !== null), + ); + return rows.map((row) => { + const identity = identities.get(row.user_id ?? row.bot_id ?? ""); + return { playerIndex: row.player_index, userId: row.user_id, botId: row.bot_id, type: row.type, username: identity?.username ?? null, displayName: identity?.displayName ?? null }; + }); + } + + /** + * Join both stores for one game: the index row, the session, the transition + * log, and the derived diagnosis. + * + * Returns null only when neither store has heard of the game. + */ + game(ref: string): LocalGameView | null { + const gameId = this.resolveGameId(ref); + if (gameId === null) return null; + + const d1 = this.d1(); + const indexRow = d1 === null ? undefined : (d1.db.prepare("select * from games where id = ?").get(gameId) as Record | undefined); + const index = indexRow === undefined ? null : toGameRow(indexRow); + const indexSeats = + d1 === null + ? [] + : this.#seats( + d1.db.prepare("select player_index, user_id, bot_id, type from participants where game_id = ? order by player_index").all(gameId) as { + player_index: number; + user_id: string | null; + bot_id: string | null; + type: string; + }[], + ); + + const durableObject = this.gameDurableObject(gameId); + let meta: LocalGameMeta | null = null; + let roster: LocalSeat[] = []; + let transitions: LocalTransition[] = []; + let commands: LocalGameView["commands"] = []; + let outbox: LocalGameView["outbox"] = []; + + if (durableObject !== null) { + const db = this.#handle(durableObject.file); + if (tableExists(db, "meta")) { + const row = db.prepare("select * from meta where id = 1").get() as Record | undefined; + if (row !== undefined) meta = toMeta(row); + } + if (tableExists(db, "roster")) { + roster = this.#seats(db.prepare("select player_index, user_id, bot_id, type from roster order by player_index").all() as { player_index: number; user_id: string | null; bot_id: string | null; type: string }[]); + } + if (tableExists(db, "transitions")) { + const frameSeats = new Map(); + if (tableExists(db, "frames")) { + for (const row of db.prepare("select version, player_index from frames order by version, player_index").all() as { version: number; player_index: number }[]) { + frameSeats.set(row.version, [...(frameSeats.get(row.version) ?? []), row.player_index]); + } + } + transitions = (db.prepare("select * from transitions order by version").all() as Record[]).map((row) => toTransition(row, frameSeats)); + } + if (tableExists(db, "commands")) { + commands = (db.prepare("select command_id, response, created_at from commands order by created_at desc").all() as { command_id: string; response: unknown; created_at: number }[]).map((row) => ({ + commandId: row.command_id, + createdAt: row.created_at, + result: json(row.response), + })); + } + if (tableExists(db, "outbox")) { + outbox = (db.prepare("select finish_id, outcomes, created_at from outbox order by created_at").all() as { finish_id: string; outcomes: unknown; created_at: number }[]).map((row) => ({ + finishId: row.finish_id, + createdAt: row.created_at, + outcomes: json(row.outcomes), + })); + } + } + + const alarm = durableObject === null ? null : this.alarm(durableObject); + const view: Omit = { gameId, index, indexSeats, durableObject, meta, roster, transitions, commands, outbox, alarm }; + return { ...view, diagnosis: diagnose(view) }; + } + + /** Run a read-only statement against D1 or one game's Durable Object: the + * escape hatch for a question this reader does not model. */ + query(sql: string, options: { game?: string; params?: LocalQueryParam[] } = {}): Record[] { + const db = options.game === undefined ? this.d1()?.db : this.#gameDb(options.game); + if (db === undefined || db === null) { + throw new Error(options.game === undefined ? "no local D1 database found in this Worker's state" : `no local Durable Object found for game ${options.game}`); + } + return db.prepare(sql).all(...(options.params ?? [])) as Record[]; + } + + /** Table names and row counts, for D1 or one game's Durable Object. */ + tables(options: { game?: string } = {}): { name: string; rows: number }[] { + const db = options.game === undefined ? this.d1()?.db : this.#gameDb(options.game); + if (db === undefined || db === null) return []; + const names = (db.prepare("select name from sqlite_master where type = 'table' order by name").all() as { name: string }[]).map((r) => r.name); + return names.map((name) => ({ name, rows: (db.prepare(`select count(*) as n from "${name}"`).get() as { n: number }).n })); + } + + #gameDb(ref: string): DatabaseSync | null { + const gameId = this.resolveGameId(ref); + const target = gameId === null ? null : this.gameDurableObject(gameId); + return target === null ? null : this.#handle(target.file); + } + + /** Release every open handle. */ + close(): void { + for (const db of this.#open.values()) db.close(); + this.#open.clear(); + this.#d1 = undefined; + } +} + +function toGameRow(row: Record): LocalGameRow { + return { + id: row.id as string, + createdBy: (row.created_by as string | null) ?? null, + status: row.status as string, + access: row.access as string, + schemaVersion: row.schema_version as number, + config: json(row.config), + turnSeconds: (row.turn_seconds as number | null) ?? null, + budgetSeconds: (row.budget_seconds as number | null) ?? null, + incrementSeconds: (row.increment_seconds as number | null) ?? null, + rated: bool(row.rated), + ratingPool: (row.rating_pool as string | null) ?? null, + minPlayers: row.min_players as number, + maxPlayers: row.max_players as number, + shortCode: row.short_code as string, + pendingPlayers: row.pending_players === null || row.pending_players === undefined ? null : numbers(row.pending_players), + turnDeadline: (row.turn_deadline as number | null) ?? null, + outcomes: json(row.outcomes), + finishId: (row.finish_id as string | null) ?? null, + finishedAt: (row.finished_at as number | null) ?? null, + archivedAt: (row.archived_at as number | null) ?? null, + createdAt: row.created_at as number, + updatedAt: row.updated_at as number, + }; +} + +function toMeta(row: Record): LocalGameMeta { + return { + gameId: row.game_id as string, + status: row.status as string, + access: row.access as string, + schemaVersion: row.schema_version as number, + config: json(row.config), + turnSeconds: (row.turn_seconds as number | null) ?? null, + budgetSeconds: (row.budget_seconds as number | null) ?? null, + incrementSeconds: (row.increment_seconds as number | null) ?? null, + rated: bool(row.rated), + ratingPool: (row.rating_pool as string | null) ?? null, + minPlayers: row.min_players as number, + maxPlayers: row.max_players as number, + createdBy: (row.created_by as string | null) ?? null, + rngSeed: (row.rng_seed as string | null) ?? null, + }; +} + +function toTransition(row: Record, frameSeats: Map): LocalTransition { + const version = row.version as number; + const action = json(row.action) as LocalTransition["action"]; + return { + version, + state: json(row.state), + action: action ?? null, + pending: numbers(row.pending), + deadline: (row.deadline as number | null) ?? null, + playerTimes: row.player_times === null || row.player_times === undefined ? null : numbers(row.player_times), + turnStartedAt: (row.turn_started_at as number | null) ?? null, + frameSeats: frameSeats.get(version) ?? [], + }; +} + +/** + * Say what the game is waiting for, and whether the two stores agree. + * + * The engine's statuses each have exactly one thing that moves them, so this is + * a total function over `status` rather than a heuristic. The cases developers + * actually hit are the first two: a lobby that will not fill, and a full lobby + * whose creator has not pressed start. + */ +function diagnose(view: Omit): LocalGameDiagnosis { + const status = view.meta?.status ?? view.index?.status ?? "unknown"; + const seated = (view.meta === null ? view.indexSeats : view.roster).length; + const min = view.meta?.minPlayers ?? view.index?.minPlayers ?? 0; + const max = view.meta?.maxPlayers ?? view.index?.maxPlayers ?? 0; + const latest = view.transitions.at(-1) ?? null; + + const mirrorDrift: string[] = []; + if (view.index !== null && view.meta !== null) { + if (view.index.status !== view.meta.status) { + mirrorDrift.push(`D1 says status=${view.index.status}, the Durable Object says ${view.meta.status}. The DO is authoritative; a mirror write was lost.`); + } + if (view.indexSeats.length !== view.roster.length) { + mirrorDrift.push(`D1 mirrors ${view.indexSeats.length} seat(s), the Durable Object holds ${view.roster.length}.`); + } + const mirroredPending = view.index.pendingPlayers ?? null; + const actualPending = latest?.pending ?? null; + if (status === "active" && JSON.stringify(mirroredPending) !== JSON.stringify(actualPending)) { + mirrorDrift.push(`D1 mirrors pendingPlayers=${JSON.stringify(mirroredPending)}, the newest transition has ${JSON.stringify(actualPending)}.`); + } + } + if (view.index === null && view.meta !== null) { + mirrorDrift.push("no D1 index row for a game whose Durable Object exists; discovery and history lists cannot show it."); + } + + let summary: string; + switch (status) { + case "waiting": + summary = `waiting for players: ${seated}/${min} needed to become ready (${max} max). The next event is a join, or add-bot from the creator.`; + break; + case "ready": + summary = `ready and not started: ${seated}/${max} seats filled. Nothing happens until the CREATOR calls POST /api/engine/games/${view.gameId}/start. A start is explicit and creator-only; filling the lobby does not start a game.`; + break; + case "active": + if (latest === null) { + summary = "status is active but no transition is committed, which should be impossible: a start commits v0 in the same transaction that sets the status."; + } else { + const waitingOn = latest.pending.length === 0 ? "nobody, which means the game should have finished at this version" : `seat(s) ${latest.pending.join(", ")}`; + const timer = latest.deadline === null ? "untimed, so no alarm is armed and only a move or a forfeit moves it" : `deadline ${new Date(latest.deadline).toISOString()}`; + summary = `at v${latest.version}, waiting on ${waitingOn}. ${timer}.`; + } + break; + case "finished": + summary = view.outbox.length > 0 ? `finished, but ${view.outbox.length} outbox row(s) survive: the D1 finish apply has not succeeded, so ratings and history are not published yet.` : `finished at v${latest?.version ?? "?"}; the outbox is clear, so D1 has the outcomes.`; + break; + case "aborted": + summary = "aborted: cancelled by its creator, reaped as abandoned, or torn down by an account purge. Nothing moves an aborted game."; + break; + default: + summary = view.durableObject === null ? "created in D1 but its Durable Object does not exist yet. That is normal for a lobby nobody has touched: the DO is created lazily by the first command or socket." : `unrecognised status ${status}.`; + } + + return { summary, mirrorDrift }; +} diff --git a/packages/testkit/test/local-state.spec.ts b/packages/testkit/test/local-state.spec.ts new file mode 100644 index 0000000..fdee793 --- /dev/null +++ b/packages/testkit/test/local-state.spec.ts @@ -0,0 +1,197 @@ +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it } from "vitest"; +import { findWranglerState, LocalStore } from "../src/local-state.js"; + +/** + * These build the same file layout `wrangler dev` leaves behind: a D1 database + * under `d1/miniflare-D1DatabaseObject`, and one database per game under + * `do/-`, each carrying Miniflare's `__miniflare_do_name` row. + * Writing the layout rather than mocking it is the point, since what the reader + * has to get right IS the layout. + */ + +const GAME_ID = "11111111-2222-3333-4444-555555555555"; + +interface Fixture { + root: string; + worker: string; +} + +function fixture(): Fixture { + const worker = mkdtempSync(join(tmpdir(), "eigen-local-state-")); + const root = join(worker, ".wrangler", "state", "v3"); + mkdirSync(join(root, "d1", "miniflare-D1DatabaseObject"), { recursive: true }); + mkdirSync(join(root, "do", "my-game-GameDO"), { recursive: true }); + return { root, worker }; +} + +function writeD1(f: Fixture, overrides: Partial<{ status: string; pendingPlayers: string | null; seats: number }> = {}): void { + const db = new DatabaseSync(join(f.root, "d1", "miniflare-D1DatabaseObject", "abc123.sqlite")); + db.exec(` + create table games (id text primary key, created_by text, status text not null, access text not null, schema_version integer not null, + config text not null, turn_seconds integer, budget_seconds integer, increment_seconds integer, rated integer not null, rating_pool text, + min_players integer not null, max_players integer not null, short_code text not null, pending_players text, turn_deadline integer, + outcomes text, finish_id text, finished_at integer, archived_at integer, created_at integer not null, updated_at integer not null); + create table participants (id text primary key, game_id text not null, user_id text, bot_id text, player_index integer not null, type text not null, created_at integer not null); + create table users (id text primary key, username text not null, email text, display_name text not null, avatar_url text, is_anonymous integer not null, created_at integer not null, updated_at integer not null); + create table bots (id text primary key, username text not null, display_name text not null, avatar_url text, schema_version integer not null, type text not null, webhook_url text, rated_eligible integer not null, config text not null, created_at integer not null); + `); + const now = Date.now(); + db.prepare("insert into games values (?, 'user-a', ?, 'private', 1, '{\"target\":10}', null, null, null, 0, null, 2, 2, 'ABC123', ?, null, null, null, null, null, ?, ?)").run(GAME_ID, overrides.status ?? "ready", overrides.pendingPlayers ?? null, now, now); + for (let seat = 0; seat < (overrides.seats ?? 2); seat += 1) { + db.prepare("insert into participants values (?, ?, ?, null, ?, 'human', ?)").run(`p${seat}`, GAME_ID, `user-${seat === 0 ? "a" : "b"}`, seat, now); + } + db.prepare("insert into users values ('user-a', 'ana', null, 'Ana', null, 0, ?, ?)").run(now, now); + db.prepare("insert into users values ('user-b', 'ben', null, 'Ben', null, 0, ?, ?)").run(now, now); + db.close(); +} + +function writeGameDO(f: Fixture, options: { status: string; named?: boolean; transitions?: { version: number; action: unknown; pending: number[]; deadline?: number | null }[]; outbox?: boolean } = { status: "ready" }): void { + const db = new DatabaseSync(join(f.root, "do", "my-game-GameDO", "deadbeef.sqlite")); + db.exec(` + create table __miniflare_do_name (id integer primary key, name text); + create table meta (id integer primary key, game_id text not null, status text not null, access text not null, schema_version integer not null, + config text not null, turn_seconds integer, budget_seconds integer, increment_seconds integer, rated integer not null, rating_pool text, + min_players integer not null, max_players integer not null, created_by text, rng_seed text); + create table roster (player_index integer primary key, user_id text, bot_id text, type text not null); + create table transitions (version integer primary key, state text not null, action text, pending text not null, deadline integer, player_times text, turn_started_at integer); + create table frames (version integer not null, player_index integer not null, data text not null, pending_players text not null, primary key (version, player_index)); + create table commands (command_id text primary key, response text not null, created_at integer not null); + create table outbox (finish_id text primary key, outcomes text not null, created_at integer not null); + `); + if (options.named !== false) db.prepare("insert into __miniflare_do_name values (1, ?)").run(GAME_ID); + db.prepare("insert into meta values (1, ?, ?, 'private', 1, '{\"target\":10}', null, null, null, 0, null, 2, 2, 'user-a', ?)").run(GAME_ID, options.status, options.transitions === undefined ? null : "seed-1"); + db.prepare("insert into roster values (0, 'user-a', null, 'human')").run(); + db.prepare("insert into roster values (1, 'user-b', null, 'human')").run(); + for (const transition of options.transitions ?? []) { + db.prepare("insert into transitions values (?, ?, ?, ?, ?, null, null)").run(transition.version, JSON.stringify({ count: transition.version }), transition.action === null ? null : JSON.stringify(transition.action), JSON.stringify(transition.pending), transition.deadline ?? null); + for (const seat of [0, 1]) db.prepare("insert into frames values (?, ?, ?, ?)").run(transition.version, seat, JSON.stringify({ count: transition.version }), JSON.stringify(transition.pending)); + } + if (options.outbox === true) db.prepare("insert into outbox values ('finish-1', '[]', ?)").run(Date.now()); + db.close(); +} + +const stores: LocalStore[] = []; +function open(f: Fixture, from?: string): LocalStore { + const store = LocalStore.open(from ?? f.worker); + stores.push(store); + return store; +} + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); +}); + +describe("findWranglerState", () => { + it("walks up from a nested directory", () => { + const f = fixture(); + const nested = join(f.worker, "src", "module", "fixtures"); + mkdirSync(nested, { recursive: true }); + expect(findWranglerState(nested)).toBe(f.root); + }); + + it("returns null when there is no state to read", () => { + expect(findWranglerState(mkdtempSync(join(tmpdir(), "eigen-empty-")))).toBeNull(); + }); +}); + +describe("LocalStore", () => { + it("identifies the engine's D1 by its schema, not its filename", () => { + const f = fixture(); + writeD1(f); + expect(open(f).d1()?.file).toContain("abc123.sqlite"); + }); + + it("maps a Durable Object file back to its game id and class", () => { + const f = fixture(); + writeGameDO(f, { status: "ready" }); + const [object] = open(f).durableObjects(); + expect(object).toMatchObject({ className: "GameDO", id: "deadbeef", name: GAME_ID }); + }); + + it("resolves a game by id, short code, and id prefix", () => { + const f = fixture(); + writeD1(f); + const store = open(f); + expect(store.resolveGameId(GAME_ID)).toBe(GAME_ID); + expect(store.resolveGameId("abc123")).toBe(GAME_ID); + expect(store.resolveGameId("11111111")).toBe(GAME_ID); + expect(store.resolveGameId("nope")).toBeNull(); + }); + + it("reads a game with no Durable Object as an untouched lobby", () => { + const f = fixture(); + writeD1(f, { status: "waiting", seats: 1 }); + const view = open(f).game(GAME_ID); + expect(view?.durableObject).toBeNull(); + expect(view?.indexSeats).toHaveLength(1); + expect(view?.diagnosis.summary).toContain("waiting for players"); + }); + + it("names the creator's start as the only thing a ready game waits for", () => { + const f = fixture(); + writeD1(f); + writeGameDO(f, { status: "ready" }); + const view = open(f).game("abc123"); + expect(view?.transitions).toHaveLength(0); + expect(view?.meta?.rngSeed).toBeNull(); + expect(view?.diagnosis.summary).toContain("creator"); + expect(view?.diagnosis.mirrorDrift).toEqual([]); + }); + + it("decodes the transition log and reports who is on the clock", () => { + const f = fixture(); + writeD1(f, { status: "active", pendingPlayers: "[1]" }); + writeGameDO(f, { + status: "active", + transitions: [ + { version: 0, action: null, pending: [0] }, + { version: 1, action: { type: "user", kind: "game", playerIndex: 0, data: { amount: 1 } }, pending: [1] }, + ], + }); + const view = open(f).game(GAME_ID); + expect(view?.transitions.map((t) => t.version)).toEqual([0, 1]); + expect(view?.transitions[0].action).toBeNull(); + expect(view?.transitions[1].action).toMatchObject({ kind: "game", playerIndex: 0, data: { amount: 1 } }); + expect(view?.transitions[1].frameSeats).toEqual([0, 1]); + expect(view?.diagnosis.summary).toContain("at v1, waiting on seat(s) 1"); + expect(view?.diagnosis.mirrorDrift).toEqual([]); + }); + + it("reports a stale D1 mirror as drift rather than as truth", () => { + const f = fixture(); + writeD1(f, { status: "ready" }); + writeGameDO(f, { status: "active", transitions: [{ version: 0, action: null, pending: [0] }] }); + const view = open(f).game(GAME_ID); + expect(view?.diagnosis.mirrorDrift.join(" ")).toContain("D1 says status=ready, the Durable Object says active"); + }); + + it("flags a surviving outbox row on a finished game", () => { + const f = fixture(); + writeD1(f, { status: "finished" }); + writeGameDO(f, { status: "finished", outbox: true, transitions: [{ version: 0, action: null, pending: [0] }] }); + expect(open(f).game(GAME_ID)?.diagnosis.summary).toContain("outbox row(s) survive"); + }); + + it("counts rows per table for D1 and for one game's Durable Object", () => { + const f = fixture(); + writeD1(f); + writeGameDO(f, { status: "ready" }); + const store = open(f); + expect(store.tables().find((t) => t.name === "games")?.rows).toBe(1); + expect(store.tables({ game: GAME_ID }).find((t) => t.name === "roster")?.rows).toBe(2); + }); + + it("runs ad-hoc statements against either store", () => { + const f = fixture(); + writeD1(f); + writeGameDO(f, { status: "ready" }); + const store = open(f); + expect(store.query("select count(*) as n from users")[0].n).toBe(2); + expect(store.query("select count(*) as n from roster", { game: GAME_ID })[0].n).toBe(2); + expect(() => store.query("select 1", { game: "missing" })).toThrow(/no local Durable Object/); + }); +}); diff --git a/packages/testkit/tsup.config.ts b/packages/testkit/tsup.config.ts index d55939a..b147ec7 100644 --- a/packages/testkit/tsup.config.ts +++ b/packages/testkit/tsup.config.ts @@ -1,10 +1,16 @@ import { defineConfig } from "tsup"; export default defineConfig((options) => ({ - entry: ["src/index.ts", "src/contract-cli.ts"], + entry: ["src/index.ts", "src/contract-cli.ts", "src/inspect-cli.ts", "src/local-state.ts"], format: "esm", dts: true, sourcemap: true, + // tsup rewrites `node:foo` imports to bare `foo` by default, for consumers + // old enough to predate the prefix. That is silently wrong for one import + // here: `node:sqlite` has no bare alias, so the rewrite produced a built CLI + // that failed at run time with ERR_MODULE_NOT_FOUND for a package called + // "sqlite". Keep the prefixes; every consumer of this package is Node 24+. + removeNodeProtocol: false, // See `packages/server/tsup.config.ts`: cleaning between watch rebuilds // empties `dist/` under a linked game that is reading it. clean: !options.watch,