Compare commits

..

4 Commits

Author SHA1 Message Date
Ilia Denisov 09fec2b83c Stage 17: bake decisions into PLAN, ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, READMEs; mark stage done
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 26s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
- PLAN: Stage 17 Refinements entry + caveats resolved summary + tracker done
- ARCHITECTURE §7 (move-number robot timing, composed variant-aware names), §10 (move event to the actor too), §11 (game_move_duration metric + offline admin per-user analytics), §14 (current branch model, path-conditional CI + gate, connector liveness)
- FUNCTIONAL(+ru): robot draws language-appropriate names
- UI_DESIGN: screen transitions, Telegram theme/nav, ad-banner accent, players plaque + history drawer
- backend README: robot timing/names refinements
2026-06-06 10:31:22 +02:00
Ilia Denisov 1d0bafaabb Stage 17: UI defect fixes (russian variant, Telegram theme/nav/banner, reconnect, hint zoom, plaque, history, transitions, per-game cache)
- #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400
- #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher
- #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token
- #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground
- #17 hint zoom scrolls to the placement's bounding box, not the top-left
- #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history
- #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board
- #3 (UI) disable nudge on the player's own turn
- #18a directional screen slide transitions (forward in from the right, back reveals the lobby)
- #13 per-game in-memory cache: instant render on re-entry + background refresh
- e2e: openGame waits for the slide transition to settle
2026-06-06 10:23:42 +02:00
Ilia Denisov c0b46a7ca6 Stage 17: path-conditional CI behind an aggregate gate + connector liveness probe; Grafana move-duration panel
- #10 a `changes` job path-filters unit/integration/ui; an always-running `gate` job aggregates them (success-or-skipped) and becomes the only required check
- #9 deploy adds a Telegram-connector liveness probe (docker inspect: running, not restarting, stable restart count) with a VPN-handshake grace period
- #1a Game-domain dashboard gains a 'Move think-time by phase (p50/p95)' panel
- deploy README: branch protection now requires only CI / gate
2026-06-06 10:05:01 +02:00
Ilia Denisov 635f2fd9fc Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn')
- #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback
- #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin)
- #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4)
- #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only
- #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal)
- ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
2026-06-06 09:59:12 +02:00
58 changed files with 1673 additions and 214 deletions
+117 -2
View File
@@ -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:
@@ -215,6 +302,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
+142 -2
View File
@@ -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,57 @@ 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-<lang>-<index>`.
- **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the
band interpolates from [1,5] min at the first move 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).
## Deferred TODOs (cross-stage)
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
+4 -4
View File
@@ -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`).
+40 -8
View File
@@ -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,41 @@ 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 and friend requests
// blocked so the robot never engages socially. 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(true), 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 +185,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
+35
View File
@@ -4,9 +4,11 @@ import (
"context"
"errors"
"fmt"
"math/rand/v2"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
@@ -110,6 +112,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.
+29 -2
View File
@@ -1,6 +1,7 @@
package account
import (
"regexp"
"strings"
"testing"
"unicode/utf8"
@@ -8,7 +9,8 @@ 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
@@ -21,8 +23,11 @@ func TestTelegramSeed(t *testing.T) {
"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"},
"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) {
@@ -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); }
+108
View File
@@ -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, `<svg viewBox="0 0 %d %d" class="chart" role="img" aria-label="Move duration by move number">`, w, h)
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%.1f" class="axis"/>`, padL, padT, padL, float64(h-padB))
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="axis"/>`, 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, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="grid"/>`, padL, y, w-padR, y)
fmt.Fprintf(&b, `<text x="%d" y="%.1f" class="lbl" text-anchor="end">%s</text>`, padL-5, y+3, FormatDuration(v))
}
for _, ord := range xTicks(maxOrd) {
fmt.Fprintf(&b, `<text x="%.1f" y="%d" class="lbl" text-anchor="middle">%d</text>`, xOf(ord), h-padB+15, ord)
}
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-max"/>`, line(func(p ChartPoint) float64 { return p.Max }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-avg"/>`, line(func(p ChartPoint) float64 { return p.Avg }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-min"/>`, line(func(p ChartPoint) float64 { return p.Min }))
b.WriteString(`</svg>`)
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}
}
@@ -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{"<svg", "ln-min", "ln-avg", "ln-max", "</svg>"} {
if !strings.Contains(svg, want) {
t.Errorf("chart missing %q\n%s", want, svg)
}
}
if n := strings.Count(svg, "<polyline"); n != 3 {
t.Errorf("polylines = %d, want 3", n)
}
}
func TestXTicks(t *testing.T) {
cases := map[int][]int{1: {1}, 2: {1, 2}, 3: {1, 2, 3}, 10: {1, 5, 10}}
for maxOrd, want := range cases {
got := xTicks(maxOrd)
if len(got) != len(want) {
t.Fatalf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
}
}
}
@@ -28,6 +28,12 @@
{{else}}<p class="note">no statistics</p>{{end}}
</section>
</div>
{{if .MoveChart}}
<section class="panel"><h2>Move timing</h2>
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
{{.MoveChart}}
</section>
{{end}}
<section class="panel"><h2>Identities</h2>
<table class="list">
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
@@ -2,7 +2,7 @@
<h1>Users</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
@@ -11,9 +11,10 @@
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
</tr>
{{else}}
<tr><td colspan="5"><span class="note">no users</span></td></tr>
<tr><td colspan="8"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
+11 -1
View File
@@ -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.
@@ -50,7 +52,8 @@ type UsersView struct {
Pager Pager
}
// 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
@@ -58,6 +61,10 @@ type UserRow struct {
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 +87,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.
+116
View File
@@ -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)
}
+52
View File
@@ -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")
}
}
+26
View File
@@ -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))
+15
View File
@@ -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
+9 -5
View File
@@ -226,6 +226,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if err != nil {
return MoveResult{}, err
}
// 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
}
@@ -287,14 +290,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 {
@@ -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)
}
}
}
+6 -3
View File
@@ -82,13 +82,16 @@ 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)
@@ -109,7 +112,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 +213,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)
}
+1 -1
View File
@@ -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)
}
+2 -1
View File
@@ -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
+2 -2
View File
@@ -197,12 +197,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 {
+4 -2
View File
@@ -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
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
}
+146
View File
@@ -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
}
}
+119
View File
@@ -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)
}
}
+56 -47
View File
@@ -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
@@ -92,7 +85,8 @@ type Service struct {
log *zap.Logger
mu sync.RWMutex
pool []uuid.UUID
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))
en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN())
if err != nil {
return fmt.Errorf("robot: provision %q: %w", name, err)
return 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)
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)
}
return s.pool[rand.IntN(len(s.pool))], nil
ids = append(ids, acc.ID)
}
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
}
+55 -16
View File
@@ -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 = 1.0
delayEarlyHiMinutes = 5.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,48 @@ 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).
// 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))
}
+37 -10
View File
@@ -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 < 1 || d > 5 {
t.Fatalf("first-move delay %.2f out of [1,5] 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)
+1
View File
@@ -46,6 +46,7 @@ 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"},
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
+3 -1
View File
@@ -148,8 +148,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):
@@ -82,6 +82,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
return
}
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
ids := make([]uuid.UUID, 0, len(accs))
for _, a := range accs {
kind := "registered"
if a.IsGuest {
@@ -91,6 +92,17 @@ func (s *Server) consoleUsers(c *gin.Context) {
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
})
ids = append(ids, a.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 +146,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)
}
+5 -2
View File
@@ -110,5 +110,8 @@ resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentional
- **Host caddy** route `<domain> → 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".
+13 -1
View File
@@ -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}}" }
]
}
]
}
+55 -24
View File
@@ -300,10 +300,16 @@ The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
behaviour on every scan and after a restart — the same philosophy as journal
replay. A pool of durable accounts — each a `kind='robot'` identity (§4),
provisioned at startup with chat and friend requests blocked — backs the
human-like name pool; those two profile toggles are all the friend/DM blocking
requires (there is no DM surface; chat is per-game).
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
`robot-<lang>-<index>` and provisioned at startup with chat and friend requests
blocked — backs the human-like names; those two profile toggles are all the
friend/DM blocking requires (there is no DM surface; chat is per-game). Names are
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
a surname pool (gender-agreed for Russian) in one of three forms (first only /
first + surname initial / first + full surname), deterministically per pool slot so
they stay stable across restarts. Substitution is **variant-aware**: a Russian game
(Russian Scrabble or Эрудит) draws a Russian-named robot with at most ~20% Latin, an
English game the Latin pool.
- **Balance**: at game start it decides once whether to play to win, with
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
@@ -313,13 +319,15 @@ requires (there is no DM surface; chat is per-game).
(playing to lose) is closest to a small band (**130 points**), rather than
always the maximum; with no legal play it exchanges a full rack when the bag can
refill it, else passes.
- **Timing**: per-move delay sampled from a right-skewed distribution (short
delays frequent, median ≈ 10 min), clamped to **[2, 90] minutes**; it
- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample
(exponent k=4, short delays frequent) from a band that interpolates from
**[1, 5] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
quick and the endgame can run long, clamped to **[1, 90] minutes**; it
**sleeps 00:0007:00** anchored to the **opponent's** profile timezone with a
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
rather than running anti-phase; on a daytime nudge it replies within
**210 minutes**; it proactively nudges the human after **12 hours** idle
(subject to the once-per-hour chat limit).
rather than running anti-phase; on a daytime nudge it replies near the move's lower
band; it proactively nudges the human after **12 hours** idle (subject to the
once-per-hour chat limit).
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
@@ -451,7 +459,9 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
including the mover**, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, invitation or game-started; emitted on a friend-request and invitation
@@ -499,13 +509,21 @@ promotions) is future work and would deliver short markdown messages (text + lin
client-measured RTT piggybacked on the next request is a later enhancement.
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
until an exporter is configured: histograms `game_replay_duration` (journal
rebuild on a cache miss) and `game_move_validate_duration`; counters
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop),
`chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`;
an observable gauge `game_cache_active`; the gateway `edge_request_duration`
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
metrics. Game-scoped metrics carry a `variant` attribute
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
console, below); counters `games_started_total`, `games_abandoned_total` (a
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
(english/russian_scrabble/erudit).
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
console from the move journal (`game_moves.created_at` deltas, the first move from
the game's creation), not Prometheus labels (which an `account_id` would explode):
the user list shows each account's min/avg/max think time, and the user-detail page
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
@@ -579,14 +597,27 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
## 14. CI & branches
- Trunk is **`master`**; feature work happens on `feature/*` branches merged via
PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily
lands on `master`).
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test
on Go changes; `integration.yaml` runs the Postgres-backed tests behind the
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
serial). Further workflows (ui-test, deploy) are added with the components they
cover.
- **Two long-lived branches** (Stage 16): **`development`** is the integration
trunk and **`master`** the production trunk; `feature/*` branches are cut from
`development` and PR back into it (the genesis commit necessarily landed on
`master`). A commit to a `feature/*` branch triggers nothing.
- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the
suite on a PR into `development`/`master` and on a push to `development`. Its
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
`changes` job filters by changed paths), and an always-running **`gate`** job
aggregates them (passing when each succeeded or was **skipped**) and is the single
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
a merge.
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
to — `development` (`docker compose up -d --build` on the runner host), then probes
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
`docker inspect`: running, not restarting, stable restart count, with a
VPN-handshake grace period, since the connector has no public ingress and a
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
`TEST_`/`PROD_` per contour.
- The engine consumes `scrabble-solver` as a **published, versioned module**
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
+2 -1
View File
@@ -91,7 +91,8 @@ wins most games), aims for a close score rather than crushing or throwing the ga
and plays at a human pace — short thinking times for most moves, the occasional long
one, and a night-time pause that tracks the player's own day. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It
carries a human-like name and neither chats nor accepts friend requests.
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
names) and neither chats nor accepts friend requests.
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
Become friends in two ways: redeem a **one-time code** the other player issues (six
+3 -2
View File
@@ -92,8 +92,9 @@ Mini App** авторизует по подписанным `initData` плат
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
в чате и не принимает заявки в друзья.
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
языку партии (в русской партии — в основном русские имена), не общается в чате и не
принимает заявки в друзья.
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
+25 -3
View File
@@ -33,6 +33,18 @@ Login uses `Screen`.
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
screen entered from the lobby flies in from the right; returning to the lobby reveals it
from the left (back). Transitions are local (so they do not play on first load) and
collapse to nothing under reduce-motion. A per-game in-memory cache (`lib/gamecache.ts`)
renders a re-opened game instantly and refreshes it in the background, removing the
blank-loading flash on lobby ↔ game navigation.
- **Telegram theme** (Stage 17): inside the Mini App the colour scheme is forced from
`Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the
Telegram Desktop webview and otherwise fights it), the Settings theme switcher is hidden,
the nav bar takes Telegram's background (`header_bg_color`), and a live stream dropped by
a background suspend silently reconnects on return to the foreground (the connection
banner is suppressed while hidden).
## Tiles & board
@@ -45,7 +57,15 @@ Login uses `Screen`.
they stay a constant size as the cells grow (relatively smaller at higher zoom).
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
target; the custom pinch and swipe-to-open-history gestures were dropped because they
fight native scroll — history opens from the menu.
fight native scroll — history opens from the menu or a tap on the players plaque (below).
A **hint** auto-zooms centred on the hint's placement, not the top-left (Stage 17).
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
shadow) pins to the board as the board slides down, instead of tracking the table as
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
jitter. A move's row lists every word it formed (the main word first).
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
last completed word gets a dark tile background — static while it is the opponent's
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
@@ -70,8 +90,10 @@ Login uses `Screen`.
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
A one-line inset strip under the nav bar. Content is minimal markdown (text + links,
escaped + linkified). A parameterised **rotator** drives messages: a fitting message
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) —
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
+4
View File
@@ -10,6 +10,10 @@ async function openGame(page: Page): Promise<void> {
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 }) => {
+40 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
import { router } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
@@ -17,11 +18,36 @@
onMount(() => {
void bootstrap();
});
// Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so
// they do not play on the initial mount, and collapse to nothing under reduce-motion.
const dir = $derived(router.route.name === 'lobby' ? 'back' : 'forward');
const enterSign = $derived(dir === 'forward' ? 1 : -1);
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
const animMs = $derived(app.reduceMotion ? 0 : 260);
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
// innerWidth, and the .router clips the off-screen pane.
function slideX(_node: Element, { duration, sign }: { duration: number; sign: number }) {
return {
duration,
easing: cubicOut,
css: (tt: number) => `transform: translateX(${(1 - tt) * sign * 100}%)`,
};
}
</script>
{#if !app.ready}
<div class="splash">{t('common.loading')}</div>
{:else if router.route.name === 'login'}
{:else}
<div class="router">
{#key routeKey}
<div class="pane" in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
{#if router.route.name === 'login'}
<Login />
{:else if router.route.name === 'new'}
<NewGame />
@@ -40,6 +66,10 @@
{:else}
<Lobby />
{/if}
</div>
{/key}
</div>
{/if}
<Toast />
@@ -50,4 +80,13 @@
place-items: center;
color: var(--text-muted);
}
.router {
position: relative;
height: 100%;
overflow: hidden;
}
.pane {
position: absolute;
inset: 0;
}
</style>
+3
View File
@@ -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;
+1 -1
View File
@@ -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;
+5 -1
View File
@@ -6,12 +6,16 @@
messages,
myId,
busy,
canNudge = true,
onsend,
onnudge,
}: {
messages: ChatMessage[];
myId: string;
busy: boolean;
// Nudging only makes sense while waiting on the opponent; it is disabled on the
// player's own turn (there is no one to hurry along).
canNudge?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
@@ -47,7 +51,7 @@
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy} aria-label={t('chat.nudge')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || !canNudge} aria-label={t('chat.nudge')}>🛎️</button>
</div>
</div>
+52 -9
View File
@@ -18,6 +18,7 @@
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import {
BLANK,
newPlacement,
@@ -94,6 +95,7 @@
]);
view = st;
moves = hist.moves;
setCachedGame(id, st, hist.moves);
placement = newPlacement(st.rack);
preview = null;
selected = null;
@@ -109,7 +111,17 @@
handleError(e);
}
}
onMount(load);
onMount(() => {
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
if (cached) {
view = cached.view;
moves = cached.moves;
placement = newPlacement(cached.view.rack);
}
void load();
});
$effect(() => {
const e = app.lastEvent;
@@ -269,6 +281,17 @@
const h = await gateway.hint(id);
if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box.
const p = placement.pending;
if (p.length) {
const rows = p.map((tt) => tt.row);
const cols = p.map((tt) => tt.col);
focus = {
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
}
if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining };
recompute();
@@ -428,7 +451,9 @@
{/snippet}
{#if view}
<div class="scoreboard">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}>
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
@@ -599,26 +624,39 @@
{#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} onsend={sendChat} onnudge={nudge} />
<Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
gap: 2px;
padding: 6px var(--pad);
gap: 6px;
padding: 8px var(--pad);
background: var(--bg-elev);
cursor: pointer;
}
.seat {
flex: 1;
text-align: center;
padding: 4px;
padding: 5px 4px;
border-radius: var(--radius-sm);
background: var(--surface-2);
/* inactive seats read as "sunk in" */
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22);
}
.seat.turn {
background: var(--surface-2);
outline: 1px solid var(--accent);
/* the active seat is "raised": lifted clear of the others with side shadows */
background: var(--bg-elev);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.16),
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
3px 0 6px -2px rgba(0, 0, 0, 0.26);
position: relative;
z-index: 1;
}
.seat.turn .nm {
color: var(--accent);
}
.seat.win .sc {
color: var(--ok);
@@ -642,8 +680,13 @@
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
max-height: 60%;
/* A fixed-height drawer matching the board's slid offset, so the bottom border
and its shadow pin to the board immediately instead of tracking the table as
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
column does not jump left/right when the list overflows. */
height: 62%;
overflow: auto;
scrollbar-gutter: stable;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
+48 -5
View File
@@ -9,9 +9,10 @@ import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram';
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -47,8 +48,15 @@ export const app = $state<{
});
let unsubscribeStream: (() => void) | null = null;
let streamAlive = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
/** documentHidden reports whether the app is currently backgrounded. */
function documentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
@@ -70,6 +78,7 @@ export function handleError(err: unknown): void {
function openStream(): void {
closeStream();
streamAlive = true;
unsubscribeStream = gateway.subscribe(
(e) => {
app.lastEvent = e;
@@ -85,10 +94,30 @@ function openStream(): void {
void refreshNotifications();
}
},
() => showToast(t('error.unavailable'), 'error'),
() => {
streamAlive = false;
// A background suspend (iOS / Telegram) drops the single-shot stream. Don't
// alarm the user with the connection banner while hidden — reconnect silently
// on return (the visibilitychange handler). Show the banner only on a failure
// seen in the foreground, and retry it.
if (!documentHidden()) {
showToast(t('error.unavailable'), 'error');
scheduleReconnect();
}
},
);
}
/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
* app is in the foreground (a single pending attempt at a time). */
function scheduleReconnect(): void {
if (reconnectTimer || !app.session) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (app.session && !streamAlive && !documentHidden()) openStream();
}, 4000);
}
/**
* refreshNotifications recomputes the lobby badge count (incoming friend requests
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
@@ -111,8 +140,13 @@ export async function refreshNotifications(): Promise<void> {
}
function closeStream(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
unsubscribeStream?.();
unsubscribeStream = null;
streamAlive = false;
}
async function adoptSession(s: Session): Promise<void> {
@@ -170,6 +204,10 @@ export async function bootstrap(): Promise<void> {
if (insideTelegram()) {
const launch = telegramLaunch();
if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
try {
await adoptSession(await gateway.authTelegram(launch.initData));
await routeStartParam(launch.startParam);
@@ -249,6 +287,7 @@ export async function loginEmail(email: string, code: string): Promise<void> {
export async function logout(): Promise<void> {
closeStream();
clearGameCache();
gateway.setToken(null);
await clearSession();
app.session = null;
@@ -314,10 +353,14 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs();
}
// Refresh the lobby badge when the app returns to the foreground — a push 'notify'
// may have been missed while the client was hidden/closed (poll + push, see §10).
// On return to the foreground: silently re-establish a stream dropped while the app
// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any
// push 'notify' missed while hidden (poll + push, see §10).
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
if (document.visibilityState === 'visible' && app.session) {
if (!streamAlive) openStream();
void refreshNotifications();
}
});
}
+30
View File
@@ -0,0 +1,30 @@
// In-memory per-game cache. A game the player has opened once is kept here so a
// later re-entry renders instantly from the cache while a fresh fetch updates it in
// the background — removing the blank "loading" flash and the full redraw on every
// lobby <-> game navigation. It is intentionally process-memory only (no persistence):
// stale entries are corrected by the background refresh, and the cache is cleared on
// logout.
import type { MoveRecord, StateView } from './model';
interface CachedGame {
view: StateView;
moves: MoveRecord[];
}
const cache = new Map<string, CachedGame>();
/** getCachedGame returns the last-seen state+history for a game, or undefined. */
export function getCachedGame(id: string): CachedGame | undefined {
return cache.get(id);
}
/** setCachedGame stores the latest state+history for a game. */
export function setCachedGame(id: string, view: StateView, moves: MoveRecord[]): void {
cache.set(id, { view, moves });
}
/** clearGameCache drops every cached game (called on logout). */
export function clearGameCache(): void {
cache.clear();
}
+1
View File
@@ -148,6 +148,7 @@ export const en = {
'lang.ru': 'Русский',
'error.not_your_turn': "It is not your turn.",
'error.nudge_own_turn': 'It is your turn — there is no one to nudge.',
'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
+1
View File
@@ -149,6 +149,7 @@ export const ru: Record<MessageKey, string> = {
'lang.ru': 'Русский',
'error.not_your_turn': 'Сейчас не ваш ход.',
'error.nudge_own_turn': 'Сейчас ваш ход — некого торопить.',
'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
+1 -1
View File
@@ -12,7 +12,7 @@ import type { Variant } from '../model';
const SPECS: Record<Variant, string> = {
english:
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10',
russian:
russian_scrabble:
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3',
erudit:
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3',
+1 -1
View File
@@ -62,7 +62,7 @@ function emptyLinked(): LinkResult {
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
};
+1 -1
View File
@@ -203,7 +203,7 @@ function finishedG3(): MockGame {
return {
view: {
id: 'g3',
variant: 'russian',
variant: 'russian_scrabble',
dictVersion: 'v1',
status: 'finished',
players: 2,
+1 -1
View File
@@ -3,7 +3,7 @@
// FlatBuffers) and the mock transport speak this model, so the UI never touches
// generated wire code directly.
export type Variant = 'english' | 'russian' | 'erudit';
export type Variant = 'english' | 'russian_scrabble' | 'erudit';
/** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string;
+1 -1
View File
@@ -23,7 +23,7 @@ describe('premium layout', () => {
it('doubles the centre for standard variants but not for erudit', () => {
expect(centre('english')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('english')[7][7]).toBe('DW');
expect(premiumGrid('russian')[7][7]).toBe('DW');
expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW');
expect(centre('erudit')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('erudit')[7][7]).toBe('');
});
+12
View File
@@ -9,6 +9,7 @@ interface TelegramWebApp {
initData: string;
initDataUnsafe?: { start_param?: string };
themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark';
ready?: () => void;
expand?: () => void;
}
@@ -48,6 +49,17 @@ export function telegramLaunch(): TelegramLaunch {
return { initData: w.initData, startParam, theme: w.themeParams };
}
/**
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
* or undefined outside Telegram. Inside the Mini App this — not the OS
* prefers-color-scheme — is the authoritative theme: on some clients (Telegram
* Desktop) the OS scheme leaks into the webview and fights Telegram's own setting,
* so the app forces this value on launch.
*/
export function telegramColorScheme(): 'light' | 'dark' | undefined {
return webApp()?.colorScheme;
}
/**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe.
+5
View File
@@ -27,6 +27,7 @@ export interface TelegramThemeParams {
button_color?: string;
button_text_color?: string;
secondary_bg_color?: string;
header_bg_color?: string;
}
/** applyTelegramTheme overrides token values at runtime from Telegram themeParams. */
@@ -44,4 +45,8 @@ export function applyTelegramTheme(p: TelegramThemeParams): void {
set(p.button_color, '--accent');
set(p.button_text_color, '--accent-text');
set(p.link_color, '--accent');
// The nav bar tracks Telegram's chrome so it doesn't fall out of the design; the
// announcement banner takes the secondary surface so it reads as a subtle accent.
set(p.header_bg_color ?? p.bg_color, '--bg-elev');
set(p.secondary_bg_color, '--ad-bg');
}
+2 -2
View File
@@ -13,10 +13,10 @@ describe('availableVariants', () => {
});
it('offers Russian and Эрудит for a ru-only service', () => {
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian', 'erudit']);
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian_scrabble', 'erudit']);
});
it('offers every variant for a bilingual service', () => {
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian', 'erudit']);
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian_scrabble', 'erudit']);
});
});
+2 -2
View File
@@ -14,13 +14,13 @@ export interface VariantOption {
// ALL_VARIANTS lists every variant in display order.
export const ALL_VARIANTS: VariantOption[] = [
{ id: 'english', label: 'new.english' },
{ id: 'russian', label: 'new.russian' },
{ id: 'russian_scrabble', label: 'new.russian' },
{ id: 'erudit', label: 'new.erudit' },
];
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian: 'ru', erudit: 'ru' };
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
// or absent set is ungated (a web/legacy session without a declared set), returning
+1 -1
View File
@@ -78,7 +78,7 @@
const variantKey: Record<string, MessageKey> = {
english: 'new.english',
russian: 'new.russian',
russian_scrabble: 'new.russian',
erudit: 'new.erudit',
};
</script>
+3
View File
@@ -10,6 +10,7 @@
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram';
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
@@ -28,6 +29,7 @@
<Screen title={t('settings.title')} back="/">
<div class="page">
{#if !insideTelegram()}
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
@@ -38,6 +40,7 @@
{/each}
</div>
</section>
{/if}
<section>
<h3>{t('settings.language')}</h3>