9e0f929e40
Add an integration test asserting the guest-until-confirmed squat fix (an email-login account is a reapable guest until the code is confirmed, then durable). Document the branded relay pipeline in ARCHITECTURE (go-mail, TLS by port, no client cert, PUBLIC_BASE_URL anti-injection, per-recipient send throttle, guest-until-confirmed), FUNCTIONAL (+ru), TESTING and the backend README config table (BACKEND_SMTP_* + BACKEND_PUBLIC_BASE_URL).
469 lines
36 KiB
Markdown
469 lines
36 KiB
Markdown
# Scrabble Game — Functional spec
|
||
|
||
Per-domain user stories: what each user-visible operation does. This is the
|
||
starting point for any change request that touches behaviour. The English
|
||
version is authoritative; [`FUNCTIONAL_ru.md`](FUNCTIONAL_ru.md) is a mirror for
|
||
the project owner — mirror every point edit in the same patch (translate only
|
||
the changed paragraphs).
|
||
|
||
## Domains
|
||
|
||
### Client app
|
||
The web/app client (Svelte + Vite) realizes these stories. It
|
||
covers signing in (guest or email), the "my games" lobby, starting an
|
||
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. It also handles 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 / PNG-image 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.
|
||
A public **landing page** at the site root introduces the game, switches language and
|
||
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
|
||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
||
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
||
render with arbitrary languages, so detection would make the indexed content
|
||
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
||
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
||
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||
shell is `noindex` — the landing is the only indexable page.
|
||
|
||
### First-run onboarding
|
||
The first time a player opens the app it walks them through the interface with a light
|
||
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
|
||
tail — at a real control, and a **tap anywhere** advances to the next; after the last hint the
|
||
overlay is gone for good. Two independent series run. The **lobby** series points at the
|
||
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
|
||
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
|
||
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
|
||
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
|
||
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
|
||
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
|
||
bubbles re-anchor to wherever the controls move) and the hint texts are localized (en/ru). The
|
||
advertising banner hides while the overlay is up and returns when it closes.
|
||
|
||
### Identity & sessions
|
||
A player arrives from a platform (Telegram first), via email login, or as an
|
||
ephemeral guest. The gateway validates the credential once and mints a thin
|
||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
|
||
the device safe-area, and — on first contact — seeds the new account's interface
|
||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||
A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters
|
||
(verified by the gateway), and on first contact seeds the new account's interface language from
|
||
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
||
the name in the signed launch). While the theme preference is "auto" the app follows the VK
|
||
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
|
||
"couldn't load" screen applies inside VK.
|
||
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
|
||
(en/ru), no images — to the address; entering it signs the player in. A new address
|
||
is provisioned on the first request but only becomes a durable account once the code
|
||
is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address
|
||
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
|
||
per address), so a mistyped address or an impatient tap cannot flood an inbox.
|
||
Telegram runs a **single bot**: every player uses
|
||
the same bot, and all of its chat and out-of-app notifications are written in the
|
||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||
main one — its only job is to answer `/start` with a short message and a button that opens the
|
||
**main** bot's app, where the player picks their game variant; it is an onboarding entry point that
|
||
touches nothing else. Guests are session-only with restricted features
|
||
(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 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"
|
||
notification names the opponent and recaps their last move — the word and the running score
|
||
for a scoring play, or that they swapped or passed — and a finished game sends a "game over"
|
||
notification with your result and the final score (scores read with yours first). If the
|
||
connection drops or the server is rate-limiting, the app does not nag with errors: the header
|
||
shows a quiet **"Connecting…"** spinner while it reconnects, actions that send to the server
|
||
pause until it is back (a server-data screen still opens, with the spinner, and fills in on
|
||
reconnect), and pending reads resume on their own — the interface stays usable instead of
|
||
flashing a red banner each time.
|
||
|
||
### Accounts, linking & merge
|
||
_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it
|
||
returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below
|
||
describes it for when it does._
|
||
|
||
First platform contact auto-provisions a durable account. From the profile a player
|
||
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
||
who links their first identity becomes a durable account. The "already taken" status
|
||
of an identity is never revealed before the code/sign-in is verified. If the linked
|
||
identity already belongs to another account, the player is shown an explicit,
|
||
**irreversible** confirmation and the two accounts are merged into the one they are
|
||
using (statistics summed, games and friends transferred, duplicates removed) — except
|
||
when a guest links an identity that already has a durable account, where the durable
|
||
account is kept and the guest's games move into it. A merge is blocked only while the
|
||
two accounts share a game still in progress.
|
||
|
||
### Lobby & matchmaking
|
||
On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles
|
||
spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the
|
||
games list is ready, so the list never flashes an "empty" state on a slow connection.
|
||
|
||
The lobby lists **my games** and offers a bottom tab bar — new game, statistics, and a
|
||
**⚙️ settings** tab opening the settings hub (settings, profile, friends, about). The
|
||
**my games** list groups games into three
|
||
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||
opponent-turn and finished games are most-recent first; within the your-turn and
|
||
opponent-turn sections a game with any unread notification floats to the top (the finished
|
||
section keeps its order). It renders as a compact, line-separated list. While the lobby is open and a listed game **becomes your turn or
|
||
finishes**, its status icon **blinks twice** to draw the eye (an opponent's-turn change is
|
||
silent, applied in place). You can **remove a finished game from your own list**:
|
||
swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it.
|
||
The removal is per-account and permanent — the game disappears only from your list and stays
|
||
in the other players' lists, and there is no undo. A finished **AI game (🤖) you left** — by
|
||
resigning or by letting it lapse to the 7-day timeout — drops from your *finished* list
|
||
automatically, with no swipe needed; a normally finished AI game stays until you remove it, and
|
||
no other game type is auto-removed. The game types offered on **New Game** are
|
||
limited to the player's chosen **variant preferences** (see *Profile & settings*) —
|
||
Erudite-first, defaulting to Erudite only. Variants are shown by their **display name** — both Scrabble variants read
|
||
"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 you initiate —
|
||
auto-match, a vs-AI game and a friend invitation **you create** — so a player still sees and plays
|
||
existing games of any variant, and being **invited** to a game lets you accept it in any variant.
|
||
|
||
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
|
||
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
|
||
there is no chat or nudge — only the word-check tool — add-friend is never shown; instead of a
|
||
per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely,
|
||
and the AI itself never runs out of time). Choosing a **random player** is auto-match
|
||
(always 2 players), which 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 it must still cross or extend letters already on the board along that line),
|
||
and any incidental perpendicular words are ignored and not scored — while on is
|
||
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
||
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
||
formed by inviting players from the friend list (an invitation, like a friend code,
|
||
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
|
||
Mini App, the link only opens the app and the recipient enters the copied code by hand): 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.
|
||
|
||
**Simultaneous-game cap.** A player may hold at most **10 active quick games** at once —
|
||
counting both in-progress and still-searching auto-match/AI games, but **not** friend games
|
||
created by invitation. At the cap the **New Game** button is disabled (greyed) and a plain,
|
||
low-emphasis line under the lists reads *"You've reached the simultaneous games limit."*; both
|
||
clear automatically once an active game finishes and the count drops below ten. The cap blocks
|
||
**starting** any game (a quick game or a friend invitation, which share the New Game entry), but
|
||
**accepting an incoming invitation is never blocked** — so friend games are capped from the other
|
||
end (you cannot initiate one while at the cap) while you can always join a game you are invited to.
|
||
|
||
### Playing a game
|
||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
|
||
without choosing a direction — the game infers the play's orientation, so a single
|
||
tile that extends
|
||
an existing word (down a column or across a row) is accepted. A play is validated
|
||
against the game's dictionary at submit time and scored; an unlimited preview
|
||
reports the word(s) a tentative move would form and its score, or that it is not
|
||
legal, and the move is offered for submission only once it is confirmed legal. The preview is
|
||
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
||
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
||
local dictionary is unavailable. The dictionary check tool is
|
||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||
English) to look it up. Hints are governed per game —
|
||
whether they are allowed and how many each player starts with — and draw on a
|
||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||
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. 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. A player's **board composition is kept per game**:
|
||
the rack arrangement and the tiles laid but not yet submitted are saved as they compose
|
||
and restored on return (including on another device); a player may **arrange tiles during
|
||
the opponent's turn**, but that draft is position-only — the score preview and submission
|
||
stay available only on the player's own turn.
|
||
|
||
### Robot opponent
|
||
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,
|
||
now and then plays a single move against that plan — a surprise lead or a slack move —
|
||
yet holds to the plan once the bag empties, and plays at a human pace — short thinking
|
||
times for most moves, the occasional long one, and a night-time pause that tracks the
|
||
player's own day; once a game is clearly decided and both sides are only passing, it stops
|
||
dragging it out, answering its forced passes at roughly the player's own pace rather than
|
||
after a long deliberation. It answers a nudge
|
||
within a few minutes and, when the player keeps it waiting, nudges back on a sparse
|
||
schedule — roughly every 9-12 hours, and never during its night-time pause. It
|
||
carries a human-like, language-appropriate name — a fresh one each game, drawn from a wide
|
||
international pool of real names and handles, so the arena feels populated by many different
|
||
players (a Russian game shows mostly Russian names, never East-Asian scripts; an international
|
||
game the full mix); it does not chat, and **silently ignores friend requests** — a request to a
|
||
robot stays pending and expires, exactly like a human who never responds.
|
||
|
||
The same robot also backs the **honest-AI quick game** the player chooses directly (above). There
|
||
it makes no pretence: it is shown as **🤖** everywhere, joins and moves at once (no thinking time
|
||
or night pause), keeps the same strength (it still plays to win only about 40% of the time, with
|
||
the same occasional move against its plan that fades out by the endgame), and chat, nudge and
|
||
add-friend are off. AI games are **practice** — they never count toward a player's
|
||
statistics.
|
||
|
||
### Social: friends, block, chat, nudge
|
||
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. In a game, each opponent's score card (while the move history is open) carries two controls:
|
||
an **add-to-friends 🤝** on the right and a **block ✖️** on the left. Each confirms with a tap
|
||
on a fading ✅ — the card reads *Add friend?* (or *Block?*, in red) in place of the score while
|
||
confirming — and while one is confirming the other is hidden so they never overlap. The 🤝
|
||
goes **disabled** while a request is pending or was declined and **disappears** once you are
|
||
friends; both controls **disappear and the opponent's name is struck through** once you have
|
||
blocked them. They update in place the moment the relationship changes and stay correct across
|
||
reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a
|
||
live event; a transport failure rolls the control back to its prior state.
|
||
|
||
Block globally — switch off incoming chat and/or friend requests — or block an **individual
|
||
player**. A per-user block is **one-directional and silent**: you stop receiving everything
|
||
**from** that person — chat, nudges, friend requests and game invitations — and the matchmaker
|
||
never pairs you with them, while they **notice nothing**. Their messages and nudges still send
|
||
by the normal rules but never reach you (anything that would show as unread for you is marked
|
||
read at once), and a friend request or invitation they send you is kept but never surfaces. A
|
||
block **overrides but does not delete** an existing friendship (so you may block a friend, and
|
||
they keep seeing you as one); active games are never interrupted — you can finish them, with
|
||
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
|
||
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only.
|
||
On Settings → Friends each friend is a one-line row whose right-hand kebab (⋮) slides open
|
||
**block 🚫** and **remove ✖️** icon actions, and each action is gated by a confirmation
|
||
that names the friend (*Block this player?* / *Remove from friends?*).
|
||
Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game
|
||
(struck name, hidden composer) and lists the blocked opponent under the name you saw, but is
|
||
recorded only against that game — the disguise holds, the shared robot is never globally
|
||
blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out
|
||
of opponents). Sending the 🤝 to such a **disguised robot** likewise records the request only
|
||
against that game (pinned to the seat, so it stays *sent* there without touching that robot in
|
||
your other games); the robot **ignores it**, the request never appears in **Settings → Friends**,
|
||
and it drops automatically about a week after the game ends. 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. You may send **one message per turn, on your own turn**; once it is sent
|
||
the field gives way to a short caption until your next turn. Nudge the player whose turn
|
||
is awaited at most once per hour (the
|
||
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
|
||
via the platform) **name the nudger** ("<opponent>: waiting for your move").
|
||
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
||
tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no
|
||
chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the
|
||
dictionary closed) the 💬 entry disappears entirely. An unread chat
|
||
entry — a message **or a nudge** from an opponent — raises a small **dot** next to the game
|
||
in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a
|
||
softer amber when only nudges are**. Opening the **move history** counts as reading
|
||
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
|
||
also clears the moment its recipient **takes their move**, and **all of a game's nudges clear once
|
||
the game finishes** (the badge is stale once the game is over) — but unread chat messages stay unread.
|
||
|
||
### Profile & settings
|
||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||
optional trailing "." or a trailing run of up to five digits, up to 32 characters and at most
|
||
5 special characters — the "." / "_" punctuation, spaces and digits aside), the timezone
|
||
(chosen as a UTC offset, and pre-filled from your device's detected offset when the account
|
||
is first created — so robot games are timed correctly before you ever open this form), the
|
||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||
entry that opens this screen, and your display preferences (theme, board-label style and
|
||
reduce-motion — not the interface language, which follows your account) sync across your
|
||
Telegram devices.
|
||
|
||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
|
||
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
|
||
**at least one** selected. This list is exactly what **New Game** offers when you start a game
|
||
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
||
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
||
invited friend may accept an invitation in **any** variant, and you can always open and play
|
||
existing games of any variant.
|
||
|
||
### Feedback
|
||
A registered player reaches the operators from Settings → Info: a **Feedback** screen with a
|
||
message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images,
|
||
PDF, text/log, office documents, RTF or archives; an unsupported file is refused on the form
|
||
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
|
||
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
|
||
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
|
||
below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
|
||
marked read once the screen shows it and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
|
||
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
|
||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||
|
||
### Telegram support chat
|
||
A user can also reach the operators straight from the Telegram bot: anything they send the bot
|
||
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
|
||
private support group, grouped into a per-user thread. The user sees no automatic reply; an
|
||
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
||
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
||
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
||
Feedback above — it serves people who write to the bot directly rather than through the app.
|
||
|
||
### History & statistics
|
||
Finished games are archived in a dictionary-independent form and exportable in
|
||
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
|
||
the final position; the export is offered **only once a game is finished**, and
|
||
never for an honest-AI practice game (a live game's export would leak the move
|
||
journal; an AI game is throwaway). The format chooser is Telegram's **native popup**
|
||
on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
|
||
no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery
|
||
is the OS share sheet, which a native-popup callback cannot open, and VK has no
|
||
native chooser.
|
||
|
||
The **image** is rendered on the server (the internal render sidecar runs the same
|
||
drawing module the web client tests; always the light theme): the final board with
|
||
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
|
||
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
|
||
name and final score in the header (🏆 by the winner), then one row per move
|
||
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
|
||
down play column-first, `H8` — the GCG convention), the word and its points; extra
|
||
words of a multi-word play ride a smaller second line, non-play moves show as
|
||
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
|
||
endgame rack settlement when there was one (the final scores are authoritative —
|
||
running totals alone do not include it). The footer stamps the site host and the
|
||
finish date in the device locale. The scoresheet typography is fixed; a long game
|
||
stretches the board (never below its minimum) so the image carries no dead space.
|
||
|
||
Delivery is **one signed, short-lived link for both formats on every platform**,
|
||
handed to the best affordance each platform has (each branch verified on-device):
|
||
Telegram Android/desktop use Telegram's download dialog (whose own preview shares
|
||
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
|
||
takes both formats through VK's download into its native share flow, while on the
|
||
**VK Android client** — whose downloader hangs — the image opens in VK's native
|
||
photo viewer (saving from its controls) and the GCG copies to the clipboard (the
|
||
desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets
|
||
the OS share sheet** (nothing lands in Downloads first); a desktop browser
|
||
downloads the file. The link needs no login to fetch (the platforms' downloaders
|
||
carry none) and is valid for minutes. The single exception is a legacy Telegram
|
||
client without the download dialog (pre-Bot API 8.0): there the GCG falls back to
|
||
the old clipboard copy (with the confirming toast) and the image option is not
|
||
offered. 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). It
|
||
also shows the player's **move count** (their plays — passes and exchanges do not
|
||
count) and their **hint share** (the percentage of those plays where a hint was taken).
|
||
The best move is also broken down **per game variant**, showing the **word itself**
|
||
drawn as game tiles (a wildcard shows its letter but no value) — one row per variant
|
||
the player has played, omitting variants with no plays.
|
||
A game that can no longer be continued — because a rule changed and an earlier
|
||
move would now be illegal — is closed as a **draw** the moment someone opens it,
|
||
never left stuck on an error: the move history shows an impersonal organizer note
|
||
at its end (the game could not be continued), and the same note rides the GCG export.
|
||
|
||
### Administration
|
||
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
|
||
renders it; the gateway gates it with HTTP Basic Auth on its public listener and
|
||
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
|
||
identities, their games) and **games** — summary, seats, the recorded **first-move tile
|
||
draw** (who drew what, deciding who leads), and a simple **step-by-step board replay** of the
|
||
whole game — works the **word-complaint review queue** — resolving each as reject / accept-add / accept-remove — and exposes
|
||
the **dictionary**: the active version new games pin, the resident versions per
|
||
variant, an online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz`
|
||
release archive, preview the words added and removed per variant against the active
|
||
dictionary, then install — which writes the version, loads it and makes it active;
|
||
versions are immutable and games in progress keep their own), and the **pending
|
||
wordlist changes** derived from accepted complaints (which feed the offline rebuild
|
||
and are marked applied after an update). When the Telegram bot channel is configured an operator can also
|
||
**message a user** (by their Telegram identity) or **post to the game channel**.
|
||
State-changing actions are protected by a same-origin check; the console tracks no
|
||
operator identity.
|
||
|
||
The console also surfaces **rate-limit abuse**: a **Throttled** page lists the
|
||
recently throttled users/IPs the gateway reported (an in-memory window — it resets on
|
||
a backend restart) and the accounts currently carrying the soft **high-rate flag**. An
|
||
account sustaining rejections past a tunable threshold is flagged automatically —
|
||
the marker is reversible, shown as a badge in the user list and on the user card, and
|
||
**never blocks play**; the operator reviews and clears it from the user card. The
|
||
account flag itself is never a ban. In **production** the same page also lists the
|
||
**active IP bans** the gateway is enforcing: a temporary block of a client IP that
|
||
floods the service past a threshold, or trips a hidden **honeypot** path or a planted
|
||
**honeytoken** — a high-confidence sign of a scanner or hostile bot, never a normal
|
||
player. Each ban shows its reason and expiry with an **Unban** action; bans auto-expire
|
||
and the operator can lift one early. IP bans are a production-only safeguard — the
|
||
shared test environment cannot tell its clients apart, so it does not enforce them.
|
||
|
||
The console also lets an operator **manually block** an account — the hard counterpart to the
|
||
soft high-rate flag. From the user card the operator blocks the account **permanently** or
|
||
**until a date** (presets: 1 day / 3 days / 1 week / 1 month, or a custom date in UTC), optionally
|
||
citing a **reason** chosen from an editable **en+ru picklist** managed on a **Reasons** page; the
|
||
blocked player sees the reason in their own language. Blocking instantly **forfeits** the player's
|
||
active games (each opponent wins, as if the player resigned) and removes them from matchmaking. A
|
||
blocked player — on opening the app or on any action — is shown a terminal screen: *"Your account
|
||
is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary, in their timezone),
|
||
plus the reason when one was given, and the app stops all background traffic with the server. A
|
||
temporary block lifts itself when it expires; the operator can also **unblock** from the user card
|
||
at any time (games already lost stay lost).
|
||
|
||
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
||
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
||
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
||
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
||
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
||
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
||
take effect for a player already in the chat; one who is not in it is unaffected until they next join.
|
||
|
||
From the user card the operator can also **top up a player's hint wallet**: an additive grant
|
||
(1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** —
|
||
the console can never lower a wallet (a player only loses hints by spending them in a game), so an
|
||
over-grant cannot be reversed there.
|
||
|
||
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
|
||
**unread / read / archived** with per-user search, each shown with its sender, source, channel
|
||
(with the bot language — en/ru — for a Telegram message), the sender's interface
|
||
language, the **app version** it was sent from, IP, the filed time (in three zones — UTC, the
|
||
browser zone detected at submit, and the sender's saved zone, each shown `N/A` when not known) and
|
||
any attachment. The operator can mark a message read, **reply** to the player (delivered
|
||
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
|
||
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
|
||
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
||
message does not mark it read — only the explicit actions do; message bodies and attachments are
|
||
shown defensively (text escaped, attachments downloaded rather than rendered).
|
||
|
||
### Advertising banner
|
||
|
||
A one-line banner under the nav shows short promotional or operational messages to **free
|
||
players**: anyone who has **not** paid for a lifetime account, holds **no purchased hints**, and
|
||
does not carry the **`no_banner`** role (which suppresses it unconditionally). Buying a paid account
|
||
or any hints — or being granted `no_banner` — removes it; guests, the freest users, see it. When an
|
||
operator changes one of those properties, the banner appears or disappears **in place**, without a
|
||
reload.
|
||
|
||
Messages come from operator-run **campaigns**, each a placement order with a **show weight** (a
|
||
percent) and an optional start/end **window**. Campaigns running at the same time **compete** for the
|
||
banner in proportion to their weights; a permanent **house (default)** campaign fills whatever share
|
||
paid campaigns leave unsold and steps aside entirely when they sell the full 100%. Each message is
|
||
written in **both languages** (English + Russian); a player sees the variant for the **bot they play
|
||
through**, regardless of their interface language. The client rotates the eligible campaigns
|
||
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
||
and a campaign with several messages shows them in turn.
|
||
|
||
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
||
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
|
||
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
||
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
||
keeps at least one message.
|