R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
+74
-77
@@ -28,10 +28,10 @@ Three executables plus per-platform side-services:
|
||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build;
|
||||
no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC +
|
||||
FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto`
|
||||
and `scrabble.fbs` and committed under `ui/src/gen/`. The **playable slice**
|
||||
(Stage 7) covers auth, "my games", auto-match, the board (play/pass/exchange/
|
||||
and `scrabble.fbs` and committed under `ui/src/gen/`. The client covers auth,
|
||||
"my games", auto-match, the board (play/pass/exchange/
|
||||
resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile
|
||||
view; the social/account/history surfaces follow in Stage 8. There is no board on
|
||||
view, plus the social/account/history surfaces. There is no board on
|
||||
the wire — the client **reconstructs the 15×15 board by replaying the move
|
||||
journal** (§9.1) and renders board, tiles, premium squares and effects as pure
|
||||
CSS + Unicode (no image/font/SVG assets). Tiles are placed by Pointer-Events drag
|
||||
@@ -41,7 +41,7 @@ Three executables plus per-platform side-services:
|
||||
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
|
||||
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
|
||||
a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off
|
||||
in the build until polished after release**, Stage 17 — a server-driven channel later, §10),
|
||||
in the build until polished after release** — a server-driven channel later, §10),
|
||||
and a client **board-style** setting (bonus-label
|
||||
mode). The visual/interaction design system is documented in
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||
@@ -89,7 +89,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as
|
||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||
@@ -98,7 +98,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
response was lost — its button is disabled while offline and the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
**Edge hardening (R3):** every request body on the public listener is capped at
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
`Execute` is refused (`resource_exhausted`) without buffering. The h2c server carries explicit
|
||||
@@ -106,8 +106,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
`Subscribe` stream plus a few unary calls) and a 3-minute connection `IdleTimeout` (a live
|
||||
`Subscribe` stream keeps its connection active, so only abandoned connections are reaped); the
|
||||
`http.Server` sets only `ReadHeaderTimeout` (10 s) — Read/WriteTimeout would kill the stream.
|
||||
R7 revisits the exact values under load.
|
||||
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
|
||||
- **Alphabet on the wire**: live play exchanges **alphabet indices**, not
|
||||
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
|
||||
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
|
||||
(a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a
|
||||
@@ -139,7 +138,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
||||
Telegram contact seeds the new account's language (from the launch `language_code`)
|
||||
and display name (§4).
|
||||
- **Service language & variant gating (Stage 15).** The connector hosts **one bot per
|
||||
- **Service language & variant gating.** The connector hosts **one bot per
|
||||
service language** (`en`/`ru`), each its own token + game channel; the same Telegram
|
||||
user id spans both. `ValidateInitData` tries each token in turn and returns the
|
||||
validating bot's **service language** and its **supported-languages set**. The set
|
||||
@@ -151,7 +150,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
**persisted** per account (`accounts.service_language`, updated on every Telegram
|
||||
login — last-login-wins) and routes the user's out-of-app push back through the right
|
||||
bot (§10) — **except a game event, which routes by the game's own language** (its variant →
|
||||
en/ru, Stage 17), so a game's notification always comes from the game's bot rather than the
|
||||
en/ru), so a game's notification always comes from the game's bot rather than the
|
||||
recipient's latest login bot. The service language is distinct from `preferred_language` (the
|
||||
interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the
|
||||
gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default).
|
||||
@@ -170,7 +169,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
require one, the same way the robot pool is durable), not a profile: no
|
||||
friends, statistics or history are kept for it, and it is restricted to
|
||||
auto-match. Platform and email users are auto-provisioned **durable** accounts
|
||||
with an identity. (Reaping abandoned guest rows is deferred — PLAN.md TODO-3.)
|
||||
with an identity.
|
||||
|
||||
## 4. Accounts, identities, linking & merge
|
||||
|
||||
@@ -179,15 +178,15 @@ 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. A synthetic `kind='robot'` identity (Stage 5) backs each pooled
|
||||
robot opponent (§7). The **email confirm-code flow** (Stage 4) binds an email to the
|
||||
`confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
|
||||
robot opponent (§7). The **email confirm-code flow** 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. Accounts and identities use application-generated
|
||||
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
|
||||
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
||||
- **Linking** (Stage 11) is initiated from an authenticated profile and proves
|
||||
- **Linking** is initiated from an authenticated profile and proves
|
||||
control of the identity before attaching it: **email** through the confirm-code
|
||||
flow, **Telegram** through the web **Login Widget** (validated by the connector,
|
||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
||||
@@ -210,7 +209,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
already has a **durable** owner: then the durable account wins, the guest's active
|
||||
games move into it, the guest is retired, and a **fresh session** is minted for the
|
||||
durable account (the client switches to it). The secondary's sessions are revoked
|
||||
(§3). High blast-radius; an isolated, well-tested stage.
|
||||
(§3). High blast-radius; isolated and well-tested.
|
||||
|
||||
## 5. Game engine integration (`scrabble-solver`)
|
||||
|
||||
@@ -241,8 +240,7 @@ Key points:
|
||||
boot version plus each subdirectory). In-flight games keep their pinned version;
|
||||
new games use the latest. (The solver is published as a versioned module and the
|
||||
dictionaries ship as a separate versioned **release artifact** from the
|
||||
`scrabble-dictionary` repo — TODO-1/TODO-2, Stage 14; the runtime contract above is
|
||||
unchanged.)
|
||||
`scrabble-dictionary` repo; the runtime contract above is unchanged.)
|
||||
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
||||
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
||||
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
||||
@@ -258,7 +256,7 @@ Key points:
|
||||
unconditionally the other player in a two-player game. A player may resign **on the
|
||||
opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)`
|
||||
resigns that player's own seat whoever is to move, and the game domain skips the turn
|
||||
check for resign (Stage 17). The engine exposes a
|
||||
check for resign. The engine exposes a
|
||||
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
||||
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
||||
- The **game domain** (`internal/game`) owns everything the engine does not —
|
||||
@@ -326,7 +324,7 @@ behaviour on every scan and after a restart — the same philosophy as journal
|
||||
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
|
||||
`robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend
|
||||
requests open** — a request to a robot is accepted as pending and expires unanswered
|
||||
(the robot never responds), mirroring a human who ignores it (Stage 17); the chat
|
||||
(the robot never responds), mirroring a human who ignores it; the chat
|
||||
block backs the human-like names (there is no DM surface; chat is per-game). Names are
|
||||
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
|
||||
a surname pool (gender-agreed for Russian) in one of three forms (first only /
|
||||
@@ -352,12 +350,12 @@ English game the Latin pool.
|
||||
rather than running anti-phase; on a daytime nudge it replies near the move's lower
|
||||
band; it proactively nudges the idle human on a **lengthening, randomized schedule** — the
|
||||
first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long
|
||||
wait gets a handful of increasingly-spaced nudges rather than an hourly stream (Stage 17).
|
||||
wait gets a handful of increasingly-spaced nudges rather than an hourly stream.
|
||||
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
|
||||
authoritative balance metric (target ≈ 40% robot wins) — and a
|
||||
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
|
||||
The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
|
||||
the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17).
|
||||
the seed) and, on the robot's turn, its deterministic **next-move ETA**.
|
||||
|
||||
## 8. Lobby & social
|
||||
|
||||
@@ -371,8 +369,8 @@ English game the Latin pool.
|
||||
`Poll` remains as a fallback for a client that is not currently streaming.
|
||||
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
|
||||
pending matched result, so a cancelled quick-match is dequeued rather than left for
|
||||
the reaper to robot-substitute (Stage 17).
|
||||
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
|
||||
the reaper to robot-substitute.
|
||||
- **Friends**: two add paths over one `friendships` table. A **one-time
|
||||
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
||||
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||
rate-limited) is redeemed by the other player to become friends immediately.
|
||||
@@ -382,7 +380,7 @@ English game the Latin pool.
|
||||
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.)
|
||||
friend list or platform deep-link is future work.)
|
||||
- **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
|
||||
@@ -395,25 +393,25 @@ English game the Latin pool.
|
||||
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,
|
||||
gateway) for moderation. A sender who has disabled chat cannot post,
|
||||
and messages from a blocked sender are hidden from the viewer. The operator console
|
||||
has a **Messages** section (Stage 17) that lists posted messages (nudges excluded)
|
||||
has a **Messages** section that lists posted messages (nudges excluded)
|
||||
newest-first with the sender's resolved name, **source** (guest / robot / oldest
|
||||
identity kind), IP and game, searchable by sender name / external-id glob masks and
|
||||
pinnable to one game or sender (linked from the game and user cards).
|
||||
- **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).
|
||||
turn. The platform-native delivery runs through the gateway and the platform
|
||||
side-service.
|
||||
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
|
||||
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
|
||||
block toggles — all editable through `account.UpdateProfile`, which validates them
|
||||
(Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_`
|
||||
block toggles — all editable through `account.UpdateProfile`, which validates them:
|
||||
a display name is Unicode letters joined by single ` `/`.`/`_`
|
||||
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
|
||||
fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
|
||||
for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
|
||||
picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
|
||||
accounts and merge are Stage 11.
|
||||
accounts and merge are covered in §4.
|
||||
|
||||
## 9. Persistence
|
||||
|
||||
@@ -423,23 +421,22 @@ English game the Latin pool.
|
||||
into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
|
||||
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
|
||||
keys are application-generated **UUIDv7**.
|
||||
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
|
||||
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's
|
||||
migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's
|
||||
migration `00007` added the `notifications_in_app_only` out-of-app push toggle;
|
||||
Stage 11's migration `00009` added the `paid_account` service flag and the
|
||||
merge-tombstone columns `merged_into`/`merged_at`),
|
||||
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
|
||||
Stage 5's migration `00004` admits the `robot` kind),
|
||||
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
|
||||
`games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`,
|
||||
- Tables: `accounts` (durable internal accounts, carrying the away-window
|
||||
columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest`
|
||||
flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push
|
||||
toggle, the `paid_account` service flag and the merge-tombstone columns
|
||||
`merged_into`/`merged_at`),
|
||||
`identities` (platform/email/robot identities, unique `(kind, external_id)`,
|
||||
the `kind` admitting `robot`),
|
||||
`sessions` (revoke-only opaque-token hashes), the game tables
|
||||
`games` (carrying 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`
|
||||
social/lobby tables `friendships` (the request/accept graph, its status admitting
|
||||
`declined`), `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). Stage 8's migration `00006` widened the `friendships`
|
||||
status to admit `declined` and added `friend_codes` (one-time add-a-friend codes).
|
||||
Stage 17 added `game_drafts` (a player's in-progress rack order + board composition per
|
||||
(pending confirm-codes), `game_invitations` / `game_invitation_invitees`
|
||||
(friend-game invitations), `friend_codes` (one-time add-a-friend codes),
|
||||
`game_drafts` (a player's in-progress rack order + board composition per
|
||||
game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
|
||||
account's own lobby list, leaving it visible to the other players — finished-only and
|
||||
irreversible by design, so there is no un-hide).
|
||||
@@ -479,18 +476,18 @@ 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. **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
|
||||
otherwise), 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.
|
||||
|
||||
The Stage 13 alphabet-on-the-wire change does **not** touch this invariant: the live edge
|
||||
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
|
||||
game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary.
|
||||
|
||||
## 10. Notifications
|
||||
|
||||
Two channels: the **in-app live stream** (delivered from Stage 6) and
|
||||
**platform-native push** (out-of-app, via the platform side-service — Stage 9).
|
||||
Two channels: the **in-app live stream** and
|
||||
**platform-native push** (out-of-app, via the platform side-service).
|
||||
The backend emits notification intents through an in-process hub
|
||||
(`internal/notify`, a `Publisher` seam installed on the game, social and lobby
|
||||
services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
|
||||
@@ -501,16 +498,16 @@ robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every
|
||||
including the mover**, so the mover's own other devices and their lobby refresh — it is
|
||||
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
||||
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
||||
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
|
||||
on answering one (accept → friend-added, decline → friend-declined — to the original
|
||||
requester, so a game screen watching that opponent re-derives its "add to friends" state,
|
||||
Stage 17), and on an invitation create or its game start). Stage 17 added **game-over** (emitted to every
|
||||
requester, so a game screen watching that opponent re-derives its "add to friends" state),
|
||||
and on an invitation create or its game start). **game-over** is emitted to every
|
||||
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
|
||||
resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now
|
||||
resign or timeout — and **your-turn** is enriched so the out-of-app push reads in full: it
|
||||
also carries the mover's display name, their last action and the main word of a scoring play,
|
||||
and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first).
|
||||
**R4 enriched the in-app stream into a delta channel** so the client renders from the event
|
||||
The in-app stream is a **delta channel** so the client renders from the event
|
||||
without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move
|
||||
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
|
||||
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
|
||||
@@ -526,12 +523,12 @@ verbatim. A client that is not currently streaming falls back to the matchmaker'
|
||||
match-found — the client polls **only while the stream is down**, since a live stream delivers
|
||||
match-found itself; for the lobby **notification badge** (incoming friend requests + open
|
||||
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
|
||||
missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
|
||||
missed while the app was hidden. **Out-of-app platform push** is a fallback
|
||||
the **gateway** routes from the same firehose: for an event whose recipient has **no
|
||||
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
|
||||
`external_id`, the **service language** — the bot they last signed in through, falling
|
||||
back to the interface language — and the `notifications_in_app_only` flag). A **game** event,
|
||||
however, carries the **game's own language** on the push (Stage 17), and the gateway routes by
|
||||
however, carries the **game's own language** on the push, and the gateway routes by
|
||||
that instead of the service language — so a game's notification always comes from the game's bot,
|
||||
not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a
|
||||
localized message with a Mini App deep-link button — only when the recipient has a Telegram
|
||||
@@ -560,19 +557,19 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus
|
||||
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
|
||||
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
||||
are stood up with the deploy (`deploy/`, Stage 16); the default exporter stays
|
||||
are stood up with the deploy (`deploy/`); the default exporter stays
|
||||
`none`, so CI needs no collector. The contour also runs **cAdvisor** (per-container
|
||||
CPU/memory/network) and **postgres_exporter** (connections, cache-hit ratio,
|
||||
transactions, db size), scraped by Prometheus and surfaced on the **Scrabble —
|
||||
Resources** Grafana dashboard (R2), so the pre-release stress runs capture a resource
|
||||
Resources** Grafana dashboard, which captures a resource
|
||||
baseline; these export directly in Prometheus format (not through the collector).
|
||||
- Per-request server-side timing via gin middleware from day one (the access log
|
||||
carries method, route, status, latency and the active trace id). A
|
||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
|
||||
- Domain/operational metrics, recorded through the meter and invisible
|
||||
until an exporter is configured: histograms `game_replay_duration` (journal
|
||||
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
|
||||
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
|
||||
(a seat's think time per committed move, attributed by `variant` and a
|
||||
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
|
||||
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
|
||||
console, below); counters `games_started_total`, `games_abandoned_total` (a
|
||||
@@ -581,18 +578,18 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
||||
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
||||
(scrabble_en/scrabble_ru/erudit_ru).
|
||||
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
|
||||
- Per-user move-time analytics are **offline**, derived in the admin
|
||||
console from the move journal (`game_moves.created_at` deltas, the first move from
|
||||
the game's creation), not Prometheus labels (which an `account_id` would explode):
|
||||
the user list shows each account's min/avg/max think time, and the user-detail page
|
||||
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
|
||||
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
|
||||
- User metrics: a backend counter `accounts_created_total` (`kind` =
|
||||
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
|
||||
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
|
||||
distinct accounts that performed an authenticated edge action in the window. The
|
||||
gauge is single-process by design (single-instance MVP, §10): it is correct for one
|
||||
gateway, resets on restart, and is a live operational figure, not a billing count.
|
||||
- **Rate-limit observability (R3):** every limiter rejection increments the gateway
|
||||
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
||||
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||
a gateway reporter drains the per-key rejection tracker every 30 s, emits one **Warn**
|
||||
@@ -617,19 +614,19 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
|
||||
| Concern | Enforced by |
|
||||
| --- | --- |
|
||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — R3, §11) |
|
||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) |
|
||||
| Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector |
|
||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth (R3) — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
||||
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
||||
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
||||
|
||||
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
|
||||
network segment defeats backend authentication. Mitigated by network isolation;
|
||||
mutual auth is a future hardening step.
|
||||
|
||||
**Short numeric codes** (email confirm-codes and Stage 8 friend codes) are stored
|
||||
**Short numeric codes** (email confirm-codes and friend codes) are stored
|
||||
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
|
||||
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
|
||||
is authenticated, so it rides the per-user limit (300 / min) and is further bounded
|
||||
@@ -645,7 +642,7 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
|
||||
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
|
||||
redirects to the root — the client-side guard); a stray hit on the gateway's `/`
|
||||
308-redirects to `/app/`. The **landing** ships in its own static container (R3): the
|
||||
308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
||||
@@ -671,23 +668,23 @@ network (project-scoped DNS); only caddy joins the shared external `edge` networ
|
||||
(alias `scrabble`).
|
||||
|
||||
Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
- **Test** (Stage 16): auto-deploys on a PR into — or a push to — `development`
|
||||
- **Test**: auto-deploys on a PR into — or a push to — `development`
|
||||
(`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner
|
||||
host, then `GET /` + `GET /app/` probes through caddy — the landing container and
|
||||
the gateway, R3). The host caddy terminates TLS and
|
||||
the gateway). The host caddy terminates TLS and
|
||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||
real peer, so the single config is correct and spoof-safe in both contours (Stage 17).
|
||||
- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no
|
||||
real peer, so the single config is correct and spoof-safe in both contours.
|
||||
- **Prod**: a manual SSH deploy after `development → master`. There is no
|
||||
host caddy, so the contour ships its own caddy terminating TLS — set
|
||||
`CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
- **Two long-lived branches** (Stage 16): **`development`** is the integration
|
||||
- **Two long-lived branches**: **`development`** is the integration
|
||||
trunk and **`master`** the production trunk; `feature/*` branches are cut from
|
||||
`development` and PR back into it (the genesis commit necessarily landed on
|
||||
`master`). A commit to a `feature/*` branch triggers nothing.
|
||||
@@ -695,18 +692,18 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
suite on a PR into `development`/`master` and on a push to `development`. Its
|
||||
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
|
||||
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
|
||||
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
|
||||
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (a
|
||||
`changes` job filters by changed paths), and an always-running **`gate`** job
|
||||
aggregates them (passing when each succeeded or was **skipped**) and is the single
|
||||
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
|
||||
a merge.
|
||||
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
|
||||
to — `development` (`docker compose up -d --build` on the runner host), then probes
|
||||
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
|
||||
the gateway (`GET /`) **and the Telegram connector's liveness** (via
|
||||
`docker inspect`: running, not restarting, stable restart count, with a
|
||||
VPN-handshake grace period, since the connector has no public ingress and a
|
||||
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
|
||||
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
|
||||
deploy is the manual workflow. Secrets/variables are prefixed
|
||||
`TEST_`/`PROD_` per contour.
|
||||
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
||||
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
|
||||
@@ -714,6 +711,6 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
(no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release
|
||||
artifact** from the `scrabble-dictionary` repo; the workflows download
|
||||
`scrabble-dawg-<DICT_VERSION>.tar.gz` and point the engine tests at it via
|
||||
`BACKEND_DICT_DIR` (TODO-1/TODO-2 discharged in Stage 14).
|
||||
`BACKEND_DICT_DIR`.
|
||||
- After any push, the run is watched to green before a stage is declared done
|
||||
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
|
||||
|
||||
+19
-20
@@ -4,18 +4,17 @@ 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). Sections deepen as stages land; *(Stage N)* marks where
|
||||
the detail is authored.
|
||||
the changed paragraphs).
|
||||
|
||||
## Domains
|
||||
|
||||
### Client app *(Stage 7 / 8)*
|
||||
The web/app client (Svelte + Vite) realizes these stories. The **playable slice**
|
||||
(Stage 7) covers signing in (guest or email), the "my games" lobby, starting an
|
||||
### 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. **Stage 8** adds managing friends (including one-time friend
|
||||
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
|
||||
@@ -26,7 +25,7 @@ theme, and links to the matching per-language Telegram channel; the game itself
|
||||
`/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 *(Stage 1 / 6 / 9 / 15)*
|
||||
### 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
|
||||
@@ -56,7 +55,7 @@ pause until it is back (a server-data screen still opens, with the spinner, and
|
||||
reconnect), and pending reads resume on their own — the interface stays usable instead of
|
||||
flashing a red banner each time.
|
||||
|
||||
### Accounts, linking & merge *(Stage 1 / 11)*
|
||||
### 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
|
||||
@@ -68,12 +67,12 @@ when a guest links an identity that already has a durable account, where the dur
|
||||
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 *(Stage 4 / 15)*
|
||||
### Lobby & matchmaking
|
||||
Bottom tab menu: **my games**, **profile**. 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 (Stage 17). You can **remove a finished game from your own list**:
|
||||
line-separated list. 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. The game types offered on **New Game** are
|
||||
@@ -84,13 +83,13 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va
|
||||
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
|
||||
invitation — so a player still sees and plays existing games of any language. Auto-match
|
||||
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
|
||||
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
|
||||
after 10 s with no human the robot substitutes. 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.
|
||||
|
||||
### Playing a game *(Stage 3)*
|
||||
### Playing a game
|
||||
Place tiles, pass, exchange, or resign. A play is validated against the game's
|
||||
dictionary at submit time and scored; an unlimited preview reports what a
|
||||
tentative move would score and whether it is legal. The dictionary check tool is
|
||||
@@ -111,7 +110,7 @@ and restored on return (including on another device); a player may **arrange til
|
||||
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 *(Stage 5)*
|
||||
### Robot opponent
|
||||
When auto-match finds no human within ten seconds, a robot opponent takes the empty
|
||||
seat so the game starts without waiting. It is meant to feel like a person: it
|
||||
decides once per game whether to play to win (about 40% of the time, so the human
|
||||
@@ -123,7 +122,7 @@ carries a human-like, language-appropriate name (a Russian game draws mostly Rus
|
||||
names); 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.
|
||||
|
||||
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
|
||||
### 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
|
||||
@@ -132,7 +131,7 @@ a code). Cancelling your own pending request withdraws it; unfriending removes t
|
||||
friendship. In a game, an **add to friends** item for each opponent mirrors the live
|
||||
relationship: it reads *request sent* (disabled) while a request is pending or was
|
||||
declined, and *in friends* once accepted — updating in place the moment the opponent
|
||||
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
|
||||
answers, and staying correct across reloads. 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
|
||||
@@ -142,16 +141,16 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo
|
||||
Chat and the word-check tool open as their **own screens** (with a back to the game), and a
|
||||
new chat message raises an **unread badge** on the game's menu until the chat is opened.
|
||||
|
||||
### Profile & settings *(Stage 4 / 8)*
|
||||
### Profile & settings
|
||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||
optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_"
|
||||
punctuation, spaces 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" (Stage 11).
|
||||
merge".
|
||||
|
||||
### History & statistics *(Stage 3 / 8)*
|
||||
### 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** (exporting a live game
|
||||
would leak the move journal), and the client shares the `.gcg` file where the
|
||||
@@ -159,7 +158,7 @@ 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)*
|
||||
### 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,
|
||||
@@ -173,7 +172,7 @@ applied after a reload). When a Telegram connector is configured an operator can
|
||||
State-changing actions are protected by a same-origin check; the console tracks no
|
||||
operator identity.
|
||||
|
||||
The console also surfaces **rate-limit abuse** (R3): a **Throttled** page lists the
|
||||
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 —
|
||||
|
||||
+19
-20
@@ -3,18 +3,17 @@
|
||||
Пользовательские сценарии по доменам: что делает каждая видимая пользователю
|
||||
операция. Это зеркало [`FUNCTIONAL.md`](FUNCTIONAL.md) для владельца проекта;
|
||||
**авторитетна английская версия**. Любую точечную правку переносим в том же
|
||||
патче (переводим только изменённые абзацы). Разделы наполняются по мере этапов;
|
||||
*(Stage N)* помечает, где пишется детализация.
|
||||
патче (переводим только изменённые абзацы).
|
||||
|
||||
## Домены
|
||||
|
||||
### Клиентское приложение *(Stage 7 / 8)*
|
||||
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. **Играбельный срез**
|
||||
(Stage 7) покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
|
||||
### Клиентское приложение
|
||||
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. Он
|
||||
покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
|
||||
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
|
||||
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
|
||||
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
|
||||
профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч.
|
||||
профиль только для чтения. Он также включает управление друзьями (в т.ч.
|
||||
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
|
||||
редактирование профиля и привязку email, экран статистики и просмотр истории
|
||||
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
|
||||
@@ -27,7 +26,7 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||
|
||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
||||
### Личность и сессии
|
||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||
@@ -58,7 +57,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся
|
||||
рабочим вместо красного баннера каждый раз.
|
||||
|
||||
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
|
||||
### Аккаунты, привязка и слияние
|
||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||
@@ -70,12 +69,12 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
### Лобби и подбор *(Stage 4 / 15)*
|
||||
### Лобби и подбор
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||
линиями-разделителями (Stage 17). Завершённую партию можно **убрать из своего списка**:
|
||||
линиями-разделителями. Завершённую партию можно **убрать из своего списка**:
|
||||
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
||||
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
||||
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
|
||||
@@ -88,13 +87,13 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
||||
любом языке. Авто-подбор (всегда 2 игрока)
|
||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
|
||||
без человека подставляется робот. Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
ответа приглашение протухает через семь дней.
|
||||
|
||||
### Игровой процесс *(Stage 3)*
|
||||
### Игровой процесс
|
||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
|
||||
сдаче и считается; безлимитный предпросмотр сообщает, сколько принёс бы
|
||||
предполагаемый ход и легален ли он. Инструмент проверки слова безлимитный и
|
||||
@@ -115,7 +114,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
|
||||
предпросмотр счёта и отправка доступны лишь в собственный ход.
|
||||
|
||||
### Робот-соперник *(Stage 5)*
|
||||
### Робот-соперник
|
||||
Если авто-подбор не находит человека за десять секунд, свободное место занимает
|
||||
робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека:
|
||||
один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что
|
||||
@@ -127,7 +126,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
|
||||
ровно как у человека, который не отвечает.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||
@@ -136,7 +135,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
|
||||
в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
|
||||
чат и/или заявки —
|
||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||
@@ -147,16 +146,16 @@ push доставляется через платформу.
|
||||
Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в
|
||||
партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата.
|
||||
|
||||
### Профиль и настройки *(Stage 4 / 8)*
|
||||
### Профиль и настройки
|
||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||
«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов —
|
||||
пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от
|
||||
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
|
||||
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
||||
|
||||
### История и статистика *(Stage 3 / 8)*
|
||||
### История и статистика
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии
|
||||
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это
|
||||
@@ -164,7 +163,7 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
||||
|
||||
### Администрирование *(Stage 10)*
|
||||
### Администрирование
|
||||
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
|
||||
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
|
||||
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
|
||||
@@ -177,7 +176,7 @@ identity, их игры) и **игры** (сводка + места), разби
|
||||
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
|
||||
защищены проверкой same-origin; личность оператора не отслеживается.
|
||||
|
||||
Консоль также показывает **злоупотребление лимитами** (R3): страница **Throttled**
|
||||
Консоль также показывает **злоупотребление лимитами**: страница **Throttled**
|
||||
перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти —
|
||||
сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate
|
||||
флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается
|
||||
|
||||
+22
-22
@@ -9,19 +9,19 @@ tests or touching CI.
|
||||
Every functional change ships with regression coverage. Run:
|
||||
`go test -count=1 ./backend/... ./pkg/... ./gateway/...` (the module list grows
|
||||
with the workspace).
|
||||
- **Integration** *(Stage 1+)* — Postgres-backed tests behind the `integration`
|
||||
- **Integration** — Postgres-backed tests behind the `integration`
|
||||
build tag spin a throwaway `postgres:17-alpine` via `testcontainers-go`. They
|
||||
live in `backend/internal/inttest` and run with
|
||||
`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. Stage 8 adds Vitest for
|
||||
the new FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||
- **UI** — Vitest (unit) + Playwright
|
||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||
the 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
|
||||
- **Engine** — 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
|
||||
DAWGs and validate a known word, including Эрудит), bag draw/return determinism
|
||||
@@ -32,33 +32,33 @@ tests or touching CI.
|
||||
win/loss rule** (the resigner keeps their score yet loses). The engine tests
|
||||
read the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver`
|
||||
checkout) and fail loudly when it is absent.
|
||||
- **Game domain** *(Stage 3+)* — `backend/internal/game` adds pure unit tests
|
||||
- **Game domain** — `backend/internal/game` adds pure unit tests
|
||||
(the GCG writer, the away-window / effective-deadline boundaries, the hint
|
||||
budget, the live-game cache and per-game lock, payload round-trips) plus
|
||||
Postgres-backed integration tests in `inttest` (full lifecycle to a natural
|
||||
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). Stage 4 adds
|
||||
word-check and complaint capture, and per-game-lock serialisation). It also covers
|
||||
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**. The engine also gains a `Candidates`
|
||||
ranked/decoded test (Stage 5).
|
||||
- **Social & lobby** *(Stage 4+)* — `backend/internal/social` unit-tests the chat
|
||||
for a 3-player **timeout that continues**, and the engine's `Candidates`
|
||||
ranked/decoded test.
|
||||
- **Social & lobby** — `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, plus the Stage 5 **robot substitution** reaper and
|
||||
cancel, per-variant pools, plus the **robot substitution** reaper and
|
||||
`Poll` delivery) with fake game-creator and robot-provider seams. 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. Stage 8 adds the
|
||||
email, expiry and attempt-cap) with a fixture mailer. It also covers 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:
|
||||
- **Robot** — `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
|
||||
and out-of-band fallbacks, no-play exchange/pass), the sleep window with drift
|
||||
@@ -66,7 +66,7 @@ tests or touching CI.
|
||||
drives a robot through a full auto-match to a natural end (asserting a robot
|
||||
statistics row), the matchmaker substitution end-to-end (enqueue → reap →
|
||||
`[human, robot]`, discoverable via `Poll`), and a proactive 12-hour nudge.
|
||||
- **Gateway & contracts** *(Stage 6+)* — `backend/internal/notify` unit-tests the
|
||||
- **Gateway & contracts** — `backend/internal/notify` unit-tests the
|
||||
hub fan-out (delivery, overflow drop, unsubscribe) and the FlatBuffers event
|
||||
constructors (payload round-trip). `gateway/...` unit-tests are hermetic (no
|
||||
real network — an `httptest` fake backend and fixtures): the Telegram initData
|
||||
@@ -76,20 +76,20 @@ tests or touching CI.
|
||||
unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID
|
||||
forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth
|
||||
reverse proxy (401 / forward), and a full Connect `Execute` path end to end
|
||||
(guest auth, unauthenticated rejection, unknown message type). **R3** adds the
|
||||
(guest auth, unauthenticated rejection, unknown message type). The
|
||||
edge-hardening cases: an oversized `Execute` payload is refused
|
||||
(`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection
|
||||
lands in `gateway_rate_limited_total{class}` and the rejection tracker
|
||||
(drain/aggregate unit tests), the report POST reaches
|
||||
`/api/v1/internal/ratelimit/report` with the agreed JSON shape, the `/_gm`
|
||||
mount is 429-guarded by the per-IP admin class, and the gateway's `/`
|
||||
308-redirects to `/app/` (the landing left the embed). The backend gains
|
||||
308-redirects to `/app/` (the landing left the embed). The backend covers
|
||||
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`. Stage 8 adds gateway transcode round-trips for the new social/account
|
||||
in `inttest`. Gateway transcode round-trips cover the 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.
|
||||
- **Admin & dictionary ops** *(Stage 10)* — `backend/internal/adminconsole` unit-tests
|
||||
- **Admin & dictionary ops** — `backend/internal/adminconsole` unit-tests
|
||||
the template renderer over every page plus the embedded asset; `backend/internal/engine`
|
||||
adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present
|
||||
variants, `OpenWithVersions` scans version subdirectories, a reload registers a new
|
||||
@@ -99,13 +99,13 @@ tests or touching CI.
|
||||
404 when not). Postgres-backed `inttest` drives the **complaint resolution →
|
||||
dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
|
||||
applied), the admin **list/count** read queries, and the **/_gm console over HTTP**
|
||||
(pages render; a resolve POST needs a same-origin header). **R3** adds `ratewatch`
|
||||
(pages render; a resolve POST needs a same-origin header). `ratewatch` has
|
||||
unit tests (window accumulation, the auto-flag threshold + expiry, the bounded
|
||||
episode map), the account-store **high-rate flag round-trip** (set-once / clear /
|
||||
re-flag) and a console flow in `inttest`: a gateway report auto-flags the account,
|
||||
the **Throttled** page shows the episode and the flagged queue, the user card
|
||||
carries the marker and the CSRF-guarded **Clear** reverses it.
|
||||
- **Observability & performance** *(Stage 12)* — `pkg/telemetry` unit-tests the exporter
|
||||
- **Observability & performance** — `pkg/telemetry` unit-tests the exporter
|
||||
selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector;
|
||||
the nil-runtime fallback). The domain metrics are exercised through a manual
|
||||
`sdkmetric` reader: `backend/internal/game` and `…/social` assert the counters and
|
||||
@@ -115,7 +115,7 @@ tests or touching CI.
|
||||
`otlp` now accepted, an unsupported exporter rejected) and the guest-reaper knobs.
|
||||
Postgres-backed `inttest` drives the **guest reaper** end to end (an abandoned guest is
|
||||
reaped; a too-young guest, a seated guest and a durable account are kept).
|
||||
- **Load test & resource baseline** *(R2)* — a reusable `loadtest/` module
|
||||
- **Load test & resource baseline** — a reusable `loadtest/` module
|
||||
(`scrabble/loadtest`) is the pre-release stress harness. It **seeds** a large account
|
||||
population with pre-created sessions directly in Postgres (token hashes matching
|
||||
`backend/internal/session`), **drives** virtual players through the edge protocol —
|
||||
@@ -128,7 +128,7 @@ tests or touching CI.
|
||||
engine tests do). It is **not** part of the per-PR suite's behavioural assertions: it
|
||||
runs ad hoc as a one-shot container against the contour, producing a trip report (bugs
|
||||
+ a resource baseline) read off the **cAdvisor + postgres_exporter** Grafana dashboard
|
||||
added to the contour in R2. See [`../loadtest/README.md`](../loadtest/README.md).
|
||||
on the contour. See [`../loadtest/README.md`](../loadtest/README.md).
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
+11
-11
@@ -5,7 +5,7 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in
|
||||
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client
|
||||
is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or
|
||||
emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via
|
||||
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9):
|
||||
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
|
||||
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
|
||||
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
|
||||
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
|
||||
@@ -33,14 +33,14 @@ Login uses `Screen`.
|
||||
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
|
||||
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
|
||||
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
|
||||
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
|
||||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||||
from the left (back). Transitions are local (so they do not play on first load) and
|
||||
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
|
||||
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
|
||||
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
|
||||
on lobby ↔ game navigation.
|
||||
- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
|
||||
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
|
||||
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
|
||||
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||||
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
||||
@@ -67,7 +67,7 @@ Login uses `Screen`.
|
||||
preventDefault fires only for two touches, so one-finger scroll stays native, and a second
|
||||
finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
|
||||
as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
|
||||
back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
|
||||
@@ -78,15 +78,15 @@ Login uses `Screen`.
|
||||
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||||
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
|
||||
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
|
||||
recalled tile returns to its original rack slot (Stage 17).
|
||||
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
|
||||
recalled tile returns to its original rack slot.
|
||||
- **Players plaque & history** (`Game.svelte`): the seats above the board share
|
||||
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
|
||||
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
|
||||
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
||||
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||||
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
||||
jitter. A move's row lists every word it formed (the main word first).
|
||||
- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the
|
||||
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
||||
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
||||
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
||||
@@ -99,7 +99,7 @@ Login uses `Screen`.
|
||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||
nothing. Default **beginner**.
|
||||
- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless
|
||||
- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless
|
||||
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
|
||||
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
|
||||
board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
|
||||
@@ -110,7 +110,7 @@ Login uses `Screen`.
|
||||
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
|
||||
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
|
||||
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
|
||||
- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots
|
||||
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
|
||||
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
||||
button) beside the rack commits the move — no popover, and disabled while the pending word
|
||||
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
|
||||
@@ -123,7 +123,7 @@ Login uses `Screen`.
|
||||
|
||||
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
|
||||
|
||||
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) —
|
||||
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token —
|
||||
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
|
||||
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
|
||||
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
|
||||
@@ -138,7 +138,7 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba
|
||||
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 /
|
||||
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌.
|
||||
|
||||
## Social, account & history surfaces (Stage 8)
|
||||
## Social, account & history surfaces
|
||||
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user