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
+11 -6
View File
@@ -41,8 +41,9 @@ language, not whichever bot the player signed in through last. Guests are sessio
(auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move,
your turn, chat, nudges and a found match. Each update lands as the event itself, applied in place
with no reload, so the board refreshes seamlessly and a found or invited game opens instantly. When the app is **closed**, the chosen
your turn, chat, nudges and an opponent joining a game you are waiting in. Each update lands as the
event itself, applied in place with no reload, so the board refreshes seamlessly and an invited game
opens instantly. When the app is **closed**, the chosen
out-of-app events (your turn, game over, nudge, a found match, an invitation or friend
request) arrive as a **Telegram notification** instead — unless the player keeps
notifications in the app only (a profile setting, **on by default**). The "your turn"
@@ -84,8 +85,12 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes. For Russian games (auto-match or friend
(always 2 players) drops you **straight into the game and you wait inside it**: if it is your turn you
can already move, otherwise you watch your tiles. While no opponent has joined, the opponent card (and
the game's row in the lobby) reads **"searching for opponent"**, and resign, chat and nudge are
unavailable. Another player searching the same variant and rule joins your game; failing that, a robot
takes the empty seat after **1.53 minutes**, so a game always starts — and the New Game screen notes
you can close the app while you wait and come back later. For Russian games (auto-match or friend
invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays
the simplified **single-word rule** — only the word laid along the player's line must be a
real word, and any incidental perpendicular words are ignored and not scored — while on is
@@ -121,8 +126,8 @@ the opponent's turn**, but that draft is position-only — the score preview and
stay available only on the player's own turn.
### Robot opponent
When auto-match finds no human within ten seconds, a robot opponent takes the empty
seat so the game starts without waiting. It is meant to feel like a person: it
When auto-match finds no human within the wait window (1.53 minutes), a robot opponent
takes the empty seat of the game you are already waiting in. It is meant to feel like a person: it
decides once per game whether to play to win (about 40% of the time, so the human
wins most games), aims for a close score rather than crushing or throwing the game,
and plays at a human pace — short thinking times for most moves, the occasional long
+12 -8
View File
@@ -42,9 +42,9 @@ nudge) приходят от бота **этой партии** — по язы
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без
перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья)
чат, nudge и подключение соперника к игре, в которой вы ждёте. Каждое обновление приходит самим событием и применяется на месте без
перезагрузки — доска обновляется бесшовно, а приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, конец партии, nudge, приглашение или заявка в друзья)
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
@@ -87,9 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране
любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо
в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
игре; если такого нет — через **1,53 минуты** свободное место занимает робот, так что игра всегда
стартует, и экран новой игры подсказывает, что можно закрыть приложение на время ожидания и вернуться
позже. Для русских игр (авто-подбор или приглашение) на экране
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
@@ -126,8 +130,8 @@ nudge) приходят от бота **этой партии** — по язы
предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник
Если авто-подбор не находит человека за десять секунд, свободное место занимает
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
Если авто-подбор не находит человека за время ожидания (1,5–3 минуты), свободное место в игре,
в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
+6 -1
View File
@@ -193,7 +193,12 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
of starting a game; a lone offered variant is pre-selected, and a bottom **Start game**
button (disabled until a variant is chosen) confirms. For a **Russian** variant (either
flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word
rule) appears once that variant is selected; English variants never show it.
rule) appears once that variant is selected; English variants never show it. Starting an
auto-match **enters the game immediately** and waits inside it: until an opponent joins, the
opponent's score card (and the game's lobby row) reads the localized **"searching for opponent"**
placeholder, the add-friend 🤝 is hidden, and resign and the chat's send/nudge are disabled; an
**opponent_joined** push restores them in place when a human or robot takes the seat, and a line
under Start game notes the wait can take a while (the app may be closed meanwhile).
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts.