Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s

Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
This commit is contained in:
Ilia Denisov
2026-06-02 19:29:30 +02:00
parent 571bc8c9f2
commit bfa8797f8c
54 changed files with 4270 additions and 81 deletions
+61 -21
View File
@@ -87,8 +87,13 @@ arrive from a platform rather than completing a mandatory registration).
a platform auto-provisions a durable account bound to that platform identity.
Concretely, platform and email identities share one `identities` table keyed by
a unique `(kind, external_id)`; email is an identity with `kind=email` and a
`confirmed` flag (the confirm-code flow lands later). Accounts and identities
use application-generated **UUIDv7** primary keys.
`confirmed` flag. The **email confirm-code flow** (Stage 4) binds an email to the
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. An email already confirmed by **another** account is
refused — adopting it would be a merge, which Stage 10 owns. Accounts and
identities use application-generated **UUIDv7** primary keys.
- **Linking** is initiated from an authenticated profile: choose a platform →
complete that platform's web-auth confirm → attach the identity to the
current account.
@@ -162,10 +167,16 @@ Key points:
timed out while asleep.
- **Players**: auto-match is always 2 players; friend games are 24 players.
`backend` owns turn order and the bag for any player count. A resignation or
timeout in a two-player game ends it with the other player winning; **richer
multi-player drop-out (a leaver's seat skipped while the rest play on, with a
per-game disposition of their tiles) is deferred to Stage 4**, when friend games
are formed.
timeout in a two-player game ends it with the other player winning. In a game
with **three or more seats** a resignation or timeout **drops that seat and the
rest play on** — the engine skips the resigned seat in the turn rotation and
excludes it from the win, finishing the game (the sole survivor wins) only once
one active seat remains, or by the ordinary end conditions among the active
seats. A per-game **drop-out tile disposition**, chosen at creation
(`dropout_tiles`: `remove` from play — the default — or `return` to the bag),
governs the leaver's rack, which is **never revealed** to the remaining players;
it is recorded for deterministic journal replay. (Two-player games end on the
first drop-out, so the disposition does not affect them.)
- **Hint**: governed by two per-game settings — whether hints are allowed and the
starting per-player allowance — plus a per-account hint **wallet**
(`hint_balance`, spent after the allowance; top-ups are a later feature). A hint
@@ -197,17 +208,39 @@ within 10 seconds. Designed to be indistinguishable from a person.
## 8. Lobby & social
- **Matchmaking** *(detail planned)*: a FIFO pool keyed by `(variant,
language)`; 10 s with no human match → substitute the robot.
- **Friends**: add by friend list, internal ID, or platform deep-link.
- **Block** settings independently suppress in-game chat and friend requests.
- **Chat**: per-game, persisted, length-limited, suppressed by the block
setting.
- **Nudge**: a player may nudge the opponent whose turn is awaited once per
hour; the opponent receives a platform-native notification.
- **Profile**: `preferred_language` (en/ru), display name, linked platform
accounts, email (confirm-code binding), **timezone** (drives robot sleep;
default from platform/locale, user-editable), block toggles.
- **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. The 10 s wait and the **robot substitution** for a missing human are
added in Stage 5.
- **Friends**: a **request → accept** graph (one `friendships` table) — add by
friend list or internal ID now, by platform deep-link with Stage 8. Declining or
cancelling removes the pending request; blocking someone severs an existing
friendship.
- **Block**: two independent **global** account toggles (`block_chat`,
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
applied mutually: it hides the pair's chat from each other and refuses friend
requests and game invitations between them.
- **Friend games**: formed by **invitation → accept** (an `game_invitations`
record with one row per invitee). The 24 player game starts once **every**
invitee accepts; any decline cancels the invitation, and a pending invitation
expires after 7 days (enforced lazily on access).
- **Chat**: per-game, persisted (kept with the game's archive), **≤ 60 runes**,
and **validated on input** — links, email addresses and phone numbers (including
lightly obfuscated forms) are rejected, since the chat is for quick reactions,
not contact exchange. Each message stores the sender's IP (forwarded by the
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
and messages from a blocked sender are hidden from the viewer.
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery is wired with the gateway / platform
side-service (Stage 6 / 8).
- **Profile**: `preferred_language` (en/ru), display name, email
(confirm-code binding, see §4), **timezone** (drives the away window and the
robot's sleep; user-editable), the daily **away window** and the block toggles —
all editable through `account.UpdateProfile`. Linked platform accounts and merge
are Stage 10.
## 9. Persistence
@@ -220,9 +253,14 @@ within 10 seconds. Designed to be indistinguishable from a person.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`),
`identities` (platform/email identities, unique `(kind, external_id)`),
`sessions` (revoke-only opaque-token hashes), and the Stage 3 game tables
`games`, `game_players`, `game_moves` (the move journal), `complaints` and
`account_stats`.
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
`games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`,
`game_moves` (the move journal), `complaints` and `account_stats`, and the
Stage 4 social/lobby tables `friendships` (the request/accept graph), `blocks`
(per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
(pending confirm-codes) and `game_invitations` / `game_invitation_invitees`
(friend-game invitations). The matchmaking pool is **in-memory** and persists
nothing.
- **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
@@ -263,7 +301,9 @@ does not cover.
Two channels: **platform-native push** (out-of-app, via the platform
side-service — your-turn, nudge) and the **in-app live stream** (chat,
opponent-moved, while the app is open). Backend emits notification intents;
delivery fans out to the appropriate channel.
delivery fans out to the appropriate channel. Stage 4 **persists** the
notification-worthy events (chat messages and nudges) but does not yet deliver
them: the gRPC stream to the gateway and the platform push arrive in Stage 6 / 8.
## 11. Observability
+26 -10
View File
@@ -23,9 +23,13 @@ linking an identity that already has history merges it into the current
account (stats summed, games/friends transferred).
### Lobby & matchmaking *(Stage 4)*
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins
a `(variant, language)` pool; after 10 s with no human, the robot substitutes.
Friend games (24) are formed by friend list, internal ID, or deep-link.
Bottom tab menu: **my games**, **profile**. 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 (the robot arrives in Stage 5). Friend games (24) are
formed by inviting players from the friend list or by internal ID (deep-link
invites arrive with the platform integration): the inviter chooses the settings
and the game starts once every invitee has accepted — any decline cancels it, and
an unanswered invitation expires after seven days.
### Playing a game *(Stage 3)*
Place tiles, pass, exchange, or resign. A play is validated against the game's
@@ -37,9 +41,12 @@ personal hint wallet once the per-game allowance is spent. The game ends when th
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
24 hours): a missed turn auto-resigns, except while the player is inside their
daily away window. A resignation or timeout gives the win to the other player and
the leaver keeps their score (two-player games; multi-player drop-out-and-continue
arrives with the lobby in Stage 4).
daily away window. In a two-player game a resignation or timeout gives the win to
the other player and the leaver keeps their score. In a game with three or four
players the leaver's seat is dropped and the others play on, the game ending when a
single active player remains; the disposition of the leaver's tiles (returned to
the bag or removed from play) is chosen when the game is created, and the leaver's
rack is never shown to the others.
### Robot opponent *(Stage 5)*
Indistinguishable-from-human substitute in auto-match. Decides once whether to
@@ -47,12 +54,21 @@ play to win (~40%), targets a small score margin, plays with human-like timing
and a night sleep window, and nudges/answers nudges like a person.
### Social: friends, block, chat, nudge *(Stage 4)*
Add friends; block chat and/or friend requests independently; per-game chat;
nudge the awaited opponent at most once per hour (platform-native push).
Send a friend request and have it accepted (decline or cancel withdraws it,
unfriending removes the friendship). Block globally — switch off incoming chat
and/or friend requests — and block individual players (a per-user block hides that
person's chat and stops requests and game invitations both ways; it also ends any
existing friendship). Per-game chat is for quick reactions: messages are short
(up to 60 characters) and may not contain links, email addresses or phone numbers,
even disguised. Nudge the player whose turn is awaited at most once per hour (the
nudge is part of the game chat); the out-of-app push is delivered via the platform.
### Profile & settings *(Stage 4)*
Language (en/ru), display name, linked accounts, email binding, timezone, block
toggles.
Edit language (en/ru), display name, timezone, the daily away window and the block
toggles, and bind an email by confirm-code: the backend emails a short code that,
once entered, attaches the email to the account (an email already confirmed by
another account cannot be taken — that is a merge, a later stage). Linked platform
accounts and merge arrive in Stage 10.
### History & statistics *(Stage 3)*
Finished games are archived in a dictionary-independent form and exportable to
+26 -11
View File
@@ -23,9 +23,12 @@ session-токен; backend сопоставляет его с внутренн
### Лобби и подбор *(Stage 4)*
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
встаёт в пул по `(вариант, язык)`; через 10 с без человека подставляется
робот. Игры с друзьями (2–4) формируются по списку друзей, внутреннему ID
или deep-link.
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей или по внутреннему ID
(приглашения по deep-link появятся с платформенной интеграцией): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой
отказ отменяет приглашение, а без ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)*
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
@@ -37,9 +40,12 @@ session-токен; backend сопоставляет его с внутренн
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
суточного окна отсутствия (away). Сдача или таймаут отдают победу другому игроку,
а вышедший сохраняет свои очки (партии на двоих; выход одного с продолжением для
остальных появится вместе с лобби в Stage 4).
суточного окна отсутствия (away). В партии на двоих сдача или таймаут отдают
победу другому игроку, а вышедший сохраняет свои очки. В партии на троих-четверых
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
убрать из игры) выбирается при создании партии, а его стойка никогда не
показывается остальным.
### Робот-соперник *(Stage 5)*
Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на
@@ -47,13 +53,22 @@ session-токен; backend сопоставляет его с внутренн
таймингом и ночным сном, делает и принимает nudge как человек.
### Социальное: друзья, блок, чат, nudge *(Stage 4)*
Добавление в друзья; независимая блокировка чата и/или заявок в друзья;
чат в рамках партии; nudge ожидаемого соперника не чаще раза в час
(платформенное уведомление).
Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление —
расторгает дружбу). Глобальная блокировка — отключить входящие чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу.
### Профиль и настройки *(Stage 4)*
Язык (en/ru), отображаемое имя, привязанные аккаунты, привязка email, таймзона,
переключатели блокировок.
Редактирование языка (en/ru), отображаемого имени, таймзоны, суточного окна
отсутствия (away) и переключателей блокировок, а также привязка email по
confirm-коду: backend шлёт на почту короткий код, и после ввода email
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 10.
### История и статистика *(Stage 3)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
+14 -1
View File
@@ -33,7 +33,20 @@ tests or touching CI.
end, **journal-replay equivalence**, the turn-timeout sweep with away-window
grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
word-check and complaint capture, and per-game-lock serialisation). The robot
balance/margin regression tests arrive with Stage 5.
balance/margin regression tests arrive with Stage 5. Stage 4 adds the engine's
**multi-player drop-out** cases (continue after one resign, last-survivor win,
the tile-disposition bag effect) and a domain integration test for a 3-player
**timeout that continues**.
- **Social & lobby** *(Stage 4+)*`backend/internal/social` unit-tests the chat
**content filter** (links/emails/phones plus obfuscated forms) and
`backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing,
cancel, per-variant pools) with a fake game creator. Postgres-backed `inttest`
covers the friend request/accept lifecycle with the block/toggle guards, the
per-user block (and its severing of friendships), chat post/list with the IP,
content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
email, expiry and attempt-cap) with a fixture mailer.
## Principles