diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index bfb321b..c5e381d 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,6 @@ name: CI -# Single gated pipeline for the test contour (Stage 16). Gitea cannot express +# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express # cross-workflow `needs`, so the full test suite and the auto test-deploy live in # one workflow. # @@ -11,6 +11,12 @@ name: CI # (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual # workflow (Stage 18). # +# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their +# code changed (the `changes` job decides). Because a skipped required check would +# block a merge under branch protection, the always-running `gate` job aggregates +# their results and is the ONLY required status check; it passes when every +# upstream job either succeeded or was skipped. +# # Console output is kept plain (NO_COLOR + `docker compose --ansi never` + # `--progress plain`) so the Gitea logs stay readable. @@ -21,7 +27,57 @@ on: branches: [development] jobs: + # changes detects which areas a PR/push touched, so the test jobs can skip when + # irrelevant. It defaults to running everything when the diff cannot be computed. + changes: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + outputs: + go: ${{ steps.filter.outputs.go }} + ui: ${{ steps.filter.outputs.ui }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed paths + id: filter + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch -q origin "${{ github.base_ref }}" || true + range="origin/${{ github.base_ref }}...HEAD" + else + before="${{ github.event.before }}" + if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "${before}^{commit}" 2>/dev/null; then + range="HEAD~1...HEAD" + else + range="${before}...HEAD" + fi + fi + echo "comparison range: $range" + # Default to running everything; narrow only when the diff is computable. + go=true; ui=true + files="$(git diff --name-only "$range" 2>/dev/null || echo __DIFF_FAILED__)" + if [ "$files" != "__DIFF_FAILED__" ]; then + echo "changed files:"; echo "$files" + go=false; ui=false + if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|go\.work)'; then go=true; fi + if echo "$files" | grep -qE '^ui/'; then ui=true; fi + # A workflow or deploy change re-runs everything as a safety net. + if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi + else + echo "diff failed; running all jobs" + fi + echo "selected: go=$go ui=$ui" + echo "go=$go" >> "$GITHUB_OUTPUT" + echo "ui=$ui" >> "$GITHUB_OUTPUT" + unit: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -67,6 +123,8 @@ jobs: run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... integration: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -102,6 +160,8 @@ jobs: run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/... ui: + needs: changes + if: ${{ needs.changes.outputs.ui == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -142,10 +202,37 @@ jobs: run: pnpm run test:e2e timeout-minutes: 5 + # gate is the single branch-protection required check. It always runs and passes + # only when each upstream job succeeded or was skipped (a path-filtered no-op), + # failing the merge if any actually failed or was cancelled. + gate: + needs: [unit, integration, ui] + if: always() + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Aggregate required checks + run: | + fail= + for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do + name="${r%%:*}"; res="${r#*:}" + echo "$name = $res" + case "$res" in + success|skipped) ;; + *) echo "::error::$name=$res"; fail=1 ;; + esac + done + [ -z "$fail" ] || { echo "one or more required jobs failed"; exit 1; } + echo "all required jobs passed or were skipped" + deploy: # Auto test-deploy on a PR into development and on the push that merges it. # A PR into master is test-only (this job is skipped); prod deploy is manual. - needs: [unit, integration, ui] + # Gates on `gate` (so a real test failure blocks the deploy) but runs even when + # some test jobs were path-skipped. + needs: [gate] if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }} runs-on: ubuntu-latest defaults: @@ -198,8 +285,16 @@ jobs: mkdir -p "$conf" cp -r caddy otelcol prometheus tempo grafana "$conf"/ export SCRABBLE_CONFIG_DIR="$conf" + # App version for the About screen: the git tag if present, else the short SHA + # (the test checkout is shallow/untagged, so this is the SHA here — fine). + export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)" docker compose --ansi never build --progress plain docker compose --ansi never up -d --remove-orphans + # The config-only services bind-mount the reseeded config dir. A plain `up -d` + # leaves them on the previous bind mount (the dir was rm'd + recreated), so a + # changed Caddyfile or Grafana dashboard is ignored — force-recreate them to + # pick up the fresh config. + docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana - name: Probe the gateway through caddy run: | @@ -215,6 +310,34 @@ jobs: docker logs --tail 50 scrabble-gateway || true exit 1 + - name: Probe the Telegram connector liveness + run: | + set -u + # The gateway probe cannot see a crash-looping connector (it long-polls and + # egresses through the VPN sidecar, with no public ingress). Inspect the + # container directly: it must be running, not restarting, with a stable + # restart count. A grace period lets the VPN handshake settle (the connector + # may restart a few times first). + sleep 20 + for i in $(seq 1 20); do + status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)" + restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)" + if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then + c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)" + sleep 5 + c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)" + if [ "$c1" = "$c2" ]; then + echo "connector healthy: status=$status restarts=$c2" + exit 0 + fi + echo "connector still restarting ($c1 -> $c2); waiting" + fi + sleep 3 + done + echo "connector not healthy; recent logs:" + docker logs --tail 80 scrabble-telegram || true + exit 1 + - name: Prune dangling images if: always() run: docker image prune -f diff --git a/PLAN.md b/PLAN.md index edbc419..4c0c937 100644 --- a/PLAN.md +++ b/PLAN.md @@ -50,7 +50,7 @@ independent (see ARCHITECTURE §9.1). | 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** | | 15 | Dual Telegram bots & language-gated variants | **done** | | 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** | -| 17 | Test-contour verification & defect fixes | todo | +| 17 | Test-contour verification & defect fixes | **done** | | 18 | Prod contour deploy (SSH export/import, manual after merge) | todo | Scaffolding is incremental: `go.work` lists only existing modules; each stage @@ -298,7 +298,7 @@ h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go bu build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one; collector/Tempo/Prometheus retention. -### Stage 17 — Test-contour verification & defect fixes +### Stage 17 — Test-contour verification & defect fixes *(done)* Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the "does it actually work in the contour" pass before prod. Bring up the `development` deploy, then verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin @@ -316,6 +316,95 @@ are in-scope vs deferred; the changed-paths design + the aggregate gate job; the liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before it settles). +#### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*) + +The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss), +discussed where they were forks, and resolved in one session with tests where practical. The +per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the +raw list is kept here as the record of what the first contour run surfaced. + +- /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/ + Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth. + +- нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход, + скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести + метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?), + а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа + способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров. + +- регистрация пользователя из телеграм (как и других коннекторов): + пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле. + если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр), + язык в зависимости от внешнего коннектора. + +- game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход". + Думаю, опечатка - "не" лишняя, проверь на всех языках. + +- если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная", + т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает + на самих стилях) + +- возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема. + когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar + рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка. + Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в + самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там + системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним. + +- не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios, + то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби. + интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли? + если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого? + нужен твой анализ и совет. + +- надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода + выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал + сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно. + На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону. + А то я сейчас поиграл, роботы на первых ходах по 15 минут думают. + Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры. + Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя + активного игрока. Этот пункт требует тщательного обсуждения, пожалуй. + +- при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает + каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то + неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать + каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь + проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п. + +- при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна. + +- а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой), + чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое? + ну и для собственного дефолтного стиля тоже надо выбрать соответствующие. + +- Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем". + Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся. + Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать + плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry. + +- при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка. + +- В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён + было не больше 20%. + +- Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации. + +- Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство + поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы + остальные игроки были как бы "утоплены" внутрь. + +- В игре клик/тач по плашке с именами игроков открывает/закрывает историю. + +- В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо. + +- В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить) + +- При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей. + +- Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка + "что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится. + ### Stage 18 — Prod contour deploy Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import** (`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP @@ -1115,6 +1204,139 @@ provided cert) at the contour caddy; prod VPN; rollback. environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`. +- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's + collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session. + - **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical + string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in + the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`, + `Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged + (persisted games, GCG and the `variant` metric attribute keep it). + - **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with + `game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct + `nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn. + - **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the + editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by + language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor + validation (e.g. "Peter J."). + - **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names + paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only / + first + surname initial / first + full surname), composed deterministically per pool slot (stable + across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20% + Latin, an English game the Latin pool. Robot identities are keyed `robot--`. + - **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the + band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min + by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime + nudge pulls the reply toward the move's lower band. + - **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own + other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the + actor), and the gateway already fans each event out to all of a user's live streams. + - **Move-duration analytics** (#1, interview): a live `game_move_duration{variant,phase}` histogram + (opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console — + min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number, + computed from the journal (`game_moves.created_at` deltas; no schema change). Per-user stays offline, + not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots + included), so the per-human admin view is authoritative. + - **CI** (#9/#10, interview): `unit`/`integration`/`ui` are path-conditional behind a `changes` job; an + always-running `gate` job aggregates them (success-or-skipped) and is the single branch-protection + required check (`CI / gate`), so a skipped job never blocks a merge. The deploy job gained a + Telegram-connector liveness probe (`docker inspect`: running, not restarting, stable restart count, + with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector + was invisible to the gateway-only probe. + - **UI theming / UX**: inside Telegram the colour scheme is forced from `WebApp.colorScheme` over the OS + `prefers-color-scheme` (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden + (#11); the nav bar takes Telegram's bg and the announcement banner a subtle `--ad-bg` accent (#14/#15); + the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint + zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others + with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow + to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders + instantly on re-entry and refreshes in the background (#13). + - **Grafana repeated password (#8) — not a server defect**: verified live that caddy challenges `/_gm` + and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a + browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify, + no server change. **Multi-word history (#22)** was already implemented (all formed words shown). + - **Contour-verification follow-ups** (rounds 2–3, from live testing): the Grafana + double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is + disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the + move-duration panel was invisible because the deploy reseed (`rm -rf`) left the + config-only services on a stale bind mount — the deploy now **force-recreates** + caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 → + 300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram + reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide + + Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted — + chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native + **BackButton**, **HapticFeedback**, **closing confirmation** in a game, + **disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat + pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint + button disables at zero hints; plus **board-only vertical scroll** (#9) and a + **keyboard-overlay** check-word dialog (#10). + - **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised + [1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel + toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a + single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap** + or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over + a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to + their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings + toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming + ~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native + 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. + - **Contour-verification follow-ups** (round 6, from live testing) — **shipped & deployed:** profile drops + the hint-balance line; no mobile tap-flash on a board cell (`-webkit-tap-highlight-color`); variant + display names keyed by the game's **alphabet**, not the UI language (english → "Scrabble", + russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game + title shows the variant name; **chat & nudge are mutually exclusive by turn** (message field on your + turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced + server-side to your own turn (`ErrChatNotYourTurn`); the **nudge cooldown resets** once the player has + moved or chatted since the last nudge (`game.LastMoveAt` + last chat vs last nudge; the UI mirrors it); + the **About** screen got localized titles + a rules link + the random/friends sections, and the app + **version comes from `git describe`** (Vite define `__APP_VERSION__` ← Docker build-arg in the deploy + step, default "dev"); the **quick-game buttons** became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a + bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences + from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when + available; redraw the USSR emblem as a thin schematic hammer & sickle); **#3 drag-reorder of rack tiles** + with a visual gap (the dragged tile lifts out, the rest slide to open a slot; `reorderIndices` + unit-tested; only with no pending tiles); and the **persistence backend foundation** (#4/#5/#6): a + `game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every + committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play + overlapped — 5 integration tests. + - **Stage 17 round 6 — REMAINING (next pass), designs ready:** + 1. **Persistence gateway slice + UI (#4/#5/#6).** *FB (lean):* `DraftRequest{game_id, json}` (save) + + a game-id request (get) + `DraftView{json}` — one string field; the client serializes/deserializes + `{rack_order, board_tiles}` itself (no FB tile array). Regen Go (`make -C pkg fbs`) + TS + (`pnpm codegen`); flatc is pinned **23.5.26** (the local one matches). *Gateway* forwards the JSON as + `json.RawMessage` (no double-encode). *REST:* `GET`/`PUT /games/:id/draft` (decodes `game.Draft`). + *UI:* save the rack order (#4) and board draft (#6) on change (debounced) and restore on load + (next to `gameState`); **#5** — allow placing tiles on the opponent's turn (relax the `isMyTurn` + gate on placement only; the evaluate-preview and Make-move stay your-turn-only, so an off-turn draft + is position-only — never scored/submitted). + 2. **Landing + `/app/` move (#16–20).** An extra Svelte page at `/`, the game SPA under `/app/` (Vite + `base` conditional: `/app/` for web, `./` for Capacitor); the gateway serves the landing at `/` and + the SPA at `/app/*`; a bundled Telegram logo (from `.claude/telegram-logo.svg`, **copied into + `ui/public/`, the reference itself not committed**) linking to the per-language t.me bot (ru + `Erudit_Game` / en `Scrabble_Game`); theme + language switchers reusing the app stores; reuse the + `aboutContent` copy. **Note:** moving the game to `/app/` means the Telegram Mini App URL must point + to `/app/`. + ## Deferred TODOs (cross-stage) - ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is diff --git a/backend/README.md b/backend/README.md index e52e1fd..8c32500 100644 --- a/backend/README.md +++ b/backend/README.md @@ -46,13 +46,13 @@ but their live delivery, and all REST endpoints, arrive with the `gateway` Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts — each a `kind='robot'` identity, provisioned at startup with chat and friend -requests blocked — backs a human-like name pool. A background driver plays the +requests blocked — backs human-like, per-language composed names. A background driver plays the robot's moves through the public game API as an ordinary seated player (so only `internal/engine` imports the solver): it decides once per game whether to play to -win (≈ 40%), targets a small score margin, and times its moves with a right-skewed -delay, a night-sleep window anchored to the opponent's timezone, and nudge +win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware +right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra -state. The matchmaker now substitutes a pooled robot after a 10-second wait and +state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and exposes `Poll` so a waiting player can collect the started game (the live match-found notification arrives with the `gateway`). diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index ae6046c..8fee67a 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -12,7 +12,6 @@ import ( "fmt" "strings" "time" - "unicode/utf8" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" @@ -112,10 +111,43 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string return s.provision(ctx, kind, externalID, provisionSeed{}) } +// ProvisionRobot provisions (or finds) the durable account backing a robot pool +// member: a KindRobot identity carrying displayName, with chat blocked but friend +// requests NOT blocked — a request to a robot is accepted as pending and, since the +// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human +// who ignores the request. Robot names are system-generated, not player-edited, so they +// bypass the editable display-name validation and may carry forms the editor rejects (an +// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the +// display name and both block flags. +func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) { + acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName}) + if err != nil { + return Account{}, err + } + if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests { + return acc, nil + } + stmt := table.Accounts.UPDATE( + table.Accounts.DisplayName, table.Accounts.BlockChat, + table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt, + ).SET( + postgres.String(displayName), postgres.Bool(true), + postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))). + RETURNING(table.Accounts.AllColumns) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err) + } + return modelToAccount(row), nil +} + // ProvisionTelegram provisions (or finds) the account bound to a Telegram // identity. On first contact only, it seeds the new account's preferred language // from the Telegram client languageCode (when it maps to a supported language) and -// its display name from firstName (falling back to username); an already-existing +// its display name sanitized from firstName (falling back to username, then to a +// generated placeholder when neither yields any letters); an already-existing // account is returned unchanged, so a later profile edit is never overwritten. func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) { return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) @@ -155,19 +187,21 @@ type provisionSeed struct { // telegramSeed derives the create-time seed from Telegram launch fields: a // supported preferred language from languageCode (an ISO-639 code, possibly -// region-tagged like "ru-RU"), and a display name from firstName or, failing that, -// username (capped to maxDisplayName runes). +// region-tagged like "ru-RU"), and a display name sanitized from firstName or, +// failing that, username (sanitizeDisplayName strips disallowed characters to the +// editable format). When neither yields any letters, it falls back to a generated +// placeholder in the seeded language (placeholderDisplayName). func telegramSeed(languageCode, username, firstName string) provisionSeed { var seed provisionSeed if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { seed.preferredLanguage = lang } - name := strings.TrimSpace(firstName) + name := sanitizeDisplayName(firstName) if name == "" { - name = strings.TrimSpace(username) + name = sanitizeDisplayName(username) } - if utf8.RuneCountInString(name) > maxDisplayName { - name = string([]rune(name)[:maxDisplayName]) + if name == "" { + name = placeholderDisplayName(seed.preferredLanguage) } seed.displayName = name return seed diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 03f6150..8aa7985 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "regexp" "strings" "time" + "unicode" "unicode/utf8" "github.com/go-jet/jet/v2/postgres" @@ -26,9 +28,10 @@ const maxAwayWindow = 12 * time.Hour // displayNameRe enforces the editable display-name format (Stage 8): Unicode letters // joined by single space / "." / "_" separators, where a "." or "_" may be followed -// by a single space. No leading or trailing separator and no two adjacent separators, -// except " ". So "Name_P. Last" is valid, "Name P._Last" is not. -var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`) +// by a single space. No leading separator and no two adjacent separators (except +// " "); a single trailing "." is allowed (Stage 17), so +// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not. +var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`) // ErrInvalidProfile is returned when a profile update carries an unacceptable // field (an unknown language, an invalid timezone, or an over-long display name). @@ -110,6 +113,39 @@ func ValidateDisplayName(raw string) (string, error) { return name, nil } +// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram +// first name) to the editable display-name format: it keeps the maximal runs of +// Unicode letters and joins them with a single space, dropping every other rune +// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The +// result therefore always satisfies ValidateDisplayName, or is empty when the input +// carries no letters — in which case the caller substitutes placeholderDisplayName. +// Mirroring the profile editor's rule means a connector-provisioned name is editable +// later without first failing validation. +func sanitizeDisplayName(raw string) string { + fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) }) + if len(fields) == 0 { + return "" + } + name := strings.Join(fields, " ") + if utf8.RuneCountInString(name) > maxDisplayName { + name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ") + } + return name +} + +// placeholderDisplayName builds a fallback display name for a platform account whose +// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or +// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally +// carries digits and a hyphen, so it lies outside the editable format and the player +// is expected to rename it; provisioned names bypass that editor validation. +func placeholderDisplayName(lang string) string { + prefix := "Player" + if lang == "ru" { + prefix = "Игрок" + } + return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000)) +} + // validateAwayWindow checks that the daily away window's duration, wrapping across // midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means // "no away time" and is allowed. diff --git a/backend/internal/account/provision_test.go b/backend/internal/account/provision_test.go index 5417f13..afa08a9 100644 --- a/backend/internal/account/provision_test.go +++ b/backend/internal/account/provision_test.go @@ -1,6 +1,7 @@ package account import ( + "regexp" "strings" "testing" "unicode/utf8" @@ -8,21 +9,25 @@ import ( // TestTelegramSeed covers the pure mapping from Telegram launch fields to the // create-time account seed: supported-language detection (bare and region-tagged), -// the first-name / username display-name precedence, and trimming. +// the first-name / username display-name precedence, and the sanitization that +// strips disallowed characters (emoji, digits, punctuation) to the editable format. func TestTelegramSeed(t *testing.T) { cases := map[string]struct { languageCode, username, firstName string wantLang, wantName string }{ - "ru bare": {"ru", "user", "Иван", "ru", "Иван"}, - "en region-tagged": {"en-US", "user", "John", "en", "John"}, - "ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"}, - "unknown language": {"fr", "frodo", "Frodo", "", "Frodo"}, - "empty language": {"", "neo", "Neo", "", "Neo"}, - "first name wins": {"en", "handle", "Real Name", "en", "Real Name"}, - "username fallback": {"en", "handle", "", "en", "handle"}, - "both empty": {"en", "", "", "en", ""}, - "trimmed": {" RU ", " ", " Anna ", "ru", "Anna"}, + "ru bare": {"ru", "user", "Иван", "ru", "Иван"}, + "en region-tagged": {"en-US", "user", "John", "en", "John"}, + "ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"}, + "unknown language": {"fr", "frodo", "Frodo", "", "Frodo"}, + "empty language": {"", "neo", "Neo", "", "Neo"}, + "first name wins": {"en", "handle", "Real Name", "en", "Real Name"}, + "username fallback": {"en", "handle", "", "en", "handle"}, + "trimmed": {" RU ", " ", " Anna ", "ru", "Anna"}, + "emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"}, + "punct to space": {"en", "user", "John❤Doe", "en", "John Doe"}, + "digits dropped": {"ru", "user", "Маша123", "ru", "Маша"}, + "garbage to username": {"en", "good", "123!@#", "en", "good"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -37,6 +42,28 @@ func TestTelegramSeed(t *testing.T) { } } +// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to +// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN"). +func TestTelegramSeedPlaceholder(t *testing.T) { + cases := map[string]struct { + languageCode, username, firstName string + wantRe string + }{ + "en empty": {"en", "", "", `^Player-\d{5}$`}, + "ru empty": {"ru", "", "", `^Игрок-\d{5}$`}, + "default en": {"fr", "", "", `^Player-\d{5}$`}, + "both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName + if !regexp.MustCompile(tc.wantRe).MatchString(got) { + t.Errorf("displayName = %q, want match %s", got, tc.wantRe) + } + }) + } +} + // TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to // maxDisplayName runes (counted in runes, not bytes). func TestTelegramSeedTruncatesLongName(t *testing.T) { diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go new file mode 100644 index 0000000..e2c12fc --- /dev/null +++ b/backend/internal/account/userlist.go @@ -0,0 +1,108 @@ +package account + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// UserListItem is the admin user-list projection: a small subset of the account plus +// whether it is a robot (derived from its identities), so the console can label the kind +// without a per-row identity query. +type UserListItem struct { + ID uuid.UUID + DisplayName string + PreferredLanguage string + IsGuest bool + IsRobot bool + CreatedAt time.Time +} + +// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the +// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = +// one char) matched case-insensitively against the display name / any identity's external +// id. An empty mask means no filter on that field. +type UserFilter struct { + Robots bool + NameMask string + ExternalIDMask string +} + +// robotExists is the correlated subquery testing whether account a is a robot. +const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')` + +// IsRobot reports whether the account is a robot pool member (it carries a robot +// identity). The admin console uses it to label a game's robot seats. +func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`, + accountID).Scan(&ok) + if err != nil { + return false, fmt.Errorf("account: is-robot %s: %w", accountID, err) + } + return ok, nil +} + +// userListWhere builds the shared WHERE clause and its positional args (from $1). +func userListWhere(f UserFilter) (string, []any) { + args := []any{f.Robots} + where := robotExists + ` = $1` + if name := likePattern(f.NameMask); name != "" { + args = append(args, name) + where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) + } + if ext := likePattern(f.ExternalIDMask); ext != "" { + args = append(args, ext) + where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) + } + return where, args +} + +// ListUsers returns the filtered admin user list, newest first, paginated. +func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) { + where, args := userListWhere(f) + q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.created_at, ` + robotExists + ` AS is_robot +FROM backend.accounts a WHERE ` + where + + fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("account: list users: %w", err) + } + defer rows.Close() + var out []UserListItem + for rows.Next() { + var it UserListItem + if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil { + return nil, fmt.Errorf("account: scan user: %w", err) + } + out = append(out, it) + } + return out, rows.Err() +} + +// CountUsers counts the filtered admin user list, for pagination. +func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) { + where, args := userListWhere(f) + var n int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("account: count users: %w", err) + } + return n, nil +} + +// likePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern, +// escaping the SQL wildcards already in the input first. An empty/blank mask returns "". +func likePattern(mask string) string { + mask = strings.TrimSpace(mask) + if mask == "" { + return "" + } + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask) + escaped = strings.ReplaceAll(escaped, "*", "%") + return strings.ReplaceAll(escaped, "?", "_") +} diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go index 1e8c978..0bdee79 100644 --- a/backend/internal/account/validate_test.go +++ b/backend/internal/account/validate_test.go @@ -12,19 +12,21 @@ func TestValidateDisplayName(t *testing.T) { want string ok bool }{ - "plain": {"Kaya", "Kaya", true}, - "cyrillic": {"Кая", "Кая", true}, - "dot underscore mix": {"Name_P. Last", "Name_P. Last", true}, - "single dot": {"Mr.Smith", "Mr.Smith", true}, - "dot then space": {"Mr. Smith", "Mr. Smith", true}, - "trim surrounding": {" Kaya ", "Kaya", true}, - "adjacent specials": {"Name P._Last", "", false}, - "two spaces": {"Name Last", "", false}, - "leading special": {"_Name", "", false}, - "trailing special": {"Name.", "", false}, - "digit rejected": {"Name2", "", false}, - "blank": {" ", "", false}, - "too long": {strings.Repeat("a", 33), "", false}, + "plain": {"Kaya", "Kaya", true}, + "cyrillic": {"Кая", "Кая", true}, + "dot underscore mix": {"Name_P. Last", "Name_P. Last", true}, + "single dot": {"Mr.Smith", "Mr.Smith", true}, + "dot then space": {"Mr. Smith", "Mr. Smith", true}, + "trim surrounding": {" Kaya ", "Kaya", true}, + "adjacent specials": {"Name P._Last", "", false}, + "two spaces": {"Name Last", "", false}, + "leading special": {"_Name", "", false}, + "trailing underscore": {"Name_", "", false}, + "trailing dot ok": {"Anna B.", "Anna B.", true}, + "double trailing dot": {"Name..", "", false}, + "digit rejected": {"Name2", "", false}, + "blank": {" ", "", false}, + "too long": {strings.Repeat("a", 33), "", false}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 780d1cd..8386ee3 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -101,3 +101,17 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } .actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; } .actions form { margin: 0; } .pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; } + +/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */ +.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; } +.chart .axis { stroke: var(--line); stroke-width: 1; } +.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; } +.chart .lbl { fill: var(--ink-dim); font-size: 11px; } +.chart .ln { fill: none; stroke-width: 1.5; } +.chart .ln-min { stroke: var(--ok); } +.chart .ln-avg { stroke: var(--accent); } +.chart .ln-max { stroke: var(--danger); } +.lg { font-weight: 600; } +.lg-min { color: var(--ok); } +.lg-avg { color: var(--accent); } +.lg-max { color: var(--danger); } diff --git a/backend/internal/adminconsole/chart.go b/backend/internal/adminconsole/chart.go new file mode 100644 index 0000000..bfa7b39 --- /dev/null +++ b/backend/internal/adminconsole/chart.go @@ -0,0 +1,108 @@ +package adminconsole + +import ( + "fmt" + "html/template" + "strings" + "time" +) + +// ChartPoint is one move-number sample of the move-duration chart: the min, mean and +// max think time (seconds) the account took on its Ordinal-th move across its games. +type ChartPoint struct { + Ordinal int + Min float64 + Max float64 + Avg float64 +} + +// FormatDuration renders a think-time in seconds as a compact human string +// ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels. +func FormatDuration(secs float64) string { + d := time.Duration(secs * float64(time.Second)) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds()+0.5)) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes()+0.5)) + default: + h := int(d.Hours()) + if m := int(d.Minutes()) - h*60; m > 0 { + return fmt.Sprintf("%dh%dm", h, m) + } + return fmt.Sprintf("%dh", h) + } +} + +// MoveDurationChart renders the per-move-number think-time chart as a self-contained, +// script-free inline SVG with three series (min, mean, max). The coordinates and +// labels are all derived from numeric data, so the result is safe template.HTML. +// An empty series renders nothing. +func MoveDurationChart(points []ChartPoint) template.HTML { + if len(points) == 0 { + return "" + } + const ( + w, h = 640, 240 + padL = 46 + padR = 12 + padT = 10 + padB = 28 + ) + maxOrd := points[len(points)-1].Ordinal + if maxOrd < 1 { + maxOrd = 1 + } + var maxY float64 + for _, p := range points { + maxY = max(maxY, p.Max) + } + if maxY <= 0 { + maxY = 1 + } + xOf := func(ord int) float64 { + if maxOrd == 1 { + return padL + } + return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR) + } + yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) } + line := func(get func(ChartPoint) float64) string { + pts := make([]string, len(points)) + for i, p := range points { + pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p))) + } + return strings.Join(pts, " ") + } + + var b strings.Builder + fmt.Fprintf(&b, ``, w, h) + fmt.Fprintf(&b, ``, padL, padT, padL, float64(h-padB)) + fmt.Fprintf(&b, ``, padL, float64(h-padB), w-padR, float64(h-padB)) + for _, frac := range []float64{0, 0.5, 1} { + v := maxY * frac + y := yOf(v) + fmt.Fprintf(&b, ``, padL, y, w-padR, y) + fmt.Fprintf(&b, `%s`, padL-5, y+3, FormatDuration(v)) + } + for _, ord := range xTicks(maxOrd) { + fmt.Fprintf(&b, `%d`, xOf(ord), h-padB+15, ord) + } + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Max })) + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Avg })) + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Min })) + b.WriteString(``) + return template.HTML(b.String()) +} + +// xTicks returns up to three distinct ordinal labels for the chart's X axis. +func xTicks(maxOrd int) []int { + if maxOrd <= 2 { + out := make([]int, 0, maxOrd) + for i := 1; i <= maxOrd; i++ { + out = append(out, i) + } + return out + } + return []int{1, (maxOrd + 1) / 2, maxOrd} +} diff --git a/backend/internal/adminconsole/chart_test.go b/backend/internal/adminconsole/chart_test.go new file mode 100644 index 0000000..5a53fa9 --- /dev/null +++ b/backend/internal/adminconsole/chart_test.go @@ -0,0 +1,51 @@ +package adminconsole + +import ( + "strings" + "testing" +) + +func TestFormatDuration(t *testing.T) { + cases := map[float64]string{ + 0: "0s", 30: "30s", 59: "59s", 60: "1m", 150: "3m", 3600: "1h", 3660: "1h1m", 7800: "2h10m", + } + for secs, want := range cases { + if got := FormatDuration(secs); got != want { + t.Errorf("FormatDuration(%v) = %q, want %q", secs, got, want) + } + } +} + +func TestMoveDurationChartEmpty(t *testing.T) { + if got := MoveDurationChart(nil); got != "" { + t.Errorf("empty chart = %q, want empty", got) + } +} + +func TestMoveDurationChart(t *testing.T) { + pts := []ChartPoint{{Ordinal: 1, Min: 5, Max: 20, Avg: 10}, {Ordinal: 2, Min: 8, Max: 40, Avg: 18}, {Ordinal: 3, Min: 12, Max: 90, Avg: 30}} + svg := string(MoveDurationChart(pts)) + for _, want := range []string{""} { + if !strings.Contains(svg, want) { + t.Errorf("chart missing %q\n%s", want, svg) + } + } + if n := strings.Count(svg, "Complaints Dictionary Broadcast + Grafana ↗
diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 313b31f..976d691 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -17,13 +17,14 @@

