f3768d20f2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
In a dead-drawn endgame — the two most recent journal moves are both passes, so the board and the robot's rack are frozen and the robot is bound to pass again — the robot still waited out its long late-game think time (up to 90 min) before passing, needlessly dragging out a decided game. Shorten that delay to a [0.8, 1.5]x band around the human's last-move think time (the gap between the last two journal entries), clamped to [30s, 8min] and taken as a min with the normal schedule, so the robot never moves slower. A slow human collapses to the 8-min cap; a fast human is tracked, with the floor keeping the robot from passing suspiciously instantly. The anchor reads the move journal only (no schema change), stays deterministic from the seed, and still defers to the sleep window. RobotTurns now carries EndgamePass + OppLastMove, filled by one batched journal query on the scan; the honest-AI single-game trigger keeps the normal path (it moves at once). NextMoveAt (admin ETA) is left as the normal-schedule upper bound.
339 lines
26 KiB
Markdown
339 lines
26 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 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 matching per-language Telegram channel; the game itself runs at
|
||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||
|
||
### 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, and — on first contact — seeds the new account's interface
|
||
language from the Telegram client. The sign-in service also declares the **game
|
||
languages** it offers (a set of en/ru, at least one), which gate the New Game variant
|
||
choice in the lobby. Telegram runs a separate bot per language (an English bot and a
|
||
Russian bot, the same player spanning both); the bot a player signed in through sets their
|
||
offered languages, and their non-game notifications come from it. A **game's** notifications
|
||
(your turn, game over, a nudge), though, always come from **that game's** bot — by the game's
|
||
language, not whichever bot the player signed in through last. 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
|
||
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
|
||
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; 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 languages the player's sign-in service supports (English → Scrabble;
|
||
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
|
||
unrestricted). 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 — both auto-match and a friend
|
||
invitation — so a player still sees and plays existing games of any language.
|
||
|
||
**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): 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 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 nudges back when the player has been away a long time. 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.
|
||
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). 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 **red dot** next to the game
|
||
in the **lobby** and on the game's **score bar**. 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**.
|
||
|
||
### 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), 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".
|
||
|
||
### 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.
|
||
|
||
### History & statistics
|
||
Finished games are archived in a dictionary-independent form and exportable to
|
||
GCG; 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 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). 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), 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 a Telegram connector 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. There is
|
||
no automatic ban.
|
||
|
||
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).
|
||
|
||
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 connector bot language — en/ru — for a Telegram message), the sender's interface
|
||
language, IP 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.
|