Stage 17: bake decisions into PLAN, ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, READMEs; mark stage done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 26s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s

- PLAN: Stage 17 Refinements entry + caveats resolved summary + tracker done
- ARCHITECTURE §7 (move-number robot timing, composed variant-aware names), §10 (move event to the actor too), §11 (game_move_duration metric + offline admin per-user analytics), §14 (current branch model, path-conditional CI + gate, connector liveness)
- FUNCTIONAL(+ru): robot draws language-appropriate names
- UI_DESIGN: screen transitions, Telegram theme/nav, ad-banner accent, players plaque + history drawer
- backend README: robot timing/names refinements
This commit is contained in:
Ilia Denisov
2026-06-06 10:31:22 +02:00
parent 1d0bafaabb
commit 09fec2b83c
6 changed files with 231 additions and 36 deletions
+55 -24
View File
@@ -300,10 +300,16 @@ The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
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),
provisioned at startup with chat and friend requests blocked — backs the
human-like name pool; those two profile toggles are all the friend/DM blocking
requires (there is no DM surface; chat is per-game).
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
`robot-<lang>-<index>` and provisioned at startup with chat and friend requests
blocked — backs the human-like names; those two profile toggles are all the
friend/DM blocking requires (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 /
first + surname initial / first + full surname), deterministically per pool slot so
they stay stable across restarts. Substitution is **variant-aware**: a Russian game
(Russian Scrabble or Эрудит) draws a Russian-named robot with at most ~20% Latin, an
English game the Latin pool.
- **Balance**: at game start it decides once whether to play to win, with
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
@@ -313,13 +319,15 @@ requires (there is no DM surface; chat is per-game).
(playing to lose) is closest to a small band (**130 points**), rather than
always the maximum; with no legal play it exchanges a full rack when the bag can
refill it, else passes.
- **Timing**: per-move delay sampled from a right-skewed distribution (short
delays frequent, median ≈ 10 min), clamped to **[2, 90] minutes**; it
- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample
(exponent k=4, short delays frequent) from a band that interpolates from
**[1, 5] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
quick and the endgame can run long, clamped to **[1, 90] minutes**; it
**sleeps 00:0007:00** anchored to the **opponent's** profile timezone with a
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
rather than running anti-phase; on a daytime nudge it replies within
**210 minutes**; it proactively nudges the human after **12 hours** idle
(subject to the once-per-hour chat limit).
rather than running anti-phase; on a daytime nudge it replies near the move's lower
band; it proactively nudges the human after **12 hours** idle (subject to the
once-per-hour chat limit).
- **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.
@@ -451,7 +459,9 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
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,
friend-added, invitation or game-started; emitted on a friend-request and invitation
@@ -499,13 +509,21 @@ promotions) is future work and would deliver short markdown messages (text + lin
client-measured RTT piggybacked on the next request is a later enhancement.
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
until an exporter is configured: histograms `game_replay_duration` (journal
rebuild on a cache miss) and `game_move_validate_duration`; counters
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop),
`chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`;
an observable gauge `game_cache_active`; the gateway `edge_request_duration`
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
metrics. Game-scoped metrics carry a `variant` attribute
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
`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
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
(english/russian_scrabble/erudit).
- Per-user move-time analytics (Stage 17) 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` =
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) —
@@ -579,14 +597,27 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
## 14. CI & branches
- Trunk is **`master`**; feature work happens on `feature/*` branches merged via
PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily
lands on `master`).
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test
on Go changes; `integration.yaml` runs the Postgres-backed tests behind the
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
serial). Further workflows (ui-test, deploy) are added with the components they
cover.
- **Two long-lived branches** (Stage 16): **`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.
- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the
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
`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 —
`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
`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
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
+2 -1
View File
@@ -91,7 +91,8 @@ wins most games), aims for a close score rather than crushing or throwing the ga
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. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It
carries a human-like name and neither chats nor accepts friend requests.
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
names) and neither chats nor accepts friend requests.
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
Become friends in two ways: redeem a **one-time code** the other player issues (six
+3 -2
View File
@@ -92,8 +92,9 @@ Mini App** авторизует по подписанным `initData` плат
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
в чате и не принимает заявки в друзья.
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
языку партии (в русской партии — в основном русские имена), не общается в чате и не
принимает заявки в друзья.
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
+25 -3
View File
@@ -33,6 +33,18 @@ 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 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. A per-game in-memory cache (`lib/gamecache.ts`)
renders a re-opened game instantly and refreshes it in the background, removing the
blank-loading flash on lobby ↔ game navigation.
- **Telegram theme** (Stage 17): 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), the Settings theme switcher is hidden,
the nav bar takes Telegram's background (`header_bg_color`), and a live stream dropped by
a background suspend silently reconnects on return to the foreground (the connection
banner is suppressed while hidden).
## Tiles & board
@@ -45,7 +57,15 @@ Login uses `Screen`.
they stay a constant size as the cells grow (relatively smaller at higher zoom).
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
target; the custom pinch and swipe-to-open-history gestures were dropped because they
fight native scroll — history opens from the menu.
fight native scroll — history opens from the menu or a tap on the players plaque (below).
A **hint** auto-zooms centred on the hint's placement, not the top-left (Stage 17).
- **Players plaque & history** (`Game.svelte`, Stage 17): 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).
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
last completed word gets a dark tile background — static while it is the opponent's
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
@@ -70,8 +90,10 @@ Login uses `Screen`.
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
A one-line inset strip under the nav bar. Content is minimal markdown (text + links,
escaped + linkified). A parameterised **rotator** drives messages: a fitting message
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) —
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
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short