Seats

- + {{range .Seats}} - + {{end}}
SeatPlayerScoreHintsWinner
SeatPlayerScoreHints usedWinnerRobot
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}
next move {{.NextMove}}{{end}}{{end}}
+{{if .HasRobot}}

Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.

{{end}}
{{end}} {{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 0d932c5..eb0f664 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -28,6 +28,12 @@ {{else}}

no statistics

{{end}} +{{if .MoveChart}} +

Move timing

+

Think time per move number across all games — min · mean · max.

+{{.MoveChart}} +
+{{end}}

Identities

diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index 4ed3b6d..ef8cf21 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -1,8 +1,18 @@ {{define "content" -}}

Users

{{with .Data}} + + +{{if .Robots}}{{end}} + + + +
KindExternal IDConfirmedCreated
- + {{range .Items}} @@ -11,16 +21,17 @@ +{{if .HasMoveStats}}{{else}}{{end}} {{else}} - + {{end}}
AccountDisplay nameKindLangCreated
AccountDisplay nameKindLangCreatedMove minavgmax
{{.Kind}} {{.Language}} {{.CreatedAt}}{{.MoveMin}}{{.MoveAvg}}{{.MoveMax}}
no users
no users
{{end}} {{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index d05ddc6..cb92d26 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -1,5 +1,7 @@ package adminconsole +import "html/template" + // The *View types are the display models the gin handlers fill and the templates // render. Time values are pre-formatted to strings by the handlers so the // templates stay logic-free. @@ -48,16 +50,27 @@ type DashboardView struct { type UsersView struct { Items []UserRow Pager Pager + // Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current + // glob filters; FilterQuery is those encoded for pager/toggle links. + Robots bool + NameMask string + ExternalIDMask string + FilterQuery string } -// UserRow is one account row in the list. +// UserRow is one account row in the list. MoveMin/Avg/Max are the account's +// pre-formatted move-duration summary (empty when it has no timed move). type UserRow struct { - ID string - DisplayName string - Kind string - Language string - Guest bool - CreatedAt string + ID string + DisplayName string + Kind string + Language string + Guest bool + CreatedAt string + HasMoveStats bool + MoveMin string + MoveAvg string + MoveMax string } // UserDetailView is one account with its stats, identities and recent games. @@ -80,6 +93,9 @@ type UserDetailView struct { Games []GameRow TelegramID string ConnectorEnabled bool + // MoveChart is the pre-rendered inline SVG of the account's per-move-number think + // time (min/mean/max), empty when the account has no timed move. + MoveChart template.HTML } // StatsRow is an account's lifetime statistics. @@ -129,9 +145,15 @@ type GameDetailView struct { UpdatedAt string FinishedAt string Seats []SeatRow + // HasRobot is true when any seat is a robot, gating the robot-target caption; + // RobotTargetPct is the configured global play-to-win rate, in percent. + HasRobot bool + RobotTargetPct int } -// SeatRow is one seat of a game. +// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's +// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the +// scheduled next-move ETA shown only while it is that robot's turn in an active game. type SeatRow struct { Seat int DisplayName string @@ -139,6 +161,9 @@ type SeatRow struct { Score int HintsUsed int Winner bool + IsRobot bool + RobotIntent string + NextMove string } // ComplaintsView is the paginated complaint review queue. diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index 4b55e5c..ecbe5d0 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -248,17 +248,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) { // winning regardless of score. A missed-turn timeout reuses Resign in the game // domain, so it inherits this win/loss. func (g *Game) Resign() (MoveRecord, error) { + return g.ResignSeat(g.toMove) +} + +// ResignSeat resigns a specific seat regardless of whose turn it is, so a player +// may forfeit on the opponent's turn. The resigning seat always loses (winner() +// skips resigned seats). The turn cursor only advances when the seat that resigned +// was the one to move; resigning an off-turn seat leaves the current player's turn +// intact. It returns ErrGameOver on a finished game or for an out-of-range or +// already-resigned seat. +func (g *Game) ResignSeat(seat int) (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver } - player := g.toMove - g.resigned[player] = true - g.disposeHand(player) - rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]} + if seat < 0 || seat >= len(g.hands) || g.resigned[seat] { + return MoveRecord{}, ErrGameOver + } + g.resigned[seat] = true + g.disposeHand(seat) + rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]} g.log = append(g.log, rec) if g.activeCount() <= 1 { g.finish(EndResign) - } else { + } else if seat == g.toMove { g.advance() } return rec, nil diff --git a/backend/internal/engine/resign_test.go b/backend/internal/engine/resign_test.go index 1df334a..8c08b44 100644 --- a/backend/internal/engine/resign_test.go +++ b/backend/internal/engine/resign_test.go @@ -69,6 +69,39 @@ func TestResignTrailingPlayerLoses(t *testing.T) { } } +// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0 +// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner +// loses, the opponent wins, and the game ends. +func TestResignSeatOffTurn(t *testing.T) { + g := openingGame(t) + + hint, ok := g.HintView() + if !ok { + t.Fatal("opening game has no hint") + } + if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves + t.Fatalf("player 0 play: %v", err) + } + if g.ToMove() != 1 { + t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove()) + } + + // Player 0 resigns although it is player 1's turn. + rec, err := g.ResignSeat(0) + if err != nil { + t.Fatalf("player 0 off-turn resign: %v", err) + } + if rec.Player != 0 || rec.Action != ActionResign { + t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action) + } + if !g.Over() || g.Reason() != EndResign { + t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason()) + } + if res := g.Result(); res.Winner != 1 { + t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner) + } +} + // TestResignOnFinishedGame rejects a second transition. func TestResignOnFinishedGame(t *testing.T) { g := newEnglishGame(t, 1) diff --git a/backend/internal/game/analytics.go b/backend/internal/game/analytics.go new file mode 100644 index 0000000..3b0a301 --- /dev/null +++ b/backend/internal/game/analytics.go @@ -0,0 +1,116 @@ +package game + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// A move's "duration" is the think time from the previous move's commit (the moment +// the turn started) to this move's commit. Only play/pass/exchange moves count; +// timeouts and resignations are not think time. The very first move of a game has no +// previous move, so its baseline is the game's creation time. The figures are derived +// from the move journal (game_moves.created_at), so no schema change is needed. +// +// timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for +// every timed move; the two reports aggregate it differently. +const timedMovesCTE = ` + SELECT gp.account_id AS aid, + m.game_id AS gid, + ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord, + EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs + FROM backend.game_moves m + JOIN backend.games g ON g.game_id = m.game_id + LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1 + JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat + WHERE m.action IN ('play', 'pass', 'exchange')` + +// MoveDurationStat is the min, max and mean per-move think time (in seconds) for an +// account across all its games, with the number of timed moves counted. +type MoveDurationStat struct { + MinSecs float64 + MaxSecs float64 + AvgSecs float64 + Moves int +} + +// MoveDurationStats returns the move-duration summary for each of accountIDs that has +// at least one timed move; accounts with none are absent from the map. It powers the +// admin user-list columns. The scan over the journal is acceptable for the low-traffic +// console; per-human analysis is the authoritative use (the live metric aggregates all +// seats including robots). +func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) { + if len(accountIDs) == 0 { + return map[uuid.UUID]MoveDurationStat{}, nil + } + q := `WITH d AS (` + timedMovesCTE + `) +SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid` + rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs)) + if err != nil { + return nil, fmt.Errorf("game: move-duration stats: %w", err) + } + defer rows.Close() + out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs)) + for rows.Next() { + var id uuid.UUID + var st MoveDurationStat + if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil { + return nil, fmt.Errorf("game: scan move-duration stat: %w", err) + } + out[id] = st + } + return out, rows.Err() +} + +// OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move +// (Ordinal) across all its games. +type OrdinalDuration struct { + Ordinal int + MinSecs float64 + MaxSecs float64 + AvgSecs float64 +} + +// MoveDurationByOrdinal returns the account's per-move-number think-time summary, +// ordered by move number, for the admin user-detail chart. The ordinal counts the +// account's own moves within each game (its 1st, 2nd, … move). +func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) { + q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1) +SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord` + rows, err := s.db.QueryContext(ctx, q, accountID) + if err != nil { + return nil, fmt.Errorf("game: move-duration by ordinal: %w", err) + } + defer rows.Close() + var out []OrdinalDuration + for rows.Next() { + var od OrdinalDuration + if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil { + return nil, fmt.Errorf("game: scan ordinal duration: %w", err) + } + out = append(out, od) + } + return out, rows.Err() +} + +// uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an +// ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe. +func uuidArrayLiteral(ids []uuid.UUID) string { + ss := make([]string, len(ids)) + for i, id := range ids { + ss[i] = id.String() + } + return "{" + strings.Join(ss, ",") + "}" +} + +// MoveDurationStats exposes the store report to the admin console handlers. +func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) { + return svc.store.MoveDurationStats(ctx, accountIDs) +} + +// MoveDurationByOrdinal exposes the per-move-number report to the admin console. +func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) { + return svc.store.MoveDurationByOrdinal(ctx, accountID) +} diff --git a/backend/internal/game/draft.go b/backend/internal/game/draft.go new file mode 100644 index 0000000..9114b86 --- /dev/null +++ b/backend/internal/game/draft.go @@ -0,0 +1,163 @@ +package game + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" +) + +// DraftTile is one tile a player has laid on the board but not yet submitted. +type DraftTile struct { + Row int `json:"row"` + Col int `json:"col"` + Letter string `json:"letter"` + Blank bool `json:"blank"` +} + +// Draft is a player's persisted client-side composition for a game (Stage 17): the +// preferred rack tile order and the board tiles laid but not yet submitted. The server +// keeps it so a reload or a second device resumes the same arrangement. +type Draft struct { + RackOrder string + BoardTiles []DraftTile +} + +// GetDraft returns the player's draft for a game, or a zero Draft when none is stored. +func (svc *Service) GetDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) { + return svc.store.getDraft(ctx, gameID, accountID) +} + +// SaveDraft upserts the player's draft; the account must be seated in the game. +func (svc *Service) SaveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error { + seats, _, _, err := svc.Participants(ctx, gameID) + if err != nil { + return err + } + if !slices.Contains(seats, accountID) { + return ErrNotAPlayer + } + return svc.store.saveDraft(ctx, gameID, accountID, d) +} + +// getDraft reads one draft row, returning a zero Draft when absent. +func (s *Store) getDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) { + var rackOrder string + var boardJSON []byte + err := s.db.QueryRowContext(ctx, + `SELECT rack_order, board_tiles FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`, + gameID, accountID).Scan(&rackOrder, &boardJSON) + if errors.Is(err, sql.ErrNoRows) { + return Draft{}, nil + } + if err != nil { + return Draft{}, fmt.Errorf("game: get draft %s: %w", gameID, err) + } + d := Draft{RackOrder: rackOrder} + if len(boardJSON) > 0 { + if err := json.Unmarshal(boardJSON, &d.BoardTiles); err != nil { + return Draft{}, fmt.Errorf("game: decode draft tiles: %w", err) + } + } + return d, nil +} + +// saveDraft upserts the player's draft. +func (s *Store) saveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error { + tiles := d.BoardTiles + if tiles == nil { + tiles = []DraftTile{} + } + boardJSON, err := json.Marshal(tiles) + if err != nil { + return fmt.Errorf("game: encode draft tiles: %w", err) + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO backend.game_drafts (game_id, account_id, rack_order, board_tiles, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (game_id, account_id) + DO UPDATE SET rack_order = $3, board_tiles = $4, updated_at = now()`, + gameID, accountID, d.RackOrder, boardJSON); err != nil { + return fmt.Errorf("game: save draft: %w", err) + } + return nil +} + +// clearDraft drops a player's draft row (their composition is consumed or discarded). +func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`, + gameID, accountID); err != nil { + return fmt.Errorf("game: clear draft: %w", err) + } + return nil +} + +// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has +// a tile on one of the just-committed cells, since that draft can no longer be placed; the +// rack order is kept (Stage 17 #6). +func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error { + if len(cells) == 0 { + return nil + } + occupied := make(map[[2]int]bool, len(cells)) + for _, c := range cells { + occupied[[2]int{c.Row, c.Col}] = true + } + rows, err := s.db.QueryContext(ctx, + `SELECT account_id, board_tiles FROM backend.game_drafts + WHERE game_id = $1 AND account_id <> $2 AND board_tiles <> '[]'::jsonb`, + gameID, actorID) + if err != nil { + return fmt.Errorf("game: scan drafts for conflict: %w", err) + } + var toClear []uuid.UUID + func() { + defer func() { _ = rows.Close() }() + for rows.Next() { + var acc uuid.UUID + var boardJSON []byte + if err = rows.Scan(&acc, &boardJSON); err != nil { + return + } + var tiles []DraftTile + if json.Unmarshal(boardJSON, &tiles) != nil { + continue // skip a malformed draft + } + for _, t := range tiles { + if occupied[[2]int{t.Row, t.Col}] { + toClear = append(toClear, acc) + break + } + } + } + err = rows.Err() + }() + if err != nil { + return fmt.Errorf("game: read drafts for conflict: %w", err) + } + for _, acc := range toClear { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.game_drafts SET board_tiles = '[]'::jsonb, updated_at = now() + WHERE game_id = $1 AND account_id = $2`, + gameID, acc); err != nil { + return fmt.Errorf("game: clear conflicting draft: %w", err) + } + } + return nil +} + +// draftTilesFrom projects a play's committed tiles into draft cells, for the conflict scan. +func draftTilesFrom(rec engine.MoveRecord) []DraftTile { + out := make([]DraftTile, 0, len(rec.Tiles)) + for _, t := range rec.Tiles { + out = append(out, DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}) + } + return out +} diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go new file mode 100644 index 0000000..03f6509 --- /dev/null +++ b/backend/internal/game/emit_test.go @@ -0,0 +1,52 @@ +package game + +import ( + "slices" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/notify" +) + +// recordingPublisher captures every published intent for assertions. +type recordingPublisher struct{ intents []notify.Intent } + +func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) } + +// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every +// seat — including the actor's own account, so the mover's other devices refresh — +// and your_turn only to the next mover. +func TestEmitMoveNotifiesActor(t *testing.T) { + actor, opp := uuid.New(), uuid.New() + pub := &recordingPublisher{} + svc := &Service{pub: pub} + g := Game{ + ID: uuid.New(), + Status: StatusActive, + ToMove: 1, + TurnStartedAt: time.Now(), + TurnTimeout: time.Hour, + Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}}, + } + svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10}) + + kinds := map[uuid.UUID][]string{} + for _, in := range pub.intents { + kinds[in.UserID] = append(kinds[in.UserID], in.Kind) + } + if !slices.Contains(kinds[actor], notify.KindOpponentMoved) { + t.Errorf("actor should get opponent_moved, got %v", kinds[actor]) + } + if !slices.Contains(kinds[opp], notify.KindOpponentMoved) { + t.Errorf("opponent should get opponent_moved, got %v", kinds[opp]) + } + if !slices.Contains(kinds[opp], notify.KindYourTurn) { + t.Errorf("next mover should get your_turn, got %v", kinds[opp]) + } + if slices.Contains(kinds[actor], notify.KindYourTurn) { + t.Errorf("actor is not next to move, should not get your_turn") + } +} diff --git a/backend/internal/game/metrics.go b/backend/internal/game/metrics.go index 8a59185..b9596d3 100644 --- a/backend/internal/game/metrics.go +++ b/backend/internal/game/metrics.go @@ -22,6 +22,7 @@ const meterName = "scrabble/backend/game" type gameMetrics struct { replay metric.Float64Histogram validate metric.Float64Histogram + moveDur metric.Float64Histogram started metric.Int64Counter abandoned metric.Int64Counter } @@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics { return &gameMetrics{ replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."), validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."), + moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."), started: counter(meter, "games_started_total", "Games created and started."), abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."), } @@ -75,6 +77,30 @@ func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, star m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v)) } +// recordMoveDuration records how long a seat spent on a committed move, attributed by +// variant and the game phase derived from moveCount. A non-positive duration (a clock +// skew or a move with no recorded turn start) is dropped. +func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) { + if d <= 0 { + return + } + m.moveDur.Record(ctx, d.Seconds(), + metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount)))) +} + +// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The +// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7). +func phaseOf(moveCount int) string { + switch { + case moveCount <= 8: + return "opening" + case moveCount <= 20: + return "middle" + default: + return "endgame" + } +} + // recordStarted counts one started game of variant. func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) { m.started.Add(ctx, 1, variantAttr(v)) diff --git a/backend/internal/game/metrics_test.go b/backend/internal/game/metrics_test.go index dad8b97..cd1d5c5 100644 --- a/backend/internal/game/metrics_test.go +++ b/backend/internal/game/metrics_test.go @@ -26,6 +26,8 @@ func TestGameMetrics(t *testing.T) { m.recordAbandoned(ctx, engine.VariantErudit) m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond)) m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond)) + m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second) + m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped var rm metricdata.ResourceMetrics if err := reader.Collect(ctx, &rm); err != nil { @@ -45,6 +47,19 @@ func TestGameMetrics(t *testing.T) { if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 { t.Errorf("game_move_validate_duration observations = %d, want 1", c) } + if c := histogramCount(t, rm, "game_move_duration"); c != 1 { + t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c) + } +} + +// TestPhaseOf checks the move-ordinal to phase bucketing. +func TestPhaseOf(t *testing.T) { + cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"} + for mc, want := range cases { + if got := phaseOf(mc); got != want { + t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want) + } + } } // counterByAttr sums the int64 counter named name, grouped by the value of the diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 0b220aa..d57eddf 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -171,11 +171,47 @@ func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, t } // Resign ends the game on the player's turn; the remaining player wins. +// Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is +// allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does +// not go through transition's turn check: it resigns the actor's own seat, whoever is to +// move. The resigning seat always loses (docs/ARCHITECTURE.md §7). func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) { - return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) { - rec, err := g.Resign() - return rec, nil, err - }) + pre, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return MoveResult{}, err + } + seat, ok := pre.seatOf(accountID) + if !ok { + return MoveResult{}, ErrNotAPlayer + } + if pre.Status != StatusActive { + return MoveResult{}, ErrFinished + } + + unlock := svc.locks.lock(gameID) + defer unlock() + + g, err := svc.liveGame(ctx, pre) + if err != nil { + return MoveResult{}, err + } + if g.Over() { + return MoveResult{}, ErrFinished + } + + rackBefore := g.Hand(seat) + rec, err := g.ResignSeat(seat) + if err != nil { + return MoveResult{}, err + } + post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats) + if err != nil { + return MoveResult{}, err + } + svc.afterCommitDrafts(ctx, gameID, accountID, rec) + // A resignation carries no think time (it can happen on the opponent's turn), so it + // is intentionally excluded from the move-duration metric. + return MoveResult{Move: rec, Game: post}, nil } // GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet @@ -185,6 +221,19 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V return svc.store.GetGameVariant(ctx, gameID) } +// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's +// robot-schedule panel (the deterministic play-to-win intent and next-move ETA). +func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { + return svc.store.RobotSchedule(ctx, gameID) +} + +// LastMoveAt returns the time of an account's most recent move in a game (and whether it +// has moved). The social service uses it to reset the nudge cooldown once a player has +// taken a turn (Stage 17). +func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { + return svc.store.LastMoveAt(ctx, gameID, accountID) +} + // transition validates the actor and turn, applies op under the per-game lock and // commits the result. func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { @@ -226,9 +275,28 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, if err != nil { return MoveResult{}, err } + svc.afterCommitDrafts(ctx, gameID, accountID, rec) + // Record the seat's think time (turn start to commit) for the move-duration + // metric; the timeout path commits separately and is excluded by design. + svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt)) return MoveResult{Move: rec, Game: post}, nil } +// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own +// composition is consumed, so clear it; a play's tiles may overlap an opponent's board +// draft, which is then reset. Best-effort — the move is already committed, so a draft +// cleanup failure is logged rather than failing the move. +func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) { + if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil { + svc.log.Warn("clear actor draft", zap.Error(err)) + } + if rec.Action == engine.ActionPlay { + if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil { + svc.log.Warn("reset conflicting board drafts", zap.Error(err)) + } + } +} + // commit persists a just-applied transition: the journal row, the post-move turn // cursor and scores, and on a game-ending move the finish stamp and statistics. // On a persistence failure it evicts the now-divergent live game so the next @@ -287,14 +355,15 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game } // emitMove publishes the live events for a just-committed move: opponent_moved to -// every seat other than the actor, and your_turn to the next mover while the game -// is still active. Delivery is best-effort (notify.Publisher never blocks). +// every seat — including the actor's own account, so the mover's other devices (and +// their lobby) refresh too — and your_turn to the next mover while the game is still +// active. opponent_moved is in-app only (the gateway never turns it into an +// out-of-app push), so the actor is not notified out of band about their own move. +// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each +// event out to all of the recipient's live streams. func (svc *Service) emitMove(post Game, rec engine.MoveRecord) { intents := make([]notify.Intent, 0, len(post.Seats)+1) for _, s := range post.Seats { - if s.Seat == rec.Player { - continue - } intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) } if post.Status == StatusActive { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index c06c508..d97a2ea 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -651,6 +651,43 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) { return row.Seed, nil } +// LastMoveAt returns the time of the account's most recent move in the game and true, or +// the zero time and false when it has not moved. The social service uses it to reset the +// nudge cooldown once the player has taken a turn (Stage 17). +func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { + var at sql.NullTime + err := s.db.QueryRowContext(ctx, + `SELECT MAX(m.created_at) FROM backend.game_moves m + JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat + WHERE m.game_id = $1 AND p.account_id = $2`, + gameID, accountID).Scan(&at) + if err != nil { + return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err) + } + if !at.Valid { + return time.Time{}, false, nil + } + return at.Time, true, nil +} + +// RobotSchedule returns a game's bag seed and current turn-start time. The admin console +// combines them with the robot strategy to show a robot seat's play-to-win intent and its +// next-move ETA. Both are server-only state, never part of the public game view. +func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { + stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt). + FROM(table.Games). + WHERE(table.Games.GameID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.Games + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return 0, time.Time{}, ErrNotFound + } + return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err) + } + return row.Seed, row.TurnStartedAt, nil +} + // projectGame builds a Game from a games row and its ordered seat rows. func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { variant, err := engine.ParseVariant(g.Variant) diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 3059643..6b77c97 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -167,6 +167,45 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) { } } +// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's +// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17). +func TestConsoleGameDetailRobotSchedule(t *testing.T) { + ctx := context.Background() + svc := newGameService() + robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + human := provisionAccount(t) + // Seat the robot first so it is to move (seat 0), exposing the next-move ETA. + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(), + }) + code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "") + if code != http.StatusOK { + t.Fatalf("game detail = %d, want 200", code) + } + if !strings.Contains(body, "🤖") { + t.Error("robot seat is not marked in the game detail") + } + if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") { + t.Error("robot play-to-win intent missing") + } + if !strings.Contains(body, "next move") { + t.Error("robot is to move but the next-move ETA is missing") + } + if !strings.Contains(body, "~40%") { + t.Error("robot play-to-win target caption missing") + } +} + // consoleDo issues a request to h, optionally with an Origin header, and returns // the status and body. Form bodies are sent as application/x-www-form-urlencoded. func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { diff --git a/backend/internal/inttest/analytics_test.go b/backend/internal/inttest/analytics_test.go new file mode 100644 index 0000000..4697dd8 --- /dev/null +++ b/backend/internal/inttest/analytics_test.go @@ -0,0 +1,81 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/game" +) + +// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the +// admin-console move-duration reports compute the think time (gap to the previous +// move, the first move measured from game creation) correctly, per account and per +// the account's move ordinal. +func TestMoveDurationAnalytics(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision A: %v", err) + } + b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) + if err != nil { + t.Fatalf("provision B: %v", err) + } + + gid := uuid.New() + t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + if _, err := testDB.ExecContext(ctx, + `INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at) + VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil { + t.Fatalf("insert game: %v", err) + } + if _, err := testDB.ExecContext(ctx, + `INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil { + t.Fatalf("insert seats: %v", err) + } + // seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200. + moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}} + for _, m := range moves { + if _, err := testDB.ExecContext(ctx, + `INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`, + gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil { + t.Fatalf("insert move %d: %v", m.seq, err) + } + } + + store := game.NewStore(testDB) + stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID}) + if err != nil { + t.Fatalf("stats: %v", err) + } + if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 { + t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa) + } + if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 { + t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb) + } + + byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID) + if err != nil { + t.Fatalf("by ordinal: %v", err) + } + want := []game.OrdinalDuration{ + {Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60}, + {Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50}, + } + if len(byOrd) != len(want) { + t.Fatalf("by ordinal = %+v, want %+v", byOrd, want) + } + for i, w := range want { + if byOrd[i] != w { + t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w) + } + } +} diff --git a/backend/internal/inttest/draft_test.go b/backend/internal/inttest/draft_test.go new file mode 100644 index 0000000..41bad24 --- /dev/null +++ b/backend/internal/inttest/draft_test.go @@ -0,0 +1,104 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// newDraftGame creates a started two-player English game on an opening seed and returns the +// service, game id, seats, and the opening play (from a mirror) used to drive a real commit. +func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) { + t.Helper() + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + seed := openingSeed(t) + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + hint, ok := newMirror(t, seed, 2).HintView() + if !ok || len(hint.Tiles) == 0 { + t.Fatal("no opening move") + } + return svc, g.ID, seats, hint +} + +// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the +// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's +// board draft reset when a committed play overlaps one of its cells (the rack order kept). +func TestDraftPersistAndConflictReset(t *testing.T) { + ctx := context.Background() + svc, gameID, seats, hint := newDraftGame(t) + + // Round-trip seat 0's rack order + a board draft. + d0 := game.Draft{RackOrder: "QANIWE?", BoardTiles: []game.DraftTile{{Row: 1, Col: 1, Letter: "Q"}}} + if err := svc.SaveDraft(ctx, gameID, seats[0], d0); err != nil { + t.Fatalf("save draft 0: %v", err) + } + if got, err := svc.GetDraft(ctx, gameID, seats[0]); err != nil || + got.RackOrder != "QANIWE?" || len(got.BoardTiles) != 1 || got.BoardTiles[0].Letter != "Q" { + t.Fatalf("get draft 0 = %+v (err %v)", got, err) + } + + // Seat 1 drafts a board tile on a cell the opening play will commit. + overlap := hint.Tiles[0] + if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{ + RackOrder: "ABCDEFG", + BoardTiles: []game.DraftTile{{Row: overlap.Row, Col: overlap.Col, Letter: "X"}}, + }); err != nil { + t.Fatalf("save draft 1: %v", err) + } + + if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { + t.Fatalf("seat0 play: %v", err) + } + + // Seat 0's own draft is cleared by their move. + if d, _ := svc.GetDraft(ctx, gameID, seats[0]); d.RackOrder != "" || len(d.BoardTiles) != 0 { + t.Errorf("actor draft not cleared: %+v", d) + } + // Seat 1's board draft overlapped the play and is reset; the rack order is kept. + if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 0 || d.RackOrder != "ABCDEFG" { + t.Errorf("conflicting draft not reset (or rack order lost): %+v", d) + } +} + +// TestDraftSurvivesNonConflictingMove checks an opponent's board draft is kept when a +// committed play does not touch any of its cells. +func TestDraftSurvivesNonConflictingMove(t *testing.T) { + ctx := context.Background() + svc, gameID, seats, hint := newDraftGame(t) + + // Seat 1 drafts a far corner tile the central opening play cannot reach. + if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{ + BoardTiles: []game.DraftTile{{Row: 0, Col: 0, Letter: "Z"}}, + }); err != nil { + t.Fatalf("save draft 1: %v", err) + } + if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil { + t.Fatalf("seat0 play: %v", err) + } + if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" { + t.Errorf("non-conflicting draft should survive: %+v", d) + } +} + +// TestSaveDraftRejectsOutsider checks only a seated player may save a draft. +func TestSaveDraftRejectsOutsider(t *testing.T) { + ctx := context.Background() + svc, gameID, _, _ := newDraftGame(t) + if err := svc.SaveDraft(ctx, gameID, provisionAccount(t), game.Draft{RackOrder: "X"}); err == nil { + t.Fatal("outsider SaveDraft should fail") + } +} diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 0ce8914..9706f13 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -299,6 +299,42 @@ func TestResignWinnerAndStats(t *testing.T) { } } +// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the +// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own +// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses +// despite leading on score. +func TestResignOnOpponentTurn(t *testing.T) { + ctx := context.Background() + svc := newGameService() + seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} + seed := openingSeed(t) + g, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + hint, ok := newMirror(t, seed, 2).HintView() + if !ok { + t.Fatal("no opening move") + } + if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn + t.Fatalf("p0 play: %v", err) + } + + res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn + if err != nil { + t.Fatalf("off-turn resign = %v, want nil", err) + } + if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" { + t.Fatalf("after off-turn resign: %+v", res.Game) + } + if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner { + t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats) + } +} + // TestTimeoutSweep auto-resigns an overdue game and records it as a timeout. func TestTimeoutSweep(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index a212484..f8ef7fa 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -82,19 +82,25 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) { if err := r.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool (idempotent): %v", err) } - id, err := r.Pick() + id, err := r.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } if !isRobotAccount(t, id) { t.Errorf("picked account %s is not a robot identity", id) } + if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) { + t.Errorf("russian pick = (%s, %v), want a robot account", ru, err) + } acc, err := account.NewStore(testDB).GetByID(ctx, id) if err != nil { t.Fatalf("get robot account: %v", err) } - if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests { - t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) + // A robot blocks chat but NOT friend requests: a request to a robot stays pending and + // expires, mirroring a human who ignores it (Stage 17). + if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests { + t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)", + acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) } } @@ -109,7 +115,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) { if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } - robotID, err := robots.Pick() + robotID, err := robots.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } @@ -210,7 +216,7 @@ func TestRobotProactiveNudge(t *testing.T) { if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } - robotID, err := robots.Pick() + robotID, err := robots.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 5de9c3d..33908fa 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -40,6 +40,38 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { return g.ID, seats } +// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as +// pending rather than blocked: robots no longer block friend requests, so the request +// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17). +func TestFriendRequestToRobotStaysPending(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + accs := account.NewStore(testDB) + + human := provisionAccount(t) + robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + if robot.BlockFriendRequests { + t.Fatal("robot must not block friend requests") + } + // A request is only allowed between players who share a game. + if _, err := newGameService().Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID}, + TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }); err != nil { + t.Fatalf("create game: %v", err) + } + + if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil { + t.Fatalf("request to robot = %v, want nil (accepted as pending)", err) + } + if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human { + t.Fatalf("robot incoming = %v, want [human]", got) + } +} + func TestFriendRequestLifecycle(t *testing.T) { ctx := context.Background() svc := newSocialService() @@ -282,6 +314,20 @@ func TestChatRejectsBadContent(t *testing.T) { } } +// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17): +// the player to move can post, the waiting player gets ErrChatNotYourTurn. +func TestChatOnlyOnYourTurn(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening + if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) { + t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil { + t.Fatalf("on-turn chat = %v, want nil", err) + } +} + func TestNudgeRulesAndRateLimit(t *testing.T) { ctx := context.Background() svc := newSocialService() @@ -307,3 +353,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) { t.Fatalf("nudge after window: %v", err) } } + +// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has +// acted (moved or chatted) since their last nudge, even within the hour (Stage 17). +func TestNudgeCooldownResetsOnAction(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gsvc := newGameService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 to move + + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges + t.Fatalf("nudge: %v", err) + } + if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) { + t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err) + } + // Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes. + if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil { + t.Fatalf("seat0 pass: %v", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil { + t.Fatalf("seat1 chat: %v", err) + } + if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil { + t.Fatalf("seat1 pass: %v", err) + } + // Back on the opponent's turn, the cooldown is reset by the action since the nudge. + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { + t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err) + } +} diff --git a/backend/internal/inttest/stage6_test.go b/backend/internal/inttest/stage6_test.go index bade281..6c3ea48 100644 --- a/backend/internal/inttest/stage6_test.go +++ b/backend/internal/inttest/stage6_test.go @@ -38,7 +38,7 @@ func TestGuestAutoMatchLeavesNoStats(t *testing.T) { if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } - robotID, err := robots.Pick() + robotID, err := robots.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick: %v", err) } diff --git a/backend/internal/inttest/userlist_test.go b/backend/internal/inttest/userlist_test.go new file mode 100644 index 0000000..8e47e5f --- /dev/null +++ b/backend/internal/inttest/userlist_test.go @@ -0,0 +1,86 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// TestUserListFilter checks the admin user-list filter: the people/robots split (by a +// robot identity) and the case-insensitive glob masks on display name and external id. +func TestUserListFilter(t *testing.T) { + ctx := context.Background() + st := account.NewStore(testDB) + uniq := uuid.NewString() + + human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") + if err != nil { + t.Fatalf("provision human: %v", err) + } + robot, err := st.ProvisionRobot(ctx, "robot-uxz-"+uniq, "Zzqxbot") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + guest, err := st.ProvisionGuest(ctx) + if err != nil { + t.Fatalf("provision guest: %v", err) + } + + collect := func(f account.UserFilter) map[uuid.UUID]account.UserListItem { + items, err := st.ListUsers(ctx, f, 5000, 0) + if err != nil { + t.Fatalf("list users %+v: %v", f, err) + } + m := make(map[uuid.UUID]account.UserListItem, len(items)) + for _, it := range items { + m[it.ID] = it + } + return m + } + + people := collect(account.UserFilter{}) + if _, ok := people[human.ID]; !ok { + t.Error("human missing from people") + } + if _, ok := people[guest.ID]; !ok { + t.Error("guest missing from people") + } + if _, ok := people[robot.ID]; ok { + t.Error("robot must not appear in people") + } + if it := people[human.ID]; it.IsRobot || it.IsGuest { + t.Errorf("human flags wrong: robot=%v guest=%v (want both false)", it.IsRobot, it.IsGuest) + } + + robots := collect(account.UserFilter{Robots: true}) + if it, ok := robots[robot.ID]; !ok || !it.IsRobot { + t.Errorf("robot missing from robots or IsRobot=false (ok=%v)", ok) + } + if _, ok := robots[human.ID]; ok { + t.Error("human must not appear in robots") + } + + // Name mask (people). + if _, ok := collect(account.UserFilter{NameMask: "Zzqx*"})[human.ID]; !ok { + t.Error("name mask Zzqx* should match the human") + } + if _, ok := collect(account.UserFilter{NameMask: "nomatch*"})[human.ID]; ok { + t.Error("name mask nomatch* should not match the human") + } + // External-id mask (robots). + if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"})[robot.ID]; !ok { + t.Error("external-id mask robot-uxz-* should match the robot") + } + if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-zzz-*"})[robot.ID]; ok { + t.Error("external-id mask robot-zzz-* should not match the robot") + } + // CountUsers agrees that robots exist. + if n, err := st.CountUsers(ctx, account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"}); err != nil || n != 1 { + t.Errorf("count robots robot-uxz-* = (%d, %v), want 1", n, err) + } +} diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 80395a8..8908a38 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" + "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) @@ -25,7 +26,7 @@ type GameCreator interface { // auto-match. robot.Service satisfies it; it returns an error when no robot is // available so the matchmaker can defer substitution. type RobotProvider interface { - Pick() (uuid.UUID, error) + Pick(variant engine.Variant) (uuid.UUID, error) } // Blocker reports whether two accounts have a block between them (either diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 649e98f..2342291 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -142,11 +142,14 @@ func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult return EnqueueResult{}, nil } -// Cancel removes accountID from whatever pool it waits in, reporting whether it -// was queued. +// Cancel removes accountID from whatever pool it waits in and drops any pending +// matched result, reporting whether it was queued. Clearing the result closes the +// race where the reaper substituted a robot just before the player cancelled: the +// stale game must not later surface through Poll as a game the player did not want. func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool { m.mu.Lock() defer m.mu.Unlock() + delete(m.results, accountID) variant, ok := m.queued[accountID] if !ok { return false @@ -197,12 +200,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { } var subs []sub for _, acc := range due { - robotID, err := m.robots.Pick() + variant := m.queued[acc] + robotID, err := m.robots.Pick(variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } - variant := m.queued[acc] m.removeLocked(acc, variant) seats := []uuid.UUID{acc, robotID} if m.rng.Intn(2) == 0 { diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 6d59772..4092e57 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -28,13 +28,15 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, } // fakeRobots is a RobotProvider returning a fixed robot id, or an error to model -// an empty pool. +// an empty pool. It records the variant of the last substitution request. type fakeRobots struct { - id uuid.UUID - err error + id uuid.UUID + err error + lastVariant engine.Variant } -func (f *fakeRobots) Pick() (uuid.UUID, error) { +func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) { + f.lastVariant = variant if f.err != nil { return uuid.Nil, f.err } @@ -238,6 +240,27 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) { } } +// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a +// robot just before the player cancels: Cancel must drop the pending result so the +// abandoned game never surfaces through Poll (Stage 17). +func TestMatchmakerCancelClearsPendingResult(t *testing.T) { + creator := &fakeCreator{} + mm := newTestMatchmaker(creator, uuid.New()) + base := time.Now() + mm.clock = func() time.Time { return base } + ctx := context.Background() + a := uuid.New() + + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + t.Fatalf("enqueue: %v", err) + } + mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result + mm.Cancel(ctx, a) // ... then the player cancels + if got, _ := mm.Poll(ctx, a); got.Matched { + t.Error("cancel must drop the pending substituted game; Poll still matched") + } +} + func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) { creator := &fakeCreator{} mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop()) diff --git a/backend/internal/postgres/migrations/00011_game_drafts.sql b/backend/internal/postgres/migrations/00011_game_drafts.sql new file mode 100644 index 0000000..b6bf77c --- /dev/null +++ b/backend/internal/postgres/migrations/00011_game_drafts.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Stage 17: a per-(game, account) draft the server persists across reloads and devices — +-- the player's preferred rack tile order (#4) and the tiles they have laid on the board but +-- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps +-- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no +-- generated jet code is needed. +SET search_path = backend, pg_catalog; + +CREATE TABLE game_drafts ( + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + rack_order text NOT NULL DEFAULT '', + board_tiles jsonb NOT NULL DEFAULT '[]', + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (game_id, account_id) +); + +-- +goose Down +SET search_path = backend, pg_catalog; + +DROP TABLE game_drafts; diff --git a/backend/internal/robot/names.go b/backend/internal/robot/names.go new file mode 100644 index 0000000..a49d2fe --- /dev/null +++ b/backend/internal/robot/names.go @@ -0,0 +1,146 @@ +package robot + +// Robot display names are composed, not hand-listed. Per language there is a pool of +// 32 full first names and a paired pool of 32 colloquial forms (William/Bill, +// Анастасия/Настя), a surname pool, and three rendering forms: first name only; +// first name plus a surname initial; first name plus full surname. Because robots are +// durable accounts whose name must stay stable across restarts (a player's opponent +// must not rename itself on every deploy, nor mid-game), the composition is +// deterministic per pool slot — seeded by the slot index through mix — rather than +// re-randomised each boot. Russian surnames are gender-agreed with the first name. + +// robotPoolSize is the number of robot accounts provisioned per language. It equals +// the first-name pool size, so each slot draws a distinct person. +const robotPoolSize = 32 + +// latinShareInRussian is the approximate percentage of Russian-variant games that +// draw a Latin-named robot rather than a Russian-named one (the owner's "≤20%"). +const latinShareInRussian = 20 + +// name composition forms. +const ( + nameFormFirstOnly = iota // "Anna" + nameFormInitial // "Anna C." + nameFormFull // "Anna Carter" +) + +// genderedName is a Russian first name tagged by grammatical gender so the surname +// form (masculine vs feminine) can agree with it. +type genderedName struct { + name string + female bool +} + +// surnamePair holds a Russian surname's masculine and feminine forms. +type surnamePair struct{ m, f string } + +// firstNamesFullEN and firstNamesShortEN are paired by index: the same person's +// official and colloquial English first name (William/Bill). +var firstNamesFullEN = []string{ + "William", "Robert", "Thomas", "Nicholas", "Joseph", "Edward", "Charles", "Margaret", + "Elizabeth", "Katherine", "Alexander", "Samuel", "Andrew", "Christopher", "Benjamin", "Daniel", + "Matthew", "Anthony", "Michael", "Richard", "Jonathan", "Patricia", "Jennifer", "Jessica", + "Stephanie", "Victoria", "Theodore", "Frederick", "Gabriel", "Vincent", "Eleanor", "Josephine", +} + +var firstNamesShortEN = []string{ + "Will", "Bob", "Tom", "Nick", "Joe", "Eddie", "Charlie", "Maggie", + "Liz", "Kate", "Alex", "Sam", "Drew", "Chris", "Ben", "Dan", + "Matt", "Tony", "Mike", "Rick", "Jon", "Pat", "Jen", "Jess", + "Steph", "Vicky", "Ted", "Fred", "Gabe", "Vince", "Ellie", "Josie", +} + +// surnamesEN is a pool of gender-neutral English surnames. +var surnamesEN = []string{ + "Carter", "Hayes", "Brooks", "Reed", "Cole", "Lane", "Bishop", "Hart", + "Shaw", "Wells", "Pierce", "Wade", "Frost", "Doyle", "Boyd", "Marsh", + "Hale", "Nash", "Webb", "Dale", "Park", "Lyon", "Snow", "Cross", + "Vance", "Knox", "Page", "Ford", "Banks", "Foster", "Greer", "Mills", +} + +// firstNamesFullRU and firstNamesShortRU are paired by index: the same person's +// official and colloquial Russian first name (Анастасия/Настя), gender-tagged. +var firstNamesFullRU = []genderedName{ + {"Александр", false}, {"Дмитрий", false}, {"Сергей", false}, {"Алексей", false}, + {"Михаил", false}, {"Николай", false}, {"Владимир", false}, {"Константин", false}, + {"Павел", false}, {"Григорий", false}, {"Андрей", false}, {"Иван", false}, + {"Пётр", false}, {"Роман", false}, {"Антон", false}, {"Евгений", false}, + {"Анна", true}, {"Мария", true}, {"Анастасия", true}, {"Наталья", true}, + {"Татьяна", true}, {"Екатерина", true}, {"Юлия", true}, {"Ольга", true}, + {"Елена", true}, {"Ирина", true}, {"Светлана", true}, {"Полина", true}, + {"Дарья", true}, {"София", true}, {"Алина", true}, {"Вера", true}, +} + +var firstNamesShortRU = []genderedName{ + {"Саша", false}, {"Дима", false}, {"Серёжа", false}, {"Лёша", false}, + {"Миша", false}, {"Коля", false}, {"Володя", false}, {"Костя", false}, + {"Паша", false}, {"Гриша", false}, {"Андрюша", false}, {"Ваня", false}, + {"Петя", false}, {"Рома", false}, {"Антоша", false}, {"Женя", false}, + {"Аня", true}, {"Маша", true}, {"Настя", true}, {"Наташа", true}, + {"Таня", true}, {"Катя", true}, {"Юля", true}, {"Оля", true}, + {"Лена", true}, {"Ира", true}, {"Света", true}, {"Поля", true}, + {"Даша", true}, {"Соня", true}, {"Аля", true}, {"Верочка", true}, +} + +// surnamesRU is a pool of common Russian surnames in masculine and feminine forms. +var surnamesRU = []surnamePair{ + {"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"}, + {"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"}, + {"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"}, + {"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"}, + {"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"}, + {"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"}, + {"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"}, + {"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"}, + {"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"}, + {"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"}, + {"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, +} + +// robotDisplayNamesEN builds the English robot display-name pool, one per slot. Each +// slot draws its paired full or colloquial first name, a surname, and a form. +func robotDisplayNamesEN() []string { + out := make([]string, robotPoolSize) + for i := range out { + h := mix(int64(i), "robot-en") + first := firstNamesFullEN[i%len(firstNamesFullEN)] + if (h>>16)&1 == 1 { + first = firstNamesShortEN[i%len(firstNamesShortEN)] + } + surname := surnamesEN[h%uint64(len(surnamesEN))] + out[i] = composeName(first, surname, int((h>>8)%3)) + } + return out +} + +// robotDisplayNamesRU builds the Russian robot display-name pool, one per slot, with +// the surname form agreeing with the first name's gender. +func robotDisplayNamesRU() []string { + out := make([]string, robotPoolSize) + for i := range out { + h := mix(int64(i), "robot-ru") + fn := firstNamesFullRU[i%len(firstNamesFullRU)] + if (h>>16)&1 == 1 { + fn = firstNamesShortRU[i%len(firstNamesShortRU)] + } + sp := surnamesRU[h%uint64(len(surnamesRU))] + surname := sp.m + if fn.female { + surname = sp.f + } + out[i] = composeName(fn.name, surname, int((h>>8)%3)) + } + return out +} + +// composeName renders one of the three name forms from a first name and a surname. +func composeName(first, surname string, form int) string { + switch form { + case nameFormInitial: + return first + " " + string([]rune(surname)[:1]) + "." + case nameFormFull: + return first + " " + surname + default: + return first + } +} diff --git a/backend/internal/robot/names_test.go b/backend/internal/robot/names_test.go new file mode 100644 index 0000000..7925b8c --- /dev/null +++ b/backend/internal/robot/names_test.go @@ -0,0 +1,119 @@ +package robot + +import ( + "errors" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" +) + +// TestComposeName covers the three rendering forms, including a Cyrillic initial. +func TestComposeName(t *testing.T) { + cases := []struct { + first, surname string + form int + want string + }{ + {"Anna", "Carter", nameFormFirstOnly, "Anna"}, + {"Anna", "Carter", nameFormInitial, "Anna C."}, + {"Anna", "Carter", nameFormFull, "Anna Carter"}, + {"Маша", "Суханова", nameFormInitial, "Маша С."}, + {"Маша", "Суханова", nameFormFull, "Маша Суханова"}, + } + for _, c := range cases { + if got := composeName(c.first, c.surname, c.form); got != c.want { + t.Errorf("composeName(%q,%q,%d) = %q, want %q", c.first, c.surname, c.form, got, c.want) + } + } +} + +// TestNamePoolsPaired checks the full and colloquial first-name pools line up by +// index (so a slot's gender and person are consistent) and the surname forms differ. +func TestNamePoolsPaired(t *testing.T) { + if len(firstNamesFullEN) != robotPoolSize || len(firstNamesShortEN) != robotPoolSize { + t.Fatalf("EN first-name pools must each hold %d names", robotPoolSize) + } + if len(firstNamesFullRU) != robotPoolSize || len(firstNamesShortRU) != robotPoolSize { + t.Fatalf("RU first-name pools must each hold %d names", robotPoolSize) + } + for i := range firstNamesFullRU { + if firstNamesFullRU[i].female != firstNamesShortRU[i].female { + t.Errorf("RU pair %d disagrees on gender: %q vs %q", i, firstNamesFullRU[i].name, firstNamesShortRU[i].name) + } + } + for _, sp := range surnamesRU { + if sp.m == sp.f { + t.Errorf("RU surname forms should differ: %q", sp.m) + } + } +} + +// TestRobotDisplayNames checks the generated pools are the right size, non-empty and +// deterministic — durable robot accounts must keep a stable name across restarts. +func TestRobotDisplayNames(t *testing.T) { + en1, en2 := robotDisplayNamesEN(), robotDisplayNamesEN() + ru1, ru2 := robotDisplayNamesRU(), robotDisplayNamesRU() + if len(en1) != robotPoolSize || len(ru1) != robotPoolSize { + t.Fatalf("pool sizes en=%d ru=%d, want %d", len(en1), len(ru1), robotPoolSize) + } + for i := range en1 { + if en1[i] != en2[i] || ru1[i] != ru2[i] { + t.Fatalf("pool not deterministic at %d: en %q/%q ru %q/%q", i, en1[i], en2[i], ru1[i], ru2[i]) + } + if en1[i] == "" || ru1[i] == "" { + t.Fatalf("empty composed name at index %d", i) + } + } +} + +// TestPickVariantRouting checks English games draw the Latin pool and Russian games +// draw mostly Russian names with a Latin minority. +func TestPickVariantRouting(t *testing.T) { + enID, ruID := uuid.New(), uuid.New() + s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}} + for i := 0; i < 200; i++ { + if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID { + t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID) + } + } + var en, ru int + for i := 0; i < 4000; i++ { + got, err := s.Pick(engine.VariantRussianScrabble) + if err != nil { + t.Fatalf("russian Pick: %v", err) + } + switch got { + case enID: + en++ + case ruID: + ru++ + } + } + if ru <= en { + t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en) + } + if en == 0 { + t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)") + } + // Эрудит routes like Russian Scrabble. + if _, err := s.Pick(engine.VariantErudit); err != nil { + t.Errorf("erudit Pick: %v", err) + } +} + +// TestPickFallback checks an empty side falls back to the other pool and an empty pool +// errors. +func TestPickFallback(t *testing.T) { + id := uuid.New() + if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id { + t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id) + } + if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id { + t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id) + } + if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) { + t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err) + } +} diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go index f4ef5cc..e6aa40d 100644 --- a/backend/internal/robot/robot.go +++ b/backend/internal/robot/robot.go @@ -55,13 +55,6 @@ type Nudger interface { LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) } -// robotNames is the curated, human-like name pool. Each name backs one durable -// robot account, addressed by a stable robot identity (its lower-cased name). -var robotNames = []string{ - "Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan", - "Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage", -} - // Config configures the robot subsystem. type Config struct { // DriveInterval is how often the driver scans for robot turns. Sourced from @@ -91,8 +84,9 @@ type Service struct { clock func() time.Time log *zap.Logger - mu sync.RWMutex - pool []uuid.UUID + mu sync.RWMutex + poolEN []uuid.UUID + poolRU []uuid.UUID } // NewService constructs a robot Service. games and social are the domain seams it @@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met } } -// EnsurePool idempotently provisions the named robot accounts and records their -// ids as the pool. Each robot is a durable account bound to a robot identity, -// with chat and friend requests blocked so it never engages socially -// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary -// registry: a failure fails the boot. +// EnsurePool idempotently provisions the robot accounts (one per slot of each +// language's composed name pool) and records their ids. Each robot is a durable +// account bound to a stable, index-keyed robot identity, with chat and friend +// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a +// startup dependency, like the dictionary registry: a failure fails the boot. func (s *Service) EnsurePool(ctx context.Context) error { - ids := make([]uuid.UUID, 0, len(robotNames)) - for _, name := range robotNames { - acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name)) - if err != nil { - return fmt.Errorf("robot: provision %q: %w", name, err) - } - if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests { - if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{ - DisplayName: name, - PreferredLanguage: acc.PreferredLanguage, - TimeZone: acc.TimeZone, - AwayStart: acc.AwayStart, - AwayEnd: acc.AwayEnd, - BlockChat: true, - BlockFriendRequests: true, - }); err != nil { - return fmt.Errorf("robot: profile %q: %w", name, err) - } - } - ids = append(ids, acc.ID) + en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN()) + if err != nil { + return err + } + ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU()) + if err != nil { + return err } s.mu.Lock() - s.pool = ids + s.poolEN, s.poolRU = en, ru s.mu.Unlock() return nil } -// Pick returns a random robot account from the pool, for the matchmaker to -// substitute into an auto-match. It satisfies lobby.RobotProvider. -func (s *Service) Pick() (uuid.UUID, error) { - s.mu.RLock() - defer s.mu.RUnlock() - if len(s.pool) == 0 { - return uuid.Nil, ErrNoRobotAvailable +// provisionPool provisions one durable robot account per name and returns their ids +// in order. The identity is keyed by language and slot index (stable across restarts +// and independent of the composed display name); account.ProvisionRobot sets the +// display name and social blocks and is idempotent, so EnsurePool can run every boot. +func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0, len(names)) + for i, name := range names { + acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name) + if err != nil { + return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err) + } + ids = append(ids, acc.ID) } - return s.pool[rand.IntN(len(s.pool))], nil + return ids, nil } -// poolIDs returns a snapshot of the pool for the driver scan. +// Pick returns a random robot account for the matchmaker to substitute into an +// auto-match of the given variant. An English game draws from the Latin pool; a +// Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a +// Latin name about latinShareInRussian% of the time; either side falls back to the +// other when its pool is empty. It satisfies lobby.RobotProvider. +func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) { + s.mu.RLock() + defer s.mu.RUnlock() + primary, secondary := s.poolEN, s.poolRU + if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit { + primary, secondary = s.poolRU, s.poolEN + if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian { + primary, secondary = secondary, primary + } + } + if len(primary) == 0 { + primary = secondary + } + if len(primary) == 0 { + return uuid.Nil, ErrNoRobotAvailable + } + return primary[rand.IntN(len(primary))], nil +} + +// poolIDs returns a snapshot of the whole pool (both languages) for the driver scan, +// which is variant-agnostic — it acts on every robot's active games. func (s *Service) poolIDs() []uuid.UUID { s.mu.RLock() defer s.mu.RUnlock() - return append([]uuid.UUID(nil), s.pool...) -} - -// externalID is the stable robot identity for a pool name. -func externalID(name string) string { - return "robot-" + name + ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU)) + ids = append(ids, s.poolEN...) + ids = append(ids, s.poolRU...) + return ids } diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index 7301670..4219863 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -23,17 +23,27 @@ const ( // human wins about 60% of games (docs/ARCHITECTURE.md §7). playToWinPercent = 40 - // delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the - // right-skewed distribution (short delays frequent). With skew 3.5 the median - // is about 10 minutes and the mean about 20, with a tail out to the maximum. - delayMinMinutes = 2.0 - delayMaxMinutes = 90.0 - delaySkew = 3.5 + // The robot's think time depends on how far the game has progressed: early moves + // are quick and late moves can be long (endgame deliberation). The delay is drawn + // from a band that interpolates with the move count from [delayEarlyLoMinutes, + // delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes] + // by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates + // delays near the band's floor — an active player). The result is clamped to + // [delayHardMinMinutes, delayHardMaxMinutes]. The numbers are deliberate estimates, + // to be retuned once real play statistics arrive (docs/ARCHITECTURE.md §7). + delayEarlyLoMinutes = 3.0 + delayEarlyHiMinutes = 10.0 + delayLateLoMinutes = 10.0 + delayLateHiMinutes = 90.0 + delaySkew = 4.0 + avgGameMoves = 28.0 + delayHardMinMinutes = 1.0 + delayHardMaxMinutes = 90.0 - // nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot - // answers a daytime nudge on its turn. - nudgeReplyMinMinutes = 2.0 - nudgeReplyMaxMinutes = 10.0 + // nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's + // lower band (delayBand's lo), within which the robot answers a daytime nudge on + // its turn — so a nudged robot replies near the floor of its think time. + nudgeReplySpreadMinutes = 5.0 // sleepStartHour and sleepEndHour bound the robot's nightly sleep in its // (opponent-anchored, drifted) local time: it makes no move and sends no nudge @@ -104,19 +114,82 @@ func playToWin(seed int64) bool { return mix(seed, "win")%100 < playToWinPercent } -// moveDelay is the robot's think time for the move at moveCount, sampled from the -// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes). +// PlayToWin exposes the once-per-game play-to-win decision for a game's bag seed, for the +// admin console (it is deterministic and fixed for the whole game). +func PlayToWin(seed int64) bool { return playToWin(seed) } + +// PlayToWinTargetPercent is the configured probability, in percent, that a robot plays to +// win in any given game (the admin console shows it alongside the per-game decision). +const PlayToWinTargetPercent = playToWinPercent + +// NextMoveAt is the deterministic instant the robot is scheduled to play the move at +// moveCount, given when the turn started and the opponent's timezone (which anchors the +// robot's sleep window). It is the sampled think-time delay, deferred to the end of the +// sleep window when it would otherwise land while the robot is asleep. The driver acts on +// a scan tick, so the real move lands at the first scan at or after this instant. It is +// meaningful only on the robot's own turn; the admin console surfaces it as an ETA. +func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time { + t := turnStartedAt.Add(moveDelay(seed, moveCount)) + drift := sleepDrift(seed) + if asleep(opponentTZ, drift, t) { + t = wakeAfter(opponentTZ, drift, t) + } + return t +} + +// wakeAfter returns the first instant at or after t when the robot is awake — the local +// hour reaches sleepEndHour in the opponent's drifted timezone — converted back to UTC. +func wakeAfter(opponentTZ string, drift time.Duration, t time.Time) time.Time { + local := t.In(loadLocation(opponentTZ)).Add(drift) + wake := time.Date(local.Year(), local.Month(), local.Day(), sleepEndHour, 0, 0, 0, local.Location()) + if !wake.After(local) { + wake = wake.Add(24 * time.Hour) + } + return wake.Add(-drift).UTC() +} + +// delayBand returns the lower and upper bounds, in minutes, of the move-delay band +// for the move at moveCount. It interpolates linearly with game progress (the move +// count over avgGameMoves, capped at 1): early moves sit in a short band and late +// moves in a long one. +func delayBand(moveCount int) (lo, hi float64) { + p := float64(moveCount) / avgGameMoves + if p > 1 { + p = 1 + } + lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p + hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p + return lo, hi +} + +// moveDelay is the robot's think time for the move at moveCount: a right-skewed +// sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew +// > 1) makes short delays frequent and long ones rare, with a tail to the band's top. func moveDelay(seed int64, moveCount int) time.Duration { + lo, hi := delayBand(moveCount) u := unitFloat(mix(seed, "delay", moveCount)) - mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew) - return time.Duration(mins * float64(time.Minute)) + return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew)) } // nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at -// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes). +// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes], +// where lo is the move's lower band — so a nudge pulls the move in near the floor of +// the robot's think time. func nudgeReplyDelay(seed int64, moveCount int) time.Duration { + lo, _ := delayBand(moveCount) u := unitFloat(mix(seed, "nudge", moveCount)) - mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u + return clampMinutes(lo + nudgeReplySpreadMinutes*u) +} + +// clampMinutes converts a minute count to a duration, clamping it to the hard delay +// bounds so an out-of-range band can never produce an absurd think time. +func clampMinutes(mins float64) time.Duration { + if mins < delayHardMinMinutes { + mins = delayHardMinMinutes + } + if mins > delayHardMaxMinutes { + mins = delayHardMaxMinutes + } return time.Duration(mins * float64(time.Minute)) } diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index 3161e4b..91092bb 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) { } } -// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in -// [2min, 90min) and is reproducible for a (seed, moveCount). +// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard +// bounds [1min, 90min] and is reproducible for a (seed, moveCount). func TestMoveDelayBoundsAndDeterminism(t *testing.T) { for seed := int64(1); seed <= 200; seed++ { for mc := 0; mc < 50; mc++ { d := moveDelay(seed, mc) - if d < 2*time.Minute || d >= 90*time.Minute { - t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc) + if d < 1*time.Minute || d > 90*time.Minute { + t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc) } if moveDelay(seed, mc) != d { t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc) @@ -43,22 +43,49 @@ func TestMoveDelayBoundsAndDeterminism(t *testing.T) { } } -// TestMoveDelaySkew checks the distribution is right-skewed with the intended -// ~10-minute median: most delays are short, the mean sits above the median. +// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the +// first move lives in the short [1,5]min band, a late move in the long [10,90]min +// band, so the median think time rises with the move count. +func TestMoveDelayGrowsWithMoveCount(t *testing.T) { + median := func(mc int) float64 { + const n = 4000 + xs := make([]float64, n) + for s := 0; s < n; s++ { + xs[s] = moveDelay(int64(s+1), mc).Minutes() + } + sort.Float64s(xs) + return xs[n/2] + } + for s := int64(1); s <= 500; s++ { + if d := moveDelay(s, 0).Minutes(); d < 3 || d > 10 { + t.Fatalf("first-move delay %.2f out of [3,10] for seed %d", d, s) + } + if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 { + t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s) + } + } + if early, late := median(0), median(30); early >= late { + t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late) + } +} + +// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move +// count: short delays are frequent (median near the band floor) and the mean sits +// above the median, with a tail toward the cap. func TestMoveDelaySkew(t *testing.T) { const n = 20000 mins := make([]float64, 0, n) var sum float64 - for mc := 0; mc < n; mc++ { - m := moveDelay(42, mc).Minutes() + for s := 0; s < n; s++ { + m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90] mins = append(mins, m) sum += m } sort.Float64s(mins) median := mins[n/2] mean := sum / float64(n) - if median < 7 || median > 13 { - t.Errorf("median delay = %.1f min, want ~10 (7-13)", median) + if median < 12 || median > 20 { + t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median) } if mean <= median { t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median) @@ -180,6 +207,37 @@ func TestMixDeterministic(t *testing.T) { } } +// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never +// earlier than the sampled think-time delay, and it never lands while the robot is asleep +// (a delay that would fall in the sleep window is deferred to the wake time). +func TestNextMoveAt(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for seed := int64(1); seed <= 500; seed++ { + for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day + start := base.Add(time.Duration(h) * time.Hour) + at := NextMoveAt(seed, 3, start, "UTC") + if at.Before(start.Add(moveDelay(seed, 3))) { + t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at) + } + if asleep("UTC", sleepDrift(seed), at) { + t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at) + } + } + } +} + +// TestPlayToWinExport checks the exported decision matches the internal one and the target. +func TestPlayToWinExport(t *testing.T) { + for seed := int64(1); seed <= 200; seed++ { + if PlayToWin(seed) != playToWin(seed) { + t.Fatalf("PlayToWin(%d) != playToWin", seed) + } + } + if PlayToWinTargetPercent != playToWinPercent { + t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent) + } +} + // plays builds candidate plays carrying only the given scores (ranked as passed). func plays(scores ...int) []engine.MoveRecord { out := make([]engine.MoveRecord, len(scores)) diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index 7f0ae7a..a5d2c66 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -46,6 +46,9 @@ func TestStatusForError(t *testing.T) { }{ "not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"}, "not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"}, + "nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"}, + "nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"}, + "chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index dbbe120..c4104b5 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -69,6 +69,7 @@ func (s *Server) registerRoutes() { } if s.matchmaker != nil { u.POST("/lobby/enqueue", s.handleEnqueue) + u.POST("/lobby/cancel", s.handleCancel) u.GET("/lobby/poll", s.handlePoll) } if s.invitations != nil { @@ -148,8 +149,10 @@ func statusForError(err error) (int, string) { return http.StatusNotFound, "not_found" case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant): return http.StatusForbidden, "not_a_player" - case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn): + case errors.Is(err, game.ErrNotYourTurn): return http.StatusConflict, "not_your_turn" + case errors.Is(err, social.ErrNudgeOnOwnTurn): + return http.StatusConflict, "nudge_own_turn" case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive): return http.StatusConflict, "game_finished" case errors.Is(err, game.ErrGameActive): @@ -198,9 +201,14 @@ func statusForError(err error) (int, string) { case errors.Is(err, session.ErrNotFound): return http.StatusUnauthorized, "session_invalid" case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong), - errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent), - errors.Is(err, social.ErrNudgeTooSoon): + errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent): return http.StatusUnprocessableEntity, "chat_rejected" + case errors.Is(err, social.ErrNudgeTooSoon): + // A too-frequent nudge is a distinct, non-content rejection — the UI must say + // "don't rush the player so often", not the chat content-rejection message. + return http.StatusConflict, "nudge_too_soon" + case errors.Is(err, social.ErrChatNotYourTurn): + return http.StatusConflict, "chat_not_your_turn" case errors.Is(err, social.ErrSelfRelation): return http.StatusBadRequest, "self_relation" case errors.Is(err, social.ErrRequestExists): diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index d8e2265..5631c8b 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "net/http" + "net/url" "path/filepath" "strconv" "strings" @@ -17,6 +18,7 @@ import ( "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/robot" ) // adminPageSize is the page size of the admin console's paginated lists. @@ -75,22 +77,55 @@ func (s *Server) consoleDashboard(c *gin.Context) { func (s *Server) consoleUsers(c *gin.Context) { ctx := c.Request.Context() page := consolePage(c) - total, _ := s.accounts.CountAccounts(ctx) - accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize) + filter := account.UserFilter{ + Robots: c.Query("kind") == "robots", + NameMask: c.Query("name"), + ExternalIDMask: c.Query("ext"), + } + total, _ := s.accounts.CountUsers(ctx, filter) + items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize) if err != nil { s.consoleError(c, err) return } - view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)} - for _, a := range accs { + q := url.Values{} + if filter.Robots { + q.Set("kind", "robots") + } + if strings.TrimSpace(filter.NameMask) != "" { + q.Set("name", filter.NameMask) + } + if strings.TrimSpace(filter.ExternalIDMask) != "" { + q.Set("ext", filter.ExternalIDMask) + } + view := adminconsole.UsersView{ + Pager: adminconsole.NewPager(page, adminPageSize, total), + Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, + FilterQuery: q.Encode(), + } + ids := make([]uuid.UUID, 0, len(items)) + for _, it := range items { kind := "registered" - if a.IsGuest { + if it.IsRobot { + kind = "robot" + } else if it.IsGuest { kind = "guest" } view.Items = append(view.Items, adminconsole.UserRow{ - ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind, - Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt), + ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind, + Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt), }) + ids = append(ids, it.ID) + } + if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil { + for i := range view.Items { + if st, ok := stats[ids[i]]; ok && st.Moves > 0 { + view.Items[i].HasMoveStats = true + view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs) + view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs) + view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs) + } + } } s.renderConsole(c, "users", "users", "Users", view) } @@ -134,6 +169,13 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view.Games = append(view.Games, gameRow(g)) } } + if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 { + cps := make([]adminconsole.ChartPoint, len(pts)) + for i, p := range pts { + cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs} + } + view.MoveChart = adminconsole.MoveDurationChart(cps) + } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } @@ -207,16 +249,58 @@ func (s *Server) consoleGameDetail(c *gin.Context) { MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt), FinishedAt: fmtTimePtr(g.FinishedAt), } + // Resolve seats and detect robot seats; capture the human opponent's timezone, which + // anchors the robot's sleep window for the next-move ETA. + oppTZ := "" for _, seat := range g.Seats { row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner} - if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil { + acc, accErr := s.accounts.GetByID(ctx, seat.AccountID) + if accErr == nil { row.DisplayName = acc.DisplayName } + if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot { + row.IsRobot = true + view.HasRobot = true + } else if accErr == nil { + oppTZ = acc.TimeZone + } view.Seats = append(view.Seats, row) } + // For each robot seat, surface the game's deterministic play-to-win intent and — while + // it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed. + if view.HasRobot { + view.RobotTargetPct = robot.PlayToWinTargetPercent + if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil { + now := time.Now().UTC() + for i := range view.Seats { + if !view.Seats[i].IsRobot { + continue + } + if robot.PlayToWin(seed) { + view.Seats[i].RobotIntent = "play to win" + } else { + view.Seats[i].RobotIntent = "play to lose" + } + if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat { + view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now) + } + } + } + } s.renderConsole(c, "game_detail", "games", "Game", view) } +// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a +// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)". +func robotETA(at, now time.Time) string { + mins := int(at.Sub(now).Round(time.Minute).Minutes()) + rel := fmt.Sprintf("in ~%d min", mins) + if mins <= 0 { + rel = "due now" + } + return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel) +} + // consoleComplaints renders the paginated complaint review queue. func (s *Server) consoleComplaints(c *gin.Context) { ctx := c.Request.Context() diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 7e181fc..180335a 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -153,6 +153,20 @@ func (s *Server) handleEnqueue(c *gin.Context) { c.JSON(http.StatusOK, dto) } +// handleCancel removes the caller from the auto-match pool (and drops any pending +// matched result), so a cancelled quick-match neither blocks a re-queue nor later +// surfaces a robot-substituted game the player abandoned. It is idempotent: cancelling +// when not queued is a no-op success. +func (s *Server) handleCancel(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + s.matchmaker.Cancel(c.Request.Context(), uid) + c.Status(http.StatusNoContent) +} + // handlePoll reports whether the caller has been paired since queueing. func (s *Server) handlePoll(c *gin.Context) { uid, ok := userID(c) diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 387440e..0d9edc8 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -49,13 +49,22 @@ type Message struct { // rune limit, and free of links/emails/phone numbers (the content filter). The // gateway-forwarded senderIP is validated and stored for moderation. func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) { - seats, _, _, err := svc.games.Participants(ctx, gameID) + seats, toMove, status, err := svc.games.Participants(ctx, gameID) if err != nil { return Message{}, err } - if !slices.Contains(seats, senderID) { + idx := slices.Index(seats, senderID) + if idx < 0 { return Message{}, ErrNotParticipant } + // Chat is allowed only on the sender's own turn in an active game; the opponent's-turn + // control is the nudge (Stage 17). + if status != statusActive { + return Message{}, ErrGameNotActive + } + if idx != toMove { + return Message{}, ErrChatNotYourTurn + } sender, err := svc.accounts.GetByID(ctx, senderID) if err != nil { return Message{}, err @@ -105,7 +114,15 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return Message{}, err } if ok && svc.now().Sub(last) < nudgeInterval { - return Message{}, ErrNudgeTooSoon + // The cooldown resets once the sender has acted (moved or chatted) since the last + // nudge — engagement clears the "don't spam" limit (Stage 17). + acted, err := svc.actedSince(ctx, gameID, senderID, last) + if err != nil { + return Message{}, err + } + if !acted { + return Message{}, ErrNudgeTooSoon + } } msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil) if err != nil { @@ -118,6 +135,22 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return msg, nil } +// actedSince reports whether senderID made a move or posted a chat message in the game +// after t — the events that reset the nudge cooldown (Stage 17). +func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) { + if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil { + return false, err + } else if ok && mv.After(t) { + return true, nil + } + if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil { + return false, err + } else if ok && msg.After(t) { + return true, nil + } + return false, nil +} + // emitChat pushes a chat message to every seated player except the sender // (best-effort live delivery; the recipients still read it via Messages). func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) { @@ -252,6 +285,27 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti return row.CreatedAt, true, nil } +// lastMessageAt returns the time of senderID's most recent non-nudge chat message in +// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale +// nudge no longer blocks a new one (Stage 17). +func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) { + stmt := postgres.SELECT(table.ChatMessages.CreatedAt). + FROM(table.ChatMessages). + WHERE( + table.ChatMessages.GameID.EQ(postgres.UUID(gameID)). + AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))). + AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))), + ).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1) + var row model.ChatMessages + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return time.Time{}, false, nil + } + return time.Time{}, false, fmt.Errorf("social: last message: %w", err) + } + return row.CreatedAt, true, nil +} + // messageFromRow projects a generated row into the public Message. func messageFromRow(r model.ChatMessages) Message { m := Message{ diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 43002c2..1f0aaa2 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -28,6 +28,9 @@ type GameReader interface { // SharedGame reports whether two accounts are seated together in any game // (active or finished); it gates the "befriend an opponent" request path. SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) + // LastMoveAt is the time of an account's most recent move in a game (and whether it + // has moved); the nudge cooldown resets once the player has taken a turn. + LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) } // Sentinel errors returned by the service. @@ -67,6 +70,10 @@ var ( ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour") // ErrGameNotActive is returned when a nudge is attempted on a finished game. ErrGameNotActive = errors.New("social: game is not active") + // ErrChatNotYourTurn is returned when a chat message is sent while it is not the + // sender's turn — chat is allowed only on your own turn (the opponent's-turn control + // is the nudge, Stage 17). + ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn") ) // Service is the social domain. It is the only writer of the friendships, blocks diff --git a/deploy/README.md b/deploy/README.md index 62ab89d..b5778f4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -110,5 +110,8 @@ resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentional - **Host caddy** route ` → scrabble:80` (the in-compose caddy serves HTTP in the test contour; the host caddy terminates TLS). Not needed on prod, where the contour caddy owns TLS (set `CADDY_SITE_ADDRESS` to the domain). -- **Branch protection** required-status-check names are `CI / unit`, - `CI / integration`, `CI / ui` (see [`../CLAUDE.md`](../CLAUDE.md) "Branching & CI"). +- **Branch protection** requires the single status check `CI / gate` (Stage 17). + The `unit` / `integration` / `ui` jobs are path-conditional (they skip when their + code did not change), and the always-running `gate` job aggregates them (passing + when each succeeded or was skipped), so a skipped job never blocks a merge. See + [`../CLAUDE.md`](../CLAUDE.md) "Branching & CI". diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 09cb03d..7836d94 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -79,6 +79,7 @@ services: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} + VITE_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped depends_on: [backend] environment: @@ -209,6 +210,10 @@ services: GF_AUTH_BASIC_ENABLED: "false" GF_USERS_ALLOW_SIGN_UP: "false" GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + # Disable Grafana Live: its WebSocket (/_gm/grafana/api/live/ws) otherwise hits + # caddy's Basic-Auth and re-prompts for the password on every dashboard; the + # dashboards poll and do not need Live. + GF_LIVE_MAX_CONNECTIONS: "0" volumes: - ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro # Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the diff --git a/deploy/grafana/dashboards/game-domain.json b/deploy/grafana/dashboards/game-domain.json index 90d76f9..53594c2 100644 --- a/deploy/grafana/dashboards/game-domain.json +++ b/deploy/grafana/dashboards/game-domain.json @@ -4,7 +4,7 @@ "tags": ["scrabble"], "timezone": "", "schemaVersion": 39, - "version": 1, + "version": 2, "refresh": "30s", "time": { "from": "now-24h", "to": "now" }, "panels": [ @@ -54,6 +54,18 @@ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, "datasource": { "type": "prometheus", "uid": "prometheus" }, "targets": [{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(game_move_validate_duration_bucket[5m])) by (le, variant))", "legendFormat": "{{variant}}" }] + }, + { + "type": "timeseries", + "title": "Move think-time by phase (p50 / p95)", + "description": "Seconds a seat spent on a committed move, by game phase. Aggregates all seats including robots; per-human analysis is in the admin console.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "histogram_quantile(0.5, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p50 {{phase}}" }, + { "refId": "B", "expr": "histogram_quantile(0.95, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p95 {{phase}}" } + ] } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f369f0..46045bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 — @@ -300,10 +303,17 @@ 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--` 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 +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,16 +323,20 @@ requires (there is no DM surface; chat is per-game). (playing to lose) is closest to a small band (**1–30 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 + **[3, 10] 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:00–07: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 - **2–10 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. + 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 @@ -334,6 +348,9 @@ requires (there is no DM surface; chat is per-game). 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 @@ -451,7 +468,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 +518,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 +606,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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 923fcff..771ae57 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 (2–4) are @@ -91,7 +93,9 @@ 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); 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 @@ -108,9 +112,10 @@ even disguised. Nudge the player whose turn is awaited at most once per hour (th nudge is part of the game chat); the out-of-app push is delivered via the platform. ### Profile & settings *(Stage 4 / 8)* -Edit the display name (letters joined by single space / "." / "_" separators, up to -32 characters), 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. Linking +Edit the display name (letters joined by a single space / "." / "_" separator, with an +optional trailing ".", up to 32 characters), 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). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 92db5c5..cbbd2aa 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -56,9 +56,11 @@ Mini App** авторизует по подписанным `initData` плат ### Лобби и подбор *(Stage 4 / 15)* Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра** -ограничены языками, которые поддерживает сервис входа игрока (английский → English; -русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не -ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и +ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; +русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не +ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble +читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), +и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на любом языке. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с @@ -92,8 +94,10 @@ Mini App** авторизует по подписанным `initData` плат человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и -сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается -в чате и не принимает заявки в друзья. +сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее +языку партии (в русской партии — в основном русские имена); не общается в чате и +**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает, +ровно как у человека, который не отвечает. ### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)* Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает @@ -111,11 +115,12 @@ Mini App** авторизует по подписанным `initData` плат push доставляется через платформу. ### Профиль и настройки *(Stage 4 / 8)* -Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» / -«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия -(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и -переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов -вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11). +Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / +«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от +UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с +переходом через полночь) и переключателей блокировок. Форма профиля редактируется +сразу (без отдельного режима редактирования). Привязка email и Telegram, а также +слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11). ### История и статистика *(Stage 3 / 8)* Завершённые партии архивируются в независимом от словаря виде и экспортируются diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d42d6ec..3ba425a 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -33,6 +33,25 @@ 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. 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 + 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` / + `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the + native header **BackButton** drives back-navigation (the app's chevron is hidden in + Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing + confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise) + are disabled so they don't fight tile drag or the board scroll; and a live stream dropped + by a background suspend reconnects silently on return — the connection banner is + suppressed while hidden and for a short grace after resume (visibilitychange + + pageshow/pagehide + Telegram `activated`/`deactivated`). ## Tiles & board @@ -43,9 +62,36 @@ Login uses `Screen`. that works consistently across browsers; no `transform`, which broke scrolling 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** 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. + **Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending** + 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; 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 + 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 + 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 + overlays the empty area below, so the layout doesn't resize/jank; other modals stay + keyboard-aware (they size to the area above the keyboard). - **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 @@ -53,25 +99,34 @@ 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**: the inter-cell gap shows a contrasting `--cell-line` (darker in light, - lighter in dark) to avoid a wavy-line optical illusion. +- **Grid lines** — a Settings toggle, **default off** (Stage 17). 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 + `--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion. ## Controls - **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. Reused by: - - **MakeMove** (appears when ≥1 tile is pending; the rack collapses its used slots and - shifts left to free room): a **🏁** button whose popover offers **Make move ✅** / - **Reset ❌**. - - **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a - remaining-count badge) — each confirmed by an **Ok ✅** popover; 🔀 Shuffle has no - label and no confirm. The under-board slot shows the **Scores: N** preview. + 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 **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, 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`) -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 diff --git a/gateway/Dockerfile b/gateway/Dockerfile index bb0dd60..4a22bad 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -17,12 +17,15 @@ WORKDIR /ui RUN corepack enable && corepack prepare pnpm@11.0.9 --activate # Prod UI build vars (Vite reads VITE_-prefixed env at build; baked into the bundle). +# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev"). ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_LINK= ARG VITE_GATEWAY_URL= +ARG VITE_APP_VERSION= ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ - VITE_GATEWAY_URL=$VITE_GATEWAY_URL + VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ + VITE_APP_VERSION=$VITE_APP_VERSION # Install with the lockfile first (the workspace file carries pnpm's build-script # approval for esbuild), then build. Committed src/gen/ means no codegen here. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 5eb17e1..4320517 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -255,6 +255,11 @@ func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) { return out, err } +// Cancel removes the caller from the auto-match pool (idempotent; 204 No Content). +func (c *Client) Cancel(ctx context.Context, userID string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/lobby/cancel", userID, "", nil, nil) +} + // ChatPost stores a chat message, forwarding the client IP for moderation. func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) { var out ChatResp diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 0f442a5..cd89309 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -88,7 +88,11 @@ var ( func DefaultRateLimit() RateLimitConfig { return RateLimitConfig{ PublicPerMinute: 30, PublicBurst: 10, - UserPerMinute: 120, UserBurst: 40, + // Per-user (not per-IP): one user may run several devices, each holding a + // Subscribe stream and reloading state on every live event, so the authenticated + // budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17 + // after multi-device play tripped the old 120/40. + UserPerMinute: 300, UserBurst: 80, AdminPerMinute: 60, AdminBurst: 20, EmailPer10Min: 5, EmailBurst: 2, } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 6cf0ca4..60a77f2 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -24,6 +24,7 @@ const ( MsgGameSubmitPlay = "game.submit_play" MsgGameState = "game.state" MsgLobbyEnqueue = "lobby.enqueue" + MsgLobbyCancel = "lobby.cancel" MsgLobbyPoll = "lobby.poll" MsgChatPost = "chat.post" MsgGamesList = "games.list" @@ -93,6 +94,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true} + r.ops[MsgLobbyCancel] = Op{Handler: cancelHandler(backend), Auth: true} r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true} r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true} r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true} @@ -233,6 +235,17 @@ func pollHandler(backend *backendclient.Client) Handler { } } +// cancelHandler removes the caller from the auto-match pool. It carries no result; +// it echoes an empty (unmatched) Match so the client has a well-formed payload. +func cancelHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + if err := backend.Cancel(ctx, req.UserID); err != nil { + return nil, err + } + return encodeMatch(backendclient.MatchResp{}), nil + } +} + func chatPostHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsChatPostRequest(req.Payload, 0) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index b38d17f..f6e77e4 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -10,22 +10,77 @@ async function openGame(page: Page): Promise { await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann await expect(page.locator('[data-cell]').first()).toBeVisible(); + // Wait for the screen-slide transition to settle so only the game pane remains; + // until it does, the leaving lobby pane's header (its menu button) is also in the + // DOM, which would make shared locators like .burger ambiguous. + await expect(page.locator('.pane')).toHaveCount(1); } -test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => { +test('placing a tile and confirming via ✅ commits the move', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); - await page.locator('.make').click(); // open the MakeMove popover (short tap) - await page.locator('.pop.go').click(); // "Make move ✅" + await page.locator('.make').click(); // ✅ commits the move directly (no popover) - // After the commit the placement is cleared: no pending tile, no 🏁 control. + // After the commit the placement is cleared: no pending tile, no ✅ control. await expect(page.locator('[data-cell].pending')).toHaveCount(0); await expect(page.locator('.make')).toBeHidden(); }); +test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match + await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary + await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons +}); + +test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + // A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger). + await page.waitForTimeout(350); // clear the double-tap window from the placing tap + await page.locator('[data-cell].pending').first().click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + // A double-tap (two synchronous clicks on the same cell) recalls it to the rack. + await page.locator('[data-cell].pending').first().evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + await expect(page.locator('[data-cell].pending')).toHaveCount(0); +}); + +test('the board is a gapless checkerboard by default; grid lines toggle in Settings', async ({ page }) => { + await openGame(page); + await expect(page.locator('.grid.gridless')).toBeVisible(); // lines off by default + + await page.evaluate(() => (location.hash = '/settings')); + await page.locator('.gridlines input').check(); // turn grid lines on + await page.evaluate(() => (location.hash = '/game/g1')); + + await expect(page.locator('.grid')).toBeVisible(); + await expect(page.locator('.grid.gridless')).toHaveCount(0); +}); + +test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => { + await openGame(page); + const before = await page.locator('.rack .tile').allTextContents(); + expect(before.length).toBeGreaterThan(1); + + await page.locator('button:has-text("🔀")').click(); // the shuffle tab (no pending tiles) + await page.waitForTimeout(650); // let the hop animation settle + + // Same multiset of tiles after the shuffle — no tile is dropped or duplicated. + const after = await page.locator('.rack .tile').allTextContents(); + expect([...after].sort()).toEqual([...before].sort()); +}); + test('history slides the board down and closes on a board tap', async ({ page }) => { await openGame(page); await page.locator('.burger').click(); diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index 112cd49..ace5d86 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -25,6 +25,6 @@ test('guest reaches a board and previews a placement', async ({ page }) => { // The score preview appears where the hints count used to be. await expect(page.locator('.scores')).toContainText(/\d/); - // The contextual MakeMove control (🏁) appears once a tile is pending. + // The contextual MakeMove control (✅) appears once a tile is pending. await expect(page.locator('.make')).toBeVisible(); }); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 3f03d88..33cdd70 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -54,7 +54,6 @@ test('profile edit saves a new display name', async ({ page }) => { await loginLobby(page); await page.locator('.burger').first().click(); await page.getByRole('button', { name: /Profile/ }).click(); - await page.getByRole('button', { name: /Edit profile/ }).click(); await page.locator('.edit input').first().fill('Kaya Test'); await page.getByRole('button', { name: /^Save$/ }).click(); await expect(page.locator('.name')).toHaveText('Kaya Test'); @@ -127,7 +126,6 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa await loginLobby(page); await page.locator('.burger').first().click(); await page.getByRole('button', { name: /Profile/ }).click(); - await page.getByRole('button', { name: /Edit profile/ }).click(); const name = page.locator('.edit input').first(); const save = page.getByRole('button', { name: /^Save$/ }); @@ -167,12 +165,14 @@ test('link account: the Telegram web sign-in control is offered in a browser', a await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); }); -test('chat send and nudge are icon buttons', async ({ page }) => { +test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => { await loginLobby(page); - await page.getByRole('button', { name: /Ann/ }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn await page.locator('.burger').first().click(); await page.getByRole('button', { name: 'Chat' }).click(); - // Icon-only controls expose their action through the aria-label. + // On your turn the message field + Send are shown and the nudge is hidden (Stage 17); + // chat and nudge are mutually exclusive by turn. Icon-only controls expose their action + // through the aria-label. await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Nudge' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); }); diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg new file mode 100644 index 0000000..0bdc2ea --- /dev/null +++ b/ui/public/flag-ussr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 1acb335..f58157f 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,8 +1,10 @@ {#if !app.ready}
{t('common.loading')}
-{:else if router.route.name === 'login'} - -{:else if router.route.name === 'new'} - -{:else if router.route.name === 'game'} - -{:else if router.route.name === 'profile'} - -{:else if router.route.name === 'settings'} - -{:else if router.route.name === 'about'} - -{:else if router.route.name === 'friends'} - -{:else if router.route.name === 'stats'} - {:else} - +
+ {#key routeKey} +
+ {#if router.route.name === 'login'} + + {:else if router.route.name === 'new'} + + {:else if router.route.name === 'game'} + + {:else if router.route.name === 'profile'} + + {:else if router.route.name === 'settings'} + + {:else if router.route.name === 'about'} + + {:else if router.route.name === 'friends'} + + {:else if router.route.name === 'stats'} + + {:else} + + {/if} +
+ {/key} +
{/if} @@ -50,4 +90,13 @@ place-items: center; color: var(--text-muted); } + .router { + position: relative; + height: 100%; + overflow: hidden; + } + .pane { + position: absolute; + inset: 0; + } diff --git a/ui/src/app.css b/ui/src/app.css index 2bde4aa..ff1ed3e 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -11,6 +11,7 @@ --bg-elev: #ffffff; --surface: #ffffff; --surface-2: #eef0f3; + --ad-bg: #e3e7ee; /* announcement banner: a subtle accent, darker in light theme */ --text: #14181f; --text-muted: #6b7280; --border: #d8dce2; @@ -51,6 +52,7 @@ --bg-elev: #171a21; --surface: #171a21; --surface-2: #1f242d; + --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */ --text: #e7eaf0; --text-muted: #9aa3b2; --border: #2a313c; @@ -82,6 +84,7 @@ --bg-elev: #171a21; --surface: #171a21; --surface-2: #1f242d; + --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */ --text: #e7eaf0; --text-muted: #9aa3b2; --border: #2a313c; diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index 843d2b0..e5d2e04 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -57,7 +57,7 @@ overflow: hidden; white-space: nowrap; padding: 6px 0; - background: var(--surface-2); + background: var(--ad-bg); color: var(--text-muted); font-size: 0.85rem; line-height: 1.2; diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index f95ff0c..e39b0a6 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -1,14 +1,19 @@