Stage 17: test-contour verification & defect fixes #19
+125
-2
@@ -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
|
||||
|
||||
@@ -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 2–3, from live testing): the Grafana
|
||||
double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is
|
||||
disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the
|
||||
move-duration panel was invisible because the deploy reseed (`rm -rf`) left the
|
||||
config-only services on a stale bind mount — the deploy now **force-recreates**
|
||||
caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 →
|
||||
300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram
|
||||
reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide
|
||||
+ Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted —
|
||||
chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native
|
||||
**BackButton**, **HapticFeedback**, **closing confirmation** in a game,
|
||||
**disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat
|
||||
pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
|
||||
button disables at zero hints; plus **board-only vertical scroll** (#9) and a
|
||||
**keyboard-overlay** check-word dialog (#10).
|
||||
- **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised
|
||||
[1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel
|
||||
toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a
|
||||
single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap**
|
||||
or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over
|
||||
a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to
|
||||
their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings
|
||||
toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming
|
||||
~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native
|
||||
scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
|
||||
**robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
|
||||
across the game/robot package boundary, to be picked up when that seam is added.
|
||||
- **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn**
|
||||
now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit
|
||||
the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op
|
||||
(only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the
|
||||
matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later
|
||||
robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win —
|
||||
`result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend
|
||||
request to a robot** is accepted as pending and expires like a human ignore (robots no longer set
|
||||
`BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the
|
||||
chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish:
|
||||
**even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no
|
||||
lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch
|
||||
zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop
|
||||
capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known-
|
||||
illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit
|
||||
→ Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game
|
||||
card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's
|
||||
turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would
|
||||
show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
|
||||
- **Contour-verification follow-ups** (round 6, from live testing) — **shipped & deployed:** profile drops
|
||||
the hint-balance line; no mobile tap-flash on a board cell (`-webkit-tap-highlight-color`); variant
|
||||
display names keyed by the game's **alphabet**, not the UI language (english → "Scrabble",
|
||||
russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game
|
||||
title shows the variant name; **chat & nudge are mutually exclusive by turn** (message field on your
|
||||
turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced
|
||||
server-side to your own turn (`ErrChatNotYourTurn`); the **nudge cooldown resets** once the player has
|
||||
moved or chatted since the last nudge (`game.LastMoveAt` + last chat vs last nudge; the UI mirrors it);
|
||||
the **About** screen got localized titles + a rules link + the random/friends sections, and the app
|
||||
**version comes from `git describe`** (Vite define `__APP_VERSION__` ← Docker build-arg in the deploy
|
||||
step, default "dev"); the **quick-game buttons** became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a
|
||||
bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences
|
||||
from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when
|
||||
available; redraw the USSR emblem as a thin schematic hammer & sickle); **#3 drag-reorder of rack tiles**
|
||||
with a visual gap (the dragged tile lifts out, the rest slide to open a slot; `reorderIndices`
|
||||
unit-tested; only with no pending tiles); and the **persistence backend foundation** (#4/#5/#6): a
|
||||
`game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every
|
||||
committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
|
||||
overlapped — 5 integration tests.
|
||||
- **Stage 17 round 6 — REMAINING (next pass), designs ready:**
|
||||
1. **Persistence gateway slice + UI (#4/#5/#6).** *FB (lean):* `DraftRequest{game_id, json}` (save) +
|
||||
a game-id request (get) + `DraftView{json}` — one string field; the client serializes/deserializes
|
||||
`{rack_order, board_tiles}` itself (no FB tile array). Regen Go (`make -C pkg fbs`) + TS
|
||||
(`pnpm codegen`); flatc is pinned **23.5.26** (the local one matches). *Gateway* forwards the JSON as
|
||||
`json.RawMessage` (no double-encode). *REST:* `GET`/`PUT /games/:id/draft` (decodes `game.Draft`).
|
||||
*UI:* save the rack order (#4) and board draft (#6) on change (debounced) and restore on load
|
||||
(next to `gameState`); **#5** — allow placing tiles on the opponent's turn (relax the `isMyTurn`
|
||||
gate on placement only; the evaluate-preview and Make-move stay your-turn-only, so an off-turn draft
|
||||
is position-only — never scored/submitted).
|
||||
2. **Landing + `/app/` move (#16–20).** An extra Svelte page at `/`, the game SPA under `/app/` (Vite
|
||||
`base` conditional: `/app/` for web, `./` for Capacitor); the gateway serves the landing at `/` and
|
||||
the SPA at `/app/*`; a bundled Telegram logo (from `.claude/telegram-logo.svg`, **copied into
|
||||
`ui/public/`, the reference itself not committed**) linking to the per-language t.me bot (ru
|
||||
`Erudit_Game` / en `Scrabble_Game`); theme + language switchers reusing the app stores; reuse the
|
||||
`aboutContent` copy. **Note:** moving the game to `/app/` means the Telegram Mini App URL must point
|
||||
to `/app/`.
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
||||
|
||||
+4
-4
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "?", "_")
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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}}">« prev</a>{{end}}
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 (**1–30 points**), rather than
|
||||
always the maximum; with no legal play it exchanges a full rack when the bag can
|
||||
refill it, else passes.
|
||||
- **Timing**: per-move delay sampled from a right-skewed distribution (short
|
||||
delays frequent, median ≈ 10 min), clamped to **[2, 90] minutes**; it
|
||||
- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample
|
||||
(exponent k=4, short delays frequent) from a band that interpolates from
|
||||
**[3, 10] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
|
||||
quick and the endgame can run long, clamped to **[1, 90] minutes**; it
|
||||
**sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a
|
||||
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
|
||||
rather than running anti-phase; on a daytime nudge it replies within
|
||||
**2–10 minutes**; it proactively nudges the human after **12 hours** idle
|
||||
(subject to the once-per-hour chat limit).
|
||||
rather than running anti-phase; on a daytime nudge it replies near the move's lower
|
||||
band; it proactively nudges the human after **12 hours** idle (subject to the
|
||||
once-per-hour chat limit).
|
||||
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
|
||||
authoritative balance metric (target ≈ 40% robot wins) — and a
|
||||
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
|
||||
The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
|
||||
the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17).
|
||||
|
||||
## 8. Lobby & social
|
||||
|
||||
@@ -334,6 +348,9 @@ requires (there is no DM surface; chat is per-game).
|
||||
robot (§7) and starts the game. On a pairing or substitution the matchmaker
|
||||
emits a **match-found** notification (§10), delivered over the live stream;
|
||||
`Poll` remains as a fallback for a client that is not currently streaming.
|
||||
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
|
||||
pending matched result, so a cancelled quick-match is dequeued rather than left for
|
||||
the reaper to robot-substitute (Stage 17).
|
||||
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
|
||||
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
||||
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||
@@ -451,7 +468,9 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
|
||||
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
|
||||
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
|
||||
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
|
||||
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
|
||||
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
|
||||
including the mover**, so the mover's own other devices and their lobby refresh — it is
|
||||
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
||||
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
||||
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||
friend-added, invitation or game-started; emitted on a friend-request and invitation
|
||||
@@ -499,13 +518,21 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
|
||||
until an exporter is configured: histograms `game_replay_duration` (journal
|
||||
rebuild on a cache miss) and `game_move_validate_duration`; counters
|
||||
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop),
|
||||
`chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`;
|
||||
an observable gauge `game_cache_active`; the gateway `edge_request_duration`
|
||||
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
|
||||
metrics. Game-scoped metrics carry a `variant` attribute
|
||||
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
|
||||
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
|
||||
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
|
||||
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
|
||||
console, below); counters `games_started_total`, `games_abandoned_total` (a
|
||||
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
|
||||
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
|
||||
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
||||
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
||||
(english/russian_scrabble/erudit).
|
||||
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
|
||||
console from the move journal (`game_moves.created_at` deltas, the first move from
|
||||
the game's creation), not Prometheus labels (which an `account_id` would explode):
|
||||
the user list shows each account's min/avg/max think time, and the user-detail page
|
||||
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
|
||||
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
|
||||
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
|
||||
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
|
||||
@@ -579,14 +606,27 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
- Trunk is **`master`**; feature work happens on `feature/*` branches merged via
|
||||
PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily
|
||||
lands on `master`).
|
||||
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test
|
||||
on Go changes; `integration.yaml` runs the Postgres-backed tests behind the
|
||||
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
|
||||
serial). Further workflows (ui-test, deploy) are added with the components they
|
||||
cover.
|
||||
- **Two long-lived branches** (Stage 16): **`development`** is the integration
|
||||
trunk and **`master`** the production trunk; `feature/*` branches are cut from
|
||||
`development` and PR back into it (the genesis commit necessarily landed on
|
||||
`master`). A commit to a `feature/*` branch triggers nothing.
|
||||
- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the
|
||||
suite on a PR into `development`/`master` and on a push to `development`. Its
|
||||
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
|
||||
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
|
||||
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
|
||||
`changes` job filters by changed paths), and an always-running **`gate`** job
|
||||
aggregates them (passing when each succeeded or was **skipped**) and is the single
|
||||
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
|
||||
a merge.
|
||||
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
|
||||
to — `development` (`docker compose up -d --build` on the runner host), then probes
|
||||
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
|
||||
`docker inspect`: running, not restarting, stable restart count, with a
|
||||
VPN-handshake grace period, since the connector has no public ingress and a
|
||||
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
|
||||
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
|
||||
`TEST_`/`PROD_` per contour.
|
||||
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
||||
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
|
||||
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
|
||||
|
||||
+12
-7
@@ -55,9 +55,11 @@ two accounts share a game still in progress.
|
||||
|
||||
### Lobby & matchmaking *(Stage 4 / 15)*
|
||||
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
|
||||
limited to the languages the player's sign-in service supports (English → English;
|
||||
Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is
|
||||
unrestricted). This gates only **starting** a new game — both auto-match and a friend
|
||||
limited to the languages the player's sign-in service supports (English → Scrabble;
|
||||
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
|
||||
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
|
||||
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
|
||||
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
|
||||
invitation — so a player still sees and plays existing games of any language. Auto-match
|
||||
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
|
||||
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
|
||||
@@ -91,7 +93,9 @@ wins most games), aims for a close score rather than crushing or throwing the ga
|
||||
and plays at a human pace — short thinking times for most moves, the occasional long
|
||||
one, and a night-time pause that tracks the player's own day. It answers a nudge
|
||||
within a few minutes and nudges back when the player has been away a long time. It
|
||||
carries a human-like name and neither chats nor accepts friend requests.
|
||||
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
|
||||
names); it does not chat, and **silently ignores friend requests** — a request to a
|
||||
robot stays pending and expires, exactly like a human who never responds.
|
||||
|
||||
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
@@ -108,9 +112,10 @@ even disguised. Nudge the player whose turn is awaited at most once per hour (th
|
||||
nudge is part of the game chat); the out-of-app push is delivered via the platform.
|
||||
|
||||
### Profile & settings *(Stage 4 / 8)*
|
||||
Edit the display name (letters joined by single space / "." / "_" separators, up to
|
||||
32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
|
||||
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking
|
||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
|
||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
merge" (Stage 11).
|
||||
|
||||
|
||||
+15
-10
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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': 'Вы уже в очереди.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -203,7 +203,7 @@ function finishedG3(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g3',
|
||||
variant: 'russian',
|
||||
variant: 'russian_scrabble',
|
||||
dictVersion: 'v1',
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
|
||||
+1
-1
@@ -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,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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('premium layout', () => {
|
||||
it('doubles the centre for standard variants but not for erudit', () => {
|
||||
expect(centre('english')).toEqual({ row: 7, col: 7 });
|
||||
expect(premiumGrid('english')[7][7]).toBe('DW');
|
||||
expect(premiumGrid('russian')[7][7]).toBe('DW');
|
||||
expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW');
|
||||
expect(centre('erudit')).toEqual({ row: 7, col: 7 });
|
||||
expect(premiumGrid('erudit')[7][7]).toBe('');
|
||||
});
|
||||
|
||||
@@ -12,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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '🏅' });
|
||||
|
||||
@@ -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: '🏅' };
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user