Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s

Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
This commit is contained in:
Ilia Denisov
2026-06-03 19:47:40 +02:00
parent 539e24fba1
commit d733ce3119
114 changed files with 8210 additions and 149 deletions
+28 -13
View File
@@ -269,10 +269,17 @@ requires (there is no DM surface; chat is per-game).
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.
- **Friends**: a **request → accept** graph (one `friendships` table) — add by
friend list or internal ID now, by platform deep-link with Stage 9. Declining or
cancelling removes the pending request; blocking someone severs an existing
friendship.
- **Friends** (Stage 8): 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
rate-limited) is redeemed by the other player to become friends immediately.
Alternatively a **request → accept** is sent to someone you **share a game with**
(active or finished); the recipient may accept, ignore (the pending row lazily
expires after **30 days** and may be re-sent), or **decline** — a decline is
remembered (`status='declined'`) and blocks further requests from that sender,
unless they hand them a code, which overrides it. The requester's own cancel still
deletes the row; blocking someone severs an existing friendship. (Discovery by
friend list or platform deep-link arrives with Stage 9 / TODO-5.)
- **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
@@ -316,8 +323,9 @@ requires (there is no DM surface; chat is per-game).
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.
(friend-game invitations). Stage 8's migration `00006` widened the `friendships`
status to admit `declined` and added `friend_codes` (one-time add-a-friend codes).
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
@@ -352,7 +360,9 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover.
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available, else downloads it.
## 10. Notifications
@@ -365,12 +375,17 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
(from the social service), and **match-found** (from the matchmaker, §8). Event
payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client
that is not currently streaming falls back to the matchmaker's `Poll` for
match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 9;
session-revocation events and cursor-based stream resume are deferred
(single-instance MVP).
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, invitation or game-started; emitted on a friend-request and invitation
create and on an invitation's game start). Event payloads are FlatBuffers-encoded by
the backend and forwarded verbatim. A client that is not currently streaming falls
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
badge** (incoming friend requests + open invitations), the client polls on lobby
open and on focus as well as re-polling on the `notify` event — covering a push
missed while the app was hidden. Out-of-app platform push (your-turn, nudge) is
wired in Stage 9; session-revocation events and cursor-based stream resume are
deferred (single-instance MVP).
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
It is a client-side **mock** rotation today; a server-driven source (operational notices,
+21 -15
View File
@@ -15,10 +15,10 @@ The web/app client (Svelte + Vite) realizes these stories. The **playable slice*
auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign),
the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge,
real-time in-app updates, switching interface language (en/ru) and theme, and a
read-only profile. Managing friends and blocks, creating friend games (invitations),
editing the profile, the statistics screen and the history/GCG viewer arrive in
Stage 8. Settings also pick the board's bonus-label style (beginner / classic /
none). A hint **lays the suggested tiles on the board** for the player to confirm and
read-only profile. **Stage 8** adds managing friends (including one-time friend
codes) and blocks, friend-game invitations, editing the profile and binding an
email, the statistics screen, and the in-game history viewer with GCG export.
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
@@ -42,10 +42,10 @@ account (stats summed, games/friends transferred).
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.
formed by inviting players from the friend list (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
@@ -74,9 +74,13 @@ one, and a night-time pause that tracks the player's own day. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It
carries a human-like name and neither chats nor accepts friend requests.
### Social: friends, block, chat, nudge *(Stage 4)*
Send a friend request and have it accepted (decline or cancel withdraws it,
unfriending removes the friendship). Block globally — switch off incoming chat
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
Become friends in two ways: redeem a **one-time code** the other player issues (six
digits, valid for twelve hours), or send a **request to someone you have played
with** — they accept, ignore it (a request lapses after thirty days and can then be
re-sent), or decline (a decline blocks further requests from you until they hand you
a code). Cancelling your own pending request 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
@@ -91,11 +95,13 @@ 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 11.
### History & statistics *(Stage 3)*
### History & statistics *(Stage 3 / 8)*
Finished games are archived in a dictionary-independent form and exportable to
GCG. Statistics (durable accounts only): wins, losses, draws, max points in a
game, and max points for a single move (the best play, which already includes
every word it formed plus the all-tiles bonus).
GCG; the export is offered **only once a game is finished** (exporting a live game
would leak the move journal), and the client shares the `.gcg` file where the
platform supports it, otherwise downloads it. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus).
### Administration *(Stage 10)*
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
+22 -14
View File
@@ -14,9 +14,10 @@
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. Управление друзьями и блоками, создание дружеских игр
(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG
появятся в Stage 8. В настройках также выбирается стиль подписей бонус-клеток
профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч.
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
редактирование профиля и привязку email, экран статистики и просмотр истории
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
@@ -42,10 +43,10 @@ session-токен; backend сопоставляет его с внутренн
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей или по внутреннему ID
(приглашения по deep-link появятся с платформенной интеграцией): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой
отказ отменяет приглашение, а без ответа приглашение протухает через семь дней.
формируются приглашением игроков из списка друзей (приглашения по deep-link
появятся с платформенной интеграцией): инициатор выбирает настройки, и партия
стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)*
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
@@ -74,9 +75,14 @@ session-токен; backend сопоставляет его с внутренн
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
в чате и не принимает заявки в друзья.
### Социальное: друзья, блок, чат, nudge *(Stage 4)*
Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление —
расторгает дружбу). Глобальная блокировка — отключить входящие чат и/или заявки —
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие
чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
@@ -92,11 +98,13 @@ confirm-коду: backend шлёт на почту короткий код, и
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 11.
### История и статистика *(Stage 3)*
### История и статистика *(Stage 3 / 8)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи,
макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все
образованные им слова и бонус за все фишки).
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки).
### Администрирование *(Stage 10)*
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
+14 -3
View File
@@ -15,7 +15,12 @@ tests or touching CI.
`go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded
by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow.
- **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain.
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Stage 8 adds Vitest for
the new FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility.
- **Engine** *(Stage 2+)* — correctness of scoring and move generation is owned
by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
top of the embedded solver: per-variant smoke tests (load all three committed
@@ -48,7 +53,11 @@ tests or touching CI.
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.
email, expiry and attempt-cap) with a fixture mailer. Stage 8 adds the
**befriend-an-opponent** gate (a request needs a shared game), the **permanent
decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem,
self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and
the GCG **finished-only** gate.
- **Robot** *(Stage 5+)*`backend/internal/robot` unit-tests the pure strategy:
the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay
(bounds, ~10-min median, determinism), the margin selection (win/lose, in-band
@@ -70,7 +79,9 @@ tests or touching CI.
(guest auth, unauthenticated rejection, unknown message type). The backend gains
the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues
no statistics) and the **email-as-login** flow (request/verify, returning user)
in `inttest`.
in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account
operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
the profile-update away round-trip) and a `notify`-event constructor round-trip.
## Principles
+27 -1
View File
@@ -22,7 +22,11 @@ Login uses `Screen`.
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
`.chev`) — lighter than a glyph.
- **Hamburger**: a CSS three-bar (`Menu.svelte`), deliberately larger; opens a dropdown
of items (lobby: Profile/Settings/About; game: History/Chat/Check word/Drop game).
of items (lobby: Friends/Profile/Settings/About; game: History/Chat/Check word, plus
*Export GCG* on a finished game and *Add to friends* per opponent, then Drop game). A
red count **badge** rides the hamburger (and the lobby *Friends* item) for pending
incoming friend requests + invitations; the same dot style serves any future
notification count.
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
@@ -77,6 +81,28 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 /
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌.
## Social, account & history surfaces (Stage 8)
- **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block
pairing a code **input** with a **Show my code** action that reveals a large 6-digit
code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a
guest sees a sign-in prompt.
- **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept /
Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the
**"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move
time / hints).
- **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.
- **Profile editing** (`screens/Profile.svelte`): an inline form (display name, timezone,
the away-window time pickers, block toggles) and an email-binding sub-flow (enter email
→ enter the confirm code). Interface language stays in **Settings** (it writes through
to the account for durable users).
- **History / GCG**: the in-game slide-down history gains the running total per move;
*Export GCG* shares or downloads the `.gcg` file and appears only once the game is
finished.
## Caveat
Emoji are rendered by the platform's system emoji font, so their exact look varies across