Stage 17 round 5 docs: bake the bug fixes + UI polish + L2 into live docs
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 52s

- ARCHITECTURE: resign on the opponent's turn (ResignSeat + turn-check bypass); robots
  block chat but accept-and-ignore friend requests; quick-match /lobby/cancel; the admin
  robot play-to-win intent + next-move ETA panel.
- UI_DESIGN: even A->B zoom (recentre only on zoom-in), pinch, drop-target highlight,
  shuffle ≤0.3s + reduce-motion, borderless make-move disabled on illegal, variant title.
- FUNCTIONAL (+ru): variant display names (Scrabble/Erudite); robot ignores friend requests.
- PLAN: round-5 refinements bullet (+ the bilingual two-Scrabble open edge).
This commit is contained in:
Ilia Denisov
2026-06-07 09:48:08 +02:00
parent f916d5e0ca
commit a420d6a2cd
5 changed files with 68 additions and 25 deletions
+19
View File
@@ -1281,6 +1281,25 @@ provided cert) at the contour caddy; prod VPN; rollback.
scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
**robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
across the game/robot package boundary, to be picked up when that seam is added.
- **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn**
now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit
the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op
(only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the
matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later
robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win —
`result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend
request to a robot** is accepted as pending and expires like a human ignore (robots no longer set
`BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the
chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish:
**even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no
lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch
zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop
capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known-
illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit
→ Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game
card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's
turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would
show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
## Deferred TODOs (cross-stage)
+13 -4
View File
@@ -235,7 +235,10 @@ Key points:
applying the end-game rack-value adjustment, or a resignation. On a
**resignation the resigner keeps their accumulated score (no rack adjustment)
and never wins**: the win goes to the highest score among the remaining seats,
unconditionally the other player in a two-player game. The engine exposes a
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
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 —
@@ -301,9 +304,10 @@ 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), 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
`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
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 /
first + surname initial / first + full surname), deterministically per pool slot so
@@ -331,6 +335,8 @@ English game the Latin pool.
- **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).
## 8. Lobby & social
@@ -342,6 +348,9 @@ English game the Latin pool.
robot (§7) and starts the game. On a pairing or substitution the matchmaker
emits a **match-found** notification (§10), delivered over the live stream;
`Poll` remains as a fallback for a client that is not currently streaming.
**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
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
+7 -4
View File
@@ -55,9 +55,11 @@ two accounts share a game still in progress.
### Lobby & matchmaking *(Stage 4 / 15)*
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
limited to the languages the player's sign-in service supports (English → English;
Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is
unrestricted). This gates only **starting** a new game — both auto-match and a friend
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. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
@@ -92,7 +94,8 @@ and plays at a human pace — short thinking times for most moves, the occasiona
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, language-appropriate name (a Russian game draws mostly Russian
names) and neither chats nor accepts friend requests.
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)*
Become friends in two ways: redeem a **one-time code** the other player issues (six
+8 -5
View File
@@ -56,9 +56,11 @@ Mini App** авторизует по подписанным `initData` плат
### Лобби и подбор *(Stage 4 / 15)*
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → English;
русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
@@ -93,8 +95,9 @@ Mini App** авторизует по подписанным `initData` плат
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
языку партии (в русской партии — в основном русские имена), не общается в чате и не
принимает заявки в друзья.
языку партии (в русской партии — в основном русские имена); не общается в чате и
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
ровно как у человека, который не отвечает.
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
+21 -12
View File
@@ -63,16 +63,22 @@ Login uses `Screen`.
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
they stay a constant size as the cells grow (relatively smaller at higher zoom).
**Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending**
tile recalls it. On touch, placing a tile auto-zooms in centred on the target, and
**holding a dragged tile over a cell for ~1 s** auto-zooms there (Stage 17). The custom
pinch and swipe-to-open-history gestures stay dropped — they fight both native scroll and
the one-finger drag-back gesture; 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.
tile recalls it. **Pinch** zooms toward the pinch midpoint (a two-finger gesture;
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
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);
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.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
dragging it onto a cell; 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).
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
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
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
@@ -105,12 +111,15 @@ Login uses `Screen`.
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
and shifts left, a direct **✅** button beside the rack commits the move (no popover), and
the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
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.
- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance) with a short haptic shake. The under-board slot shows the **Scores: N** preview.
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)