Compare commits
4 Commits
6886efb6c0
...
09fec2b83c
| Author | SHA1 | Date | |
|---|---|---|---|
| 09fec2b83c | |||
| 1d0bafaabb | |||
| c0b46a7ca6 | |||
| 635f2fd9fc |
+117
-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:
|
||||
@@ -215,6 +302,34 @@ jobs:
|
||||
docker logs --tail 50 scrabble-gateway || true
|
||||
exit 1
|
||||
|
||||
- name: Probe the Telegram connector liveness
|
||||
run: |
|
||||
set -u
|
||||
# The gateway probe cannot see a crash-looping connector (it long-polls and
|
||||
# egresses through the VPN sidecar, with no public ingress). Inspect the
|
||||
# container directly: it must be running, not restarting, with a stable
|
||||
# restart count. A grace period lets the VPN handshake settle (the connector
|
||||
# may restart a few times first).
|
||||
sleep 20
|
||||
for i in $(seq 1 20); do
|
||||
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
|
||||
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
|
||||
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
||||
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
sleep 5
|
||||
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
if [ "$c1" = "$c2" ]; then
|
||||
echo "connector healthy: status=$status restarts=$c2"
|
||||
exit 0
|
||||
fi
|
||||
echo "connector still restarting ($c1 -> $c2); waiting"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "connector not healthy; recent logs:"
|
||||
docker logs --tail 80 scrabble-telegram || true
|
||||
exit 1
|
||||
|
||||
- name: Prune dangling images
|
||||
if: always()
|
||||
run: docker image prune -f
|
||||
|
||||
@@ -50,7 +50,7 @@ independent (see ARCHITECTURE §9.1).
|
||||
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
|
||||
| 15 | Dual Telegram bots & language-gated variants | **done** |
|
||||
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
|
||||
| 17 | Test-contour verification & defect fixes | todo |
|
||||
| 17 | Test-contour verification & defect fixes | **done** |
|
||||
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
|
||||
|
||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||
@@ -298,7 +298,7 @@ h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go bu
|
||||
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
|
||||
collector/Tempo/Prometheus retention.
|
||||
|
||||
### Stage 17 — Test-contour verification & defect fixes
|
||||
### Stage 17 — Test-contour verification & defect fixes *(done)*
|
||||
Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the
|
||||
"does it actually work in the contour" pass before prod. Bring up the `development` deploy, then
|
||||
verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin
|
||||
@@ -316,6 +316,95 @@ are in-scope vs deferred; the changed-paths design + the aggregate gate job; the
|
||||
liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before
|
||||
it settles).
|
||||
|
||||
#### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*)
|
||||
|
||||
The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss),
|
||||
discussed where they were forks, and resolved in one session with tests where practical. The
|
||||
per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the
|
||||
raw list is kept here as the record of what the first contour run surfaced.
|
||||
|
||||
- /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/
|
||||
Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth.
|
||||
|
||||
- нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход,
|
||||
скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести
|
||||
метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?),
|
||||
а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа
|
||||
способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров.
|
||||
|
||||
- регистрация пользователя из телеграм (как и других коннекторов):
|
||||
пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле.
|
||||
если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр),
|
||||
язык в зависимости от внешнего коннектора.
|
||||
|
||||
- game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход".
|
||||
Думаю, опечатка - "не" лишняя, проверь на всех языках.
|
||||
|
||||
- если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная",
|
||||
т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает
|
||||
на самих стилях)
|
||||
|
||||
- возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема.
|
||||
когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar
|
||||
рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка.
|
||||
Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в
|
||||
самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там
|
||||
системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним.
|
||||
|
||||
- не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios,
|
||||
то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби.
|
||||
интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли?
|
||||
если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого?
|
||||
нужен твой анализ и совет.
|
||||
|
||||
- надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода
|
||||
выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал
|
||||
сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно.
|
||||
На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону.
|
||||
А то я сейчас поиграл, роботы на первых ходах по 15 минут думают.
|
||||
Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры.
|
||||
Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя
|
||||
активного игрока. Этот пункт требует тщательного обсуждения, пожалуй.
|
||||
|
||||
- при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает
|
||||
каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то
|
||||
неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать
|
||||
каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь
|
||||
проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п.
|
||||
|
||||
- при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна.
|
||||
|
||||
- а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой),
|
||||
чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое?
|
||||
ну и для собственного дефолтного стиля тоже надо выбрать соответствующие.
|
||||
|
||||
- Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем".
|
||||
Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся.
|
||||
Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать
|
||||
плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry.
|
||||
|
||||
- при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка.
|
||||
|
||||
- В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён
|
||||
было не больше 20%.
|
||||
|
||||
- Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации.
|
||||
|
||||
- Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство
|
||||
поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы
|
||||
остальные игроки были как бы "утоплены" внутрь.
|
||||
|
||||
- В игре клик/тач по плашке с именами игроков открывает/закрывает историю.
|
||||
|
||||
- В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо.
|
||||
|
||||
- В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить)
|
||||
|
||||
- При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей.
|
||||
|
||||
- Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка
|
||||
"что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится.
|
||||
|
||||
### Stage 18 — Prod contour deploy
|
||||
Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
|
||||
(`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP
|
||||
@@ -1115,6 +1204,57 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
||||
environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
|
||||
knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`.
|
||||
|
||||
- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's
|
||||
collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session.
|
||||
- **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical
|
||||
string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in
|
||||
the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`,
|
||||
`Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged
|
||||
(persisted games, GCG and the `variant` metric attribute keep it).
|
||||
- **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with
|
||||
`game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct
|
||||
`nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn.
|
||||
- **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the
|
||||
editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by
|
||||
language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor
|
||||
validation (e.g. "Peter J.").
|
||||
- **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names
|
||||
paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only /
|
||||
first + surname initial / first + full surname), composed deterministically per pool slot (stable
|
||||
across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20%
|
||||
Latin, an English game the Latin pool. Robot identities are keyed `robot-<lang>-<index>`.
|
||||
- **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the
|
||||
band interpolates from [1,5] min at the first move to [10,90] min by ~28 moves, right-skewed by k=4,
|
||||
so early moves are quick and the endgame can be long. A daytime nudge pulls the reply toward the
|
||||
move's lower band.
|
||||
- **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own
|
||||
other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the
|
||||
actor), and the gateway already fans each event out to all of a user's live streams.
|
||||
- **Move-duration analytics** (#1, interview): a live `game_move_duration{variant,phase}` histogram
|
||||
(opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console —
|
||||
min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number,
|
||||
computed from the journal (`game_moves.created_at` deltas; no schema change). Per-user stays offline,
|
||||
not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots
|
||||
included), so the per-human admin view is authoritative.
|
||||
- **CI** (#9/#10, interview): `unit`/`integration`/`ui` are path-conditional behind a `changes` job; an
|
||||
always-running `gate` job aggregates them (success-or-skipped) and is the single branch-protection
|
||||
required check (`CI / gate`), so a skipped job never blocks a merge. The deploy job gained a
|
||||
Telegram-connector liveness probe (`docker inspect`: running, not restarting, stable restart count,
|
||||
with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector
|
||||
was invisible to the gateway-only probe.
|
||||
- **UI theming / UX**: inside Telegram the colour scheme is forced from `WebApp.colorScheme` over the OS
|
||||
`prefers-color-scheme` (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden
|
||||
(#11); the nav bar takes Telegram's bg and the announcement banner a subtle `--ad-bg` accent (#14/#15);
|
||||
the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint
|
||||
zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others
|
||||
with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow
|
||||
to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders
|
||||
instantly on re-entry and refreshes in the background (#13).
|
||||
- **Grafana repeated password (#8) — not a server defect**: verified live that caddy challenges `/_gm`
|
||||
and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a
|
||||
browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify,
|
||||
no server change. **Multi-word history (#22)** was already implemented (all formed words shown).
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
||||
|
||||
+4
-4
@@ -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,41 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||
}
|
||||
|
||||
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||
// member: a KindRobot identity carrying displayName, with chat and friend requests
|
||||
// blocked so the robot never engages socially. Robot names are system-generated, not
|
||||
// player-edited, so they bypass the editable display-name validation and may carry
|
||||
// forms the editor rejects (an abbreviated surname like "Peter J."). It is idempotent:
|
||||
// repeated calls converge the display name and both block flags.
|
||||
func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) {
|
||||
acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName})
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if acc.DisplayName == displayName && acc.BlockChat && acc.BlockFriendRequests {
|
||||
return acc, nil
|
||||
}
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.DisplayName, table.Accounts.BlockChat,
|
||||
table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(displayName), postgres.Bool(true),
|
||||
postgres.Bool(true), postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
||||
// identity. On first contact only, it seeds the new account's preferred language
|
||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
||||
// its display name from firstName (falling back to username); an already-existing
|
||||
// its display name sanitized from firstName (falling back to username, then to a
|
||||
// generated placeholder when neither yields any letters); an already-existing
|
||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
||||
@@ -155,19 +185,21 @@ type provisionSeed struct {
|
||||
|
||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
|
||||
// username (capped to maxDisplayName runes).
|
||||
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
||||
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
||||
// editable format). When neither yields any letters, it falls back to a generated
|
||||
// placeholder in the seeded language (placeholderDisplayName).
|
||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
seed.preferredLanguage = lang
|
||||
}
|
||||
name := strings.TrimSpace(firstName)
|
||||
name := sanitizeDisplayName(firstName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(username)
|
||||
name = sanitizeDisplayName(username)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
name = string([]rune(name)[:maxDisplayName])
|
||||
if name == "" {
|
||||
name = placeholderDisplayName(seed.preferredLanguage)
|
||||
}
|
||||
seed.displayName = name
|
||||
return seed
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
@@ -110,6 +112,39 @@ func ValidateDisplayName(raw string) (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram
|
||||
// first name) to the editable display-name format: it keeps the maximal runs of
|
||||
// Unicode letters and joins them with a single space, dropping every other rune
|
||||
// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The
|
||||
// result therefore always satisfies ValidateDisplayName, or is empty when the input
|
||||
// carries no letters — in which case the caller substitutes placeholderDisplayName.
|
||||
// Mirroring the profile editor's rule means a connector-provisioned name is editable
|
||||
// later without first failing validation.
|
||||
func sanitizeDisplayName(raw string) string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) })
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
name := strings.Join(fields, " ")
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// placeholderDisplayName builds a fallback display name for a platform account whose
|
||||
// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or
|
||||
// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally
|
||||
// carries digits and a hyphen, so it lies outside the editable format and the player
|
||||
// is expected to rename it; provisioned names bypass that editor validation.
|
||||
func placeholderDisplayName(lang string) string {
|
||||
prefix := "Player"
|
||||
if lang == "ru" {
|
||||
prefix = "Игрок"
|
||||
}
|
||||
return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000))
|
||||
}
|
||||
|
||||
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
||||
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
||||
// "no away time" and is allowed.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@
|
||||
{{else}}<p class="note">no statistics</p>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{if .MoveChart}}
|
||||
<section class="panel"><h2>Move timing</h2>
|
||||
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||
{{.MoveChart}}
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Identities</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
|
||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
@@ -11,9 +11,10 @@
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Language}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><span class="note">no users</span></td></tr>
|
||||
<tr><td colspan="8"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package adminconsole
|
||||
|
||||
import "html/template"
|
||||
|
||||
// The *View types are the display models the gin handlers fill and the templates
|
||||
// render. Time values are pre-formatted to strings by the handlers so the
|
||||
// templates stay logic-free.
|
||||
@@ -50,14 +52,19 @@ type UsersView struct {
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// UserRow is one account row in the list.
|
||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||
// pre-formatted move-duration summary (empty when it has no timed move).
|
||||
type UserRow struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
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 +87,9 @@ type UserDetailView struct {
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||
// time (min/mean/max), empty when the account has no timed move.
|
||||
MoveChart template.HTML
|
||||
}
|
||||
|
||||
// StatsRow is an account's lifetime statistics.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -226,6 +226,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
// Record the seat's think time (turn start to commit) for the move-duration
|
||||
// metric; the timeout path commits separately and is excluded by design.
|
||||
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
||||
return MoveResult{Move: rec, Game: post}, nil
|
||||
}
|
||||
|
||||
@@ -287,14 +290,15 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
}
|
||||
|
||||
// emitMove publishes the live events for a just-committed move: opponent_moved to
|
||||
// every seat other than the actor, and your_turn to the next mover while the game
|
||||
// is still active. Delivery is best-effort (notify.Publisher never blocks).
|
||||
// every seat — including the actor's own account, so the mover's other devices (and
|
||||
// their lobby) refresh too — and your_turn to the next mover while the game is still
|
||||
// active. opponent_moved is in-app only (the gateway never turns it into an
|
||||
// out-of-app push), so the actor is not notified out of band about their own move.
|
||||
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
|
||||
// event out to all of the recipient's live streams.
|
||||
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
||||
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
||||
for _, s := range post.Seats {
|
||||
if s.Seat == rec.Player {
|
||||
continue
|
||||
}
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
||||
}
|
||||
if post.Status == StatusActive {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the
|
||||
// admin-console move-duration reports compute the think time (gap to the previous
|
||||
// move, the first move measured from game creation) correctly, per account and per
|
||||
// the account's move ordinal.
|
||||
func TestMoveDurationAnalytics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("provision A: %v", err)
|
||||
}
|
||||
b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("provision B: %v", err)
|
||||
}
|
||||
|
||||
gid := uuid.New()
|
||||
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
|
||||
VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
||||
t.Fatalf("insert game: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil {
|
||||
t.Fatalf("insert seats: %v", err)
|
||||
}
|
||||
// seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200.
|
||||
moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}}
|
||||
for _, m := range moves {
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`,
|
||||
gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil {
|
||||
t.Fatalf("insert move %d: %v", m.seq, err)
|
||||
}
|
||||
}
|
||||
|
||||
store := game.NewStore(testDB)
|
||||
stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 {
|
||||
t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa)
|
||||
}
|
||||
if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 {
|
||||
t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb)
|
||||
}
|
||||
|
||||
byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("by ordinal: %v", err)
|
||||
}
|
||||
want := []game.OrdinalDuration{
|
||||
{Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60},
|
||||
{Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50},
|
||||
}
|
||||
if len(byOrd) != len(want) {
|
||||
t.Fatalf("by ordinal = %+v, want %+v", byOrd, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if byOrd[i] != w {
|
||||
t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,13 +82,16 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
||||
if err := r.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool (idempotent): %v", err)
|
||||
}
|
||||
id, err := r.Pick()
|
||||
id, err := r.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
if !isRobotAccount(t, id) {
|
||||
t.Errorf("picked account %s is not a robot identity", id)
|
||||
}
|
||||
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
|
||||
t.Errorf("russian pick = (%s, %v), want a robot account", ru, err)
|
||||
}
|
||||
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get robot account: %v", err)
|
||||
@@ -109,7 +112,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick()
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
@@ -210,7 +213,7 @@ func TestRobotProactiveNudge(t *testing.T) {
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick()
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,12 +197,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||
}
|
||||
var subs []sub
|
||||
for _, acc := range due {
|
||||
robotID, err := m.robots.Pick()
|
||||
variant := m.queued[acc]
|
||||
robotID, err := m.robots.Pick(variant)
|
||||
if err != nil {
|
||||
m.log.Warn("robot substitution deferred", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
variant := m.queued[acc]
|
||||
m.removeLocked(acc, variant)
|
||||
seats := []uuid.UUID{acc, robotID}
|
||||
if m.rng.Intn(2) == 0 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = 1.0
|
||||
delayEarlyHiMinutes = 5.0
|
||||
delayLateLoMinutes = 10.0
|
||||
delayLateHiMinutes = 90.0
|
||||
delaySkew = 4.0
|
||||
avgGameMoves = 28.0
|
||||
delayHardMinMinutes = 1.0
|
||||
delayHardMaxMinutes = 90.0
|
||||
|
||||
// nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot
|
||||
// answers a daytime nudge on its turn.
|
||||
nudgeReplyMinMinutes = 2.0
|
||||
nudgeReplyMaxMinutes = 10.0
|
||||
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
|
||||
// lower band (delayBand's lo), within which the robot answers a daytime nudge on
|
||||
// its turn — so a nudged robot replies near the floor of its think time.
|
||||
nudgeReplySpreadMinutes = 5.0
|
||||
|
||||
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
|
||||
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
|
||||
@@ -104,19 +114,48 @@ func playToWin(seed int64) bool {
|
||||
return mix(seed, "win")%100 < playToWinPercent
|
||||
}
|
||||
|
||||
// moveDelay is the robot's think time for the move at moveCount, sampled from the
|
||||
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
|
||||
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
|
||||
// for the move at moveCount. It interpolates linearly with game progress (the move
|
||||
// count over avgGameMoves, capped at 1): early moves sit in a short band and late
|
||||
// moves in a long one.
|
||||
func delayBand(moveCount int) (lo, hi float64) {
|
||||
p := float64(moveCount) / avgGameMoves
|
||||
if p > 1 {
|
||||
p = 1
|
||||
}
|
||||
lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p
|
||||
hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
// moveDelay is the robot's think time for the move at moveCount: a right-skewed
|
||||
// sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew
|
||||
// > 1) makes short delays frequent and long ones rare, with a tail to the band's top.
|
||||
func moveDelay(seed int64, moveCount int) time.Duration {
|
||||
lo, hi := delayBand(moveCount)
|
||||
u := unitFloat(mix(seed, "delay", moveCount))
|
||||
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
|
||||
return time.Duration(mins * float64(time.Minute))
|
||||
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
|
||||
}
|
||||
|
||||
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
|
||||
// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes).
|
||||
// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes],
|
||||
// where lo is the move's lower band — so a nudge pulls the move in near the floor of
|
||||
// the robot's think time.
|
||||
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
|
||||
lo, _ := delayBand(moveCount)
|
||||
u := unitFloat(mix(seed, "nudge", moveCount))
|
||||
mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u
|
||||
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
|
||||
}
|
||||
|
||||
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
|
||||
// bounds so an out-of-range band can never produce an absurd think time.
|
||||
func clampMinutes(mins float64) time.Duration {
|
||||
if mins < delayHardMinMinutes {
|
||||
mins = delayHardMinMinutes
|
||||
}
|
||||
if mins > delayHardMaxMinutes {
|
||||
mins = delayHardMaxMinutes
|
||||
}
|
||||
return time.Duration(mins * float64(time.Minute))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in
|
||||
// [2min, 90min) and is reproducible for a (seed, moveCount).
|
||||
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard
|
||||
// bounds [1min, 90min] and is reproducible for a (seed, moveCount).
|
||||
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
|
||||
for seed := int64(1); seed <= 200; seed++ {
|
||||
for mc := 0; mc < 50; mc++ {
|
||||
d := moveDelay(seed, mc)
|
||||
if d < 2*time.Minute || d >= 90*time.Minute {
|
||||
t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc)
|
||||
if d < 1*time.Minute || d > 90*time.Minute {
|
||||
t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc)
|
||||
}
|
||||
if moveDelay(seed, mc) != d {
|
||||
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
|
||||
@@ -43,22 +43,49 @@ func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoveDelaySkew checks the distribution is right-skewed with the intended
|
||||
// ~10-minute median: most delays are short, the mean sits above the median.
|
||||
// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the
|
||||
// first move lives in the short [1,5]min band, a late move in the long [10,90]min
|
||||
// band, so the median think time rises with the move count.
|
||||
func TestMoveDelayGrowsWithMoveCount(t *testing.T) {
|
||||
median := func(mc int) float64 {
|
||||
const n = 4000
|
||||
xs := make([]float64, n)
|
||||
for s := 0; s < n; s++ {
|
||||
xs[s] = moveDelay(int64(s+1), mc).Minutes()
|
||||
}
|
||||
sort.Float64s(xs)
|
||||
return xs[n/2]
|
||||
}
|
||||
for s := int64(1); s <= 500; s++ {
|
||||
if d := moveDelay(s, 0).Minutes(); d < 1 || d > 5 {
|
||||
t.Fatalf("first-move delay %.2f out of [1,5] for seed %d", d, s)
|
||||
}
|
||||
if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 {
|
||||
t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s)
|
||||
}
|
||||
}
|
||||
if early, late := median(0), median(30); early >= late {
|
||||
t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move
|
||||
// count: short delays are frequent (median near the band floor) and the mean sits
|
||||
// above the median, with a tail toward the cap.
|
||||
func TestMoveDelaySkew(t *testing.T) {
|
||||
const n = 20000
|
||||
mins := make([]float64, 0, n)
|
||||
var sum float64
|
||||
for mc := 0; mc < n; mc++ {
|
||||
m := moveDelay(42, mc).Minutes()
|
||||
for s := 0; s < n; s++ {
|
||||
m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90]
|
||||
mins = append(mins, m)
|
||||
sum += m
|
||||
}
|
||||
sort.Float64s(mins)
|
||||
median := mins[n/2]
|
||||
mean := sum / float64(n)
|
||||
if median < 7 || median > 13 {
|
||||
t.Errorf("median delay = %.1f min, want ~10 (7-13)", median)
|
||||
if median < 12 || median > 20 {
|
||||
t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median)
|
||||
}
|
||||
if mean <= median {
|
||||
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
|
||||
|
||||
@@ -46,6 +46,7 @@ func TestStatusForError(t *testing.T) {
|
||||
}{
|
||||
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
|
||||
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
||||
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
|
||||
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||
|
||||
@@ -148,8 +148,10 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusNotFound, "not_found"
|
||||
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
|
||||
return http.StatusForbidden, "not_a_player"
|
||||
case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn):
|
||||
case errors.Is(err, game.ErrNotYourTurn):
|
||||
return http.StatusConflict, "not_your_turn"
|
||||
case errors.Is(err, social.ErrNudgeOnOwnTurn):
|
||||
return http.StatusConflict, "nudge_own_turn"
|
||||
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
||||
return http.StatusConflict, "game_finished"
|
||||
case errors.Is(err, game.ErrGameActive):
|
||||
|
||||
@@ -82,6 +82,7 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
|
||||
ids := make([]uuid.UUID, 0, len(accs))
|
||||
for _, a := range accs {
|
||||
kind := "registered"
|
||||
if a.IsGuest {
|
||||
@@ -91,6 +92,17 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
||||
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
|
||||
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
|
||||
})
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
|
||||
for i := range view.Items {
|
||||
if st, ok := stats[ids[i]]; ok && st.Moves > 0 {
|
||||
view.Items[i].HasMoveStats = true
|
||||
view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs)
|
||||
view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs)
|
||||
view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.renderConsole(c, "users", "users", "Users", view)
|
||||
}
|
||||
@@ -134,6 +146,13 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view.Games = append(view.Games, gameRow(g))
|
||||
}
|
||||
}
|
||||
if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 {
|
||||
cps := make([]adminconsole.ChartPoint, len(pts))
|
||||
for i, p := range pts {
|
||||
cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs}
|
||||
}
|
||||
view.MoveChart = adminconsole.MoveDurationChart(cps)
|
||||
}
|
||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -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".
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"tags": ["scrabble"],
|
||||
"timezone": "",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"refresh": "30s",
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"panels": [
|
||||
@@ -54,6 +54,18 @@
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(game_move_validate_duration_bucket[5m])) by (le, variant))", "legendFormat": "{{variant}}" }]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Move think-time by phase (p50 / p95)",
|
||||
"description": "Seconds a seat spent on a committed move, by game phase. Aggregates all seats including robots; per-human analysis is in the admin console.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "histogram_quantile(0.5, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p50 {{phase}}" },
|
||||
{ "refId": "B", "expr": "histogram_quantile(0.95, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p95 {{phase}}" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+55
-24
@@ -300,10 +300,16 @@ The robot keeps **no per-game state**: every choice is derived deterministically
|
||||
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
|
||||
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
|
||||
behaviour on every scan and after a restart — the same philosophy as journal
|
||||
replay. A pool of durable accounts — each a `kind='robot'` identity (§4),
|
||||
provisioned at startup with chat and friend requests blocked — backs the
|
||||
human-like name pool; those two profile toggles are all the friend/DM blocking
|
||||
requires (there is no DM surface; chat is per-game).
|
||||
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
|
||||
`robot-<lang>-<index>` and provisioned at startup with chat and friend requests
|
||||
blocked — backs the human-like names; those two profile toggles are all the
|
||||
friend/DM blocking requires (there is no DM surface; chat is per-game). Names are
|
||||
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
|
||||
a surname pool (gender-agreed for Russian) in one of three forms (first only /
|
||||
first + surname initial / first + full surname), deterministically per pool slot so
|
||||
they stay stable across restarts. Substitution is **variant-aware**: a Russian game
|
||||
(Russian Scrabble or Эрудит) draws a Russian-named robot with at most ~20% Latin, an
|
||||
English game the Latin pool.
|
||||
|
||||
- **Balance**: at game start it decides once whether to play to win, with
|
||||
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
|
||||
@@ -313,13 +319,15 @@ requires (there is no DM surface; chat is per-game).
|
||||
(playing to lose) is closest to a small band (**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
|
||||
**[1, 5] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
|
||||
quick and the endgame can run long, clamped to **[1, 90] minutes**; it
|
||||
**sleeps 00: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.
|
||||
@@ -451,7 +459,9 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
|
||||
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
|
||||
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
|
||||
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
|
||||
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
|
||||
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
|
||||
including the mover**, so the mover's own other devices and their lobby refresh — it is
|
||||
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
||||
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
||||
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||
friend-added, invitation or game-started; emitted on a friend-request and invitation
|
||||
@@ -499,13 +509,21 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
|
||||
until an exporter is configured: histograms `game_replay_duration` (journal
|
||||
rebuild on a cache miss) and `game_move_validate_duration`; counters
|
||||
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop),
|
||||
`chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`;
|
||||
an observable gauge `game_cache_active`; the gateway `edge_request_duration`
|
||||
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
|
||||
metrics. Game-scoped metrics carry a `variant` attribute
|
||||
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
|
||||
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
|
||||
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
|
||||
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
|
||||
console, below); counters `games_started_total`, `games_abandoned_total` (a
|
||||
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
|
||||
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
|
||||
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
||||
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
||||
(english/russian_scrabble/erudit).
|
||||
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
|
||||
console from the move journal (`game_moves.created_at` deltas, the first move from
|
||||
the game's creation), not Prometheus labels (which an `account_id` would explode):
|
||||
the user list shows each account's min/avg/max think time, and the user-detail page
|
||||
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
|
||||
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
|
||||
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
|
||||
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
|
||||
@@ -579,14 +597,27 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
- Trunk is **`master`**; feature work happens on `feature/*` branches merged via
|
||||
PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily
|
||||
lands on `master`).
|
||||
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test
|
||||
on Go changes; `integration.yaml` runs the Postgres-backed tests behind the
|
||||
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
|
||||
serial). Further workflows (ui-test, deploy) are added with the components they
|
||||
cover.
|
||||
- **Two long-lived branches** (Stage 16): **`development`** is the integration
|
||||
trunk and **`master`** the production trunk; `feature/*` branches are cut from
|
||||
`development` and PR back into it (the genesis commit necessarily landed on
|
||||
`master`). A commit to a `feature/*` branch triggers nothing.
|
||||
- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the
|
||||
suite on a PR into `development`/`master` and on a push to `development`. Its
|
||||
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
|
||||
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
|
||||
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
|
||||
`changes` job filters by changed paths), and an always-running **`gate`** job
|
||||
aggregates them (passing when each succeeded or was **skipped**) and is the single
|
||||
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
|
||||
a merge.
|
||||
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
|
||||
to — `development` (`docker compose up -d --build` on the runner host), then probes
|
||||
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
|
||||
`docker inspect`: running, not restarting, stable restart count, with a
|
||||
VPN-handshake grace period, since the connector has no public ingress and a
|
||||
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
|
||||
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
|
||||
`TEST_`/`PROD_` per contour.
|
||||
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
||||
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
|
||||
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
|
||||
|
||||
+2
-1
@@ -91,7 +91,8 @@ wins most games), aims for a close score rather than crushing or throwing the ga
|
||||
and plays at a human pace — short thinking times for most moves, the occasional long
|
||||
one, and a night-time pause that tracks the player's own day. It answers a nudge
|
||||
within a few minutes and nudges back when the player has been away a long time. It
|
||||
carries a human-like name and neither chats nor accepts friend requests.
|
||||
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
|
||||
names) and neither chats nor accepts friend requests.
|
||||
|
||||
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
|
||||
@@ -92,8 +92,9 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
|
||||
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
|
||||
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
|
||||
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
|
||||
в чате и не принимает заявки в друзья.
|
||||
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
|
||||
языку партии (в русской партии — в основном русские имена), не общается в чате и не
|
||||
принимает заявки в друзья.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
|
||||
+25
-3
@@ -33,6 +33,18 @@ Login uses `Screen`.
|
||||
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
|
||||
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
|
||||
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
|
||||
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
|
||||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||||
from the left (back). Transitions are local (so they do not play on first load) and
|
||||
collapse to nothing under reduce-motion. A per-game in-memory cache (`lib/gamecache.ts`)
|
||||
renders a re-opened game instantly and refreshes it in the background, removing the
|
||||
blank-loading flash on lobby ↔ game navigation.
|
||||
- **Telegram theme** (Stage 17): inside the Mini App the colour scheme is forced from
|
||||
`Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`, which leaks into the
|
||||
Telegram Desktop webview and otherwise fights it), the Settings theme switcher is hidden,
|
||||
the nav bar takes Telegram's background (`header_bg_color`), and a live stream dropped by
|
||||
a background suspend silently reconnects on return to the foreground (the connection
|
||||
banner is suppressed while hidden).
|
||||
|
||||
## Tiles & board
|
||||
|
||||
@@ -45,7 +57,15 @@ Login uses `Screen`.
|
||||
they stay a constant size as the cells grow (relatively smaller at higher zoom).
|
||||
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
|
||||
target; the custom pinch and swipe-to-open-history gestures were dropped because they
|
||||
fight native scroll — history opens from the menu.
|
||||
fight native scroll — history opens from the menu or a tap on the players plaque (below).
|
||||
A **hint** auto-zooms centred on the hint's placement, not the top-left (Stage 17).
|
||||
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
|
||||
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
|
||||
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
|
||||
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
||||
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||||
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
||||
jitter. A move's row lists every word it formed (the main word first).
|
||||
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
||||
last completed word gets a dark tile background — static while it is the opponent's
|
||||
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
|
||||
@@ -70,8 +90,10 @@ Login uses `Screen`.
|
||||
|
||||
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
|
||||
|
||||
A one-line inset strip under the nav bar. Content is minimal markdown (text + links,
|
||||
escaped + linkified). A parameterised **rotator** drives messages: a fitting message
|
||||
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) —
|
||||
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
|
||||
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
|
||||
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
|
||||
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
|
||||
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
|
||||
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
|
||||
|
||||
@@ -10,6 +10,10 @@ async function openGame(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
// Wait for the screen-slide transition to settle so only the game pane remains;
|
||||
// until it does, the leaving lobby pane's header (its menu button) is also in the
|
||||
// DOM, which would make shared locators like .burger ambiguous.
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
}
|
||||
|
||||
test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => {
|
||||
|
||||
+56
-17
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { app, bootstrap } from './lib/app.svelte';
|
||||
import { router } from './lib/router.svelte';
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
@@ -17,28 +18,57 @@
|
||||
onMount(() => {
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
// Screen transitions: the lobby is the navigation root. Entering a screen from the
|
||||
// lobby slides it in from the right (forward); returning to the lobby slides the
|
||||
// screen out to the right and reveals the lobby (back). Transitions are local, so
|
||||
// they do not play on the initial mount, and collapse to nothing under reduce-motion.
|
||||
const dir = $derived(router.route.name === 'lobby' ? 'back' : 'forward');
|
||||
const enterSign = $derived(dir === 'forward' ? 1 : -1);
|
||||
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
|
||||
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
|
||||
const animMs = $derived(app.reduceMotion ? 0 : 260);
|
||||
|
||||
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
|
||||
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
|
||||
// innerWidth, and the .router clips the off-screen pane.
|
||||
function slideX(_node: Element, { duration, sign }: { duration: number; sign: number }) {
|
||||
return {
|
||||
duration,
|
||||
easing: cubicOut,
|
||||
css: (tt: number) => `transform: translateX(${(1 - tt) * sign * 100}%)`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !app.ready}
|
||||
<div class="splash">{t('common.loading')}</div>
|
||||
{:else if router.route.name === 'login'}
|
||||
<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 +80,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;
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
messages,
|
||||
myId,
|
||||
busy,
|
||||
canNudge = true,
|
||||
onsend,
|
||||
onnudge,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
myId: string;
|
||||
busy: boolean;
|
||||
// Nudging only makes sense while waiting on the opponent; it is disabled on the
|
||||
// player's own turn (there is no one to hurry along).
|
||||
canNudge?: boolean;
|
||||
onsend: (text: string) => void;
|
||||
onnudge: () => void;
|
||||
} = $props();
|
||||
@@ -47,7 +51,7 @@
|
||||
onkeydown={(e) => e.key === 'Enter' && send()}
|
||||
/>
|
||||
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
|
||||
<button class="iconbtn" onclick={onnudge} disabled={busy} aria-label={t('chat.nudge')}>🛎️</button>
|
||||
<button class="iconbtn" onclick={onnudge} disabled={busy || !canNudge} aria-label={t('chat.nudge')}>🛎️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+52
-9
@@ -18,6 +18,7 @@
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import { getCachedGame, setCachedGame } from '../lib/gamecache';
|
||||
import {
|
||||
BLANK,
|
||||
newPlacement,
|
||||
@@ -94,6 +95,7 @@
|
||||
]);
|
||||
view = st;
|
||||
moves = hist.moves;
|
||||
setCachedGame(id, st, hist.moves);
|
||||
placement = newPlacement(st.rack);
|
||||
preview = null;
|
||||
selected = null;
|
||||
@@ -109,7 +111,17 @@
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
onMount(load);
|
||||
onMount(() => {
|
||||
// Render instantly from the cache (a game opened before), then refresh in the
|
||||
// background. A cold open shows the loading state until load() resolves.
|
||||
const cached = getCachedGame(id);
|
||||
if (cached) {
|
||||
view = cached.view;
|
||||
moves = cached.moves;
|
||||
placement = newPlacement(cached.view.rack);
|
||||
}
|
||||
void load();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const e = app.lastEvent;
|
||||
@@ -269,6 +281,17 @@
|
||||
const h = await gateway.hint(id);
|
||||
if (h.move.tiles.length && view) {
|
||||
placement = placementFromHint(h.move.tiles, view.rack);
|
||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
||||
// focus the centre of the laid tiles' bounding box.
|
||||
const p = placement.pending;
|
||||
if (p.length) {
|
||||
const rows = p.map((tt) => tt.row);
|
||||
const cols = p.map((tt) => tt.col);
|
||||
focus = {
|
||||
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
||||
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
|
||||
};
|
||||
}
|
||||
if (isCoarse()) zoomed = true;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
recompute();
|
||||
@@ -428,7 +451,9 @@
|
||||
{/snippet}
|
||||
|
||||
{#if view}
|
||||
<div class="scoreboard">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}>
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
|
||||
@@ -599,26 +624,39 @@
|
||||
|
||||
{#if panel === 'chat'}
|
||||
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
|
||||
<Chat {messages} myId={app.session?.userId ?? ''} {busy} onsend={sendChat} onnudge={nudge} />
|
||||
<Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn} onsend={sendChat} onnudge={nudge} />
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scoreboard {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 6px var(--pad);
|
||||
gap: 6px;
|
||||
padding: 8px var(--pad);
|
||||
background: var(--bg-elev);
|
||||
cursor: pointer;
|
||||
}
|
||||
.seat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
padding: 5px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
/* inactive seats read as "sunk in" */
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.seat.turn {
|
||||
background: var(--surface-2);
|
||||
outline: 1px solid var(--accent);
|
||||
/* the active seat is "raised": lifted clear of the others with side shadows */
|
||||
background: var(--bg-elev);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.16),
|
||||
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
|
||||
3px 0 6px -2px rgba(0, 0, 0, 0.26);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.seat.turn .nm {
|
||||
color: var(--accent);
|
||||
}
|
||||
.seat.win .sc {
|
||||
color: var(--ok);
|
||||
@@ -642,8 +680,13 @@
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 2;
|
||||
max-height: 60%;
|
||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border
|
||||
and its shadow pin to the board immediately instead of tracking the table as
|
||||
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
|
||||
column does not jump left/right when the list overflows. */
|
||||
height: 62%;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -9,9 +9,10 @@ import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram';
|
||||
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram';
|
||||
import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
import { clearGameCache } from './gamecache';
|
||||
import type { BoardLabelMode } from './boardlabels';
|
||||
|
||||
export interface Toast {
|
||||
@@ -47,8 +48,15 @@ export const app = $state<{
|
||||
});
|
||||
|
||||
let unsubscribeStream: (() => void) | null = null;
|
||||
let streamAlive = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** documentHidden reports whether the app is currently backgrounded. */
|
||||
function documentHidden(): boolean {
|
||||
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||
}
|
||||
|
||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||
app.toast = { kind, text };
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
@@ -70,6 +78,7 @@ export function handleError(err: unknown): void {
|
||||
|
||||
function openStream(): void {
|
||||
closeStream();
|
||||
streamAlive = true;
|
||||
unsubscribeStream = gateway.subscribe(
|
||||
(e) => {
|
||||
app.lastEvent = e;
|
||||
@@ -85,10 +94,30 @@ function openStream(): void {
|
||||
void refreshNotifications();
|
||||
}
|
||||
},
|
||||
() => showToast(t('error.unavailable'), 'error'),
|
||||
() => {
|
||||
streamAlive = false;
|
||||
// A background suspend (iOS / Telegram) drops the single-shot stream. Don't
|
||||
// alarm the user with the connection banner while hidden — reconnect silently
|
||||
// on return (the visibilitychange handler). Show the banner only on a failure
|
||||
// seen in the foreground, and retry it.
|
||||
if (!documentHidden()) {
|
||||
showToast(t('error.unavailable'), 'error');
|
||||
scheduleReconnect();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
|
||||
* app is in the foreground (a single pending attempt at a time). */
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer || !app.session) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (app.session && !streamAlive && !documentHidden()) openStream();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshNotifications recomputes the lobby badge count (incoming friend requests
|
||||
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
|
||||
@@ -111,8 +140,13 @@ export async function refreshNotifications(): Promise<void> {
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
unsubscribeStream?.();
|
||||
unsubscribeStream = null;
|
||||
streamAlive = false;
|
||||
}
|
||||
|
||||
async function adoptSession(s: Session): Promise<void> {
|
||||
@@ -170,6 +204,10 @@ export async function bootstrap(): Promise<void> {
|
||||
if (insideTelegram()) {
|
||||
const launch = telegramLaunch();
|
||||
if (launch.theme) applyTelegramTheme(launch.theme);
|
||||
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
|
||||
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
|
||||
// cannot fight it. Falls back to the stored preference when the SDK omits it.
|
||||
applyTheme(telegramColorScheme() ?? app.theme);
|
||||
try {
|
||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||
await routeStartParam(launch.startParam);
|
||||
@@ -249,6 +287,7 @@ export async function loginEmail(email: string, code: string): Promise<void> {
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
closeStream();
|
||||
clearGameCache();
|
||||
gateway.setToken(null);
|
||||
await clearSession();
|
||||
app.session = null;
|
||||
@@ -314,10 +353,14 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// Refresh the lobby badge when the app returns to the foreground — a push 'notify'
|
||||
// may have been missed while the client was hidden/closed (poll + push, see §10).
|
||||
// On return to the foreground: silently re-establish a stream dropped while the app
|
||||
// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any
|
||||
// push 'notify' missed while hidden (poll + push, see §10).
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
|
||||
if (document.visibilityState === 'visible' && app.session) {
|
||||
if (!streamAlive) openStream();
|
||||
void refreshNotifications();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -148,6 +148,7 @@ export const en = {
|
||||
'lang.ru': 'Русский',
|
||||
|
||||
'error.not_your_turn': "It is not your turn.",
|
||||
'error.nudge_own_turn': 'It is your turn — there is no one to nudge.',
|
||||
'error.illegal_play': 'That is not a legal play.',
|
||||
'error.hint_unavailable': 'No hints available.',
|
||||
'error.no_hint_available': 'No options with your letters.',
|
||||
|
||||
@@ -149,6 +149,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'lang.ru': 'Русский',
|
||||
|
||||
'error.not_your_turn': 'Сейчас не ваш ход.',
|
||||
'error.nudge_own_turn': 'Сейчас ваш ход — некого торопить.',
|
||||
'error.illegal_play': 'Это недопустимый ход.',
|
||||
'error.hint_unavailable': 'Подсказки недоступны.',
|
||||
'error.no_hint_available': 'Нет вариантов с вашим набором.',
|
||||
|
||||
@@ -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: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ interface TelegramWebApp {
|
||||
initData: string;
|
||||
initDataUnsafe?: { start_param?: string };
|
||||
themeParams?: TelegramThemeParams;
|
||||
colorScheme?: 'light' | 'dark';
|
||||
ready?: () => void;
|
||||
expand?: () => void;
|
||||
}
|
||||
@@ -48,6 +49,17 @@ export function telegramLaunch(): TelegramLaunch {
|
||||
return { initData: w.initData, startParam, theme: w.themeParams };
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
|
||||
* or undefined outside Telegram. Inside the Mini App this — not the OS
|
||||
* prefers-color-scheme — is the authoritative theme: on some clients (Telegram
|
||||
* Desktop) the OS scheme leaks into the webview and fights Telegram's own setting,
|
||||
* so the app forces this value on launch.
|
||||
*/
|
||||
export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
||||
return webApp()?.colorScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
||||
* launch button carries the deep-link there rather than in initDataUnsafe.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,13 @@ export interface VariantOption {
|
||||
// ALL_VARIANTS lists every variant in display order.
|
||||
export const ALL_VARIANTS: VariantOption[] = [
|
||||
{ id: 'english', label: 'new.english' },
|
||||
{ id: 'russian', label: 'new.russian' },
|
||||
{ id: 'russian_scrabble', label: 'new.russian' },
|
||||
{ id: 'erudit', label: 'new.erudit' },
|
||||
];
|
||||
|
||||
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
|
||||
// ru -> Russian + Эрудит.
|
||||
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian: 'ru', erudit: 'ru' };
|
||||
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
|
||||
|
||||
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
|
||||
// or absent set is ungated (a web/legacy session without a declared set), returning
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
const variantKey: Record<string, MessageKey> = {
|
||||
english: 'new.english',
|
||||
russian: 'new.russian',
|
||||
russian_scrabble: 'new.russian',
|
||||
erudit: 'new.erudit',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
|
||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||
@@ -28,16 +29,18 @@
|
||||
|
||||
<Screen title={t('settings.title')} back="/">
|
||||
<div class="page">
|
||||
<section>
|
||||
<h3>{t('settings.theme')}</h3>
|
||||
<div class="seg">
|
||||
{#each themes as th (th)}
|
||||
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
|
||||
{t(themeLabel[th])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{#if !insideTelegram()}
|
||||
<section>
|
||||
<h3>{t('settings.theme')}</h3>
|
||||
<div class="seg">
|
||||
{#each themes as th (th)}
|
||||
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
|
||||
{t(themeLabel[th])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3>{t('settings.language')}</h3>
|
||||
|
||||
Reference in New Issue
Block a user