Skip to content
This repository was archived by the owner on Sep 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/inspect-the-local-stores.md
Original file line number Diff line number Diff line change
@@ -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 <id|code|prefix>` 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 <ref>` 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.
41 changes: 41 additions & 0 deletions .changeset/one-session-snapshot.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/scaffold-ships-inspect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"create-eigen-game": patch
---

A scaffolded game now ships the local inspector wired up: `pnpm inspect games`, `pnpm inspect game <id|code>`, `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.
17 changes: 17 additions & 0 deletions .changeset/watch-the-fixtures.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 2 additions & 6 deletions clients/dart/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions clients/dart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions clients/dart/doc/CommandAccepted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
62 changes: 52 additions & 10 deletions clients/dart/doc/GamesApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -26,7 +27,7 @@ Method | HTTP request | Description


# **addBot**
> LobbyAccepted addBot(gameId, addBot)
> CommandAccepted addBot(gameId, addBot)



Expand Down Expand Up @@ -55,7 +56,7 @@ Name | Type | Description | Notes

### Return type

[**LobbyAccepted**](LobbyAccepted.md)
[**CommandAccepted**](CommandAccepted.md)

### Authorization

Expand All @@ -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)



Expand Down Expand Up @@ -98,7 +99,7 @@ Name | Type | Description | Notes

### Return type

[**LobbyAccepted**](LobbyAccepted.md)
[**CommandAccepted**](CommandAccepted.md)

### Authorization

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)



Expand Down Expand Up @@ -440,7 +482,7 @@ Name | Type | Description | Notes

### Return type

[**Joined**](Joined.md)
[**CommandAccepted**](CommandAccepted.md)

### Authorization

Expand All @@ -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)



Expand All @@ -481,7 +523,7 @@ Name | Type | Description | Notes

### Return type

[**Joined**](Joined.md)
[**CommandAccepted**](CommandAccepted.md)

### Authorization

Expand All @@ -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)



Expand Down Expand Up @@ -524,7 +566,7 @@ Name | Type | Description | Notes

### Return type

[**LobbyAccepted**](LobbyAccepted.md)
[**CommandAccepted**](CommandAccepted.md)

### Authorization

Expand Down
16 changes: 0 additions & 16 deletions clients/dart/doc/Joined.md

This file was deleted.

15 changes: 0 additions & 15 deletions clients/dart/doc/LobbyAccepted.md

This file was deleted.

17 changes: 0 additions & 17 deletions clients/dart/doc/Roster.md

This file was deleted.

Loading