Merge pull request 'Stage 17: test-contour verification & defect fixes' (#19) from feature/stage-17-contour-verification-fixes into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 11s
CI / ui (push) Successful in 29s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m9s

This commit was merged in pull request #19.
This commit is contained in:
2026-06-07 19:20:40 +00:00
106 changed files with 4173 additions and 481 deletions
+125 -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:
@@ -198,8 +285,16 @@ jobs:
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
# App version for the About screen: the git tag if present, else the short SHA
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
docker compose --ansi never build --progress plain
docker compose --ansi never up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
- name: Probe the gateway through caddy
run: |
@@ -215,6 +310,34 @@ jobs:
docker logs --tail 50 scrabble-gateway || true
exit 1
- name: Probe the Telegram connector liveness
run: |
set -u
# The gateway probe cannot see a crash-looping connector (it long-polls and
# egresses through the VPN sidecar, with no public ingress). Inspect the
# container directly: it must be running, not restarting, with a stable
# restart count. A grace period lets the VPN handshake settle (the connector
# may restart a few times first).
sleep 20
for i in $(seq 1 20); do
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
sleep 5
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
if [ "$c1" = "$c2" ]; then
echo "connector healthy: status=$status restarts=$c2"
exit 0
fi
echo "connector still restarting ($c1 -> $c2); waiting"
fi
sleep 3
done
echo "connector not healthy; recent logs:"
docker logs --tail 80 scrabble-telegram || true
exit 1
- name: Prune dangling images
if: always()
run: docker image prune -f
+224 -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,139 @@ provided cert) at the contour caddy; prod VPN; rollback.
environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`.
- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's
collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session.
- **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical
string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in
the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`,
`Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged
(persisted games, GCG and the `variant` metric attribute keep it).
- **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with
`game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct
`nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn.
- **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the
editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by
language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor
validation (e.g. "Peter J.").
- **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names
paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only /
first + surname initial / first + full surname), composed deterministically per pool slot (stable
across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20%
Latin, an English game the Latin pool. Robot identities are keyed `robot-<lang>-<index>`.
- **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the
band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min
by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime
nudge pulls the reply toward the move's lower band.
- **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own
other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the
actor), and the gateway already fans each event out to all of a user's live streams.
- **Move-duration analytics** (#1, interview): a live `game_move_duration{variant,phase}` histogram
(opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console —
min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number,
computed from the journal (`game_moves.created_at` deltas; no schema change). Per-user stays offline,
not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots
included), so the per-human admin view is authoritative.
- **CI** (#9/#10, interview): `unit`/`integration`/`ui` are path-conditional behind a `changes` job; an
always-running `gate` job aggregates them (success-or-skipped) and is the single branch-protection
required check (`CI / gate`), so a skipped job never blocks a merge. The deploy job gained a
Telegram-connector liveness probe (`docker inspect`: running, not restarting, stable restart count,
with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector
was invisible to the gateway-only probe.
- **UI theming / UX**: inside Telegram the colour scheme is forced from `WebApp.colorScheme` over the OS
`prefers-color-scheme` (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden
(#11); the nav bar takes Telegram's bg and the announcement banner a subtle `--ad-bg` accent (#14/#15);
the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint
zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others
with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow
to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders
instantly on re-entry and refreshes in the background (#13).
- **Grafana repeated password (#8) — not a server defect**: verified live that caddy challenges `/_gm`
and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a
browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify,
no server change. **Multi-word history (#22)** was already implemented (all formed words shown).
- **Contour-verification follow-ups** (rounds 23, from live testing): the Grafana
double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is
disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the
move-duration panel was invisible because the deploy reseed (`rm -rf`) left the
config-only services on a stale bind mount — the deploy now **force-recreates**
caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 →
300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram
reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide
+ Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted —
chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native
**BackButton**, **HapticFeedback**, **closing confirmation** in a game,
**disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat
pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
button disables at zero hints; plus **board-only vertical scroll** (#9) and a
**keyboard-overlay** check-word dialog (#10).
- **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised
[1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel
toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a
single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap**
or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over
a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to
their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings
toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming
~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native
scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
**robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
across the game/robot package boundary, to be picked up when that seam is added.
- **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn**
now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit
the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op
(only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the
matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later
robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win —
`result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend
request to a robot** is accepted as pending and expires like a human ignore (robots no longer set
`BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the
chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish:
**even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no
lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch
zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop
capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known-
illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit
→ Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game
card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's
turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would
show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
- **Contour-verification follow-ups** (round 6, from live testing) — **shipped & deployed:** profile drops
the hint-balance line; no mobile tap-flash on a board cell (`-webkit-tap-highlight-color`); variant
display names keyed by the game's **alphabet**, not the UI language (english → "Scrabble",
russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game
title shows the variant name; **chat & nudge are mutually exclusive by turn** (message field on your
turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced
server-side to your own turn (`ErrChatNotYourTurn`); the **nudge cooldown resets** once the player has
moved or chatted since the last nudge (`game.LastMoveAt` + last chat vs last nudge; the UI mirrors it);
the **About** screen got localized titles + a rules link + the random/friends sections, and the app
**version comes from `git describe`** (Vite define `__APP_VERSION__` ← Docker build-arg in the deploy
step, default "dev"); the **quick-game buttons** became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a
bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences
from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when
available; redraw the USSR emblem as a thin schematic hammer & sickle); **#3 drag-reorder of rack tiles**
with a visual gap (the dragged tile lifts out, the rest slide to open a slot; `reorderIndices`
unit-tested; only with no pending tiles); and the **persistence backend foundation** (#4/#5/#6): a
`game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every
committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
overlapped — 5 integration tests.
- **Stage 17 round 6 — REMAINING (next pass), designs ready:**
1. **Persistence gateway slice + UI (#4/#5/#6).** *FB (lean):* `DraftRequest{game_id, json}` (save) +
a game-id request (get) + `DraftView{json}` — one string field; the client serializes/deserializes
`{rack_order, board_tiles}` itself (no FB tile array). Regen Go (`make -C pkg fbs`) + TS
(`pnpm codegen`); flatc is pinned **23.5.26** (the local one matches). *Gateway* forwards the JSON as
`json.RawMessage` (no double-encode). *REST:* `GET`/`PUT /games/:id/draft` (decodes `game.Draft`).
*UI:* save the rack order (#4) and board draft (#6) on change (debounced) and restore on load
(next to `gameState`); **#5** — allow placing tiles on the opponent's turn (relax the `isMyTurn`
gate on placement only; the evaluate-preview and Make-move stay your-turn-only, so an off-turn draft
is position-only — never scored/submitted).
2. **Landing + `/app/` move (#1620).** An extra Svelte page at `/`, the game SPA under `/app/` (Vite
`base` conditional: `/app/` for web, `./` for Capacitor); the gateway serves the landing at `/` and
the SPA at `/app/*`; a bundled Telegram logo (from `.claude/telegram-logo.svg`, **copied into
`ui/public/`, the reference itself not committed**) linking to the per-language t.me bot (ru
`Erudit_Game` / en `Scrabble_Game`); theme + language switchers reusing the app stores; reuse the
`aboutContent` copy. **Note:** moving the game to `/app/` means the Telegram Mini App URL must point
to `/app/`.
## Deferred TODOs (cross-stage)
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
+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`).
+42 -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,43 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return s.provision(ctx, kind, externalID, provisionSeed{})
}
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
// member: a KindRobot identity carrying displayName, with chat blocked but friend
// requests NOT blocked — a request to a robot is accepted as pending and, since the
// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human
// who ignores the request. Robot names are system-generated, not player-edited, so they
// bypass the editable display-name validation and may carry forms the editor rejects (an
// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the
// display name and both block flags.
func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) {
acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName})
if err != nil {
return Account{}, err
}
if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests {
return acc, nil
}
stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.BlockChat,
table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
).SET(
postgres.String(displayName), postgres.Bool(true),
postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err)
}
return modelToAccount(row), nil
}
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
// identity. On first contact only, it seeds the new account's preferred language
// from the Telegram client languageCode (when it maps to a supported language) and
// its display name from firstName (falling back to username); an already-existing
// its display name sanitized from firstName (falling back to username, then to a
// generated placeholder when neither yields any letters); an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
@@ -155,19 +187,21 @@ type provisionSeed struct {
// telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
// username (capped to maxDisplayName runes).
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
// failing that, username (sanitizeDisplayName strips disallowed characters to the
// editable format). When neither yields any letters, it falls back to a generated
// placeholder in the seeded language (placeholderDisplayName).
func telegramSeed(languageCode, username, firstName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
seed.preferredLanguage = lang
}
name := strings.TrimSpace(firstName)
name := sanitizeDisplayName(firstName)
if name == "" {
name = strings.TrimSpace(username)
name = sanitizeDisplayName(username)
}
if utf8.RuneCountInString(name) > maxDisplayName {
name = string([]rune(name)[:maxDisplayName])
if name == "" {
name = placeholderDisplayName(seed.preferredLanguage)
}
seed.displayName = name
return seed
+39 -3
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"
@@ -26,9 +28,10 @@ const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
// by a single space. No leading or trailing separator and no two adjacent separators,
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
// by a single space. No leading separator and no two adjacent separators (except
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
// ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name).
@@ -110,6 +113,39 @@ func ValidateDisplayName(raw string) (string, error) {
return name, nil
}
// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram
// first name) to the editable display-name format: it keeps the maximal runs of
// Unicode letters and joins them with a single space, dropping every other rune
// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The
// result therefore always satisfies ValidateDisplayName, or is empty when the input
// carries no letters — in which case the caller substitutes placeholderDisplayName.
// Mirroring the profile editor's rule means a connector-provisioned name is editable
// later without first failing validation.
func sanitizeDisplayName(raw string) string {
fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) })
if len(fields) == 0 {
return ""
}
name := strings.Join(fields, " ")
if utf8.RuneCountInString(name) > maxDisplayName {
name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ")
}
return name
}
// placeholderDisplayName builds a fallback display name for a platform account whose
// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or
// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally
// carries digits and a hyphen, so it lies outside the editable format and the player
// is expected to rename it; provisioned names bypass that editor validation.
func placeholderDisplayName(lang string) string {
prefix := "Player"
if lang == "ru" {
prefix = "Игрок"
}
return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000))
}
// validateAwayWindow checks that the daily away window's duration, wrapping across
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
// "no away time" and is allowed.
+37 -10
View File
@@ -1,6 +1,7 @@
package account
import (
"regexp"
"strings"
"testing"
"unicode/utf8"
@@ -8,21 +9,25 @@ import (
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged),
// the first-name / username display-name precedence, and trimming.
// the first-name / username display-name precedence, and the sanitization that
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
func TestTelegramSeed(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
wantLang, wantName string
}{
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
"en region-tagged": {"en-US", "user", "John", "en", "John"},
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
"empty language": {"", "neo", "Neo", "", "Neo"},
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
"username fallback": {"en", "handle", "", "en", "handle"},
"both empty": {"en", "", "", "en", ""},
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
"en region-tagged": {"en-US", "user", "John", "en", "John"},
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
"empty language": {"", "neo", "Neo", "", "Neo"},
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
"username fallback": {"en", "handle", "", "en", "handle"},
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
"emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"},
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
"garbage to username": {"en", "good", "123!@#", "en", "good"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
@@ -37,6 +42,28 @@ func TestTelegramSeed(t *testing.T) {
}
}
// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to
// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
func TestTelegramSeedPlaceholder(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
wantRe string
}{
"en empty": {"en", "", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
"default en": {"fr", "", "", `^Player-\d{5}$`},
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
}
})
}
}
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
// maxDisplayName runes (counted in runes, not bytes).
func TestTelegramSeedTruncatesLongName(t *testing.T) {
+108
View File
@@ -0,0 +1,108 @@
package account
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// UserListItem is the admin user-list projection: a small subset of the account plus
// whether it is a robot (derived from its identities), so the console can label the kind
// without a per-row identity query.
type UserListItem struct {
ID uuid.UUID
DisplayName string
PreferredLanguage string
IsGuest bool
IsRobot bool
CreatedAt time.Time
}
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
// id. An empty mask means no filter on that field.
type UserFilter struct {
Robots bool
NameMask string
ExternalIDMask string
}
// robotExists is the correlated subquery testing whether account a is a robot.
const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')`
// IsRobot reports whether the account is a robot pool member (it carries a robot
// identity). The admin console uses it to label a game's robot seats.
func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) {
var ok bool
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`,
accountID).Scan(&ok)
if err != nil {
return false, fmt.Errorf("account: is-robot %s: %w", accountID, err)
}
return ok, nil
}
// userListWhere builds the shared WHERE clause and its positional args (from $1).
func userListWhere(f UserFilter) (string, []any) {
args := []any{f.Robots}
where := robotExists + ` = $1`
if name := likePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := likePattern(f.ExternalIDMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
// ListUsers returns the filtered admin user list, newest first, paginated.
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
where, args := userListWhere(f)
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.created_at, ` + robotExists + ` AS is_robot
FROM backend.accounts a WHERE ` + where +
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("account: list users: %w", err)
}
defer rows.Close()
var out []UserListItem
for rows.Next() {
var it UserListItem
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil {
return nil, fmt.Errorf("account: scan user: %w", err)
}
out = append(out, it)
}
return out, rows.Err()
}
// CountUsers counts the filtered admin user list, for pagination.
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
where, args := userListWhere(f)
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("account: count users: %w", err)
}
return n, nil
}
// likePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
func likePattern(mask string) string {
mask = strings.TrimSpace(mask)
if mask == "" {
return ""
}
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask)
escaped = strings.ReplaceAll(escaped, "*", "%")
return strings.ReplaceAll(escaped, "?", "_")
}
+15 -13
View File
@@ -12,19 +12,21 @@ func TestValidateDisplayName(t *testing.T) {
want string
ok bool
}{
"plain": {"Kaya", "Kaya", true},
"cyrillic": {"Кая", "Кая", true},
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
"single dot": {"Mr.Smith", "Mr.Smith", true},
"dot then space": {"Mr. Smith", "Mr. Smith", true},
"trim surrounding": {" Kaya ", "Kaya", true},
"adjacent specials": {"Name P._Last", "", false},
"two spaces": {"Name Last", "", false},
"leading special": {"_Name", "", false},
"trailing special": {"Name.", "", false},
"digit rejected": {"Name2", "", false},
"blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false},
"plain": {"Kaya", "Kaya", true},
"cyrillic": {"Кая", "Кая", true},
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
"single dot": {"Mr.Smith", "Mr.Smith", true},
"dot then space": {"Mr. Smith", "Mr. Smith", true},
"trim surrounding": {" Kaya ", "Kaya", true},
"adjacent specials": {"Name P._Last", "", false},
"two spaces": {"Name Last", "", false},
"leading special": {"_Name", "", false},
"trailing underscore": {"Name_", "", false},
"trailing dot ok": {"Anna B.", "Anna B.", true},
"double trailing dot": {"Name..", "", false},
"digit rejected": {"Name2", "", false},
"blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
@@ -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)
}
}
}
}
@@ -18,6 +18,7 @@
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
</nav>
</header>
<main class="content">
@@ -17,13 +17,14 @@
</section>
<section class="panel"><h2>Seats</h2>
<table class="list">
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td><td>{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}<br><small>next move {{.NextMove}}</small>{{end}}{{end}}</td></tr>
{{end}}
</tbody>
</table>
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
</section>
{{end}}
{{- end}}
@@ -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>
@@ -1,8 +1,18 @@
{{define "content" -}}
<h1>Users</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav>
<form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<button type="submit">Filter</button>
</form>
<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,16 +21,17 @@
<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>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
+33 -8
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.
@@ -48,16 +50,27 @@ type DashboardView struct {
type UsersView struct {
Items []UserRow
Pager Pager
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
// glob filters; FilterQuery is those encoded for pager/toggle links.
Robots bool
NameMask string
ExternalIDMask string
FilterQuery string
}
// UserRow is one account row in the list.
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
// pre-formatted move-duration summary (empty when it has no timed move).
type UserRow struct {
ID string
DisplayName string
Kind string
Language string
Guest bool
CreatedAt string
ID string
DisplayName string
Kind string
Language string
Guest bool
CreatedAt string
HasMoveStats bool
MoveMin string
MoveAvg string
MoveMax string
}
// UserDetailView is one account with its stats, identities and recent games.
@@ -80,6 +93,9 @@ type UserDetailView struct {
Games []GameRow
TelegramID string
ConnectorEnabled bool
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
// time (min/mean/max), empty when the account has no timed move.
MoveChart template.HTML
}
// StatsRow is an account's lifetime statistics.
@@ -129,9 +145,15 @@ type GameDetailView struct {
UpdatedAt string
FinishedAt string
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool
RobotTargetPct int
}
// SeatRow is one seat of a game.
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
type SeatRow struct {
Seat int
DisplayName string
@@ -139,6 +161,9 @@ type SeatRow struct {
Score int
HintsUsed int
Winner bool
IsRobot bool
RobotIntent string
NextMove string
}
// ComplaintsView is the paginated complaint review queue.
+17 -5
View File
@@ -248,17 +248,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
// winning regardless of score. A missed-turn timeout reuses Resign in the game
// domain, so it inherits this win/loss.
func (g *Game) Resign() (MoveRecord, error) {
return g.ResignSeat(g.toMove)
}
// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
// skips resigned seats). The turn cursor only advances when the seat that resigned
// was the one to move; resigning an off-turn seat leaves the current player's turn
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
// already-resigned seat.
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resigned[player] = true
g.disposeHand(player)
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
return MoveRecord{}, ErrGameOver
}
g.resigned[seat] = true
g.disposeHand(seat)
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
g.log = append(g.log, rec)
if g.activeCount() <= 1 {
g.finish(EndResign)
} else {
} else if seat == g.toMove {
g.advance()
}
return rec, nil
+33
View File
@@ -69,6 +69,39 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
}
}
// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0
// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner
// loses, the opponent wins, and the game ends.
func TestResignSeatOffTurn(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves
t.Fatalf("player 0 play: %v", err)
}
if g.ToMove() != 1 {
t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove())
}
// Player 0 resigns although it is player 1's turn.
rec, err := g.ResignSeat(0)
if err != nil {
t.Fatalf("player 0 off-turn resign: %v", err)
}
if rec.Player != 0 || rec.Action != ActionResign {
t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
if res := g.Result(); res.Winner != 1 {
t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner)
}
}
// TestResignOnFinishedGame rejects a second transition.
func TestResignOnFinishedGame(t *testing.T) {
g := newEnglishGame(t, 1)
+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)
}
+163
View File
@@ -0,0 +1,163 @@
package game
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// DraftTile is one tile a player has laid on the board but not yet submitted.
type DraftTile struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
}
// Draft is a player's persisted client-side composition for a game (Stage 17): the
// preferred rack tile order and the board tiles laid but not yet submitted. The server
// keeps it so a reload or a second device resumes the same arrangement.
type Draft struct {
RackOrder string
BoardTiles []DraftTile
}
// GetDraft returns the player's draft for a game, or a zero Draft when none is stored.
func (svc *Service) GetDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
return svc.store.getDraft(ctx, gameID, accountID)
}
// SaveDraft upserts the player's draft; the account must be seated in the game.
func (svc *Service) SaveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
seats, _, _, err := svc.Participants(ctx, gameID)
if err != nil {
return err
}
if !slices.Contains(seats, accountID) {
return ErrNotAPlayer
}
return svc.store.saveDraft(ctx, gameID, accountID, d)
}
// getDraft reads one draft row, returning a zero Draft when absent.
func (s *Store) getDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
var rackOrder string
var boardJSON []byte
err := s.db.QueryRowContext(ctx,
`SELECT rack_order, board_tiles FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
gameID, accountID).Scan(&rackOrder, &boardJSON)
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, nil
}
if err != nil {
return Draft{}, fmt.Errorf("game: get draft %s: %w", gameID, err)
}
d := Draft{RackOrder: rackOrder}
if len(boardJSON) > 0 {
if err := json.Unmarshal(boardJSON, &d.BoardTiles); err != nil {
return Draft{}, fmt.Errorf("game: decode draft tiles: %w", err)
}
}
return d, nil
}
// saveDraft upserts the player's draft.
func (s *Store) saveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
tiles := d.BoardTiles
if tiles == nil {
tiles = []DraftTile{}
}
boardJSON, err := json.Marshal(tiles)
if err != nil {
return fmt.Errorf("game: encode draft tiles: %w", err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.game_drafts (game_id, account_id, rack_order, board_tiles, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (game_id, account_id)
DO UPDATE SET rack_order = $3, board_tiles = $4, updated_at = now()`,
gameID, accountID, d.RackOrder, boardJSON); err != nil {
return fmt.Errorf("game: save draft: %w", err)
}
return nil
}
// clearDraft drops a player's draft row (their composition is consumed or discarded).
func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
gameID, accountID); err != nil {
return fmt.Errorf("game: clear draft: %w", err)
}
return nil
}
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
// rack order is kept (Stage 17 #6).
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
if len(cells) == 0 {
return nil
}
occupied := make(map[[2]int]bool, len(cells))
for _, c := range cells {
occupied[[2]int{c.Row, c.Col}] = true
}
rows, err := s.db.QueryContext(ctx,
`SELECT account_id, board_tiles FROM backend.game_drafts
WHERE game_id = $1 AND account_id <> $2 AND board_tiles <> '[]'::jsonb`,
gameID, actorID)
if err != nil {
return fmt.Errorf("game: scan drafts for conflict: %w", err)
}
var toClear []uuid.UUID
func() {
defer func() { _ = rows.Close() }()
for rows.Next() {
var acc uuid.UUID
var boardJSON []byte
if err = rows.Scan(&acc, &boardJSON); err != nil {
return
}
var tiles []DraftTile
if json.Unmarshal(boardJSON, &tiles) != nil {
continue // skip a malformed draft
}
for _, t := range tiles {
if occupied[[2]int{t.Row, t.Col}] {
toClear = append(toClear, acc)
break
}
}
}
err = rows.Err()
}()
if err != nil {
return fmt.Errorf("game: read drafts for conflict: %w", err)
}
for _, acc := range toClear {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.game_drafts SET board_tiles = '[]'::jsonb, updated_at = now()
WHERE game_id = $1 AND account_id = $2`,
gameID, acc); err != nil {
return fmt.Errorf("game: clear conflicting draft: %w", err)
}
}
return nil
}
// draftTilesFrom projects a play's committed tiles into draft cells, for the conflict scan.
func draftTilesFrom(rec engine.MoveRecord) []DraftTile {
out := make([]DraftTile, 0, len(rec.Tiles))
for _, t := range rec.Tiles {
out = append(out, DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return out
}
+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
+78 -9
View File
@@ -171,11 +171,47 @@ func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, t
}
// Resign ends the game on the player's turn; the remaining player wins.
// Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is
// allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does
// not go through transition's turn check: it resigns the actor's own seat, whoever is to
// move. The resigning seat always loses (docs/ARCHITECTURE.md §7).
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Resign()
return rec, nil, err
})
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return MoveResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return MoveResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return MoveResult{}, ErrFinished
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return MoveResult{}, err
}
if g.Over() {
return MoveResult{}, ErrFinished
}
rackBefore := g.Hand(seat)
rec, err := g.ResignSeat(seat)
if err != nil {
return MoveResult{}, err
}
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats)
if err != nil {
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// A resignation carries no think time (it can happen on the opponent's turn), so it
// is intentionally excluded from the move-duration metric.
return MoveResult{Move: rec, Game: post}, nil
}
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
@@ -185,6 +221,19 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
return svc.store.GetGameVariant(ctx, gameID)
}
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
return svc.store.RobotSchedule(ctx, gameID)
}
// LastMoveAt returns the time of an account's most recent move in a game (and whether it
// has moved). The social service uses it to reset the nudge cooldown once a player has
// taken a turn (Stage 17).
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
return svc.store.LastMoveAt(ctx, gameID, accountID)
}
// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
@@ -226,9 +275,28 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
if err != nil {
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// Record the seat's think time (turn start to commit) for the move-duration
// metric; the timeout path commits separately and is excluded by design.
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
return MoveResult{Move: rec, Game: post}, nil
}
// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
// draft, which is then reset. Best-effort — the move is already committed, so a draft
// cleanup failure is logged rather than failing the move.
func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) {
if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil {
svc.log.Warn("clear actor draft", zap.Error(err))
}
if rec.Action == engine.ActionPlay {
if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil {
svc.log.Warn("reset conflicting board drafts", zap.Error(err))
}
}
}
// commit persists a just-applied transition: the journal row, the post-move turn
// cursor and scores, and on a game-ending move the finish stamp and statistics.
// On a persistence failure it evicts the now-divergent live game so the next
@@ -287,14 +355,15 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
}
// emitMove publishes the live events for a just-committed move: opponent_moved to
// every seat other than the actor, and your_turn to the next mover while the game
// is still active. Delivery is best-effort (notify.Publisher never blocks).
// every seat — including the actor's own account, so the mover's other devices (and
// their lobby) refresh too — and your_turn to the next mover while the game is still
// active. opponent_moved is in-app only (the gateway never turns it into an
// out-of-app push), so the actor is not notified out of band about their own move.
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
// event out to all of the recipient's live streams.
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
intents := make([]notify.Intent, 0, len(post.Seats)+1)
for _, s := range post.Seats {
if s.Seat == rec.Player {
continue
}
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
}
if post.Status == StatusActive {
+37
View File
@@ -651,6 +651,43 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
return row.Seed, nil
}
// LastMoveAt returns the time of the account's most recent move in the game and true, or
// the zero time and false when it has not moved. The social service uses it to reset the
// nudge cooldown once the player has taken a turn (Stage 17).
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
var at sql.NullTime
err := s.db.QueryRowContext(ctx,
`SELECT MAX(m.created_at) FROM backend.game_moves m
JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat
WHERE m.game_id = $1 AND p.account_id = $2`,
gameID, accountID).Scan(&at)
if err != nil {
return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err)
}
if !at.Valid {
return time.Time{}, false, nil
}
return at.Time, true, nil
}
// RobotSchedule returns a game's bag seed and current turn-start time. The admin console
// combines them with the robot strategy to show a robot seat's play-to-win intent and its
// next-move ETA. Both are server-only state, never part of the public game view.
func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, time.Time{}, ErrNotFound
}
return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err)
}
return row.Seed, row.TurnStartedAt, nil
}
// projectGame builds a Game from a games row and its ordered seat rows.
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
variant, err := engine.ParseVariant(g.Variant)
+39
View File
@@ -167,6 +167,45 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
}
}
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
human := provisionAccount(t)
// Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
})
if err != nil {
t.Fatalf("create: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
})
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
if code != http.StatusOK {
t.Fatalf("game detail = %d, want 200", code)
}
if !strings.Contains(body, "🤖") {
t.Error("robot seat is not marked in the game detail")
}
if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
t.Error("robot play-to-win intent missing")
}
if !strings.Contains(body, "next move") {
t.Error("robot is to move but the next-move ETA is missing")
}
if !strings.Contains(body, "~40%") {
t.Error("robot play-to-win target caption missing")
}
}
// consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
@@ -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)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// newDraftGame creates a started two-player English game on an opening seed and returns the
// service, game id, seats, and the opening play (from a mirror) used to drive a real commit.
func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) {
t.Helper()
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
hint, ok := newMirror(t, seed, 2).HintView()
if !ok || len(hint.Tiles) == 0 {
t.Fatal("no opening move")
}
return svc, g.ID, seats, hint
}
// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
func TestDraftPersistAndConflictReset(t *testing.T) {
ctx := context.Background()
svc, gameID, seats, hint := newDraftGame(t)
// Round-trip seat 0's rack order + a board draft.
d0 := game.Draft{RackOrder: "QANIWE?", BoardTiles: []game.DraftTile{{Row: 1, Col: 1, Letter: "Q"}}}
if err := svc.SaveDraft(ctx, gameID, seats[0], d0); err != nil {
t.Fatalf("save draft 0: %v", err)
}
if got, err := svc.GetDraft(ctx, gameID, seats[0]); err != nil ||
got.RackOrder != "QANIWE?" || len(got.BoardTiles) != 1 || got.BoardTiles[0].Letter != "Q" {
t.Fatalf("get draft 0 = %+v (err %v)", got, err)
}
// Seat 1 drafts a board tile on a cell the opening play will commit.
overlap := hint.Tiles[0]
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
RackOrder: "ABCDEFG",
BoardTiles: []game.DraftTile{{Row: overlap.Row, Col: overlap.Col, Letter: "X"}},
}); err != nil {
t.Fatalf("save draft 1: %v", err)
}
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err)
}
// Seat 0's own draft is cleared by their move.
if d, _ := svc.GetDraft(ctx, gameID, seats[0]); d.RackOrder != "" || len(d.BoardTiles) != 0 {
t.Errorf("actor draft not cleared: %+v", d)
}
// Seat 1's board draft overlapped the play and is reset; the rack order is kept.
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 0 || d.RackOrder != "ABCDEFG" {
t.Errorf("conflicting draft not reset (or rack order lost): %+v", d)
}
}
// TestDraftSurvivesNonConflictingMove checks an opponent's board draft is kept when a
// committed play does not touch any of its cells.
func TestDraftSurvivesNonConflictingMove(t *testing.T) {
ctx := context.Background()
svc, gameID, seats, hint := newDraftGame(t)
// Seat 1 drafts a far corner tile the central opening play cannot reach.
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
BoardTiles: []game.DraftTile{{Row: 0, Col: 0, Letter: "Z"}},
}); err != nil {
t.Fatalf("save draft 1: %v", err)
}
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
t.Fatalf("seat0 play: %v", err)
}
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" {
t.Errorf("non-conflicting draft should survive: %+v", d)
}
}
// TestSaveDraftRejectsOutsider checks only a seated player may save a draft.
func TestSaveDraftRejectsOutsider(t *testing.T) {
ctx := context.Background()
svc, gameID, _, _ := newDraftGame(t)
if err := svc.SaveDraft(ctx, gameID, provisionAccount(t), game.Draft{RackOrder: "X"}); err == nil {
t.Fatal("outsider SaveDraft should fail")
}
}
+36
View File
@@ -299,6 +299,42 @@ func TestResignWinnerAndStats(t *testing.T) {
}
}
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
// despite leading on score.
func TestResignOnOpponentTurn(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
hint, ok := newMirror(t, seed, 2).HintView()
if !ok {
t.Fatal("no opening move")
}
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
t.Fatalf("p0 play: %v", err)
}
res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
if err != nil {
t.Fatalf("off-turn resign = %v, want nil", err)
}
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
t.Fatalf("after off-turn resign: %+v", res.Game)
}
if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
}
}
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
func TestTimeoutSweep(t *testing.T) {
ctx := context.Background()
+11 -5
View File
@@ -82,19 +82,25 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
if err := r.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool (idempotent): %v", err)
}
id, err := r.Pick()
id, err := r.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
if !isRobotAccount(t, id) {
t.Errorf("picked account %s is not a robot identity", id)
}
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
t.Errorf("russian pick = (%s, %v), want a robot account", ru, err)
}
acc, err := account.NewStore(testDB).GetByID(ctx, id)
if err != nil {
t.Fatalf("get robot account: %v", err)
}
if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests {
t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
// expires, mirroring a human who ignores it (Stage 17).
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
}
}
@@ -109,7 +115,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
robotID, err := robots.Pick()
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
@@ -210,7 +216,7 @@ func TestRobotProactiveNudge(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
robotID, err := robots.Pick()
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
+76
View File
@@ -40,6 +40,38 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
return g.ID, seats
}
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
// pending rather than blocked: robots no longer block friend requests, so the request
// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17).
func TestFriendRequestToRobotStaysPending(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
accs := account.NewStore(testDB)
human := provisionAccount(t)
robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
if robot.BlockFriendRequests {
t.Fatal("robot must not block friend requests")
}
// A request is only allowed between players who share a game.
if _, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
}); err != nil {
t.Fatalf("create game: %v", err)
}
if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil {
t.Fatalf("request to robot = %v, want nil (accepted as pending)", err)
}
if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human {
t.Fatalf("robot incoming = %v, want [human]", got)
}
}
func TestFriendRequestLifecycle(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -282,6 +314,20 @@ func TestChatRejectsBadContent(t *testing.T) {
}
}
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17):
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
func TestChatOnlyOnYourTurn(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening
if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) {
t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err)
}
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil {
t.Fatalf("on-turn chat = %v, want nil", err)
}
}
func TestNudgeRulesAndRateLimit(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -307,3 +353,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
t.Fatalf("nudge after window: %v", err)
}
}
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
// acted (moved or chatted) since their last nudge, even within the hour (Stage 17).
func TestNudgeCooldownResetsOnAction(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gsvc := newGameService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 to move
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
t.Fatalf("nudge: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
}
// Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes.
if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil {
t.Fatalf("seat0 pass: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil {
t.Fatalf("seat1 chat: %v", err)
}
if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil {
t.Fatalf("seat1 pass: %v", err)
}
// Back on the opponent's turn, the cooldown is reset by the action since the nudge.
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
}
}
+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)
}
+86
View File
@@ -0,0 +1,86 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// TestUserListFilter checks the admin user-list filter: the people/robots split (by a
// robot identity) and the case-insensitive glob masks on display name and external id.
func TestUserListFilter(t *testing.T) {
ctx := context.Background()
st := account.NewStore(testDB)
uniq := uuid.NewString()
human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
if err != nil {
t.Fatalf("provision human: %v", err)
}
robot, err := st.ProvisionRobot(ctx, "robot-uxz-"+uniq, "Zzqxbot")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx)
if err != nil {
t.Fatalf("provision guest: %v", err)
}
collect := func(f account.UserFilter) map[uuid.UUID]account.UserListItem {
items, err := st.ListUsers(ctx, f, 5000, 0)
if err != nil {
t.Fatalf("list users %+v: %v", f, err)
}
m := make(map[uuid.UUID]account.UserListItem, len(items))
for _, it := range items {
m[it.ID] = it
}
return m
}
people := collect(account.UserFilter{})
if _, ok := people[human.ID]; !ok {
t.Error("human missing from people")
}
if _, ok := people[guest.ID]; !ok {
t.Error("guest missing from people")
}
if _, ok := people[robot.ID]; ok {
t.Error("robot must not appear in people")
}
if it := people[human.ID]; it.IsRobot || it.IsGuest {
t.Errorf("human flags wrong: robot=%v guest=%v (want both false)", it.IsRobot, it.IsGuest)
}
robots := collect(account.UserFilter{Robots: true})
if it, ok := robots[robot.ID]; !ok || !it.IsRobot {
t.Errorf("robot missing from robots or IsRobot=false (ok=%v)", ok)
}
if _, ok := robots[human.ID]; ok {
t.Error("human must not appear in robots")
}
// Name mask (people).
if _, ok := collect(account.UserFilter{NameMask: "Zzqx*"})[human.ID]; !ok {
t.Error("name mask Zzqx* should match the human")
}
if _, ok := collect(account.UserFilter{NameMask: "nomatch*"})[human.ID]; ok {
t.Error("name mask nomatch* should not match the human")
}
// External-id mask (robots).
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"})[robot.ID]; !ok {
t.Error("external-id mask robot-uxz-* should match the robot")
}
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-zzz-*"})[robot.ID]; ok {
t.Error("external-id mask robot-zzz-* should not match the robot")
}
// CountUsers agrees that robots exist.
if n, err := st.CountUsers(ctx, account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"}); err != nil || n != 1 {
t.Errorf("count robots robot-uxz-* = (%d, %v), want 1", n, err)
}
}
+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
+7 -4
View File
@@ -142,11 +142,14 @@ func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult
return EnqueueResult{}, nil
}
// Cancel removes accountID from whatever pool it waits in, reporting whether it
// was queued.
// Cancel removes accountID from whatever pool it waits in and drops any pending
// matched result, reporting whether it was queued. Clearing the result closes the
// race where the reaper substituted a robot just before the player cancelled: the
// stale game must not later surface through Poll as a game the player did not want.
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.results, accountID)
variant, ok := m.queued[accountID]
if !ok {
return false
@@ -197,12 +200,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
}
var subs []sub
for _, acc := range due {
robotID, err := m.robots.Pick()
variant := m.queued[acc]
robotID, err := m.robots.Pick(variant)
if err != nil {
m.log.Warn("robot substitution deferred", zap.Error(err))
continue
}
variant := m.queued[acc]
m.removeLocked(acc, variant)
seats := []uuid.UUID{acc, robotID}
if m.rng.Intn(2) == 0 {
+27 -4
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
id uuid.UUID
err error
lastVariant engine.Variant
}
func (f *fakeRobots) Pick() (uuid.UUID, error) {
func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
f.lastVariant = variant
if f.err != nil {
return uuid.Nil, f.err
}
@@ -238,6 +240,27 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
}
}
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
// robot just before the player cancels: Cancel must drop the pending result so the
// abandoned game never surfaces through Poll (Stage 17).
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
base := time.Now()
mm.clock = func() time.Time { return base }
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue: %v", err)
}
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
mm.Cancel(ctx, a) // ... then the player cancels
if got, _ := mm.Poll(ctx, a); got.Matched {
t.Error("cancel must drop the pending substituted game; Poll still matched")
}
}
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
@@ -0,0 +1,21 @@
-- +goose Up
-- Stage 17: a per-(game, account) draft the server persists across reloads and devices —
-- the player's preferred rack tile order (#4) and the tiles they have laid on the board but
-- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps
-- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no
-- generated jet code is needed.
SET search_path = backend, pg_catalog;
CREATE TABLE game_drafts (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
rack_order text NOT NULL DEFAULT '',
board_tiles jsonb NOT NULL DEFAULT '[]',
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, account_id)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE game_drafts;
+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)
}
}
+59 -50
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
@@ -91,8 +84,9 @@ type Service struct {
clock func() time.Time
log *zap.Logger
mu sync.RWMutex
pool []uuid.UUID
mu sync.RWMutex
poolEN []uuid.UUID
poolRU []uuid.UUID
}
// NewService constructs a robot Service. games and social are the domain seams it
@@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met
}
}
// EnsurePool idempotently provisions the named robot accounts and records their
// ids as the pool. Each robot is a durable account bound to a robot identity,
// with chat and friend requests blocked so it never engages socially
// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary
// registry: a failure fails the boot.
// EnsurePool idempotently provisions the robot accounts (one per slot of each
// language's composed name pool) and records their ids. Each robot is a durable
// account bound to a stable, index-keyed robot identity, with chat and friend
// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a
// startup dependency, like the dictionary registry: a failure fails the boot.
func (s *Service) EnsurePool(ctx context.Context) error {
ids := make([]uuid.UUID, 0, len(robotNames))
for _, name := range robotNames {
acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name))
if err != nil {
return fmt.Errorf("robot: provision %q: %w", name, err)
}
if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests {
if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
DisplayName: name,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart,
AwayEnd: acc.AwayEnd,
BlockChat: true,
BlockFriendRequests: true,
}); err != nil {
return fmt.Errorf("robot: profile %q: %w", name, err)
}
}
ids = append(ids, acc.ID)
en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN())
if err != nil {
return err
}
ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU())
if err != nil {
return err
}
s.mu.Lock()
s.pool = ids
s.poolEN, s.poolRU = en, ru
s.mu.Unlock()
return nil
}
// Pick returns a random robot account from the pool, for the matchmaker to
// substitute into an auto-match. It satisfies lobby.RobotProvider.
func (s *Service) Pick() (uuid.UUID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.pool) == 0 {
return uuid.Nil, ErrNoRobotAvailable
// provisionPool provisions one durable robot account per name and returns their ids
// in order. The identity is keyed by language and slot index (stable across restarts
// and independent of the composed display name); account.ProvisionRobot sets the
// display name and social blocks and is idempotent, so EnsurePool can run every boot.
func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) {
ids := make([]uuid.UUID, 0, len(names))
for i, name := range names {
acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name)
if err != nil {
return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err)
}
ids = append(ids, acc.ID)
}
return s.pool[rand.IntN(len(s.pool))], nil
return ids, nil
}
// poolIDs returns a snapshot of the pool for the driver scan.
// Pick returns a random robot account for the matchmaker to substitute into an
// auto-match of the given variant. An English game draws from the Latin pool; a
// Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a
// Latin name about latinShareInRussian% of the time; either side falls back to the
// other when its pool is empty. It satisfies lobby.RobotProvider.
func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) {
s.mu.RLock()
defer s.mu.RUnlock()
primary, secondary := s.poolEN, s.poolRU
if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit {
primary, secondary = s.poolRU, s.poolEN
if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian {
primary, secondary = secondary, primary
}
}
if len(primary) == 0 {
primary = secondary
}
if len(primary) == 0 {
return uuid.Nil, ErrNoRobotAvailable
}
return primary[rand.IntN(len(primary))], nil
}
// poolIDs returns a snapshot of the whole pool (both languages) for the driver scan,
// which is variant-agnostic — it acts on every robot's active games.
func (s *Service) poolIDs() []uuid.UUID {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]uuid.UUID(nil), s.pool...)
}
// externalID is the stable robot identity for a pool name.
func externalID(name string) string {
return "robot-" + name
ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU))
ids = append(ids, s.poolEN...)
ids = append(ids, s.poolRU...)
return ids
}
+89 -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 = 3.0
delayEarlyHiMinutes = 10.0
delayLateLoMinutes = 10.0
delayLateHiMinutes = 90.0
delaySkew = 4.0
avgGameMoves = 28.0
delayHardMinMinutes = 1.0
delayHardMaxMinutes = 90.0
// nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot
// answers a daytime nudge on its turn.
nudgeReplyMinMinutes = 2.0
nudgeReplyMaxMinutes = 10.0
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
// lower band (delayBand's lo), within which the robot answers a daytime nudge on
// its turn — so a nudged robot replies near the floor of its think time.
nudgeReplySpreadMinutes = 5.0
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
@@ -104,19 +114,82 @@ func playToWin(seed int64) bool {
return mix(seed, "win")%100 < playToWinPercent
}
// moveDelay is the robot's think time for the move at moveCount, sampled from the
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
// PlayToWin exposes the once-per-game play-to-win decision for a game's bag seed, for the
// admin console (it is deterministic and fixed for the whole game).
func PlayToWin(seed int64) bool { return playToWin(seed) }
// PlayToWinTargetPercent is the configured probability, in percent, that a robot plays to
// win in any given game (the admin console shows it alongside the per-game decision).
const PlayToWinTargetPercent = playToWinPercent
// NextMoveAt is the deterministic instant the robot is scheduled to play the move at
// moveCount, given when the turn started and the opponent's timezone (which anchors the
// robot's sleep window). It is the sampled think-time delay, deferred to the end of the
// sleep window when it would otherwise land while the robot is asleep. The driver acts on
// a scan tick, so the real move lands at the first scan at or after this instant. It is
// meaningful only on the robot's own turn; the admin console surfaces it as an ETA.
func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time {
t := turnStartedAt.Add(moveDelay(seed, moveCount))
drift := sleepDrift(seed)
if asleep(opponentTZ, drift, t) {
t = wakeAfter(opponentTZ, drift, t)
}
return t
}
// wakeAfter returns the first instant at or after t when the robot is awake — the local
// hour reaches sleepEndHour in the opponent's drifted timezone — converted back to UTC.
func wakeAfter(opponentTZ string, drift time.Duration, t time.Time) time.Time {
local := t.In(loadLocation(opponentTZ)).Add(drift)
wake := time.Date(local.Year(), local.Month(), local.Day(), sleepEndHour, 0, 0, 0, local.Location())
if !wake.After(local) {
wake = wake.Add(24 * time.Hour)
}
return wake.Add(-drift).UTC()
}
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
// for the move at moveCount. It interpolates linearly with game progress (the move
// count over avgGameMoves, capped at 1): early moves sit in a short band and late
// moves in a long one.
func delayBand(moveCount int) (lo, hi float64) {
p := float64(moveCount) / avgGameMoves
if p > 1 {
p = 1
}
lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p
hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p
return lo, hi
}
// moveDelay is the robot's think time for the move at moveCount: a right-skewed
// sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew
// > 1) makes short delays frequent and long ones rare, with a tail to the band's top.
func moveDelay(seed int64, moveCount int) time.Duration {
lo, hi := delayBand(moveCount)
u := unitFloat(mix(seed, "delay", moveCount))
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
return time.Duration(mins * float64(time.Minute))
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
}
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes).
// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes],
// where lo is the move's lower band — so a nudge pulls the move in near the floor of
// the robot's think time.
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
lo, _ := delayBand(moveCount)
u := unitFloat(mix(seed, "nudge", moveCount))
mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
}
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
// bounds so an out-of-range band can never produce an absurd think time.
func clampMinutes(mins float64) time.Duration {
if mins < delayHardMinMinutes {
mins = delayHardMinMinutes
}
if mins > delayHardMaxMinutes {
mins = delayHardMaxMinutes
}
return time.Duration(mins * float64(time.Minute))
}
+68 -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 < 3 || d > 10 {
t.Fatalf("first-move delay %.2f out of [3,10] for seed %d", d, s)
}
if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 {
t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s)
}
}
if early, late := median(0), median(30); early >= late {
t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late)
}
}
// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move
// count: short delays are frequent (median near the band floor) and the mean sits
// above the median, with a tail toward the cap.
func TestMoveDelaySkew(t *testing.T) {
const n = 20000
mins := make([]float64, 0, n)
var sum float64
for mc := 0; mc < n; mc++ {
m := moveDelay(42, mc).Minutes()
for s := 0; s < n; s++ {
m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90]
mins = append(mins, m)
sum += m
}
sort.Float64s(mins)
median := mins[n/2]
mean := sum / float64(n)
if median < 7 || median > 13 {
t.Errorf("median delay = %.1f min, want ~10 (7-13)", median)
if median < 12 || median > 20 {
t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median)
}
if mean <= median {
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
@@ -180,6 +207,37 @@ func TestMixDeterministic(t *testing.T) {
}
}
// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never
// earlier than the sampled think-time delay, and it never lands while the robot is asleep
// (a delay that would fall in the sleep window is deferred to the wake time).
func TestNextMoveAt(t *testing.T) {
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for seed := int64(1); seed <= 500; seed++ {
for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day
start := base.Add(time.Duration(h) * time.Hour)
at := NextMoveAt(seed, 3, start, "UTC")
if at.Before(start.Add(moveDelay(seed, 3))) {
t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at)
}
if asleep("UTC", sleepDrift(seed), at) {
t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at)
}
}
}
}
// TestPlayToWinExport checks the exported decision matches the internal one and the target.
func TestPlayToWinExport(t *testing.T) {
for seed := int64(1); seed <= 200; seed++ {
if PlayToWin(seed) != playToWin(seed) {
t.Fatalf("PlayToWin(%d) != playToWin", seed)
}
}
if PlayToWinTargetPercent != playToWinPercent {
t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent)
}
}
// plays builds candidate plays carrying only the given scores (ranked as passed).
func plays(scores ...int) []engine.MoveRecord {
out := make([]engine.MoveRecord, len(scores))
+3
View File
@@ -46,6 +46,9 @@ func TestStatusForError(t *testing.T) {
}{
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
"nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"},
"chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"},
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
+11 -3
View File
@@ -69,6 +69,7 @@ func (s *Server) registerRoutes() {
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
u.POST("/lobby/cancel", s.handleCancel)
u.GET("/lobby/poll", s.handlePoll)
}
if s.invitations != nil {
@@ -148,8 +149,10 @@ func statusForError(err error) (int, string) {
return http.StatusNotFound, "not_found"
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
return http.StatusForbidden, "not_a_player"
case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn):
case errors.Is(err, game.ErrNotYourTurn):
return http.StatusConflict, "not_your_turn"
case errors.Is(err, social.ErrNudgeOnOwnTurn):
return http.StatusConflict, "nudge_own_turn"
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
return http.StatusConflict, "game_finished"
case errors.Is(err, game.ErrGameActive):
@@ -198,9 +201,14 @@ func statusForError(err error) (int, string) {
case errors.Is(err, session.ErrNotFound):
return http.StatusUnauthorized, "session_invalid"
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
errors.Is(err, social.ErrNudgeTooSoon):
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent):
return http.StatusUnprocessableEntity, "chat_rejected"
case errors.Is(err, social.ErrNudgeTooSoon):
// A too-frequent nudge is a distinct, non-content rejection — the UI must say
// "don't rush the player so often", not the chat content-rejection message.
return http.StatusConflict, "nudge_too_soon"
case errors.Is(err, social.ErrChatNotYourTurn):
return http.StatusConflict, "chat_not_your_turn"
case errors.Is(err, social.ErrSelfRelation):
return http.StatusBadRequest, "self_relation"
case errors.Is(err, social.ErrRequestExists):
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
@@ -17,6 +18,7 @@ import (
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/robot"
)
// adminPageSize is the page size of the admin console's paginated lists.
@@ -75,22 +77,55 @@ func (s *Server) consoleDashboard(c *gin.Context) {
func (s *Server) consoleUsers(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
total, _ := s.accounts.CountAccounts(ctx)
accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize)
filter := account.UserFilter{
Robots: c.Query("kind") == "robots",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
}
total, _ := s.accounts.CountUsers(ctx, filter)
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, a := range accs {
q := url.Values{}
if filter.Robots {
q.Set("kind", "robots")
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExternalIDMask) != "" {
q.Set("ext", filter.ExternalIDMask)
}
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
FilterQuery: q.Encode(),
}
ids := make([]uuid.UUID, 0, len(items))
for _, it := range items {
kind := "registered"
if a.IsGuest {
if it.IsRobot {
kind = "robot"
} else if it.IsGuest {
kind = "guest"
}
view.Items = append(view.Items, adminconsole.UserRow{
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt),
})
ids = append(ids, it.ID)
}
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
for i := range view.Items {
if st, ok := stats[ids[i]]; ok && st.Moves > 0 {
view.Items[i].HasMoveStats = true
view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs)
view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs)
view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs)
}
}
}
s.renderConsole(c, "users", "users", "Users", view)
}
@@ -134,6 +169,13 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Games = append(view.Games, gameRow(g))
}
}
if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 {
cps := make([]adminconsole.ChartPoint, len(pts))
for i, p := range pts {
cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs}
}
view.MoveChart = adminconsole.MoveDurationChart(cps)
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
@@ -207,16 +249,58 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt),
}
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA.
oppTZ := ""
for _, seat := range g.Seats {
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil {
acc, accErr := s.accounts.GetByID(ctx, seat.AccountID)
if accErr == nil {
row.DisplayName = acc.DisplayName
}
if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot {
row.IsRobot = true
view.HasRobot = true
} else if accErr == nil {
oppTZ = acc.TimeZone
}
view.Seats = append(view.Seats, row)
}
// For each robot seat, surface the game's deterministic play-to-win intent and — while
// it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed.
if view.HasRobot {
view.RobotTargetPct = robot.PlayToWinTargetPercent
if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil {
now := time.Now().UTC()
for i := range view.Seats {
if !view.Seats[i].IsRobot {
continue
}
if robot.PlayToWin(seed) {
view.Seats[i].RobotIntent = "play to win"
} else {
view.Seats[i].RobotIntent = "play to lose"
}
if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat {
view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now)
}
}
}
}
s.renderConsole(c, "game_detail", "games", "Game", view)
}
// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a
// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)".
func robotETA(at, now time.Time) string {
mins := int(at.Sub(now).Round(time.Minute).Minutes())
rel := fmt.Sprintf("in ~%d min", mins)
if mins <= 0 {
rel = "due now"
}
return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel)
}
// consoleComplaints renders the paginated complaint review queue.
func (s *Server) consoleComplaints(c *gin.Context) {
ctx := c.Request.Context()
+14
View File
@@ -153,6 +153,20 @@ func (s *Server) handleEnqueue(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
// handleCancel removes the caller from the auto-match pool (and drops any pending
// matched result), so a cancelled quick-match neither blocks a re-queue nor later
// surfaces a robot-substituted game the player abandoned. It is idempotent: cancelling
// when not queued is a no-op success.
func (s *Server) handleCancel(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
s.matchmaker.Cancel(c.Request.Context(), uid)
c.Status(http.StatusNoContent)
}
// handlePoll reports whether the caller has been paired since queueing.
func (s *Server) handlePoll(c *gin.Context) {
uid, ok := userID(c)
+57 -3
View File
@@ -49,13 +49,22 @@ type Message struct {
// rune limit, and free of links/emails/phone numbers (the content filter). The
// gateway-forwarded senderIP is validated and stored for moderation.
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
seats, toMove, status, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
if !slices.Contains(seats, senderID) {
idx := slices.Index(seats, senderID)
if idx < 0 {
return Message{}, ErrNotParticipant
}
// Chat is allowed only on the sender's own turn in an active game; the opponent's-turn
// control is the nudge (Stage 17).
if status != statusActive {
return Message{}, ErrGameNotActive
}
if idx != toMove {
return Message{}, ErrChatNotYourTurn
}
sender, err := svc.accounts.GetByID(ctx, senderID)
if err != nil {
return Message{}, err
@@ -105,7 +114,15 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return Message{}, err
}
if ok && svc.now().Sub(last) < nudgeInterval {
return Message{}, ErrNudgeTooSoon
// The cooldown resets once the sender has acted (moved or chatted) since the last
// nudge — engagement clears the "don't spam" limit (Stage 17).
acted, err := svc.actedSince(ctx, gameID, senderID, last)
if err != nil {
return Message{}, err
}
if !acted {
return Message{}, ErrNudgeTooSoon
}
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
if err != nil {
@@ -118,6 +135,22 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return msg, nil
}
// actedSince reports whether senderID made a move or posted a chat message in the game
// after t — the events that reset the nudge cooldown (Stage 17).
func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) {
if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
return false, err
} else if ok && mv.After(t) {
return true, nil
}
if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil {
return false, err
} else if ok && msg.After(t) {
return true, nil
}
return false, nil
}
// emitChat pushes a chat message to every seated player except the sender
// (best-effort live delivery; the recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
@@ -252,6 +285,27 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti
return row.CreatedAt, true, nil
}
// lastMessageAt returns the time of senderID's most recent non-nudge chat message in
// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale
// nudge no longer blocks a new one (Stage 17).
func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
FROM(table.ChatMessages).
WHERE(
table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))),
).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return time.Time{}, false, nil
}
return time.Time{}, false, fmt.Errorf("social: last message: %w", err)
}
return row.CreatedAt, true, nil
}
// messageFromRow projects a generated row into the public Message.
func messageFromRow(r model.ChatMessages) Message {
m := Message{
+7
View File
@@ -28,6 +28,9 @@ type GameReader interface {
// SharedGame reports whether two accounts are seated together in any game
// (active or finished); it gates the "befriend an opponent" request path.
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
// LastMoveAt is the time of an account's most recent move in a game (and whether it
// has moved); the nudge cooldown resets once the player has taken a turn.
LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error)
}
// Sentinel errors returned by the service.
@@ -67,6 +70,10 @@ var (
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
ErrGameNotActive = errors.New("social: game is not active")
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the
// sender's turn — chat is allowed only on your own turn (the opponent's-turn control
// is the nudge, Stage 17).
ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn")
)
// Service is the social domain. It is the only writer of the friendships, blocks
+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".
+5
View File
@@ -79,6 +79,7 @@ services:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
depends_on: [backend]
environment:
@@ -209,6 +210,10 @@ services:
GF_AUTH_BASIC_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
# Disable Grafana Live: its WebSocket (/_gm/grafana/api/live/ws) otherwise hits
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
# dashboards poll and do not need Live.
GF_LIVE_MAX_CONNECTIONS: "0"
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
+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}}" }
]
}
]
}
+65 -25
View File
@@ -235,7 +235,10 @@ Key points:
applying the end-game rack-value adjustment, or a resignation. On a
**resignation the resigner keeps their accumulated score (no rack adjustment)
and never wins**: the win goes to the highest score among the remaining seats,
unconditionally the other player in a two-player game. The engine exposes a
unconditionally the other player in a two-player game. A player may resign **on the
opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)`
resigns that player's own seat whoever is to move, and the game domain skips the turn
check for resign (Stage 17). The engine exposes a
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
- The **game domain** (`internal/game`) owns everything the engine does not —
@@ -300,10 +303,17 @@ The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
behaviour on every scan and after a restart — the same philosophy as journal
replay. A pool of durable accounts — each a `kind='robot'` identity (§4),
provisioned at startup with chat and friend requests blocked — backs the
human-like name pool; those two profile toggles are all the friend/DM blocking
requires (there is no DM surface; chat is per-game).
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
`robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend
requests open** — a request to a robot is accepted as pending and expires unanswered
(the robot never responds), mirroring a human who ignores it (Stage 17); the chat
block backs the human-like names (there is no DM surface; chat is per-game). Names are
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
a surname pool (gender-agreed for Russian) in one of three forms (first only /
first + surname initial / first + full surname), deterministically per pool slot so
they stay stable across restarts. Substitution is **variant-aware**: a Russian game
(Russian Scrabble or Эрудит) draws a Russian-named robot with at most ~20% Latin, an
English game the Latin pool.
- **Balance**: at game start it decides once whether to play to win, with
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
@@ -313,16 +323,20 @@ requires (there is no DM surface; chat is per-game).
(playing to lose) is closest to a small band (**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
**[3, 10] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
quick and the endgame can run long, clamped to **[1, 90] minutes**; it
**sleeps 00: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.
The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17).
## 8. Lobby & social
@@ -334,6 +348,9 @@ requires (there is no DM surface; chat is per-game).
robot (§7) and starts the game. On a pairing or substitution the matchmaker
emits a **match-found** notification (§10), delivered over the live stream;
`Poll` remains as a fallback for a client that is not currently streaming.
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
pending matched result, so a cancelled quick-match is dequeued rather than left for
the reaper to robot-substitute (Stage 17).
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
@@ -451,7 +468,9 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
including the mover**, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, invitation or game-started; emitted on a friend-request and invitation
@@ -499,13 +518,21 @@ promotions) is future work and would deliver short markdown messages (text + lin
client-measured RTT piggybacked on the next request is a later enhancement.
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
until an exporter is configured: histograms `game_replay_duration` (journal
rebuild on a cache miss) and `game_move_validate_duration`; counters
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop),
`chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`;
an observable gauge `game_cache_active`; the gateway `edge_request_duration`
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
metrics. Game-scoped metrics carry a `variant` attribute
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
console, below); counters `games_started_total`, `games_abandoned_total` (a
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
(english/russian_scrabble/erudit).
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
console from the move journal (`game_moves.created_at` deltas, the first move from
the game's creation), not Prometheus labels (which an `account_id` would explode):
the user list shows each account's min/avg/max think time, and the user-detail page
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
@@ -579,14 +606,27 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
## 14. CI & branches
- Trunk is **`master`**; feature work happens on `feature/*` branches merged via
PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily
lands on `master`).
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test
on Go changes; `integration.yaml` runs the Postgres-backed tests behind the
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
serial). Further workflows (ui-test, deploy) are added with the components they
cover.
- **Two long-lived branches** (Stage 16): **`development`** is the integration
trunk and **`master`** the production trunk; `feature/*` branches are cut from
`development` and PR back into it (the genesis commit necessarily landed on
`master`). A commit to a `feature/*` branch triggers nothing.
- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the
suite on a PR into `development`/`master` and on a push to `development`. Its
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
`changes` job filters by changed paths), and an always-running **`gate`** job
aggregates them (passing when each succeeded or was **skipped**) and is the single
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
a merge.
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
to — `development` (`docker compose up -d --build` on the runner host), then probes
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
`docker inspect`: running, not restarting, stable restart count, with a
VPN-handshake grace period, since the connector has no public ingress and a
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
`TEST_`/`PROD_` per contour.
- The engine consumes `scrabble-solver` as a **published, versioned module**
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
+12 -7
View File
@@ -55,9 +55,11 @@ two accounts share a game still in progress.
### Lobby & matchmaking *(Stage 4 / 15)*
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
limited to the languages the player's sign-in service supports (English → English;
Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is
unrestricted). This gates only **starting** a new game — both auto-match and a friend
limited to the languages the player's sign-in service supports (English → Scrabble;
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
@@ -91,7 +93,9 @@ wins most games), aims for a close score rather than crushing or throwing the ga
and plays at a human pace — short thinking times for most moves, the occasional long
one, and a night-time pause that tracks the player's own day. It answers a nudge
within a few minutes and nudges back when the player has been away a long time. It
carries a human-like name and neither chats nor accepts friend requests.
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
names); it does not chat, and **silently ignores friend requests** — a request to a
robot stays pending and expires, exactly like a human who never responds.
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
Become friends in two ways: redeem a **one-time code** the other player issues (six
@@ -108,9 +112,10 @@ even disguised. Nudge the player whose turn is awaited at most once per hour (th
nudge is part of the game chat); the out-of-app push is delivered via the platform.
### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by single space / "." / "_" separators, up to
32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking
Edit the display name (letters joined by a single space / "." / "_" separator, with an
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge" (Stage 11).
+15 -10
View File
@@ -56,9 +56,11 @@ Mini App** авторизует по подписанным `initData` плат
### Лобби и подбор *(Stage 4 / 15)*
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → English;
русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
@@ -92,8 +94,10 @@ Mini App** авторизует по подписанным `initData` плат
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
в чате и не принимает заявки в друзья.
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
языку партии (в русской партии — в основном русские имена); не общается в чате и
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
ровно как у человека, который не отвечает.
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
@@ -111,11 +115,12 @@ Mini App** авторизует по подписанным `initData` плат
push доставляется через платформу.
### Профиль и настройки *(Stage 4 / 8)*
Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов
вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
### История и статистика *(Stage 3 / 8)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
+69 -14
View File
@@ -33,6 +33,25 @@ Login uses `Screen`.
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
screen entered from the lobby flies in from the right; returning to the lobby reveals it
from the left (back). Transitions are local (so they do not play on first load) and
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
on lobby ↔ game navigation.
- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
native header **BackButton** drives back-navigation (the app's chevron is hidden in
Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing
confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise)
are disabled so they don't fight tile drag or the board scroll; and a live stream dropped
by a background suspend reconnects silently on return — the connection banner is
suppressed while hidden and for a short grace after resume (visibilitychange +
pageshow/pagehide + Telegram `activated`/`deactivated`).
## Tiles & board
@@ -43,9 +62,36 @@ Login uses `Screen`.
that works consistently across browsers; no `transform`, which broke scrolling
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
they stay a constant size as the cells grow (relatively smaller at higher zoom).
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
target; the custom pinch and swipe-to-open-history gestures were dropped because they
fight native scroll — history opens from the menu.
**Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending**
tile recalls it. **Pinch** zooms toward the pinch midpoint (a two-finger gesture;
preventDefault fires only for two touches, so one-finger scroll stays native, and a second
finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
history opens from the menu or a tap on the players plaque (below). A **hint** auto-zooms
centred on the hint's placement, not the top-left.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
recalled tile returns to its original rack slot (Stage 17).
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
shadow) pins to the board as the board slides down, instead of tracking the table as
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
jitter. A move's row lists every word it formed (the main word first).
- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
keyboard-aware (they size to the area above the keyboard).
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
last completed word gets a dark tile background — static while it is the opponent's
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
@@ -53,25 +99,34 @@ Login uses `Screen`.
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
- **Grid lines**: the inter-cell gap shows a contrasting `--cell-line` (darker in light,
lighter in dark) to avoid a wavy-line optical illusion.
- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
`--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion.
## Controls
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
immediately. Reused by:
- **MakeMove** (appears when ≥1 tile is pending; the rack collapses its used slots and
shifts left to free room): a **🏁** button whose popover offers **Make move ✅** /
**Reset ❌**.
- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
remaining-count badge) — each confirmed by an **Ok ✅** popover; 🔀 Shuffle has no
label and no confirm. The under-board slot shows the **Scores: N** preview.
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
button) beside the rack commits the move — no popover, and disabled while the pending word
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
A one-line inset strip under the nav bar. Content is minimal markdown (text + links,
escaped + linkified). A parameterised **rotator** drives messages: a fitting message
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) —
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
+4 -1
View File
@@ -17,12 +17,15 @@ WORKDIR /ui
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
# Prod UI build vars (Vite reads VITE_-prefixed env at build; baked into the bundle).
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK=
ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION
# Install with the lockfile first (the workspace file carries pnpm's build-script
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
+5
View File
@@ -255,6 +255,11 @@ func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) {
return out, err
}
// Cancel removes the caller from the auto-match pool (idempotent; 204 No Content).
func (c *Client) Cancel(ctx context.Context, userID string) error {
return c.do(ctx, http.MethodPost, "/api/v1/user/lobby/cancel", userID, "", nil, nil)
}
// ChatPost stores a chat message, forwarding the client IP for moderation.
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
var out ChatResp
+5 -1
View File
@@ -88,7 +88,11 @@ var (
func DefaultRateLimit() RateLimitConfig {
return RateLimitConfig{
PublicPerMinute: 30, PublicBurst: 10,
UserPerMinute: 120, UserBurst: 40,
// Per-user (not per-IP): one user may run several devices, each holding a
// Subscribe stream and reloading state on every live event, so the authenticated
// budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17
// after multi-device play tripped the old 120/40.
UserPerMinute: 300, UserBurst: 80,
AdminPerMinute: 60, AdminBurst: 20,
EmailPer10Min: 5, EmailBurst: 2,
}
+13
View File
@@ -24,6 +24,7 @@ const (
MsgGameSubmitPlay = "game.submit_play"
MsgGameState = "game.state"
MsgLobbyEnqueue = "lobby.enqueue"
MsgLobbyCancel = "lobby.cancel"
MsgLobbyPoll = "lobby.poll"
MsgChatPost = "chat.post"
MsgGamesList = "games.list"
@@ -93,6 +94,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
r.ops[MsgLobbyCancel] = Op{Handler: cancelHandler(backend), Auth: true}
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true}
@@ -233,6 +235,17 @@ func pollHandler(backend *backendclient.Client) Handler {
}
}
// cancelHandler removes the caller from the auto-match pool. It carries no result;
// it echoes an empty (unmatched) Match so the client has a well-formed payload.
func cancelHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
if err := backend.Cancel(ctx, req.UserID); err != nil {
return nil, err
}
return encodeMatch(backendclient.MatchResp{}), nil
}
}
func chatPostHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsChatPostRequest(req.Payload, 0)
+59 -4
View File
@@ -10,22 +10,77 @@ 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 }) => {
test('placing a tile and confirming via commits the move', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
await page.locator('.make').click(); // open the MakeMove popover (short tap)
await page.locator('.pop.go').click(); // "Make move ✅"
await page.locator('.make').click(); // ✅ commits the move directly (no popover)
// After the commit the placement is cleared: no pending tile, no 🏁 control.
// After the commit the placement is cleared: no pending tile, no control.
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
await expect(page.locator('.make')).toBeHidden();
});
test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary
await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
});
test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
// A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger).
await page.waitForTimeout(350); // clear the double-tap window from the placing tap
await page.locator('[data-cell].pending').first().click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
// A double-tap (two synchronous clicks on the same cell) recalls it to the rack.
await page.locator('[data-cell].pending').first().evaluate((el: HTMLElement) => {
el.click();
el.click();
});
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
});
test('the board is a gapless checkerboard by default; grid lines toggle in Settings', async ({ page }) => {
await openGame(page);
await expect(page.locator('.grid.gridless')).toBeVisible(); // lines off by default
await page.evaluate(() => (location.hash = '/settings'));
await page.locator('.gridlines input').check(); // turn grid lines on
await page.evaluate(() => (location.hash = '/game/g1'));
await expect(page.locator('.grid')).toBeVisible();
await expect(page.locator('.grid.gridless')).toHaveCount(0);
});
test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
await openGame(page);
const before = await page.locator('.rack .tile').allTextContents();
expect(before.length).toBeGreaterThan(1);
await page.locator('button:has-text("🔀")').click(); // the shuffle tab (no pending tiles)
await page.waitForTimeout(650); // let the hop animation settle
// Same multiset of tiles after the shuffle — no tile is dropped or duplicated.
const after = await page.locator('.rack .tile').allTextContents();
expect([...after].sort()).toEqual([...before].sort());
});
test('history slides the board down and closes on a board tap', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
+1 -1
View File
@@ -25,6 +25,6 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
// The score preview appears where the hints count used to be.
await expect(page.locator('.scores')).toContainText(/\d/);
// The contextual MakeMove control (🏁) appears once a tile is pending.
// The contextual MakeMove control () appears once a tile is pending.
await expect(page.locator('.make')).toBeVisible();
});
+6 -6
View File
@@ -54,7 +54,6 @@ test('profile edit saves a new display name', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await page.getByRole('button', { name: /Edit profile/ }).click();
await page.locator('.edit input').first().fill('Kaya Test');
await page.getByRole('button', { name: /^Save$/ }).click();
await expect(page.locator('.name')).toHaveText('Kaya Test');
@@ -127,7 +126,6 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await page.getByRole('button', { name: /Edit profile/ }).click();
const name = page.locator('.edit input').first();
const save = page.getByRole('button', { name: /^Save$/ });
@@ -167,12 +165,14 @@ test('link account: the Telegram web sign-in control is offered in a browser', a
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
});
test('chat send and nudge are icon buttons', async ({ page }) => {
test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
await page.locator('.burger').first().click();
await page.getByRole('button', { name: 'Chat' }).click();
// Icon-only controls expose their action through the aria-label.
// On your turn the message field + Send are shown and the nudge is hidden (Stage 17);
// chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
// through the aria-label.
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Nudge' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
});
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 16" role="img" aria-label="СССР">
<rect width="24" height="16" fill="#cc0000"/>
<!-- five-pointed star (filled, slightly smaller) -->
<path fill="#ffd700" d="M6 2.4l.78 1.6 1.76.26-1.27 1.24.3 1.75L6 6.63l-1.57.82.3-1.75L3.46 4.5l1.76-.26z"/>
<!-- schematic hammer & sickle (a sketch, thin strokes) -->
<g fill="none" stroke="#ffd700" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round">
<!-- sickle: an elongated semicircle blade with a short handle -->
<path d="M8.2 7.4a3 3 0 1 1-3.3 3.9"/>
<path d="M4.9 11.3l-.8.7"/>
<!-- hammer: a T-shape (handle + head) crossing the sickle -->
<path d="M5.1 11 8.1 8"/>
<path d="M7.2 7.1 9 8.9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 760 B

+67 -18
View File
@@ -1,8 +1,10 @@
<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 { navigate, router } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
@@ -17,28 +19,66 @@
onMount(() => {
void bootstrap();
});
// Inside Telegram, drive its native header back button: show it on any sub-screen
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
$effect(() => {
if (!insideTelegram()) return;
const name = router.route.name;
telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/'));
});
// 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'}
<Login />
{:else if router.route.name === 'new'}
<NewGame />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else if router.route.name === 'friends'}
<Friends />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
<Lobby />
<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 />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else if router.route.name === 'friends'}
<Friends />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
<Lobby />
{/if}
</div>
{/key}
</div>
{/if}
<Toast />
@@ -50,4 +90,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;
+6 -1
View File
@@ -1,14 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram';
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
$props();
// Inside Telegram the native header back button (App.svelte) is the back control, so
// the app's own chevron is hidden to avoid two back affordances.
const showBack = $derived(!!back && !insideTelegram());
</script>
<header class="nav" class:grow>
<div class="bar">
{#if back}
{#if showBack}
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
<span class="chev"></span>
</button>
+12 -2
View File
@@ -4,18 +4,21 @@
let {
title = '',
onclose,
overlayKeyboard = false,
children,
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
}: { title?: string; onclose?: () => void; overlayKeyboard?: boolean; children?: Snippet } = $props();
// Track the visual viewport so the backdrop covers only the area above an open
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
// backdrop still centres it behind the keyboard. Sizing the backdrop to
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
// overlayKeyboard opts out: the sheet is small and top-anchored, so the keyboard
// simply overlays the empty lower area — no resize, no relayout jank (e.g. check word).
let vh = $state(0);
let top = $state(0);
$effect(() => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
if (!vv) return;
if (!vv || overlayKeyboard) return;
const update = () => {
vh = vv.height;
top = vv.offsetTop;
@@ -34,6 +37,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="backdrop"
class:overlay={overlayKeyboard}
style:height={vh ? `${vh}px` : null}
style:top={vh ? `${top}px` : null}
onclick={() => onclose?.()}
@@ -61,6 +65,12 @@
padding: 16px;
z-index: 40;
}
/* Overlay mode: top-anchor the (small) sheet and don't track the keyboard, so the
soft keyboard overlays the empty lower area without resizing/relaying out. */
.backdrop.overlay {
align-items: flex-start;
padding-top: 12vh;
}
.sheet {
background: var(--surface);
color: var(--text);
+9 -1
View File
@@ -14,6 +14,7 @@
children,
scroll = true,
growNav = false,
column = false,
}: {
title: string;
back?: string;
@@ -22,13 +23,16 @@
children?: Snippet;
scroll?: boolean;
growNav?: boolean;
// column lays the content out as a flex column so a child can own the vertical fit
// (the game makes only its board scroll while the score/rack/tab bar stay put).
column?: boolean;
} = $props();
</script>
<div class="screen">
<Header {title} {back} {menu} grow={growNav} />
<AdBanner />
<main class="content" class:scroll class:fill={!growNav}>{@render children?.()}</main>
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>
{/if}
@@ -50,6 +54,10 @@
.content.scroll {
overflow-y: auto;
}
.content.column {
display: flex;
flex-direction: column;
}
.tabbar {
flex: 0 0 auto;
}
+12 -1
View File
@@ -1,9 +1,20 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import { app } from '../lib/app.svelte';
const dur = $derived(app.reduceMotion ? 0 : 260);
</script>
{#if app.toast}
<div class="toast {app.toast.kind}" role="status" aria-live="polite">{app.toast.text}</div>
<div
class="toast {app.toast.kind}"
role="status"
aria-live="polite"
in:fly={{ y: 32, duration: dur }}
out:fade={{ duration: dur }}
>
{app.toast.text}
</div>
{/if}
<style>
+157 -16
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { BoardCell } from '../lib/board';
import type { Premium } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet';
@@ -16,10 +17,14 @@
zoomed,
variant,
labelMode,
lines,
locale,
focus,
dropTarget,
oncell,
ontogglezoom,
onrecall,
onpenddown,
}: {
board: (BoardCell | null)[][];
premium: Premium[][];
@@ -30,10 +35,18 @@
zoomed: boolean;
variant: Variant;
labelMode: BoardLabelMode;
/** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
lines: boolean;
locale: Locale;
focus: { row: number; col: number } | null;
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: () => void;
ontogglezoom: (row: number, col: number) => void;
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
onrecall: (row: number, col: number) => void;
/** Pointer-down on a pending cell, to start dragging that tile back to the rack. */
onpenddown: (e: PointerEvent, row: number, col: number) => void;
} = $props();
const Z = 1.85;
@@ -43,33 +56,122 @@
let viewport = $state<HTMLElement>();
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
// works in every browser. Keep the focus cell centred on every frame of the zoom-in
// (the board widens over ~0.25s) so it magnifies *into* that cell, rather than growing
// from the top-left corner and then jumping to centre once the transition ends.
// works in every browser. The pan is interpolated toward a PRE-CLAMPED final scroll as
// the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly
// from A to B in one motion instead of chasing a per-frame target that the scroll bounds
// clamp — which made the board lurch one way and then snap back near the edges/centre.
// It runs only on a zoom toggle (`zoomed`); changing `focus` while already zoomed does
// not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board.
$effect(() => {
const on = zoomed;
const vp = viewport;
if (!vp || !zoomed || !focus) return;
const f = focus;
const start = performance.now();
let raf = requestAnimationFrame(function tick(now) {
const cell = vp.scrollWidth / 15; // grows frame by frame as the board widens
vp.scrollLeft = (f.col + 0.5) * cell - vp.clientWidth / 2;
vp.scrollTop = (f.row + 0.5) * cell - vp.clientHeight / 2;
if (now - start < 300) raf = requestAnimationFrame(tick);
if (!vp) return;
const f = untrack(() => focus);
const clientW = vp.clientWidth;
const clientH = vp.clientHeight;
if (clientW === 0) return;
const fitW = clientW; // board width at scale 1 (fills the viewport)
const fullW = clientW * Z; // board width at full zoom
const startSL = vp.scrollLeft;
const startST = vp.scrollTop;
let finalSL = 0;
let finalST = 0;
if (on && f) {
const cell = fullW / 15;
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW));
finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH));
}
const fromW = on ? fitW : fullW; // board width when this transition begins
const toW = on ? fullW : fitW;
let raf = requestAnimationFrame(function tick() {
const curW = vp.scrollWidth || fromW;
let prog = (curW - fromW) / (toW - fromW);
prog = prog < 0 ? 0 : prog > 1 ? 1 : prog;
vp.scrollLeft = startSL + (finalSL - startSL) * prog;
vp.scrollTop = startST + (finalST - startST) * prog;
if (prog < 1) raf = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(raf);
});
// Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll).
// Pinch zoom (Stage 17): a two-finger spread zooms in toward the pinch midpoint, a pinch
// close zooms out. preventDefault fires only for two touches, so the one-finger native
// scroll of the zoomed board is left untouched. It maps to the same two-state zoom as
// double-tap, toggling toward the midpoint cell.
$effect(() => {
const vp = viewport;
if (!vp) return;
let startDist = 0;
let acted = false;
const span = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
const midCell = (t: TouchList) => {
const x = (t[0].clientX + t[1].clientX) / 2;
const y = (t[0].clientY + t[1].clientY) / 2;
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as HTMLElement | null;
if (!el?.dataset.row || !el.dataset.col) return null;
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
};
const onStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
startDist = span(e.touches);
acted = false;
}
};
const onMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || startDist === 0) return;
e.preventDefault(); // claim the two-finger gesture; one finger still scrolls natively
if (acted) return;
const ratio = span(e.touches) / startDist;
if (ratio > 1.25 && !zoomed) {
const c = midCell(e.touches);
if (c) {
acted = true;
ontogglezoom(c.row, c.col);
}
} else if (ratio < 0.8 && zoomed) {
const c = midCell(e.touches) ?? centre;
acted = true;
ontogglezoom(c.row, c.col);
}
};
const onEnd = (e: TouchEvent) => {
if (e.touches.length < 2) {
startDist = 0;
acted = false;
}
};
vp.addEventListener('touchstart', onStart, { passive: true });
vp.addEventListener('touchmove', onMove, { passive: false });
vp.addEventListener('touchend', onEnd);
vp.addEventListener('touchcancel', onEnd);
return () => {
vp.removeEventListener('touchstart', onStart);
vp.removeEventListener('touchmove', onMove);
vp.removeEventListener('touchend', onEnd);
vp.removeEventListener('touchcancel', onEnd);
};
});
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
// scroll of the zoomed board is never hijacked.
let lastTap = 0;
let lastCell = '';
function onTap(row: number, col: number) {
const now = Date.now();
if (now - lastTap < 300) {
ontogglezoom();
const k = key(row, col);
// A double-tap counts only when it lands twice on the same cell, so quick taps across
// different cells don't coalesce into a stray recall/zoom.
if (k === lastCell && now - lastTap < 300) {
lastTap = 0;
lastCell = '';
if (pending.has(k)) onrecall(row, col);
else ontogglezoom(row, col);
return;
}
lastTap = now;
lastCell = k;
oncell(row, col);
}
@@ -78,7 +180,7 @@
<div class="viewport" class:zoomed bind:this={viewport}>
<div class="scaler" style="--z: {z};">
<div class="grid">
<div class="grid" class:gridless={!lines}>
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
@@ -91,10 +193,13 @@
class:pending={!!p && !cell}
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
class:flash={!!cell && flash && highlight.has(key(r, c))}
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
class:droptarget={dropTarget?.row === r && dropTarget?.col === c}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
onpointerdown={(e) => { if (!!p && !cell) onpenddown(e, r, c); }}
>
{#if letter}
<span class="letter">{letter}</span>
@@ -145,6 +250,9 @@
border-radius: 1px;
background: var(--cell-bg);
color: var(--prem-text);
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
cell animation is the last-word .flash highlight). */
-webkit-tap-highlight-color: transparent;
padding: 0;
overflow: hidden;
font-size: 0;
@@ -170,8 +278,39 @@
.cell.pending {
background: var(--tile-pending);
}
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
get rounded corners and a soft right-side shadow so adjacent gapless tiles still read
as separate pieces. */
.grid.gridless {
gap: 0;
padding: 0;
background: var(--board-bg);
}
.grid.gridless .cell {
border-radius: 0;
}
.grid.gridless .cell.dark {
background: color-mix(in srgb, var(--cell-bg) 88%, #000);
}
.grid.gridless .cell.filled,
.grid.gridless .cell.pending {
border-radius: 4px;
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
}
.cell.droptarget {
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
target reads clearly while dragging without obscuring the bonus label underneath. */
box-shadow: inset 0 0 0 2px var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--cell-bg));
}
.cell.hl {
background: var(--tile-recent);
/* The bottom edge goes darker than the highlighted fill (not lighter, as the plain
--tile-edge would), so the tile still reads as raised. */
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
}
.cell.flash {
/* Two flashes to draw the eye, then settle back to normal so it does not distract. */
@@ -181,9 +320,11 @@
0%,
100% {
background: var(--tile-bg);
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
50% {
background: var(--tile-recent);
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
}
}
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
+32 -8
View File
@@ -6,12 +6,20 @@
messages,
myId,
busy,
myTurn = false,
nudgeOnCooldown = false,
onsend,
onnudge,
}: {
messages: ChatMessage[];
myId: string;
busy: boolean;
// Chat and nudge are mutually exclusive by turn (Stage 17): on the player's own turn the
// message field + send are shown (and nudging makes no sense — there is no one to
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
// cooldown is active the nudge is disabled with an "awaiting reply" caption.
myTurn?: boolean;
nudgeOnCooldown?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
@@ -40,14 +48,19 @@
{/each}
</div>
<div class="input">
<input
maxlength="60"
placeholder={t('chat.placeholder')}
bind:value={text}
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>
{#if myTurn}
<input
maxlength="60"
placeholder={t('chat.placeholder')}
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}></button>
{:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div>
</div>
@@ -95,6 +108,14 @@
.input {
display: flex;
gap: 6px;
align-items: center;
}
/* The cooldown caption sits to the left of the disabled nudge button. */
.cooldown {
flex: 1;
text-align: right;
color: var(--text-muted);
font-size: 0.85rem;
}
.input input {
flex: 1;
@@ -115,4 +136,7 @@
font-size: 1.25rem;
line-height: 1;
}
.iconbtn:disabled {
opacity: 0.45;
}
</style>
+322 -58
View File
@@ -15,9 +15,12 @@
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { replay } from '../lib/board';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
newPlacement,
@@ -25,6 +28,7 @@
placementFromHint,
rackView,
recallAt,
reorderIndices,
reset,
toSubmit,
type Placement,
@@ -41,6 +45,10 @@
let zoomed = $state(false);
let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null);
// A stable id per rack slot, permuted together with the letters on shuffle, so the rack
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
let rackIds = $state<number[]>([]);
let shuffling = $state(false);
let panel = $state<'none' | 'chat'>('none');
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
@@ -79,9 +87,33 @@
view.game.toMove === view.seat,
);
const slots = $derived(rackView(placement));
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
// Nudge cooldown (one per hour per game, mirrored from the backend): the control is
// disabled for an hour after the player's own last nudge. nudgeTick re-evaluates it on a
// timer while the chat is open, so it re-enables without waiting for a new message.
const nudgeCooldownSecs = 3600;
let nudgeTick = $state(0);
// Unix seconds of the player's own last move, which resets the nudge cooldown (mirrors the
// backend, Stage 17). A chat reset is read from `messages`; a move is tracked client-side
// (the backend stays authoritative across a reload).
let lastActedAt = $state(0);
const nudgeOnCooldown = $derived.by(() => {
void nudgeTick;
const mine = app.session?.userId ?? '';
let lastNudge = 0;
let lastChat = 0;
for (const m of messages) {
if (m.senderId !== mine) continue;
if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix);
else lastChat = Math.max(lastChat, m.createdAtUnix);
}
if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false;
// Engagement since the nudge clears the cooldown: a chat or a move.
return lastChat <= lastNudge && lastActedAt <= lastNudge;
});
async function load() {
try {
@@ -94,7 +126,9 @@
]);
view = st;
moves = hist.moves;
setCachedGame(id, st, hist.moves);
placement = newPlacement(st.rack);
rackIds = st.rack.map((_, i) => i);
preview = null;
selected = null;
dirOverride = undefined;
@@ -109,75 +143,230 @@
handleError(e);
}
}
onMount(load);
onMount(() => {
// Guard against an accidental swipe-close losing the open game (Telegram).
telegramClosingConfirmation(true);
// 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);
rackIds = cached.view.rack.map((_, i) => i);
}
void load();
});
$effect(() => {
const e = app.lastEvent;
if (!e) return;
if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load();
if (e.kind === 'opponent_moved' && e.gameId === id) {
// Skip the echo of my own move (the backend now notifies the actor too, for the
// player's other devices): this device already reloaded after the submit.
if (e.seat !== view?.seat) void load();
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
});
// Tick the nudge cooldown while the chat is open so the control re-enables on time.
$effect(() => {
if (panel !== 'chat') return;
const h = setInterval(() => (nudgeTick += 1), 20000);
return () => clearInterval(h);
});
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
// --- tile placement: pointer drag + tap ---
let downInfo: { index: number; x0: number; y0: number } | null = null;
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
// for the movement threshold that distinguishes a drag from a tap.
type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number };
let downInfo: { src: DragSrc; x0: number; y0: number } | null = null;
let dragMoved = false;
let swallowClick = false;
let hoverKey = '';
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
let dropTarget = $state<{ row: number; col: number } | null>(null);
// Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
let reorderDragId = $state<number | null>(null);
let reorderTo = $state<number | null>(null);
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
downInfo = { index, x0: e.clientX, y0: e.clientY };
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
downInfo = { src, x0: e.clientX, y0: e.clientY };
dragMoved = false;
dragPointerId = e.pointerId;
window.addEventListener('pointermove', onWinMove);
window.addEventListener('pointerup', onWinUp);
window.addEventListener('pointerdown', onExtraPointer);
}
// A second finger touching down turns the gesture into a pinch (Board handles it), so any
// drag started by the first finger — e.g. a pinch that began on a pending tile — is aborted.
// The starting pointer's own event also bubbles here, so ignore it by id.
function onExtraPointer(e: PointerEvent) {
if (downInfo && e.pointerId !== dragPointerId) cancelDrag();
}
function cancelDrag() {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
downInfo = null;
dragMoved = false;
drag = null;
}
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
beginDrag({ from: 'rack', index }, e);
}
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
function onBoardDown(e: PointerEvent, row: number, col: number) {
if (!isMyTurn || busy || zoomed) return;
beginDrag({ from: 'board', row, col }, e);
}
function cellUnder(x: number, y: number): { row: number; col: number } | null {
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as
| HTMLElement
| null;
if (!el?.dataset.row || !el.dataset.col) return null;
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
}
function clearHover() {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = null;
hoverKey = '';
dropTarget = null;
}
function clearReorder() {
reorderDragId = null;
reorderTo = null;
}
// overRack reports whether y is within the rack's row (a small margin makes the target
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
function overRack(y: number): boolean {
const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect();
return !!r && y >= r.top - 24 && y <= r.bottom + 24;
}
function dropSlotAt(x: number): number {
const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[];
for (let i = 0; i < tiles.length; i++) {
const r = tiles[i].getBoundingClientRect();
if (x < r.left + r.width / 2) return i;
}
return tiles.length;
}
// reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and
// its stable ids. Only valid with no pending tiles (the rack is then a clean permutation).
function reorderRack(fromIndex: number, toSlot: number) {
if (placement.pending.length > 0) return;
const order = reorderIndices(placement.rack.length, fromIndex, toSlot);
rackIds = order.map((i) => rackIds[i] ?? i);
placement = newPlacement(order.map((i) => placement.rack[i]));
selected = null;
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
dragMoved = true;
const slot = placement.rack[downInfo.index];
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
// No zoom on drag start: the player may still change their mind. The zoom
// (and centring) happens on drop, in attemptPlace.
const src = downInfo.src;
const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it).
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
// No zoom on drag start: the player may still change their mind. Holding the tile
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
}
if (!drag) return;
drag = { ...drag, x: e.clientX, y: e.clientY };
const c = cellUnder(e.clientX, e.clientY);
// Preview where the drop lands: a drop-target ring on a free board cell, or — for a
// rack-source drag over the rack with no pending tiles — a reorder gap at that slot.
if (c) {
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
reorderTo = null;
} else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) {
reorderTo = dropSlotAt(e.clientX);
dropTarget = null;
} else {
dropTarget = null;
reorderTo = null;
}
const ck = c ? `${c.row},${c.col}` : '';
if (ck !== hoverKey) {
hoverKey = ck;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer =
c && !zoomed
? setTimeout(() => {
// Still holding the tile over this cell: magnify into it. Only the first
// (zoom-in) hold centres; once zoomed we never move the board on hover.
if (drag && isCoarse() && !zoomed) {
focus = c;
zoomed = true;
telegramHaptic('light');
}
}, 1000)
: null;
}
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
}
function onWinUp(e: PointerEvent) {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
const di = downInfo;
downInfo = null;
if (drag && dragMoved && di) {
const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(
'[data-cell]',
) as HTMLElement | null;
drag = null;
if (el?.dataset.row && el.dataset.col) {
attemptPlace(di.index, Number(el.dataset.row), Number(el.dataset.col));
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
const cell = cellUnder(e.clientX, e.clientY);
const to = reorderTo;
if (di.src.from === 'rack' && cell) {
attemptPlace(di.src.index, cell.row, cell.col);
} else if (di.src.from === 'rack' && onRack && to != null) {
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
reorderRack(di.src.index, to);
} else if (di.src.from === 'board' && onRack) {
// Dropped a pending tile back onto the rack → recall it to its original slot.
placement = recallAt(placement, di.src.row, di.src.col);
recompute();
}
swallowClick = true;
setTimeout(() => (swallowClick = false), 60);
} else if (di) {
selected = selected === di.index ? null : di.index;
} else if (di && di.src.from === 'rack') {
selected = selected === di.src.index ? null : di.src.index;
drag = null;
} else {
drag = null;
}
clearReorder();
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
telegramClosingConfirmation(false);
});
function onCell(row: number, col: number) {
if (swallowClick) return;
if (pendingMap.has(`${row},${col}`)) {
placement = recallAt(placement, row, col);
recompute();
return;
}
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
// by a single tap (which recalled too easily — Stage 17).
if (pendingMap.has(`${row},${col}`)) return;
if (selected != null) {
// A committed tile already sits here: keep the rack selection so a stray tap
// on an occupied cell doesn't cancel placement — wait for an empty cell.
@@ -186,6 +375,10 @@
selected = null;
}
}
function onRecall(row: number, col: number) {
placement = recallAt(placement, row, col);
recompute();
}
function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return;
@@ -196,12 +389,14 @@
return;
}
placement = place(placement, index, row, col);
telegramHaptic('select');
recompute();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
blankPrompt = null;
telegramHaptic('select');
recompute();
}
@@ -226,6 +421,8 @@
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
telegramHaptic('success');
zoomed = false;
await load();
} catch (e) {
@@ -245,6 +442,7 @@
busy = true;
try {
await gateway.pass(id);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
@@ -269,6 +467,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();
@@ -284,12 +493,20 @@
}
function shuffle() {
if (placement.pending.length > 0) return;
const r = [...placement.rack];
for (let i = r.length - 1; i > 0; i--) {
// Shuffle an index permutation, then apply it to both the letters and the slot ids so
// each tile keeps its id as it flies to a new position (driving Rack's hop animation).
const order = placement.rack.map((_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[r[i], r[j]] = [r[j], r[i]];
[order[i], order[j]] = [order[j], order[i]];
}
placement = newPlacement(r);
rackIds = order.map((i) => rackIds[i] ?? i);
placement = newPlacement(order.map((i) => placement.rack[i]));
selected = null;
shuffling = true;
setTimeout(() => (shuffling = false), 600);
// A short "shake": a few quick light taps rather than one.
for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
}
function openExchange() {
resetPlacement();
@@ -306,6 +523,7 @@
busy = true;
try {
await gateway.exchange(id, tiles, variant);
lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
@@ -422,13 +640,15 @@
]);
</script>
<Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
{#snippet menu()}
<Menu items={menuItems} />
{/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>
@@ -470,16 +690,20 @@
{zoomed}
{variant}
labelMode={app.boardLabels}
lines={app.boardLines}
locale={app.locale}
{focus}
{dropTarget}
oncell={onCell}
ontogglezoom={() => { if (!gameOver) zoomed = !zoomed; }}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
onpenddown={onBoardDown}
/>
</div>
</div>
<div class="status">
<span>{t('game.bag', { n: view.bagLen })}</span>
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
@@ -494,16 +718,18 @@
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver}>
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
<Rack
slots={rackSlots}
{variant}
{selected}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
</div>
{#if !gameOver && placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
<button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{:else}
@@ -520,16 +746,22 @@
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
{#snippet trigger()}
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<button class="tab" disabled={busy || gameOver || placement.pending.length > 0} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
</button>
{:else}
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{/if}
</TabBar>
{/if}
{/snippet}
@@ -567,7 +799,7 @@
{/if}
{#if checkOpen}
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
<Modal title={t('game.checkWord')} overlayKeyboard onclose={() => (checkOpen = false)}>
<div class="check">
<input
value={checkWord}
@@ -599,26 +831,43 @@
{#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} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
gap: 2px;
padding: 6px var(--pad);
flex: none;
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);
/* inactive seats recede: they blend into the bar, slightly sunk */
background: transparent;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
}
.seat .nm {
color: var(--text-muted);
}
.seat.turn {
/* the active seat pops: a raised, accented chip lifted clear of the bar */
background: var(--surface-2);
outline: 1px solid var(--accent);
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.3),
-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);
@@ -636,14 +885,24 @@
}
.stage {
position: relative;
overflow: hidden;
/* The board is the only part that scrolls vertically when the game does not fit;
the score bar, status, rack and tab bar stay put (#9). */
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.history {
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);
@@ -691,6 +950,7 @@
}
.status {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 2px var(--pad) 6px;
@@ -712,6 +972,7 @@
}
.rack-row {
display: flex;
flex: none;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad) 6px;
@@ -724,16 +985,19 @@
flex: 1;
min-width: 0;
}
.flag {
font-size: 1.6rem;
}
:global(.make) {
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
while the pending word is known to be illegal (Stage 17). */
.make {
min-width: 56px;
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
background: none;
color: var(--text);
border: none;
display: grid;
place-items: center;
font-size: 1.8rem;
}
.make:disabled {
opacity: 0.4;
}
.pop {
padding: 9px 14px;
+45 -4
View File
@@ -8,25 +8,58 @@
slots,
variant,
selected,
shuffling = false,
draggingId = null,
dropIndex = null,
ondown,
}: {
slots: RackSlot[];
// Each slot carries a stable id that travels with its tile through a shuffle, so the
// keyed list reorders (rather than relabelling in place) and the hop animation fires.
slots: (RackSlot & { id: number })[];
variant: Variant;
selected: number | null;
shuffling?: boolean;
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
// the drag ghost stands in) and dropIndex is the slot where a gap opens (Stage 17).
draggingId?: number | null;
dropIndex?: number | null;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
// Used slots are hidden (the rack shifts left, freeing room on the right for the
// MakeMove control); the slot still exists in the model for per-tile recall.
// MakeMove control); the slot still exists in the model for per-tile recall. While
// reordering, the dragged tile is lifted out (the ghost shows it).
const visible = $derived(slots.filter((s) => !s.used));
const shown = $derived(draggingId == null ? visible : visible.filter((s) => s.id !== draggingId));
// hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile
// height). The duration scales with the horizontal distance — i.e. the arc length — so
// the longest swap (slot 1 ↔ 7) takes ~0.3s and shorter swaps land sooner. It runs only
// while a shuffle is in progress (and motion is not reduced); ordinary reflow
// (placing/recalling a tile) is instant.
function hop(node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, active: boolean) {
const dx = from.left - to.left;
const dy = from.top - to.top;
const dist = Math.hypot(dx, dy);
if (!active || dist < 2) return { duration: 0 };
const span = node.parentElement?.getBoundingClientRect().width || dist;
const lift = (to.height || from.height) * 0.5;
return {
duration: Math.max(120, Math.min(300, (dist / span) * 340)),
css: (t: number, u: number) =>
`transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`,
};
}
</script>
<div class="rack">
{#each visible as slot (slot.index)}
<div class="rack" class:reordering={draggingId != null} data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"
class:selected={selected === slot.index}
class:shift={dropIndex != null && i >= dropIndex}
data-rack-index={slot.index}
animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
@@ -63,6 +96,14 @@
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile {
transition: transform 0.14s ease;
}
.tile.shift {
transform: translateX(calc(100% + 5px));
}
.letter {
position: absolute;
top: 8%;
+62
View File
@@ -0,0 +1,62 @@
// Localised "About" / landing copy, shared by the About screen and the public landing
// page (Stage 17). Kept out of the flat i18n catalog because it is structured (a heading,
// a rules link, two bulleted sections) and only used in these two long-form places.
import type { Locale } from './i18n/index.svelte';
export interface AboutContent {
/** Prominent heading: "Scrabble" / "Эрудит (Скрэббл)". */
title: string;
rulesUrl: string;
/** Text before the rules link. */
rulesPrefix: string;
/** The rules link label. */
rulesLink: string;
randomTitle: string;
/** The "respect the opponent's time" note (rendered with a ❗️ prefix). */
randomRespect: string;
random: string[];
friendsTitle: string;
friends: string[];
}
/**
* aboutContent returns the localised About/landing copy. hours is the auto-match move clock
* (backend game.DefaultTurnTimeout), inlined into the random-game time-limit bullet.
*/
export function aboutContent(locale: Locale, hours: number): AboutContent {
if (locale === 'ru') {
return {
title: 'Эрудит (Скрэббл)',
rulesUrl: 'https://ru.wikipedia.org/wiki/Скрэббл',
rulesPrefix: 'Основные ',
rulesLink: 'правила игры',
randomTitle: 'Случайная игра',
randomRespect: 'Уважайте личное время соперника, будьте терпеливы.',
random: [
'В игре двое соперников.',
'Каждому доступна 1 подсказка в новой партии.',
`Лимит времени на ход: ${hours} ч. 00 минут.`,
'Время отсутствия задаётся в профиле и продлевает лимит.',
],
friendsTitle: 'Игра с друзьями',
friends: ['До 4-х участников.', 'Количество подсказок регулируется.', 'Произвольный лимит времени.'],
};
}
return {
title: 'Scrabble',
rulesUrl: 'https://en.wikipedia.org/wiki/Scrabble',
rulesPrefix: 'Basic ',
rulesLink: 'game rules',
randomTitle: 'Random game',
randomRespect: "Respect your opponent's time, be patient.",
random: [
'Two opponents per game.',
'Each player gets 1 hint per new game.',
`Move time limit: ${hours} h 00 min.`,
'An away window set in your profile extends the limit.',
],
friendsTitle: 'Game with friends',
friends: ['Up to 4 players.', 'The number of hints is configurable.', 'A custom time limit.'],
};
}
+118 -8
View File
@@ -9,9 +9,20 @@ 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,
telegramDisableVerticalSwipes,
telegramHaptic,
telegramLaunch,
telegramOnEvent,
telegramSetChrome,
} from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
import { clearLobby } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -29,6 +40,8 @@ export const app = $state<{
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
boardLines: boolean;
localeLocked: boolean;
/** Pending incoming friend requests + invitations, for the lobby badge. */
notifications: number;
@@ -42,13 +55,48 @@ export const app = $state<{
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
boardLines: false,
localeLocked: false,
notifications: 0,
});
let unsubscribeStream: (() => void) | null = null;
let streamAlive = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
// Background/foreground tracking, to silence the reconnect banner during a normal app
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
let backgrounded = false;
let foregroundedAt = 0;
const reconnectGraceMs = 4000;
/** documentHidden reports whether the page is currently hidden. */
function documentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
/**
* bannerSuppressed reports whether the connection banner should stay hidden: while
* backgrounded, and for a short grace after returning to the foreground — a connection
* dropped while suspended surfaces its error on resume, before the silent reconnect lands.
*/
function bannerSuppressed(): boolean {
return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs;
}
function goBackground(): void {
backgrounded = true;
}
function goForeground(): void {
backgrounded = false;
foregroundedAt = Date.now();
if (!app.session) return;
if (!streamAlive) openStream(); // silently re-establish a stream dropped while away
void refreshNotifications();
}
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
@@ -57,6 +105,7 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
telegramHaptic('error');
if (err instanceof GatewayError) {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout();
@@ -70,6 +119,7 @@ export function handleError(err: unknown): void {
function openStream(): void {
closeStream();
streamAlive = true;
unsubscribeStream = gateway.subscribe(
(e) => {
app.lastEvent = e;
@@ -85,10 +135,26 @@ function openStream(): void {
void refreshNotifications();
}
},
() => showToast(t('error.unavailable'), 'error'),
() => {
streamAlive = false;
// A background suspend drops the single-shot stream. Keep the banner hidden while
// backgrounded or just-resumed (bannerSuppressed); always schedule a retry.
if (!bannerSuppressed()) 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 && !backgrounded && !documentHidden()) openStream();
}, 4000);
}
/**
* refreshNotifications recomputes the lobby badge count (incoming friend requests
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
@@ -111,8 +177,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> {
@@ -142,11 +213,26 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
app.profile = await gateway.profileGet();
}
/**
* syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
* theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
*/
function syncTelegramChrome(): void {
if (typeof document === 'undefined') return;
const cs = getComputedStyle(document.documentElement);
telegramSetChrome(
cs.getPropertyValue('--bg-elev').trim(),
cs.getPropertyValue('--bg').trim(),
cs.getPropertyValue('--bg-elev').trim(),
);
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
app.boardLines = prefs.boardLines ?? false;
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
@@ -170,6 +256,14 @@ 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);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
// fighting tile drag / board scroll.
syncTelegramChrome();
telegramDisableVerticalSwipes();
try {
await adoptSession(await gateway.authTelegram(launch.initData));
await routeStartParam(launch.startParam);
@@ -249,6 +343,8 @@ export async function loginEmail(email: string, code: string): Promise<void> {
export async function logout(): Promise<void> {
closeStream();
clearGameCache();
clearLobby();
gateway.setToken(null);
await clearSession();
app.session = null;
@@ -262,6 +358,7 @@ function persistPrefs(): void {
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
boardLines: app.boardLines,
});
}
@@ -314,10 +411,23 @@ 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).
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
});
export function setBoardLines(on: boolean): void {
app.boardLines = on;
persistPrefs();
}
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
// reconnect quietly on return (and refresh the lobby badge for any push missed while
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0).
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () =>
document.visibilityState === 'visible' ? goForeground() : goBackground(),
);
}
if (typeof window !== 'undefined') {
window.addEventListener('pageshow', goForeground);
window.addEventListener('pagehide', goBackground);
}
telegramOnEvent('activated', goForeground);
telegramOnEvent('deactivated', goBackground);
+2
View File
@@ -65,6 +65,8 @@ export interface GatewayClient {
// --- lobby ---
lobbyEnqueue(variant: Variant): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>;
/** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
lobbyCancel(): Promise<void>;
// --- game ---
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
+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();
}
+2 -2
View File
@@ -9,8 +9,8 @@ describe('i18n catalog', () => {
});
it('interpolates parameters', () => {
expect(translate('en', 'game.bag', { n: 7 })).toBe('Bag 7');
expect(translate('ru', 'game.bag', { n: 7 })).toBe('Мешок 7');
expect(translate('en', 'game.bag', { n: 7 })).toBe('7 in the bag');
expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке');
});
it('maps error codes to keys with a generic fallback', () => {
+16 -5
View File
@@ -40,13 +40,18 @@ export const en = {
'new.title': 'New game',
'new.subtitle': 'Auto-match with another player',
'new.english': 'English',
'new.russian': 'Russian',
'new.erudit': 'Эрудит',
'new.english': 'Scrabble',
'new.russian': 'Скрэббл',
'new.erudit': 'Erudite',
'new.find': 'Find a game',
'new.searching': 'Looking for an opponent…',
'new.rulesEnglish': '100 tiles · bingo +50',
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
'new.moveLimit': 'Move time: {n} h 00 min',
'game.bag': 'Bag {n}',
'game.bag': '{n} in the bag',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn',
'game.waiting': "Waiting for {name}",
@@ -93,7 +98,9 @@ export const en = {
'chat.placeholder': 'Quick message…',
'chat.send': 'Send',
'chat.nudge': 'Nudge',
'chat.nudge': 'Waiting for your move!',
'chat.nudgeAction': 'Nudge',
'chat.awaitingReply': "Waiting for the opponent's reply",
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
@@ -138,6 +145,7 @@ export const en = {
'settings.labelsBeginner': 'Beginner',
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.boardLines': 'Grid lines',
'settings.reduceMotion': 'Reduce motion',
'about.title': 'About',
@@ -148,10 +156,13 @@ 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.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.",
'error.chat_not_your_turn': 'You can chat only on your turn.',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
'error.already_queued': 'You are already in the queue.',
+15 -4
View File
@@ -41,13 +41,18 @@ export const ru: Record<MessageKey, string> = {
'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника',
'new.english': 'Английский',
'new.russian': 'Русский',
'new.english': 'Scrabble',
'new.russian': 'Скрэббл',
'new.erudit': 'Эрудит',
'new.find': 'Найти игру',
'new.searching': 'Ищем соперника…',
'new.rulesEnglish': '100 фишек · бинго +50',
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'game.bag': 'Мешок {n}',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход',
'game.waiting': 'Ожидаем {name}',
@@ -94,7 +99,9 @@ export const ru: Record<MessageKey, string> = {
'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить',
'chat.nudge': 'Поторопить',
'chat.nudge': 'Жду вашего хода!',
'chat.nudgeAction': 'Поторопить',
'chat.awaitingReply': 'Ждём реакцию соперника',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
@@ -139,6 +146,7 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsBeginner': 'Новичок',
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.boardLines': 'Линии сетки',
'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе',
@@ -149,10 +157,13 @@ 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': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
'error.already_queued': 'Вы уже в очереди.',
+30
View File
@@ -0,0 +1,30 @@
// In-memory lobby snapshot, the lobby counterpart of gamecache.ts. The lobby re-fetches
// its lists on every entry, so without a cache the screen renders blank and "draws in"
// during the back-slide from a game. Caching the last lists lets the lobby render
// instantly (before/under the transition) and refresh in the background. Process-memory
// only; cleared on logout.
import type { AccountRef, GameView, Invitation } from './model';
interface LobbySnapshot {
games: GameView[];
invitations: Invitation[];
incoming: AccountRef[];
}
let snapshot: LobbySnapshot | null = null;
/** getLobby returns the last lobby lists, or null before the first load. */
export function getLobby(): LobbySnapshot | null {
return snapshot;
}
/** setLobby stores the latest lobby lists. */
export function setLobby(s: LobbySnapshot): void {
snapshot = s;
}
/** clearLobby drops the cached lobby (called on logout). */
export function clearLobby(): void {
snapshot = null;
}
+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',
+6 -1
View File
@@ -62,7 +62,7 @@ function emptyLinked(): LinkResult {
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
};
@@ -180,6 +180,11 @@ export class MockGateway implements GatewayClient {
return { matched: false };
}
async lobbyCancel(): Promise<void> {
// Dequeue: drop the pending substitution so a cancelled quick-match never arrives.
this.pendingMatch = null;
}
// --- game ---
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
const g = this.game(gameId);
+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;
+10
View File
@@ -10,6 +10,7 @@ import {
rackView,
recallAt,
recallIndex,
reorderIndices,
reset,
toSubmit,
} from './placement';
@@ -122,3 +123,12 @@ describe('placementFromHint', () => {
expect(p.pending.map((t) => t.letter)).toEqual(['A']);
});
});
describe('reorderIndices', () => {
it('lifts an element and drops it at the given slot among the others', () => {
expect(reorderIndices(4, 0, 2)).toEqual([1, 2, 0, 3]);
expect(reorderIndices(4, 3, 0)).toEqual([3, 0, 1, 2]);
expect(reorderIndices(4, 1, 1)).toEqual([0, 1, 2, 3]); // back to identity
expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end
});
});
+13
View File
@@ -106,6 +106,19 @@ export function reset(p: Placement): Placement {
return { ...p, pending: [] };
}
/**
* reorderIndices returns the permutation of [0, n) that lifts the element at `from` and
* drops it at slot `toSlot` among the remaining elements (clamped to a valid slot). It is
* applied in parallel to the rack letters and their stable ids when a tile is dragged to a
* new rack position.
*/
export function reorderIndices(n: number, from: number, toSlot: number): number[] {
const order: number[] = [];
for (let i = 0; i < n; i++) if (i !== from) order.push(i);
order.splice(Math.max(0, Math.min(toSlot, order.length)), 0, from);
return order;
}
/**
* direction infers the play orientation from the pending tiles: H if they share a row,
* V if they share a column, null if a single tile (ambiguous) or non-linear (invalid).
+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('');
});
+4 -1
View File
@@ -12,7 +12,10 @@ describe('validDisplayName', () => {
['Name P._Last', false],
['Name Last', false],
['_Name', false],
['Name.', false],
['Anna B.', true],
['Name.', true],
['Name..', false],
['Name_', false],
['Name2', false],
['', false],
['a'.repeat(33), false],
+4 -3
View File
@@ -9,9 +9,10 @@ export const maxDisplayName = 32;
export const maxAwayMinutes = 12 * 60;
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
// may be followed by a single space. No leading/trailing separator and no adjacent
// separators except "<dot|underscore> <space>". Same rule as the Go displayNameRe.
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*$/u;
// may be followed by a single space. No leading separator and no adjacent separators
// except "<dot|underscore> <space>"; a single trailing "." is allowed (Stage 17). Same
// rule as the Go displayNameRe.
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
/** displayNameError returns true when the trimmed name is a valid display name. */
export function validDisplayName(raw: string): boolean {
+9
View File
@@ -48,6 +48,15 @@ describe('resultBadge', () => {
});
});
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (Stage 17 regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
});
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
+5 -3
View File
@@ -21,9 +21,11 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
// Someone else won — place the viewer by score (1 + number of higher scores).
const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length;
if (rank <= 1) return { key: 'result.victory', emoji: '🏆' };
// Someone else won and it is not me, so I did not win — even when scores are level (a
// win by resignation or timeout can leave the winner at or below my score). The winner
// takes rank 1; place me among the remaining seats by score, starting at rank 2.
const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
const rank = 2 + ahead;
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
return { key: 'result.place4', emoji: '🏅' };
+2
View File
@@ -124,6 +124,8 @@ export interface Prefs {
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
/** Draw the 1px grid lines between cells; off (default) shows a gapless checkerboard. */
boardLines: boolean;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {
+104
View File
@@ -9,8 +9,27 @@ interface TelegramWebApp {
initData: string;
initDataUnsafe?: { start_param?: string };
themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark';
ready?: () => void;
expand?: () => void;
onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void;
setBottomBarColor?: (color: string) => void;
disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
HapticFeedback?: {
impactOccurred?: (style: string) => void;
notificationOccurred?: (type: string) => void;
selectionChanged?: () => void;
};
BackButton?: {
show?: () => void;
hide?: () => void;
onClick?: (cb: () => void) => void;
offClick?: (cb: () => void) => void;
};
}
function webApp(): TelegramWebApp | undefined {
@@ -48,6 +67,91 @@ export function telegramLaunch(): TelegramLaunch {
return { initData: w.initData, startParam, theme: w.themeParams };
}
/**
* telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' /
* 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client
* that predates the event, so callers can register defensively.
*/
export function telegramOnEvent(event: string, handler: () => void): void {
webApp()?.onEvent?.(event, handler);
}
/**
* 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;
}
/**
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
* outside Telegram or on a client predating a given setter.
*/
export function telegramSetChrome(header: string, background: string, bottom: string): void {
const w = webApp();
if (header) w?.setHeaderColor?.(header);
if (background) w?.setBackgroundColor?.(background);
if (bottom) w?.setBottomBarColor?.(bottom);
}
/**
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
* it does not fight tile drag-and-drop or the board's vertical scroll.
*/
export function telegramDisableVerticalSwipes(): void {
webApp()?.disableVerticalSwipes?.();
}
/** Haptic is the set of feedbacks the app triggers. */
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
/** telegramHaptic fires a Telegram haptic; a no-op outside Telegram or on older clients. */
export function telegramHaptic(kind: Haptic): void {
const h = webApp()?.HapticFeedback;
if (!h) return;
if (kind === 'select') h.selectionChanged?.();
else if (kind === 'success' || kind === 'error' || kind === 'warning') h.notificationOccurred?.(kind);
else h.impactOccurred?.(kind);
}
/**
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user
* swipes the Mini App closed — enabled during an active game so it is not lost by accident.
*/
export function telegramClosingConfirmation(on: boolean): void {
const w = webApp();
if (on) w?.enableClosingConfirmation?.();
else w?.disableClosingConfirmation?.();
}
let backHandler: (() => void) | null = null;
/**
* telegramBackButton shows or hides Telegram's native header back button, wiring its
* click to onClick (replacing any previous handler). The app hides its own back chevron
* inside Telegram so only the native control shows.
*/
export function telegramBackButton(show: boolean, onClick?: () => void): void {
const b = webApp()?.BackButton;
if (!b) return;
if (backHandler) b.offClick?.(backHandler);
backHandler = null;
if (show) {
if (onClick) {
backHandler = onClick;
b.onClick?.(onClick);
}
b.show?.();
} else {
b.hide?.();
}
}
/**
* 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');
}
+3
View File
@@ -80,6 +80,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async lobbyPoll() {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
},
async lobbyCancel() {
await exec('lobby.cancel', codec.empty());
},
async gameState(id, includeAlphabet) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
+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']);
});
});
+29 -3
View File
@@ -11,16 +11,42 @@ export interface VariantOption {
label: MessageKey;
}
// ALL_VARIANTS lists every variant in display order.
// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by
// the game's alphabet, not the interface language: the English-alphabet game is always
// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
// (Stage 17).
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' },
];
// variantNameKey returns the i18n key for a variant's display name (used by the in-game
// title and the lobby cards).
export function variantNameKey(v: Variant): MessageKey {
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
}
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
export const VARIANT_RULES: Record<Variant, MessageKey> = {
english: 'new.rulesEnglish',
russian_scrabble: 'new.rulesRussian',
erudit: 'new.rulesErudit',
};
// VARIANT_FLAG is the flag shown on a variant button: an emoji for the Scrabble variants;
// Erudit uses the bundled USSR flag SVG (public/flag-ussr.svg), so its entry is empty.
export const VARIANT_FLAG: Record<Variant, string> = {
english: '🇺🇸',
russian_scrabble: '🇷🇺',
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
+59 -3
View File
@@ -1,14 +1,37 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import { aboutContent } from '../lib/aboutContent';
const version = '0.7.0';
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24;
const version = __APP_VERSION__;
const c = $derived(aboutContent(app.locale, AUTO_MATCH_HOURS));
</script>
<Screen title={t('about.title')} back="/">
<div class="page">
<h2>{t('app.title')}</h2>
<p>{t('about.description')}</p>
<h1>{c.title}</h1>
<p>
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer">{c.rulesLink}</a>.
</p>
<section>
<h2>{c.randomTitle}</h2>
<p class="respect">❗️{c.randomRespect}</p>
<ul>
{#each c.random as item (item)}<li>{item}</li>{/each}
</ul>
</section>
<section>
<h2>{c.friendsTitle}</h2>
<ul>
{#each c.friends as item (item)}<li>{item}</li>{/each}
</ul>
</section>
<p class="muted">{t('about.version', { v: version })}</p>
</div>
</Screen>
@@ -16,8 +39,41 @@
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 14px;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
h2 {
margin: 0 0 6px;
font-size: 1.05rem;
color: var(--text-muted);
}
section {
display: flex;
flex-direction: column;
gap: 4px;
}
ul {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
a {
color: var(--accent);
}
.respect {
margin: 0;
font-weight: 600;
}
.muted {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
</style>

Some files were not shown because too many files have changed in this diff Show More