feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s

Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place.

Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent".

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
Ilia Denisov
2026-06-12 16:00:22 +02:00
parent 10dc1f0d48
commit c305363ccd
42 changed files with 1248 additions and 768 deletions
+38 -25
View File
@@ -334,10 +334,11 @@ Key points:
## 7. Robot opponent
Substitutes for a human in 2-player auto-match when the pool yields no human
within 10 seconds (§8). It lives in `internal/robot` and plays as an ordinary
seated account through the game service, so only `internal/engine` imports the
solver. It is designed to be indistinguishable from a person.
Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an
open game's empty opponent slot when no human has joined within the wait window (§8).
It lives in `internal/robot` and plays as an ordinary seated account through the game
service, so only `internal/engine` imports the solver. It is designed to be
indistinguishable from a person.
The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
@@ -381,17 +382,24 @@ English game the Latin pool.
## 8. Lobby & social
- **Matchmaking**: an **in-memory** FIFO pool keyed by `variant` (the variant
fixes the board language), pairing the next two humans into a two-player
auto-match with the seat order randomised for first-move fairness. The pool is
lost on restart (players re-queue) and is anonymous, so it does not consult
blocks. After **10 s** with no human a background reaper substitutes a pooled
robot (§7) and starts the game. On a pairing or substitution the matchmaker
emits a **match-found** notification (§10), delivered over the live stream;
`Poll` remains as a fallback for a client that is not currently streaming.
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
pending matched result, so a cancelled quick-match is dequeued rather than left for
the reaper to robot-substitute.
- **Matchmaking**: auto-match drops the player **straight into a real game and lets
them wait inside it**. `Enqueue` (`POST /lobby/enqueue`) opens a game seating the
caller with an **empty opponent seat** (status `open`, §9), or — when another player
is already waiting for the same `variant` and per-turn rule — seats the caller into
that open game and starts it; which seat the caller takes is randomised for
first-move fairness, and a re-enqueue returns the caller's own still-open game
(idempotent). Matchmaking state is therefore the **open games in the database** (not
an in-memory pool), so it survives a restart and stays anonymous (no block check);
concurrent enqueues for one bucket are serialised by a transaction-scoped advisory
lock so two callers pair rather than each opening a game. A background **reaper**
seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus
a random **090 s** (so **90180 s** total) — has elapsed, guaranteeing every game
gets an opponent. When a human or a robot takes the seat, the waiting starter
receives an **opponent-joined** notification (§10) that fills the opponent card and
re-enables resign and chat **in place** — the starter never leaves the game. While a
game is `open` the starter may move on their turn, but resign, chat and nudge are
refused (no opponent yet) and the lobby and opponent card show a "searching for
opponent" placeholder.
- **Friends**: two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
@@ -462,7 +470,10 @@ English game the Latin pool.
game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
account's own lobby list, leaving it visible to the other players — finished-only and
irreversible by design, so there is no un-hide).
The matchmaking pool is **in-memory** and persists nothing.
Auto-match has no separate store: a game **awaiting an opponent** is an ordinary
`games` row with status `open` and a single seated `game_players` row (the empty
opponent seat is a null `account_id`, filled when a human or robot joins), plus an
`open_deadline_at` stamp the reaper scans for robot substitution.
- **Active games are event-sourced.** A game is a `games` row (pinned
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
@@ -470,7 +481,8 @@ English game the Latin pool.
rebuilt by replaying the journal on a miss, which the seeded bag makes exact.
Each game is serialised by a per-game lock; a persistence failure evicts the
live game so the next access rebuilds from the journal. `game_players` records
each seat's account, running score, hints used and winner flag.
each seat's account (**null for an open game's still-empty opponent seat**),
running score, hints used and winner flag.
- **Statistics** (`account_stats`, recomputed on each finish for durable
non-guest accounts only — the finish-time recompute skips any `is_guest`
seat): wins, losses, **draws**, max points in a game, and
@@ -520,7 +532,7 @@ catalog is **your-turn** and **opponent-moved** (emitted from the game commit, s
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
including the mover**, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
(from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
@@ -535,16 +547,17 @@ without a follow-up `game.state`: **opponent-moved** carries the committed move
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history`
refetch); **your-turn** carries that move count as a consistency check; **match-found** and the
**game-started** notify carry the recipient's full **initial `StateView`**, so opening a freshly
started game is instant; **game-over** carries the final summary; the lobby **notify** sub-kinds
refetch); **your-turn** carries that move count as a consistency check; the **game-started**
notify carries the recipient's full **initial `StateView`** so opening a freshly started game is
instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the
opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds
carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for
match-found — the client polls **only while the stream is down**, since a live stream delivers
match-found itself; for the lobby **notification badge** (incoming friend requests + open
verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters
synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined**
event; for the lobby **notification badge** (incoming friend requests + open
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no
@@ -557,7 +570,7 @@ not the recipient's latest-login bot. It then asks the **Telegram connector** to
localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, game-over, nudge, match-found and the invitation / friend-request notify sub-kinds;
your-turn, game-over, nudge and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and