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
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:
+38
-25
@@ -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 **0–90 s** (so **90–180 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
@@ -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.5–3 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.5–3 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
@@ -42,9 +42,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
|
||||
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
|
||||
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
|
||||
чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без
|
||||
перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
|
||||
события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья)
|
||||
чат, nudge и подключение соперника к игре, в которой вы ждёте. Каждое обновление приходит самим событием и применяется на месте без
|
||||
перезагрузки — доска обновляется бесшовно, а приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие
|
||||
события (ваш ход, конец партии, nudge, приглашение или заявка в друзья)
|
||||
приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил
|
||||
уведомления только в приложении (настройка профиля, **включена по умолчанию**).
|
||||
Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и
|
||||
@@ -87,9 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
|
||||
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
|
||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
||||
любом языке. Авто-подбор (всегда 2 игрока)
|
||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||
без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране
|
||||
любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо
|
||||
в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не
|
||||
присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а
|
||||
сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей
|
||||
игре; если такого нет — через **1,5–3 минуты** свободное место занимает робот, так что игра всегда
|
||||
стартует, и экран новой игры подсказывает, что можно закрыть приложение на время ожидания и вернуться
|
||||
позже. Для русских игр (авто-подбор или приглашение) на экране
|
||||
новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена —
|
||||
упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное
|
||||
вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются;
|
||||
@@ -126,8 +130,8 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
предпросмотр счёта и отправка доступны лишь в собственный ход.
|
||||
|
||||
### Робот-соперник
|
||||
Если авто-подбор не находит человека за десять секунд, свободное место занимает
|
||||
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
|
||||
Если авто-подбор не находит человека за время ожидания (1,5–3 минуты), свободное место в игре,
|
||||
в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека:
|
||||
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
|
||||
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
|
||||
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
|
||||
|
||||
+6
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user