From 8700fbfae1336ae1ee8b82fac1c1971639d159a1 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Fri, 5 Jun 2026 11:42:26 +0200
Subject: [PATCH 001/223] Stage 16: deploy infra & test contour
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- backend + gateway multi-stage distroless Dockerfiles; the gateway embeds and
serves the SPA at / and /telegram/ via go:embed (committed dist placeholder,
real build baked in by the image's node stage)
- deploy/docker-compose.yml: backend + gateway + Postgres + Telegram connector
(VPN sidecar) + OTel Collector + Prometheus (15d) + Tempo (72h) + Grafana,
fronted by a caddy owning a single /_gm Basic-Auth (admin console + Grafana
subpath); inter-service on a private network, only caddy on the edge network
- new metrics: backend accounts_created_total{kind} (robots excluded) and an
in-memory gateway active_users{window=24h,7d} gauge
- CI: single .gitea/workflows/ci.yaml (unit/integration/ui + a gated test-contour
deploy) on the new feature/* -> development -> master branch model; the old
go-unit/integration/ui-test workflows are folded in; the connector-scoped
compose is retired (superseded by deploy/)
- docs: ARCHITECTURE §11/§12/§13, root + gateway READMEs, CLAUDE.md branching,
PLAN.md (stage 16 done + refinements + Stage 17 forward-notes)
---
.gitea/workflows/ci.yaml | 203 ++++++++++++++++
.gitea/workflows/go-unit.yaml | 81 -------
.gitea/workflows/integration.yaml | 71 ------
.gitea/workflows/ui-test.yaml | 67 ------
CLAUDE.md | 28 ++-
PLAN.md | 70 +++++-
README.md | 21 ++
backend/Dockerfile | 42 ++++
backend/cmd/backend/main.go | 1 +
backend/internal/account/account.go | 14 +-
backend/internal/account/metrics.go | 53 +++++
deploy/.env.example | 43 ++++
deploy/caddy/Caddyfile | 35 +++
deploy/docker-compose.yml | 217 ++++++++++++++++++
deploy/grafana/dashboards/edge-ux.json | 39 ++++
deploy/grafana/dashboards/game-domain.json | 59 +++++
.../grafana/dashboards/service-overview.json | 58 +++++
deploy/grafana/dashboards/users.json | 34 +++
.../provisioning/dashboards/dashboards.yaml | 15 ++
.../provisioning/datasources/datasources.yaml | 16 ++
deploy/otelcol/config.yaml | 38 +++
deploy/prometheus/prometheus.yml | 14 ++
deploy/tempo/tempo.yaml | 26 +++
docs/ARCHITECTURE.md | 52 +++--
gateway/Dockerfile | 53 +++++
gateway/README.md | 13 +-
gateway/internal/connectsrv/activeusers.go | 63 +++++
.../internal/connectsrv/activeusers_test.go | 45 ++++
gateway/internal/connectsrv/metrics.go | 40 +++-
gateway/internal/connectsrv/server.go | 18 +-
gateway/internal/webui/dist/assets/.gitkeep | 2 +
gateway/internal/webui/dist/index.html | 15 ++
gateway/internal/webui/webui.go | 71 ++++++
gateway/internal/webui/webui_test.go | 52 +++++
platform/telegram/deploy/docker-compose.yml | 62 -----
35 files changed, 1413 insertions(+), 318 deletions(-)
create mode 100644 .gitea/workflows/ci.yaml
delete mode 100644 .gitea/workflows/go-unit.yaml
delete mode 100644 .gitea/workflows/integration.yaml
delete mode 100644 .gitea/workflows/ui-test.yaml
create mode 100644 backend/Dockerfile
create mode 100644 backend/internal/account/metrics.go
create mode 100644 deploy/.env.example
create mode 100644 deploy/caddy/Caddyfile
create mode 100644 deploy/docker-compose.yml
create mode 100644 deploy/grafana/dashboards/edge-ux.json
create mode 100644 deploy/grafana/dashboards/game-domain.json
create mode 100644 deploy/grafana/dashboards/service-overview.json
create mode 100644 deploy/grafana/dashboards/users.json
create mode 100644 deploy/grafana/provisioning/dashboards/dashboards.yaml
create mode 100644 deploy/grafana/provisioning/datasources/datasources.yaml
create mode 100644 deploy/otelcol/config.yaml
create mode 100644 deploy/prometheus/prometheus.yml
create mode 100644 deploy/tempo/tempo.yaml
create mode 100644 gateway/Dockerfile
create mode 100644 gateway/internal/connectsrv/activeusers.go
create mode 100644 gateway/internal/connectsrv/activeusers_test.go
create mode 100644 gateway/internal/webui/dist/assets/.gitkeep
create mode 100644 gateway/internal/webui/dist/index.html
create mode 100644 gateway/internal/webui/webui.go
create mode 100644 gateway/internal/webui/webui_test.go
delete mode 100644 platform/telegram/deploy/docker-compose.yml
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
new file mode 100644
index 0000000..b606d0c
--- /dev/null
+++ b/.gitea/workflows/ci.yaml
@@ -0,0 +1,203 @@
+name: CI
+
+# Single gated pipeline for the test contour (Stage 16). Gitea cannot express
+# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
+# one workflow.
+#
+# Branch model (CLAUDE.md): feature branches are cut from `development`; a commit
+# to a feature branch triggers nothing. The pipeline runs on a PR into
+# `development` or `master` (the full test suite — the merge gate) and on a push
+# to `development` (after a merge). The deploy job runs only for `development`
+# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
+# workflow (Stage 17).
+#
+# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
+# `--progress plain`) so the Gitea logs stay readable.
+
+on:
+ pull_request:
+ branches: [development, master]
+ push:
+ branches: [development]
+
+jobs:
+ unit:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash
+ env:
+ # The engine consumes the published scrabble-solver module from this Gitea;
+ # GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
+ GOPRIVATE: gitea.iliadenisov.ru/*
+ DICT_VERSION: v1.0.0
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Fetch dictionary DAWGs
+ run: |
+ mkdir -p "${GITHUB_WORKSPACE}/dawg"
+ curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
+ tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
+ ls -la "${GITHUB_WORKSPACE}/dawg"
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.work
+ cache: true
+
+ - name: gofmt
+ run: |
+ unformatted="$(gofmt -l .)"
+ if [ -n "$unformatted" ]; then
+ echo "gofmt needed on:"; echo "$unformatted"; exit 1
+ fi
+
+ - name: vet
+ run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
+
+ - name: build
+ run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
+
+ - name: test
+ env:
+ BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
+ run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
+
+ integration:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash
+ env:
+ # Ryuk (testcontainers' reaper) does not start cleanly on every runner; the
+ # suite's TestMain terminates its own container, so disable it.
+ TESTCONTAINERS_RYUK_DISABLED: "true"
+ GOPRIVATE: gitea.iliadenisov.ru/*
+ DICT_VERSION: v1.0.0
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Fetch dictionary DAWGs
+ run: |
+ mkdir -p "${GITHUB_WORKSPACE}/dawg"
+ curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
+ tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
+ ls -la "${GITHUB_WORKSPACE}/dawg"
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.work
+ cache: true
+
+ - name: Integration tests
+ # -count=1 disables the cache; -p=1 -parallel=1 keeps the container-backed
+ # tests serial; the 15-minute timeout bounds a stuck container pull.
+ env:
+ BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
+ run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
+
+ ui:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash
+ working-directory: ui
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install pnpm
+ run: npm install -g pnpm@11.0.9
+
+ - name: Install deps
+ run: pnpm install --frozen-lockfile
+
+ - name: Type-check
+ run: pnpm run check
+
+ - name: Unit tests
+ run: pnpm run test:unit
+
+ - name: Build
+ run: pnpm run build
+
+ - name: Bundle-size budget
+ run: node scripts/bundle-size.mjs
+
+ - name: Install Playwright browsers
+ run: pnpm exec playwright install chromium webkit
+ timeout-minutes: 5
+
+ - name: E2E smoke (mock)
+ run: pnpm run test:e2e
+ timeout-minutes: 5
+
+ 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]
+ if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash
+ env:
+ NO_COLOR: "1"
+ DOCKER_CLI_HINTS: "false"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Build and (re)deploy the test contour
+ working-directory: deploy
+ env:
+ # Sensitive values -> secrets; non-sensitive -> variables. The compose
+ # interpolates these unprefixed names (see deploy/.env.example).
+ POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
+ AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
+ GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
+ GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
+ TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
+ TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
+ GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
+ GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
+ CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
+ TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
+ TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
+ TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
+ TELEGRAM_TEST_ENV: ${{ vars.TEST_TELEGRAM_TEST_ENV }}
+ VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
+ VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
+ VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
+ GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
+ run: |
+ docker compose --ansi never build --progress plain
+ docker compose --ansi never up -d --remove-orphans
+
+ - name: Probe the gateway through caddy
+ run: |
+ set -u
+ for i in $(seq 1 20); do
+ if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/; then
+ echo "healthy: GET http://scrabble/"
+ exit 0
+ fi
+ sleep 3
+ done
+ echo "probe failed; recent gateway logs:"
+ docker logs --tail 50 scrabble-gateway || true
+ exit 1
+
+ - name: Prune dangling images
+ if: always()
+ run: docker image prune -f
diff --git a/.gitea/workflows/go-unit.yaml b/.gitea/workflows/go-unit.yaml
deleted file mode 100644
index 2482fbd..0000000
--- a/.gitea/workflows/go-unit.yaml
+++ /dev/null
@@ -1,81 +0,0 @@
-name: Tests · Go
-
-# Fast unit tests for the Go side of the monorepo. Runs on every push and pull
-# request whose path filter matches a Go source directory. The module list
-# grows as new go.work modules (gateway, pkg/*, platform/*) are added by later
-# stages.
-
-on:
- push:
- paths:
- - 'backend/**'
- - 'gateway/**'
- - 'pkg/**'
- - 'platform/**'
- - 'go.work'
- - 'go.work.sum'
- - '.gitea/workflows/go-unit.yaml'
- - '!**/*.md'
- pull_request:
- paths:
- - 'backend/**'
- - 'gateway/**'
- - 'pkg/**'
- - 'platform/**'
- - 'go.work'
- - 'go.work.sum'
- - '.gitea/workflows/go-unit.yaml'
- - '!**/*.md'
-
-jobs:
- test:
- runs-on: ubuntu-latest
- defaults:
- run:
- shell: bash
- env:
- # The engine consumes the published scrabble-solver module from this Gitea;
- # GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
- # DICT_VERSION selects the dictionary DAWG release the engine tests load.
- GOPRIVATE: gitea.iliadenisov.ru/*
- DICT_VERSION: v1.0.0
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Fetch dictionary DAWGs
- # The DAWGs moved to the scrabble-dictionary repo (the solver is now a
- # versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
- # sibling clone). They ship as a release artifact, one semver per set.
- run: |
- mkdir -p "${GITHUB_WORKSPACE}/dawg"
- curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
- tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
- ls -la "${GITHUB_WORKSPACE}/dawg"
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: go.work
- cache: true
-
- - name: gofmt
- run: |
- unformatted="$(gofmt -l .)"
- if [ -n "$unformatted" ]; then
- echo "gofmt needed on:"; echo "$unformatted"; exit 1
- fi
-
- - name: vet
- run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
-
- - name: build
- run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
-
- - name: test
- # -count=1 disables the test cache so a green run never depends on a
- # previous runner's cached state. BACKEND_DICT_DIR points the engine
- # tests at the DAWGs fetched from the dictionary release.
- env:
- BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
- run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
diff --git a/.gitea/workflows/integration.yaml b/.gitea/workflows/integration.yaml
deleted file mode 100644
index f5b48e6..0000000
--- a/.gitea/workflows/integration.yaml
+++ /dev/null
@@ -1,71 +0,0 @@
-name: Tests · Integration
-
-# Postgres-backed integration tests for the Go backend, gated behind the
-# `integration` build tag. They spin a throwaway postgres:17-alpine container via
-# testcontainers-go, which reaches the host Docker daemon through the socket the
-# Gitea runner exposes. Slower than the unit job (go-unit.yaml); run serially
-# (-p=1) with Ryuk disabled — TestMain terminates its own container. The module
-# list grows as new go.work modules are added by later stages.
-
-on:
- push:
- paths:
- - 'backend/**'
- - 'pkg/**'
- - 'go.work'
- - 'go.work.sum'
- - '.gitea/workflows/integration.yaml'
- - '!**/*.md'
- pull_request:
- paths:
- - 'backend/**'
- - 'pkg/**'
- - 'go.work'
- - 'go.work.sum'
- - '.gitea/workflows/integration.yaml'
- - '!**/*.md'
-
-jobs:
- integration:
- runs-on: ubuntu-latest
- defaults:
- run:
- shell: bash
- env:
- # Ryuk (testcontainers' reaper) does not start cleanly on every runner;
- # the suite's TestMain terminates its own container, so disable it.
- TESTCONTAINERS_RYUK_DISABLED: "true"
- # The engine consumes the published scrabble-solver module from this Gitea
- # (GOPRIVATE -> direct fetch, skipping the public proxy/checksum DB);
- # DICT_VERSION selects the dictionary DAWG release the engine tests load.
- GOPRIVATE: gitea.iliadenisov.ru/*
- DICT_VERSION: v1.0.0
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Fetch dictionary DAWGs
- # The DAWGs moved to the scrabble-dictionary repo (the solver is now a
- # versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
- # sibling clone). They ship as a release artifact; the engine's untagged
- # tests (compiled here too) load them.
- run: |
- mkdir -p "${GITHUB_WORKSPACE}/dawg"
- curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
- tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
- ls -la "${GITHUB_WORKSPACE}/dawg"
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: go.work
- cache: true
-
- - name: Integration tests
- # -count=1 disables the test cache; -p=1 -parallel=1 keeps the
- # container-backed tests serial; the 15-minute timeout bounds a stuck
- # container pull. The engine package's (untagged) tests also compile and
- # run here, so BACKEND_DICT_DIR points them at the DAWGs from the release.
- env:
- BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
- run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
diff --git a/.gitea/workflows/ui-test.yaml b/.gitea/workflows/ui-test.yaml
deleted file mode 100644
index ae54c63..0000000
--- a/.gitea/workflows/ui-test.yaml
+++ /dev/null
@@ -1,67 +0,0 @@
-name: Tests · UI
-
-# Hermetic UI checks: type-check, Vitest unit tests, production build with a
-# bundle-size budget, and a Playwright smoke (Chromium + WebKit) against the in-memory
-# mock transport (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not
-# regenerated (the same model as the Go committed jet/fbs output).
-
-on:
- push:
- paths:
- - 'ui/**'
- - '.gitea/workflows/ui-test.yaml'
- pull_request:
- paths:
- - 'ui/**'
- - '.gitea/workflows/ui-test.yaml'
-
-jobs:
- test:
- runs-on: ubuntu-latest
- defaults:
- run:
- shell: bash
- working-directory: ui
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up Node
- uses: actions/setup-node@v4
- with:
- node-version: 22
-
- - name: Install pnpm
- run: npm install -g pnpm@11.0.9
-
- - name: Install deps
- run: pnpm install --frozen-lockfile
-
- - name: Type-check
- run: pnpm run check
-
- - name: Unit tests
- run: pnpm run test:unit
-
- - name: Build
- run: pnpm run build
-
- - name: Bundle-size budget
- run: node scripts/bundle-size.mjs
-
- # The Playwright system libraries are provisioned once on the runner host
- # (`sudo npx playwright@ install-deps chromium`), so the job needs no
- # apt and no sudo: it only downloads the browser binaries into the runner cache
- # (persisted by the host executor) and runs the suite. WebKit's Debian build
- # bundles most of its own libraries and runs headless without extra host deps; if
- # a runner ever lacks one, provision it once on the host with
- # `sudo npx playwright install-deps webkit`. The timeouts guard against a future
- # hang. Keep this in lockstep with @playwright/test in package.json — re-run
- # install-deps on the host after a major bump.
- - name: Install Playwright browsers
- run: pnpm exec playwright install chromium webkit
- timeout-minutes: 5
-
- - name: E2E smoke (mock)
- run: pnpm run test:e2e
- timeout-minutes: 5
diff --git a/CLAUDE.md b/CLAUDE.md
index 3676bba..69a6a6c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,9 +49,20 @@ conversation memory — is the source of continuity. Keep it that way.
## Branching & CI
-- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*`
- and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on
- `master` by necessity (an empty branch has nothing to PR into).
+- **Two long-lived branches** (Stage 16 onward): **`development`** is the
+ integration branch; **`master`** is the production trunk. Cut `feature/*`
+ branches **from `development`** and PR them back into it. (Stages 0–15 used
+ `master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
+ on `master` by necessity.)
+- A commit to a `feature/*` branch triggers **nothing**. The single workflow
+ `.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
+ on a PR into `development` or `master`, and the 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 + a `GET /` probe). A PR into
+ `master` is test-only.
+- Merge `development → master` only when CI is green; the **prod** deploy is then a
+ **manual** workflow (Stage 17), never automatic. Secrets/variables are prefixed
+ `TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
- After any push, watch the run to green before declaring a stage done — use the
ready-made watcher, never an inline poll loop:
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
@@ -113,6 +124,8 @@ backend/ # module scrabble/backend
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ # added by their stages
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
+backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile # multi-stage distroless (Stage 16)
+deploy/ # docker-compose + caddy + otelcol/prometheus/tempo/grafana (Stage 16)
```
## Build & test
@@ -127,9 +140,14 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
pnpm start # UI mock mode: lobby -> game, no backend
+
+docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the UI
+docker build -f gateway/Dockerfile -t scrabble-gateway .
+docker compose -f deploy/docker-compose.yml config # validate the full contour
```
-The `ui` module is a Node project (pnpm), **not** in `go.work`; its CI is
-`.gitea/workflows/ui-test.yaml`. Committed edge codegen under `ui/src/gen/`
+The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job
+of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit /
+integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
diff --git a/PLAN.md b/PLAN.md
index 2660855..353e789 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -49,7 +49,7 @@ independent (see ARCHITECTURE §9.1).
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
| 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) | todo |
+| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
| 17 | Prod contour deploy (SSH export/import, manual after merge) | todo |
Scaffolding is incremental: `go.work` lists only existing modules; each stage
@@ -279,7 +279,7 @@ back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the g
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
-### Stage 16 — Deploy infra & test contour
+### Stage 16 — Deploy infra & test contour *(done)*
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
gains **static UI serving** — **embedded** via `go:embed` (a node build stage in the gateway image),
@@ -300,12 +300,16 @@ collector/Tempo/Prometheus retention.
### Stage 17 — 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
-in Gitea secrets; **strictly manual** (`workflow_dispatch`) after a feature branch is merged to
-`master`. Two-contour config uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no
-deployment environments (verified: the `environments` API 404s), so a flat prefixed namespace is the
-convention.
-Open details (re-interview): export/import vs a registry trade-off; prod domain/TLS at the remote
-caddy; prod VPN; rollback.
+in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master`
+(the Stage 16 branch model: `feature/* → development → master`, merge gated green). Two-contour config
+uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no deployment environments (verified:
+the `environments` API 404s), so a flat prefixed namespace is the convention.
+Reuses the Stage 16 `deploy/docker-compose.yml` as-is, mapping the **`PROD_`** set onto the same
+unprefixed compose vars. **No host caddy on prod**, so the contour's own caddy terminates TLS — set
+`CADDY_SITE_ADDRESS` to the prod domain so caddy does its own ACME (the Caddyfile is already
+parameterised for this; the test contour leaves it `:80` behind the host caddy).
+Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
+provided cert) at the contour caddy; prod VPN; rollback.
## Refinements logged during implementation
@@ -1036,6 +1040,56 @@ caddy; prod VPN; rollback.
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
already span the touched modules).
+- **Stage 16** (interview + implementation):
+ - **Branch model reshaped** (interview, supersedes the Stage 0 `feature/* → master`): a long-lived
+ **`development`** integration branch + **`master`** as the prod trunk. Feature branches are cut from
+ `development`; a feature-branch commit triggers nothing. A single consolidated
+ `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs `unit`+`integration`+`ui` on a PR
+ into `development`/`master` and a **gated `deploy`** job (`needs` the three) that auto-rolls the test
+ contour **on a PR into — or a push to — `development`** (owner's "и PR, и push"). A PR into `master` is
+ test-only; prod is the manual Stage 17. The former `go-unit`/`integration`/`ui-test` workflows were
+ folded in (no path filters — full CI on every PR, per the owner). Console kept plain (`NO_COLOR`,
+ `docker compose --ansi never`, `--progress plain`).
+ - **Gateway serves the UI** (interview, the §13 single-origin): a new `gateway/internal/webui` embeds
+ `dist` via `go:embed` (a committed placeholder index so `go build`/CI compile without a UI build) and
+ serves the SPA at `/` and `/telegram/` (a path-stripping SPA handler, index.html fallback for the hash
+ router), mounted in the edge mux **below** the h2c wrap; `/_gm` stays an explicit 404 when the local
+ admin proxy is off so the catch-all does not leak the shell. The `gateway/Dockerfile` node stage builds
+ the UI with the `VITE_*` build-args and copies it into the embed dir before `go build`.
+ - **Images** (interview): multi-stage distroless `backend/Dockerfile` (a DAWG stage `curl`s the
+ `scrabble-dawg` release pinned to `DICT_VERSION`, `GOPRIVATE` fetches the solver) and `gateway/Dockerfile`
+ (node UI stage + Go stage), both trimming `go.work` like `platform/telegram/Dockerfile`. Built and
+ verified locally.
+ - **Contour = caddy-fronted** (interview, "caddy всё равно нужен для https"): a new `caddy` service owns
+ a **single `/_gm` Basic-Auth** and routes `/_gm/grafana/*` → Grafana (anonymous-admin + sub-path, no
+ own accounts) and the rest of `/_gm/*` → the backend console; everything else → the gateway. This
+ **supersedes Stage 10's** gateway-fronts-`/_gm` model **in the deploy topology** (the gateway's own
+ `/_gm` proxy stays for a local non-caddy run). TLS: the **host caddy** terminates it for the test
+ contour and forwards to `scrabble:80`; the in-compose caddy is parameterised (`CADDY_SITE_ADDRESS`) to
+ own ACME on prod (Stage 17) where there is no host caddy.
+ - **Networks** (engineering): inter-service traffic on a private `internal` network (project-scoped DNS,
+ no name collisions on the shared `edge`); only caddy joins the external `edge` (alias `scrabble`). The
+ connector keeps its VPN sidecar (the only egress that needs the tunnel). The connector-scoped
+ `platform/telegram/deploy/docker-compose.yml` was **retired** (the root `deploy/docker-compose.yml`
+ supersedes it; the connector Dockerfile stays).
+ - **Observability stack** (interview): OTel Collector (OTLP/gRPC → a Prometheus scrape endpoint +
+ Tempo OTLP) + Prometheus (**15d**) + Tempo (**72h**) + Grafana (provisioned Prometheus+Tempo datasources
+ + four dashboards: Service overview, Edge/UX, Game domain, Users; Traces via the Tempo datasource +
+ Explore, no fixed panels). The collector's prometheus exporter uses `add_metric_suffixes:false` +
+ `resource_to_telemetry_conversion` so the dashboards' PromQL matches the in-code metric names and carries
+ `service_name`. The three services export `otlp` in the contour (default stays `none`, so CI needs no
+ collector). Loki/logs were left out of scope (container stdout / zap JSON).
+ - **User metrics** (interview): a backend `accounts_created_total{kind}` counter (telegram/email/guest;
+ robots excluded — they are a provisioned pool, not users) via the Stage-12 `SetMetrics` no-op pattern,
+ and a gateway **in-memory** `active_users{window=24h,7d}` observable gauge (distinct authenticated edge
+ actors). The owner chose the in-memory gauge over a DB `last_seen_at` (overkill); its single-instance /
+ reset-on-restart limits are documented (a live gauge, not billing).
+ - **Owner actions before the contour is green** (surfaced, not blockers): set the **`TEST_`** Gitea
+ secrets/variables (see `deploy/.env.example`) and add a host-caddy route ` → scrabble:80`
+ on the runner host. CI bootstrap nuance: the first PR introducing `ci.yaml` may first deploy on the
+ post-merge push to `development` (depending on whether Gitea runs head/base workflows for a PR), after
+ which PR-time deploys work.
+
## Deferred TODOs (cross-stage)
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
diff --git a/README.md b/README.md
index 12f6ac4..b615c32 100644
--- a/README.md
+++ b/README.md
@@ -80,3 +80,24 @@ pnpm dev # against a running gateway (Vite proxies the RPC path to :8081)
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
+
+## Deploy (`deploy/`)
+
+The full contour is [`deploy/docker-compose.yml`](deploy/docker-compose.yml):
+`backend` + `gateway` (with the UI embedded via `go:embed`, baked in by its node
+build stage) + Postgres + the Telegram connector (with a VPN sidecar) + an
+observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a front
+**caddy** that owns a single `/_gm` Basic-Auth (admin console + Grafana). The Go
+services build from multi-stage distroless `*/Dockerfile`.
+
+```sh
+docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
+docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
+docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
+```
+
+CI auto-deploys the **test contour** on a PR into — or push to — `development`
+(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
+`development → master` (Stage 17). Env reference: [`deploy/.env.example`](deploy/.env.example);
+the topology and the two-contour model are in
+[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..8583dad
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,42 @@
+# Multi-stage build for the backend service. Mirrors platform/telegram/Dockerfile:
+# a golang-alpine builder yields a static binary shipped on distroless nonroot.
+#
+# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
+# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
+# binary at them. The published solver module is fetched directly from Gitea
+# (GOPRIVATE), so the build stage needs git and network.
+#
+# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
+# in the Docker context:
+# docker build -f backend/Dockerfile -t scrabble-backend .
+
+# --- dictionary artifact -----------------------------------------------------
+FROM alpine:3.20 AS dawg
+ARG DICT_VERSION=v1.0.0
+RUN apk add --no-cache curl tar
+RUN mkdir -p /dawg \
+ && curl -fsSL -o /tmp/dawg.tar.gz \
+ "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \
+ && tar xzf /tmp/dawg.tar.gz -C /dawg
+
+# --- build -------------------------------------------------------------------
+FROM golang:1.26.3-alpine AS build
+WORKDIR /src
+# git: the published solver module is fetched from Gitea directly (GOPRIVATE).
+RUN apk add --no-cache git
+ENV GOPRIVATE=gitea.iliadenisov.ru/*
+
+COPY go.work go.work.sum ./
+COPY pkg ./pkg
+COPY backend ./backend
+
+# Reduce the workspace to what the backend needs: backend + pkg.
+RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram
+RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
+
+# --- runtime -----------------------------------------------------------------
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/backend /usr/local/bin/backend
+COPY --from=dawg /dawg /opt/dawg
+ENV BACKEND_DICT_DIR=/opt/dawg
+ENTRYPOINT ["/usr/local/bin/backend"]
diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 4b8ce31..0fca27e 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -132,6 +132,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
hub := notify.NewHub(0)
accounts := account.NewStore(db)
+ accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
games.SetNotifier(hub)
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go
index 4d26099..ae6046c 100644
--- a/backend/internal/account/account.go
+++ b/backend/internal/account/account.go
@@ -93,12 +93,14 @@ type Identity struct {
// Store is the Postgres-backed query surface for accounts and identities.
type Store struct {
- db *sql.DB
+ db *sql.DB
+ metrics *accountMetrics
}
-// NewStore constructs a Store wrapping db.
+// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
+// SetMetrics installs the real one during startup wiring.
func NewStore(db *sql.DB) *Store {
- return &Store{db: db}
+ return &Store{db: db, metrics: defaultAccountMetrics()}
}
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
@@ -331,6 +333,11 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
if err != nil {
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
}
+ // Count genuinely new durable accounts; robots are a fixed provisioned pool,
+ // not users, so they are excluded.
+ if kind != KindRobot {
+ s.metrics.recordCreated(ctx, kind)
+ }
return created, nil
}
@@ -355,6 +362,7 @@ func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Account{}, fmt.Errorf("account: provision guest: %w", err)
}
+ s.metrics.recordCreated(ctx, kindGuest)
return modelToAccount(row), nil
}
diff --git a/backend/internal/account/metrics.go b/backend/internal/account/metrics.go
new file mode 100644
index 0000000..5821e58
--- /dev/null
+++ b/backend/internal/account/metrics.go
@@ -0,0 +1,53 @@
+package account
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/metric"
+ "go.opentelemetry.io/otel/metric/noop"
+)
+
+// meterName scopes the account domain's OpenTelemetry instruments.
+const meterName = "scrabble/backend/account"
+
+// kindGuest labels guest accounts in accounts_created_total. Guests carry no
+// identity, so they have no identity Kind; this is the metric label for them.
+const kindGuest = "guest"
+
+// accountMetrics holds the account domain's operational instruments. It defaults
+// to no-ops (see defaultAccountMetrics); SetMetrics installs the real meter during
+// startup wiring.
+type accountMetrics struct {
+ created metric.Int64Counter
+}
+
+// defaultAccountMetrics returns instruments backed by a no-op meter.
+func defaultAccountMetrics() *accountMetrics {
+ return newAccountMetrics(noop.NewMeterProvider().Meter(meterName))
+}
+
+// newAccountMetrics builds the instruments on meter, falling back to a no-op
+// counter on the (rare) construction error.
+func newAccountMetrics(meter metric.Meter) *accountMetrics {
+ c, err := meter.Int64Counter("accounts_created_total",
+ metric.WithDescription("New accounts created, labelled by kind (telegram/email/guest); robots are not counted."))
+ if err != nil {
+ c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("accounts_created_total")
+ }
+ return &accountMetrics{created: c}
+}
+
+// SetMetrics installs the meter the account store records to. It must be called
+// during startup wiring; the default is a no-op meter.
+func (s *Store) SetMetrics(meter metric.Meter) {
+ if meter == nil {
+ return
+ }
+ s.metrics = newAccountMetrics(meter)
+}
+
+// recordCreated counts one newly created account of the given kind.
+func (m *accountMetrics) recordCreated(ctx context.Context, kind string) {
+ m.created.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
+}
diff --git a/deploy/.env.example b/deploy/.env.example
new file mode 100644
index 0000000..76c9766
--- /dev/null
+++ b/deploy/.env.example
@@ -0,0 +1,43 @@
+# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
+# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; Stage 17
+# maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
+
+# --- Postgres ---------------------------------------------------------------
+POSTGRES_DB=scrabble
+POSTGRES_USER=scrabble
+POSTGRES_PASSWORD=change-me # required
+
+# --- Dictionary -------------------------------------------------------------
+DICT_VERSION=v1.0.0 # scrabble-dictionary release tag (image build-arg)
+
+# --- Logging ----------------------------------------------------------------
+LOG_LEVEL=info
+
+# --- Edge / caddy -----------------------------------------------------------
+# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
+# external `edge` network). Prod (Stage 17): a domain so caddy does its own ACME.
+CADDY_SITE_ADDRESS=:80
+GM_BASICAUTH_USER=gm
+GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash
+
+# --- UI build args (baked into the gateway image) ---------------------------
+VITE_TELEGRAM_BOT_ID=
+VITE_TELEGRAM_LINK=
+VITE_GATEWAY_URL=
+
+# --- Gateway ----------------------------------------------------------------
+GATEWAY_DEFAULT_SUPPORTED_LANGUAGES=en,ru
+
+# --- Grafana ----------------------------------------------------------------
+GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
+GRAFANA_ADMIN_PASSWORD=admin
+
+# --- Telegram connector -----------------------------------------------------
+AWG_CONF= # required; AmneziaWG sidecar config
+TELEGRAM_BOT_TOKEN_EN= # at least one of EN/RU required
+TELEGRAM_BOT_TOKEN_RU=
+TELEGRAM_GAME_CHANNEL_ID_EN=
+TELEGRAM_GAME_CHANNEL_ID_RU=
+TELEGRAM_MINIAPP_URL= # required
+TELEGRAM_TEST_ENV=false
+TELEGRAM_API_BASE_URL=
diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile
new file mode 100644
index 0000000..880ee9a
--- /dev/null
+++ b/deploy/caddy/Caddyfile
@@ -0,0 +1,35 @@
+# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
+# every operator surface under /_gm (the backend-rendered admin console and the
+# Grafana subpath); everything else (the SPA at / and /telegram/, plus the
+# Connect edge) goes to the gateway. Mirrors ../galaxy-game's /_gm model.
+#
+# CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS
+# and forwards); set it to a domain in prod (Stage 17) so this caddy does its own
+# ACME and the contour is self-contained.
+{
+ admin off
+}
+
+{$CADDY_SITE_ADDRESS::80} {
+ # Operator surfaces under /_gm: a single shared Basic-Auth, then route.
+ @gm path /_gm /_gm/*
+ handle @gm {
+ basic_auth {
+ {$GM_BASICAUTH_USER:gm} {$GM_BASICAUTH_HASH}
+ }
+ # Grafana serves from this sub-path (GF_SERVER_SERVE_FROM_SUB_PATH=true), so
+ # the prefix is forwarded intact, not stripped.
+ handle /_gm/grafana* {
+ reverse_proxy grafana:3000
+ }
+ # Everything else under /_gm is the backend-rendered admin console.
+ handle {
+ reverse_proxy backend:8080
+ }
+ }
+
+ # The SPA (/, /telegram/) and the Connect edge are served by the gateway.
+ handle {
+ reverse_proxy gateway:8081
+ }
+}
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
new file mode 100644
index 0000000..9d88a89
--- /dev/null
+++ b/deploy/docker-compose.yml
@@ -0,0 +1,217 @@
+# Full deploy descriptor for the Scrabble test contour: backend + gateway +
+# Postgres + the Telegram connector (with its VPN sidecar) + the observability
+# stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
+# .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are
+# interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy
+# job (see deploy/.env.example for the unprefixed names).
+#
+# Networking (mirrors ../galaxy-game):
+# - `internal` (scrabble-internal): all inter-service traffic, project-private
+# DNS so service names never collide on the shared `edge` network.
+# - `edge` (external): the host caddy reaches this contour at `scrabble:80`
+# (the in-compose caddy's alias). The in-compose caddy terminates only HTTP in
+# the test contour; the host caddy terminates TLS and forwards. For prod
+# (Stage 17, no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
+# does its own ACME — the contour is then self-contained.
+# - The connector egresses to api.telegram.org through the `vpn` sidecar
+# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`.
+name: scrabble
+
+services:
+ postgres:
+ container_name: scrabble-postgres
+ image: postgres:17-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-scrabble}
+ POSTGRES_USER: ${POSTGRES_USER:-scrabble}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scrabble} -d ${POSTGRES_DB:-scrabble}"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ networks: [internal]
+
+ backend:
+ container_name: scrabble-backend
+ image: scrabble-backend:latest
+ build:
+ context: ..
+ dockerfile: backend/Dockerfile
+ args:
+ DICT_VERSION: ${DICT_VERSION:-v1.0.0}
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ # search_path=backend matches the migrations (00001 creates the schema).
+ BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
+ BACKEND_HTTP_ADDR: ":8080"
+ BACKEND_GRPC_ADDR: ":9090"
+ BACKEND_CONNECTOR_ADDR: telegram:9091
+ BACKEND_LOG_LEVEL: ${LOG_LEVEL:-info}
+ BACKEND_SERVICE_NAME: scrabble-backend
+ BACKEND_OTEL_TRACES_EXPORTER: otlp
+ BACKEND_OTEL_METRICS_EXPORTER: otlp
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
+ OTEL_EXPORTER_OTLP_INSECURE: "true"
+ # No container healthcheck: the distroless image has no shell/wget. Readiness
+ # is covered by the CI post-deploy probe (GET / through caddy).
+ networks: [internal]
+
+ gateway:
+ container_name: scrabble-gateway
+ image: scrabble-gateway:latest
+ build:
+ context: ..
+ dockerfile: gateway/Dockerfile
+ args:
+ VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
+ VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
+ VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
+ restart: unless-stopped
+ depends_on: [backend]
+ environment:
+ GATEWAY_HTTP_ADDR: ":8081"
+ GATEWAY_BACKEND_HTTP_URL: http://backend:8080
+ GATEWAY_BACKEND_GRPC_ADDR: backend:9090
+ GATEWAY_CONNECTOR_ADDR: telegram:9091
+ GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${GATEWAY_DEFAULT_SUPPORTED_LANGUAGES:-en,ru}
+ GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
+ GATEWAY_SERVICE_NAME: scrabble-gateway
+ GATEWAY_OTEL_TRACES_EXPORTER: otlp
+ GATEWAY_OTEL_METRICS_EXPORTER: otlp
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
+ OTEL_EXPORTER_OTLP_INSECURE: "true"
+ # GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front
+ # caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly.
+ networks: [internal]
+
+ # --- Telegram connector (egress via the VPN sidecar) -----------------------
+ vpn:
+ container_name: scrabble-telegram-vpn
+ image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest
+ restart: unless-stopped
+ privileged: true
+ environment:
+ AWG_CONF: ${AWG_CONF:?set AWG_CONF}
+ networks:
+ internal:
+ aliases: [telegram]
+
+ telegram:
+ container_name: scrabble-telegram
+ image: scrabble-telegram:latest
+ build:
+ context: ..
+ dockerfile: platform/telegram/Dockerfile
+ restart: unless-stopped
+ depends_on: [vpn]
+ network_mode: "service:vpn"
+ environment:
+ # The bot tokens live ONLY in this container (ARCHITECTURE.md §12). At least
+ # one token is required (the connector validates this at boot).
+ TELEGRAM_BOT_TOKEN_EN: ${TELEGRAM_BOT_TOKEN_EN:-}
+ TELEGRAM_BOT_TOKEN_RU: ${TELEGRAM_BOT_TOKEN_RU:-}
+ TELEGRAM_GAME_CHANNEL_ID_EN: ${TELEGRAM_GAME_CHANNEL_ID_EN:-}
+ TELEGRAM_GAME_CHANNEL_ID_RU: ${TELEGRAM_GAME_CHANNEL_ID_RU:-}
+ TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
+ TELEGRAM_GRPC_ADDR: ":9091"
+ TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
+ TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
+ TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
+ TELEGRAM_SERVICE_NAME: scrabble-telegram
+ TELEGRAM_OTEL_TRACES_EXPORTER: otlp
+ TELEGRAM_OTEL_METRICS_EXPORTER: otlp
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
+ OTEL_EXPORTER_OTLP_INSECURE: "true"
+
+ # --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway) --
+ caddy:
+ container_name: scrabble-caddy
+ image: caddy:2-alpine
+ restart: unless-stopped
+ depends_on: [gateway, backend, grafana]
+ environment:
+ # Test: ":80" (host caddy terminates TLS). Prod: a domain for own ACME.
+ CADDY_SITE_ADDRESS: ${CADDY_SITE_ADDRESS:-:80}
+ GM_BASICAUTH_USER: ${GM_BASICAUTH_USER:-gm}
+ GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
+ volumes:
+ - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
+ - caddy-data:/data
+ networks:
+ internal: {}
+ edge:
+ aliases: [scrabble]
+
+ # --- Observability ---------------------------------------------------------
+ otelcol:
+ container_name: scrabble-otelcol
+ image: otel/opentelemetry-collector-contrib:0.119.0
+ restart: unless-stopped
+ command: ["--config=/etc/otelcol/config.yaml"]
+ volumes:
+ - ./otelcol/config.yaml:/etc/otelcol/config.yaml:ro
+ networks: [internal]
+
+ prometheus:
+ container_name: scrabble-prometheus
+ image: prom/prometheus:v2.55.1
+ restart: unless-stopped
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.retention.time=15d
+ volumes:
+ - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus-data:/prometheus
+ networks: [internal]
+
+ tempo:
+ container_name: scrabble-tempo
+ image: grafana/tempo:2.7.1
+ restart: unless-stopped
+ command: ["-config.file=/etc/tempo/tempo.yaml"]
+ volumes:
+ - ./tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
+ - tempo-data:/var/tempo
+ networks: [internal]
+
+ grafana:
+ container_name: scrabble-grafana
+ image: grafana/grafana:11.4.0
+ restart: unless-stopped
+ depends_on: [prometheus, tempo]
+ environment:
+ # Served under /_gm/grafana behind caddy's Basic-Auth; anonymous Admin so a
+ # single shared login (caddy) gates it with no per-user Grafana accounts.
+ GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-/_gm/grafana/}
+ GF_SERVER_SERVE_FROM_SUB_PATH: "true"
+ GF_AUTH_ANONYMOUS_ENABLED: "true"
+ GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
+ GF_AUTH_DISABLE_LOGIN_FORM: "true"
+ GF_AUTH_BASIC_ENABLED: "false"
+ GF_USERS_ALLOW_SIGN_UP: "false"
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
+ volumes:
+ - ./grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
+ - grafana-data:/var/lib/grafana
+ networks: [internal]
+
+networks:
+ internal:
+ name: scrabble-internal
+ edge:
+ external: true
+
+volumes:
+ postgres-data:
+ caddy-data:
+ prometheus-data:
+ tempo-data:
+ grafana-data:
diff --git a/deploy/grafana/dashboards/edge-ux.json b/deploy/grafana/dashboards/edge-ux.json
new file mode 100644
index 0000000..44b49d6
--- /dev/null
+++ b/deploy/grafana/dashboards/edge-ux.json
@@ -0,0 +1,39 @@
+{
+ "uid": "scrabble-edge",
+ "title": "Scrabble — Edge / UX",
+ "tags": ["scrabble"],
+ "timezone": "",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-6h", "to": "now" },
+ "panels": [
+ {
+ "type": "timeseries",
+ "title": "Edge request rate by message type",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count[5m])) by (message_type)", "legendFormat": "{{message_type}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Edge p95 latency",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(edge_request_duration_bucket[5m])) by (le))", "legendFormat": "p95" },
+ { "refId": "B", "expr": "histogram_quantile(0.50, sum(rate(edge_request_duration_bucket[5m])) by (le))", "legendFormat": "p50" }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "Edge requests by result",
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count[5m])) by (result)", "legendFormat": "{{result}}" }]
+ }
+ ]
+}
diff --git a/deploy/grafana/dashboards/game-domain.json b/deploy/grafana/dashboards/game-domain.json
new file mode 100644
index 0000000..90d76f9
--- /dev/null
+++ b/deploy/grafana/dashboards/game-domain.json
@@ -0,0 +1,59 @@
+{
+ "uid": "scrabble-game",
+ "title": "Scrabble — Game domain",
+ "tags": ["scrabble"],
+ "timezone": "",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-24h", "to": "now" },
+ "panels": [
+ {
+ "type": "timeseries",
+ "title": "Games started / abandoned (rate by variant)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "refId": "A", "expr": "sum(rate(games_started_total[15m])) by (variant)", "legendFormat": "started {{variant}}" },
+ { "refId": "B", "expr": "sum(rate(games_abandoned_total[15m])) by (variant)", "legendFormat": "abandoned {{variant}}" }
+ ]
+ },
+ {
+ "type": "timeseries",
+ "title": "Robot games finished (rate)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(rate(robot_games_finished_total[15m]))", "legendFormat": "robot games" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Live games in cache (by variant)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(game_cache_active) by (variant)", "legendFormat": "{{variant}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Chat messages (rate by kind)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(rate(chat_messages_total[15m])) by (kind)", "legendFormat": "{{kind}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Journal replay p95 (by variant)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(game_replay_duration_bucket[5m])) by (le, variant))", "legendFormat": "{{variant}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Move validate p95 (by variant)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
+ "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}}" }]
+ }
+ ]
+}
diff --git a/deploy/grafana/dashboards/service-overview.json b/deploy/grafana/dashboards/service-overview.json
new file mode 100644
index 0000000..0f91b30
--- /dev/null
+++ b/deploy/grafana/dashboards/service-overview.json
@@ -0,0 +1,58 @@
+{
+ "uid": "scrabble-overview",
+ "title": "Scrabble — Service overview",
+ "tags": ["scrabble"],
+ "timezone": "",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-6h", "to": "now" },
+ "panels": [
+ {
+ "type": "stat",
+ "title": "Active users (24h)",
+ "gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "max(active_users{window=\"24h\"})" }]
+ },
+ {
+ "type": "stat",
+ "title": "Active users (7d)",
+ "gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "max(active_users{window=\"7d\"})" }]
+ },
+ {
+ "type": "stat",
+ "title": "Edge requests/s",
+ "gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count[5m]))" }]
+ },
+ {
+ "type": "stat",
+ "title": "Edge error ratio",
+ "gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
+ "fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count{result!=\"ok\"}[5m])) / clamp_min(sum(rate(edge_request_duration_count[5m])), 1)" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Goroutines by service",
+ "description": "OTel Go runtime metric; verify the exact name against live Prometheus if empty (go_goroutine_count / process_runtime_go_goroutines depending on the contrib runtime version).",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "go_goroutine_count", "legendFormat": "{{service_name}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Heap memory used by service",
+ "description": "OTel Go runtime metric (best-effort name go_memory_used); verify against live Prometheus if empty.",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 },
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
+ }
+ ]
+}
diff --git a/deploy/grafana/dashboards/users.json b/deploy/grafana/dashboards/users.json
new file mode 100644
index 0000000..567538b
--- /dev/null
+++ b/deploy/grafana/dashboards/users.json
@@ -0,0 +1,34 @@
+{
+ "uid": "scrabble-users",
+ "title": "Scrabble — Users",
+ "tags": ["scrabble"],
+ "timezone": "",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-7d", "to": "now" },
+ "panels": [
+ {
+ "type": "timeseries",
+ "title": "Active users (in-memory, single gateway)",
+ "description": "Distinct accounts with an authenticated action within the window. Resets on gateway restart; correct for a single instance (MVP).",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "max(active_users) by (window)", "legendFormat": "{{window}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "New accounts (rate by kind)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(rate(accounts_created_total[1h])) by (kind)", "legendFormat": "{{kind}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "New accounts (cumulative by kind)",
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }]
+ }
+ ]
+}
diff --git a/deploy/grafana/provisioning/dashboards/dashboards.yaml b/deploy/grafana/provisioning/dashboards/dashboards.yaml
new file mode 100644
index 0000000..3772be2
--- /dev/null
+++ b/deploy/grafana/provisioning/dashboards/dashboards.yaml
@@ -0,0 +1,15 @@
+# Loads the committed dashboard JSON from /var/lib/grafana/dashboards (mounted
+# read-only from deploy/grafana/dashboards).
+apiVersion: 1
+
+providers:
+ - name: scrabble
+ orgId: 1
+ folder: Scrabble
+ type: file
+ disableDeletion: false
+ editable: true
+ allowUiUpdates: true
+ options:
+ path: /var/lib/grafana/dashboards
+ foldersFromFilesStructure: false
diff --git a/deploy/grafana/provisioning/datasources/datasources.yaml b/deploy/grafana/provisioning/datasources/datasources.yaml
new file mode 100644
index 0000000..a22f2a3
--- /dev/null
+++ b/deploy/grafana/provisioning/datasources/datasources.yaml
@@ -0,0 +1,16 @@
+# Grafana datasources for the Scrabble contour, provisioned at startup. Metrics
+# come from Prometheus (scraping the collector) and traces from Tempo.
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ type: prometheus
+ uid: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ - name: Tempo
+ type: tempo
+ uid: tempo
+ access: proxy
+ url: http://tempo:3200
diff --git a/deploy/otelcol/config.yaml b/deploy/otelcol/config.yaml
new file mode 100644
index 0000000..8a0e1f4
--- /dev/null
+++ b/deploy/otelcol/config.yaml
@@ -0,0 +1,38 @@
+# OpenTelemetry Collector for the Scrabble contour. Receives OTLP/gRPC from the
+# three services (backend, gateway, connector — pkg/telemetry exports OTLP only),
+# fans metrics out to a Prometheus scrape endpoint and traces to Tempo.
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+
+processors:
+ batch: {}
+
+exporters:
+ # Exposes the collected metrics for Prometheus to scrape (otelcol:9464/metrics).
+ # add_metric_suffixes:false keeps the instrument names verbatim (no _seconds /
+ # _total unit/type suffixes) so the dashboards' PromQL matches the names defined
+ # in code; resource_to_telemetry_conversion promotes service.name to a label.
+ prometheus:
+ endpoint: 0.0.0.0:9464
+ add_metric_suffixes: false
+ resource_to_telemetry_conversion:
+ enabled: true
+ # Forwards traces to Tempo's OTLP ingest.
+ otlp/tempo:
+ endpoint: tempo:4317
+ tls:
+ insecure: true
+
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [otlp/tempo]
+ metrics:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [prometheus]
diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml
new file mode 100644
index 0000000..af5313c
--- /dev/null
+++ b/deploy/prometheus/prometheus.yml
@@ -0,0 +1,14 @@
+# Prometheus scrape config for the Scrabble contour. The OTel Collector exposes
+# every service's metrics on its prometheus exporter; Prometheus scrapes that one
+# endpoint. Retention (15d) is set on the command line in docker-compose.yml.
+global:
+ scrape_interval: 30s
+ evaluation_interval: 30s
+
+scrape_configs:
+ - job_name: otelcol
+ static_configs:
+ - targets: ["otelcol:9464"]
+ - job_name: prometheus
+ static_configs:
+ - targets: ["localhost:9090"]
diff --git a/deploy/tempo/tempo.yaml b/deploy/tempo/tempo.yaml
new file mode 100644
index 0000000..e6c90e8
--- /dev/null
+++ b/deploy/tempo/tempo.yaml
@@ -0,0 +1,26 @@
+# Tempo for the Scrabble contour: single-binary, local filesystem storage, OTLP
+# ingest from the collector, 72h block retention.
+server:
+ http_listen_port: 3200
+
+distributor:
+ receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+
+ingester:
+ max_block_duration: 5m
+
+compactor:
+ compaction:
+ block_retention: 72h
+
+storage:
+ trace:
+ backend: local
+ local:
+ path: /var/tempo/blocks
+ wal:
+ path: /var/tempo/wal
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index c079e6e..1674fdd 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -489,8 +489,11 @@ promotions) is future work and would deliver short markdown messages (text + lin
available for debugging; **`otlp`** (gRPC, endpoint from the standard
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
- and the gateway↔connector calls. The OTLP collector and Grafana dashboards are
- stood up with the deploy (Stage 15).
+ and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus
+ metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
+ (provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
+ are stood up with the deploy (`deploy/`, Stage 16); the default exporter stays
+ `none`, so CI needs no collector.
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
@@ -503,6 +506,12 @@ promotions) is future work and would deliver short markdown messages (text + lin
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
metrics. Game-scoped metrics carry a `variant` attribute
(english/russian_scrabble/erudit).
+- 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) —
+ distinct accounts that performed an authenticated edge action in the window. The
+ gauge is single-process by design (single-instance MVP, §10): it is correct for one
+ gateway, resets on restart, and is a live operational figure, not a billing count.
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
database answers a bounded ping and the session cache is warmed).
- The backend serves a **second listener** — a gRPC server
@@ -518,7 +527,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
| Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
-| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`) on the public `/_gm/*` path and reverse-proxies it **verbatim** to the backend's server-rendered admin console; the backend trusts the gateway (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
+| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
@@ -536,16 +545,33 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
## 13. Deployment (informational)
-Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini
-App under `/telegram/`** (the gateway serves the static UI build, wired in Stage 15;
-outside Telegram that path redirects to the root), the gateway public surface and the **admin console
-at `/_gm`** (backend-rendered, Basic-Auth at the gateway) share one host that
-terminates TLS. The **Telegram connector** runs as a separate
-container with **no public ingress** — it long-polls Telegram and egresses through a
-VPN sidecar, answering only internal gRPC. MVP runs one `gateway`, one `backend`, one
-Postgres, plus the connector. The connector's Docker/compose ships now
-(`platform/telegram/deploy`, mirroring `../15-puzzle`); the gateway's static UI serving
-and the full multi-service deploy land in Stage 15.
+Single public origin, path-routed. The gateway **embeds** the static UI build
+(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves the one
+SPA at both `/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that
+path redirects to the root — the client-side guard). An in-compose **caddy** is the
+contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to
+**Grafana** (anonymous-admin, so the one shared login gates it with no per-user
+Grafana accounts) and the rest of `/_gm/*` to the backend-rendered **admin console**;
+everything else (`/`, `/telegram/`, the Connect edge) goes to the gateway. The
+**Telegram connector** runs as a separate container with **no public ingress** — it
+long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC.
+
+The full contour (`deploy/docker-compose.yml`) runs one `gateway`, one `backend`,
+one Postgres, the connector (+ its VPN sidecar) and the **observability stack** —
+OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces) and Grafana
+with provisioned datasources and dashboards. Inter-service traffic uses a private
+`internal` network (project-scoped DNS); only caddy joins the shared external `edge`
+network (alias `scrabble`).
+
+Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
+- **Test** (Stage 16): auto-deploys on a PR into — or a push to — `development`
+ (`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner
+ host, then a `GET /` probe through caddy). The host caddy terminates TLS and
+ forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
+ (`CADDY_SITE_ADDRESS=:80`).
+- **Prod** (Stage 17): a manual SSH deploy after `development → master`. There is no
+ host caddy, so the contour ships its own caddy terminating TLS — set
+ `CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
## 14. CI & branches
diff --git a/gateway/Dockerfile b/gateway/Dockerfile
new file mode 100644
index 0000000..bb0dd60
--- /dev/null
+++ b/gateway/Dockerfile
@@ -0,0 +1,53 @@
+# Multi-stage build for the gateway service. A node stage builds the static UI
+# (Vite), the result is embedded into the Go binary (gateway/internal/webui/dist),
+# and the Go stage — mirroring platform/telegram/Dockerfile — yields a static
+# binary shipped on distroless nonroot. So the single binary serves the SPA at /
+# and /telegram/ (docs/ARCHITECTURE.md §13) with no separate static container.
+#
+# The production UI build vars are image build-args, baked into the bundle.
+# Build from the repository root so go.work, pkg/, gateway/ and ui/ are all in the
+# Docker context:
+# docker build -f gateway/Dockerfile \
+# --build-arg VITE_GATEWAY_URL=https://example \
+# -t scrabble-gateway .
+
+# --- UI build ----------------------------------------------------------------
+FROM node:22-alpine AS ui
+WORKDIR /ui
+RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
+
+# Prod UI build vars (Vite reads VITE_-prefixed env at build; baked into the bundle).
+ARG VITE_TELEGRAM_BOT_ID=
+ARG VITE_TELEGRAM_LINK=
+ARG VITE_GATEWAY_URL=
+ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
+ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
+ VITE_GATEWAY_URL=$VITE_GATEWAY_URL
+
+# Install with the lockfile first (the workspace file carries pnpm's build-script
+# approval for esbuild), then build. Committed src/gen/ means no codegen here.
+COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+COPY ui ./
+RUN pnpm build
+
+# --- Go build ----------------------------------------------------------------
+FROM golang:1.26.3-alpine AS build
+WORKDIR /src
+COPY go.work go.work.sum ./
+COPY pkg ./pkg
+COPY gateway ./gateway
+
+# Replace the committed placeholder with the freshly built UI before compiling, so
+# go:embed bakes the real bundle into the binary.
+RUN rm -rf gateway/internal/webui/dist
+COPY --from=ui /ui/dist gateway/internal/webui/dist
+
+# Reduce the workspace to what the gateway needs: gateway + pkg.
+RUN go work edit -dropuse=./backend -dropuse=./platform/telegram
+RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/gateway ./gateway/cmd/gateway
+
+# --- runtime -----------------------------------------------------------------
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/gateway /usr/local/bin/gateway
+ENTRYPOINT ["/usr/local/bin/gateway"]
diff --git a/gateway/README.md b/gateway/README.md
index 706d582..43d1334 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -5,9 +5,13 @@ terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
backend over REST/JSON, and bridges the backend's gRPC push stream to each
-client's in-app live channel. It also serves the backend's admin console at `/_gm`
-on its public listener behind HTTP Basic-Auth. See
-[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12.
+client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
+in by the gateway image's node stage) and serves the one SPA at `/` (web) and
+`/telegram/` (the Mini App) — the single-origin model. It can also serve the
+backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
+in the deployed contour the front caddy owns `/_gm` (see
+[`../deploy`](../deploy)). See
+[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12, §13.
## Package layout
@@ -22,8 +26,9 @@ internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate)
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
-internal/connectsrv/ # the Connect Gateway service over h2c
+internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
+internal/webui/ # embedded SPA build (go:embed dist) served at / and /telegram/
```
The FlatBuffers payloads and the backend push proto are the shared wire
diff --git a/gateway/internal/connectsrv/activeusers.go b/gateway/internal/connectsrv/activeusers.go
new file mode 100644
index 0000000..0c3048c
--- /dev/null
+++ b/gateway/internal/connectsrv/activeusers.go
@@ -0,0 +1,63 @@
+package connectsrv
+
+import (
+ "sync"
+ "time"
+)
+
+// activeUsers tracks distinct authenticated accounts by last-action time, backing
+// the in-memory active_users gauge. It is single-process by design (the gateway is
+// single-instance in the MVP, docs/ARCHITECTURE.md §10): the distinct count is
+// correct for one process, resets on restart, and is a live operational gauge, not
+// a billing figure. Memory is bounded by the number of distinct accounts active
+// within the longest window; stale entries are pruned on observation.
+type activeUsers struct {
+ mu sync.Mutex
+ lastSeen map[string]time.Time
+ now func() time.Time
+}
+
+// newActiveUsers returns an empty tracker using the wall clock.
+func newActiveUsers() *activeUsers {
+ return &activeUsers{lastSeen: make(map[string]time.Time), now: time.Now}
+}
+
+// seen records that account uid performed an authenticated action now.
+func (a *activeUsers) seen(uid string) {
+ if uid == "" {
+ return
+ }
+ a.mu.Lock()
+ a.lastSeen[uid] = a.now()
+ a.mu.Unlock()
+}
+
+// counts returns, for each window, the number of distinct accounts last seen
+// within it, pruning entries older than the longest window in the same pass.
+func (a *activeUsers) counts(windows []time.Duration) []int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ now := a.now()
+ var longest time.Duration
+ for _, w := range windows {
+ if w > longest {
+ longest = w
+ }
+ }
+
+ res := make([]int, len(windows))
+ for uid, ts := range a.lastSeen {
+ age := now.Sub(ts)
+ if age > longest {
+ delete(a.lastSeen, uid)
+ continue
+ }
+ for i, w := range windows {
+ if age <= w {
+ res[i]++
+ }
+ }
+ }
+ return res
+}
diff --git a/gateway/internal/connectsrv/activeusers_test.go b/gateway/internal/connectsrv/activeusers_test.go
new file mode 100644
index 0000000..c04452e
--- /dev/null
+++ b/gateway/internal/connectsrv/activeusers_test.go
@@ -0,0 +1,45 @@
+package connectsrv
+
+import (
+ "testing"
+ "time"
+)
+
+func TestActiveUsersCountsAndPrune(t *testing.T) {
+ a := newActiveUsers()
+ base := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
+ cur := base
+ a.now = func() time.Time { return cur }
+
+ a.seen("u1") // at base
+ cur = base.Add(2 * time.Hour)
+ a.seen("u2") // base+2h
+ cur = base.Add(50 * time.Hour)
+ a.seen("u3") // base+50h
+
+ windows := []time.Duration{24 * time.Hour, 7 * 24 * time.Hour}
+
+ // now = base+50h: u3 within 24h; all three within 7d.
+ got := a.counts(windows)
+ if got[0] != 1 || got[1] != 3 {
+ t.Fatalf("counts at +50h = %v, want [1 3]", got)
+ }
+
+ // now = base+169h: u1 (age 169h) prunes past the 7d window; u2/u3 remain in 7d.
+ cur = base.Add(169 * time.Hour)
+ got = a.counts(windows)
+ if got[0] != 0 || got[1] != 2 {
+ t.Fatalf("counts at +169h = %v, want [0 2]", got)
+ }
+ if _, ok := a.lastSeen["u1"]; ok {
+ t.Fatalf("u1 should have been pruned from the tracker")
+ }
+}
+
+func TestActiveUsersIgnoresEmpty(t *testing.T) {
+ a := newActiveUsers()
+ a.seen("")
+ if got := a.counts([]time.Duration{time.Hour}); got[0] != 0 {
+ t.Fatalf("empty uid recorded: got %v", got)
+ }
+}
diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go
index abcfd97..a08c9c2 100644
--- a/gateway/internal/connectsrv/metrics.go
+++ b/gateway/internal/connectsrv/metrics.go
@@ -12,14 +12,26 @@ import (
// meterName scopes the gateway edge's OpenTelemetry instruments.
const meterName = "scrabble/gateway/edge"
+// activeUserWindows are the rolling windows the active_users gauge reports.
+var activeUserWindows = []struct {
+ label string
+ dur time.Duration
+}{
+ {label: "24h", dur: 24 * time.Hour},
+ {label: "7d", dur: 7 * 24 * time.Hour},
+}
+
// serverMetrics holds the edge's operational instruments. It defaults to no-ops;
// NewServer installs the real meter when one is supplied in Deps.
type serverMetrics struct {
- edge metric.Float64Histogram
+ edge metric.Float64Histogram
+ active *activeUsers
}
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
-// falling back to a no-op histogram on the (rare) construction error.
+// falling back to a no-op histogram on the (rare) construction error. The
+// active_users gauge is registered as an observable callback over the in-memory
+// tracker.
func newServerMetrics(meter metric.Meter) *serverMetrics {
if meter == nil {
meter = noop.NewMeterProvider().Meter(meterName)
@@ -30,7 +42,24 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
if err != nil {
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
}
- return &serverMetrics{edge: h}
+ m := &serverMetrics{edge: h, active: newActiveUsers()}
+
+ gauge, err := meter.Int64ObservableGauge("active_users",
+ metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance)."))
+ if err == nil {
+ windows := make([]time.Duration, len(activeUserWindows))
+ for i, w := range activeUserWindows {
+ windows[i] = w.dur
+ }
+ _, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
+ counts := m.active.counts(windows)
+ for i, w := range activeUserWindows {
+ o.ObserveInt64(gauge, int64(counts[i]), metric.WithAttributes(attribute.String("window", w.label)))
+ }
+ return nil
+ }, gauge)
+ }
+ return m
}
// recordEdge records the duration of one Execute call labelled by message type and
@@ -41,3 +70,8 @@ func (m *serverMetrics) recordEdge(ctx context.Context, msgType, result string,
attribute.String("result", result),
))
}
+
+// recordActive marks account uid active now, feeding the active_users gauge.
+func (m *serverMetrics) recordActive(uid string) {
+ m.active.seen(uid)
+}
diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go
index db3c72a..57bf31a 100644
--- a/gateway/internal/connectsrv/server.go
+++ b/gateway/internal/connectsrv/server.go
@@ -24,6 +24,7 @@ import (
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
+ "scrabble/gateway/internal/webui"
edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect"
)
@@ -89,9 +90,21 @@ func (s *Server) HTTPHandler() http.Handler {
if s.adminProxy != nil {
// The admin console (backend /_gm) is served on the public listener behind
// the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps
- // working over h2c (docs/ARCHITECTURE.md §12).
+ // working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
+ // front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves
+ // a non-caddy (local) setup.
mux.Handle("/_gm/", s.adminProxy)
+ } else {
+ // With the console disabled here, keep /_gm a 404 so the SPA catch-all below
+ // does not serve the app shell at the operator path.
+ mux.Handle("/_gm/", http.NotFoundHandler())
}
+ // The embedded single-page UI is served at the site root and, for the Telegram
+ // Mini App, under /telegram/ — the single-origin model (docs/ARCHITECTURE.md
+ // §13). Both mounts sit below the h2c wrap so the Connect edge (a more specific
+ // prefix) keeps priority; "/" is the catch-all SPA fallback for the hash router.
+ mux.Handle("/telegram/", webui.Handler("/telegram/"))
+ mux.Handle("/", webui.Handler(""))
return h2c.NewHandler(mux, &http2.Server{})
}
@@ -118,6 +131,9 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
result = "unauthenticated"
return nil, err
}
+ // A valid session proving an authenticated request is an "action" for the
+ // active_users gauge, counted before the rate-limit/domain outcome.
+ s.metrics.recordActive(uid)
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
result = "rate_limited"
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
diff --git a/gateway/internal/webui/dist/assets/.gitkeep b/gateway/internal/webui/dist/assets/.gitkeep
new file mode 100644
index 0000000..51460f1
--- /dev/null
+++ b/gateway/internal/webui/dist/assets/.gitkeep
@@ -0,0 +1,2 @@
+# Placeholder so the embedded dist/assets directory exists in a plain build.
+# The production gateway image replaces dist/ with the real Vite build.
diff --git a/gateway/internal/webui/dist/index.html b/gateway/internal/webui/dist/index.html
new file mode 100644
index 0000000..b6598b4
--- /dev/null
+++ b/gateway/internal/webui/dist/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Scrabble
+
+
+
+ UI build placeholder. The production gateway image embeds the real Vite
+ build (see gateway/Dockerfile); seeing this page means the binary was
+ built without a UI build.
+
+
+
diff --git a/gateway/internal/webui/webui.go b/gateway/internal/webui/webui.go
new file mode 100644
index 0000000..8bccfc3
--- /dev/null
+++ b/gateway/internal/webui/webui.go
@@ -0,0 +1,71 @@
+// Package webui serves the embedded single-page UI build over the public edge.
+//
+// The committed dist/ holds only a placeholder index.html so the gateway module
+// compiles with a plain `go build` (and in CI) without a UI build. The production
+// gateway image replaces dist/ with the real Vite build before compiling (see
+// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built
+// with a relative asset base, one build serves under any path: Handler is mounted
+// both at "/" (web) and at "/telegram/" (the Telegram Mini App), matching the
+// single-origin model in docs/ARCHITECTURE.md §13.
+package webui
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+ "path"
+ "strings"
+)
+
+//go:embed all:dist
+var dist embed.FS
+
+// distFS returns the embedded build rooted at dist/. The directory is embedded at
+// compile time, so its absence is a build error rather than a runtime condition.
+func distFS() fs.FS {
+ sub, err := fs.Sub(dist, "dist")
+ if err != nil {
+ panic("webui: embedded dist/ missing: " + err.Error())
+ }
+ return sub
+}
+
+// Handler serves the embedded SPA. An existing file is served directly (with the
+// standard content-type and caching headers); every other path falls back to
+// index.html so the client-side hash router can take over a deep link. When
+// stripPrefix is non-empty it is removed from the request path before lookup, so
+// the same build serves under a sub-path (e.g. "/telegram/").
+func Handler(stripPrefix string) http.Handler {
+ content := distFS()
+ files := http.FileServer(http.FS(content))
+ h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
+ if name == "" {
+ serveIndex(w, content)
+ return
+ }
+ if info, err := fs.Stat(content, name); err != nil || info.IsDir() {
+ // Unknown path or a directory: serve the SPA shell, never a listing.
+ serveIndex(w, content)
+ return
+ }
+ files.ServeHTTP(w, r)
+ })
+ if p := strings.TrimSuffix(stripPrefix, "/"); p != "" {
+ return http.StripPrefix(p, h)
+ }
+ return h
+}
+
+// serveIndex writes the SPA shell with a 200 status, so a client-routed deep link
+// still loads the app rather than a 404.
+func serveIndex(w http.ResponseWriter, content fs.FS) {
+ data, err := fs.ReadFile(content, "index.html")
+ if err != nil {
+ http.Error(w, "ui not built", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(data)
+}
diff --git a/gateway/internal/webui/webui_test.go b/gateway/internal/webui/webui_test.go
new file mode 100644
index 0000000..4528507
--- /dev/null
+++ b/gateway/internal/webui/webui_test.go
@@ -0,0 +1,52 @@
+package webui
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// get drives the handler with a GET for the given path and returns the response.
+func get(t *testing.T, h http.Handler, target string) *http.Response {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
+ return rec.Result()
+}
+
+func TestHandlerServesIndexAndFallsBack(t *testing.T) {
+ h := Handler("")
+
+ // The embedded placeholder index is served at the root.
+ if resp := get(t, h, "/"); resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET / status = %d, want 200", resp.StatusCode)
+ }
+
+ // An existing (non-index) file is served directly by the file server.
+ if resp := get(t, h, "/assets/.gitkeep"); resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET /assets/.gitkeep status = %d, want 200 (served file)", resp.StatusCode)
+ }
+
+ // An unknown deep link falls back to the SPA shell (200, not 404) so the
+ // client-side hash router can take over.
+ resp := get(t, h, "/game/abc/deep")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET /game/abc/deep status = %d, want 200 (SPA fallback)", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(body), "
Date: Fri, 5 Jun 2026 12:01:31 +0200
Subject: [PATCH 002/223] =?UTF-8?q?Stage=2016:=20deploy/README.md=20?=
=?UTF-8?q?=E2=80=94=20full=20environment-variable=20reference?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- deploy/README.md documents the services, how to run it locally and in CI, and
every variable: required (the four :? ones + ≥1 bot token) and optional with
defaults, marked secret-vs-variable and with the TEST_/PROD_ Gitea mapping;
plus the fixed internal wiring and the host-side setup.
- ci.yaml maps the remaining POSTGRES_DB/USER, DICT_VERSION and LOG_LEVEL (unset
renders empty -> the compose ":-" defaults apply), so every documented var is
per-contour overridable.
- .env.example points at the README for the full reference.
---
.gitea/workflows/ci.yaml | 5 ++
deploy/.env.example | 2 +
deploy/README.md | 102 +++++++++++++++++++++++++++++++++++++++
3 files changed, 109 insertions(+)
create mode 100644 deploy/README.md
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index b606d0c..10207a2 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -180,6 +180,11 @@ jobs:
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
+ # Unset vars render empty -> the compose ":-" defaults apply.
+ POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
+ POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
+ DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
+ LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: |
docker compose --ansi never build --progress plain
docker compose --ansi never up -d --remove-orphans
diff --git a/deploy/.env.example b/deploy/.env.example
index 76c9766..55bbd18 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -1,6 +1,8 @@
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; Stage 17
# maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
+#
+# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
# --- Postgres ---------------------------------------------------------------
POSTGRES_DB=scrabble
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..3df4c9c
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,102 @@
+# deploy
+
+The full Scrabble contour: `backend` + `gateway` + Postgres + the Telegram
+connector (with a VPN sidecar) + the observability stack (OTel Collector →
+Prometheus + Tempo → Grafana), fronted by a **caddy** that owns a single `/_gm`
+Basic-Auth (the admin console + Grafana). Topology and the decision record are in
+[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §13; this file is the
+operational reference for **every environment variable**.
+
+## Services
+
+| Service | Image | Role |
+| --- | --- | --- |
+| `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; everything else → gateway. TLS per `CADDY_SITE_ADDRESS`. |
+| `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded SPA at `/` and `/telegram/`; Connect-RPC edge. |
+| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
+| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
+| `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. |
+| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
+| `prometheus` | `prom/prometheus` | Metrics, 15d retention. |
+| `tempo` | `grafana/tempo` | Traces, 72h retention. |
+| `grafana` | `grafana/grafana` | Dashboards (provisioned), anonymous-admin behind caddy's `/_gm/grafana`. |
+
+Networking: inter-service traffic is on the private `internal` network
+(project-scoped DNS); only `caddy` joins the shared external `edge` network so the
+host caddy can reach it at `scrabble:80`. `edge` must already exist on the host
+(`docker network create edge`).
+
+## Run it
+
+**Locally** — copy the template, fill the required values, bring it up:
+
+```sh
+cp deploy/.env.example deploy/.env # then edit deploy/.env
+docker network create edge # once, if it does not exist
+cd deploy && docker compose up -d --build
+```
+
+**In CI** (the test contour) — `.gitea/workflows/ci.yaml`'s `deploy` job maps the
+Gitea **`TEST_`-prefixed** secrets/variables onto the unprefixed names below and
+runs `docker compose up -d --build` on the runner host. Stage 17 (prod) maps the
+**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
+feeds the compose's `POSTGRES_PASSWORD`, etc.
+
+## Required variables
+
+`docker compose` aborts immediately if any of these is unset (they use `:?`):
+
+| Variable | Gitea kind | Purpose |
+| --- | --- | --- |
+| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
+| `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the connector's only egress). |
+| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext ''`. |
+| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the connector hands out in deep links / buttons. |
+
+**Plus at least one bot token** — `TELEGRAM_BOT_TOKEN_EN` or `TELEGRAM_BOT_TOKEN_RU`
+(secrets). Compose cannot express "one of", so they default to empty, but the
+connector **fails at boot** if both are empty.
+
+## Optional variables (with defaults)
+
+| Variable | Gitea kind | Default | Purpose |
+| --- | --- | --- | --- |
+| `POSTGRES_DB` | variable | `scrabble` | Database name. |
+| `POSTGRES_USER` | variable | `scrabble` | Database user. |
+| `DICT_VERSION` | variable | `v1.0.0` | `scrabble-dictionary` release tag baked into the backend image (build-arg). |
+| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / connector (`debug\|info\|warn\|error`). |
+| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
+| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
+| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https:///_gm/grafana/` behind a real domain. |
+| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
+| `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English game-channel id; empty/`0` disables channel posts. |
+| `TELEGRAM_GAME_CHANNEL_ID_RU` | variable | _(empty)_ | Russian game-channel id; empty/`0` disables channel posts. |
+| `TELEGRAM_TEST_ENV` | variable | `false` | `true` routes the bot through Telegram's test environment. |
+| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
+| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
+| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
+| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me//`). |
+| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
+
+The three `VITE_*` are **build-args** baked into the gateway image at build time, so
+changing them requires a rebuild (`--build`), not just a restart.
+
+## Fixed internal wiring (not operator-set)
+
+These are hard-wired in `docker-compose.yml` (no `${...}`), pointing the services
+at each other on the `internal` network — listed here so they are not mistaken for
+missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
+`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
+`GATEWAY_CONNECTOR_ADDR`/`BACKEND_CONNECTOR_ADDR` (→ `telegram:9091`), the three
+services' `*_OTEL_*_EXPORTER=otlp` + `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
+(`_INSECURE=true`). `GATEWAY_ADMIN_*` is intentionally **unset** — caddy owns `/_gm`
+in the contour.
+
+## Host-side setup (outside this repo)
+
+- **`edge` network** must exist on the host (`docker network create edge`).
+- **Host caddy** route ` → scrabble:80` (the in-compose caddy serves HTTP
+ in the test contour; the host caddy terminates TLS). Not needed on prod, where the
+ contour caddy owns TLS (set `CADDY_SITE_ADDRESS` to the domain).
+- **Branch protection** required-status-check names are `CI / unit`,
+ `CI / integration`, `CI / ui` (see [`../CLAUDE.md`](../CLAUDE.md) "Branching & CI").
--
2.52.0
From 0ea35fe991b821a10b3d104213f34089e586dd2f Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Fri, 5 Jun 2026 16:44:10 +0200
Subject: [PATCH 003/223] Stage 16: connector test-env via UseTestEnvironment;
pin it in the test contour
- bot.New now selects Telegram's test environment with the library's native
tgbot.UseTestEnvironment() instead of a token += "/test" hack (functionally
identical URL /bot/test/METHOD, but idiomatic) + a bot test asserting
the getMe path for both test and prod.
- ci.yaml pins TELEGRAM_TEST_ENV=true for the test contour (it IS the test
environment) instead of a TEST_TELEGRAM_TEST_ENV variable: removes the
confusing double-TEST, telegram-specific, prefixed operator knob and the
secret-vs-variable footgun. Prod (Stage 17) leaves it false.
- deploy/README.md + PLAN.md updated.
---
.gitea/workflows/ci.yaml | 4 +++-
PLAN.md | 6 +++++
deploy/README.md | 2 +-
platform/telegram/internal/bot/bot.go | 13 ++++------
platform/telegram/internal/bot/bot_test.go | 28 ++++++++++++++++++++++
5 files changed, 43 insertions(+), 10 deletions(-)
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 10207a2..a71229c 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -175,7 +175,9 @@ jobs:
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
- TELEGRAM_TEST_ENV: ${{ vars.TEST_TELEGRAM_TEST_ENV }}
+ # The test contour always uses Telegram's test environment — pinned here,
+ # not an operator variable. Stage 17's prod workflow leaves it false.
+ TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
diff --git a/PLAN.md b/PLAN.md
index 353e789..d59c49e 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1089,6 +1089,12 @@ provided cert) at the contour caddy; prod VPN; rollback.
on the runner host. CI bootstrap nuance: the first PR introducing `ci.yaml` may first deploy on the
post-merge push to `development` (depending on whether Gitea runs head/base workflows for a PR), after
which PR-time deploys work.
+ - **Telegram test environment** (post-deploy fix): the connector now selects Telegram's test env with the
+ library's native `tgbot.UseTestEnvironment()` (was a `token += "/test"` hack — functionally identical,
+ verified, but the option is idiomatic and now has a `bot` test asserting the `/bot/test/getMe`
+ path). The test contour **pins `TELEGRAM_TEST_ENV=true` in `ci.yaml`** (the contour is the test
+ environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
+ knob and the secret-vs-variable footgun; prod (Stage 17) leaves it `false`.
## Deferred TODOs (cross-stage)
diff --git a/deploy/README.md b/deploy/README.md
index 3df4c9c..ba4267e 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -71,7 +71,7 @@ connector **fails at boot** if both are empty.
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
| `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English game-channel id; empty/`0` disables channel posts. |
| `TELEGRAM_GAME_CHANNEL_ID_RU` | variable | _(empty)_ | Russian game-channel id; empty/`0` disables channel posts. |
-| `TELEGRAM_TEST_ENV` | variable | `false` | `true` routes the bot through Telegram's test environment. |
+| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod (Stage 17) leaves it `false`. |
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go
index 1f639d3..c014520 100644
--- a/platform/telegram/internal/bot/bot.go
+++ b/platform/telegram/internal/bot/bot.go
@@ -43,21 +43,18 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
}
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log}
- token := cfg.Token
- if cfg.TestEnv {
- // The Bot API test environment lives under /bot/test/METHOD; the
- // client builds /bot/, so suffixing the token with
- // "/test" injects the test segment without a custom host.
- token += "/test"
- }
opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleStart),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
}
+ if cfg.TestEnv {
+ // Route to the Bot API test environment (.../bot/test/METHOD).
+ opts = append(opts, tgbot.UseTestEnvironment())
+ }
if cfg.APIBaseURL != "" {
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
}
- api, err := tgbot.New(token, opts...)
+ api, err := tgbot.New(cfg.Token, opts...)
if err != nil {
return nil, err
}
diff --git a/platform/telegram/internal/bot/bot_test.go b/platform/telegram/internal/bot/bot_test.go
index 4367b19..57b6521 100644
--- a/platform/telegram/internal/bot/bot_test.go
+++ b/platform/telegram/internal/bot/bot_test.go
@@ -75,6 +75,34 @@ func TestSendTextHasNoMarkup(t *testing.T) {
}
}
+// getMePathFor captures the path bot.New's getMe call hits for the given TestEnv,
+// so the test environment routing is covered (a misroute is exactly what makes a
+// test-environment token fail with "getMe unauthorized").
+func getMePathFor(t *testing.T, testEnv bool) string {
+ t.Helper()
+ var path string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/getMe") {
+ path = r.URL.Path
+ }
+ io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"tb"}}`)
+ }))
+ t.Cleanup(srv.Close)
+ if _, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, TestEnv: testEnv, MiniAppURL: "https://example.com/"}, zap.NewNop()); err != nil {
+ t.Fatalf("new bot (testEnv=%v): %v", testEnv, err)
+ }
+ return path
+}
+
+func TestTestEnvironmentRoutesGetMe(t *testing.T) {
+ if got, want := getMePathFor(t, true), "/bot123:ABC/test/getMe"; got != want {
+ t.Errorf("TestEnv getMe path = %q, want %q", got, want)
+ }
+ if got, want := getMePathFor(t, false), "/bot123:ABC/getMe"; got != want {
+ t.Errorf("prod getMe path = %q, want %q", got, want)
+ }
+}
+
func TestStartPayload(t *testing.T) {
cases := map[string]string{
"/start g123": "g123",
--
2.52.0
From efbaf657c6eecd87b7ddc7c49a40b80d3b400259 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Fri, 5 Jun 2026 16:57:17 +0200
Subject: [PATCH 004/223] Stage 16: insert Stage 17 (test-contour
verification); renumber prod deploy to 18
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- PLAN.md: new Stage 17 "Test-contour verification & defect fixes" (exercise the
deployed contour end-to-end and fix what it surfaces — connector liveness check,
path-conditional CI); the former prod-deploy stage becomes Stage 18.
- Renumber every "Stage 17" prod-deploy reference to "Stage 18" across docs,
compose, Caddyfile, ci.yaml and CLAUDE.md; the post-Stage-14 split range is now
"Stages 15–18".
---
.gitea/workflows/ci.yaml | 4 ++--
CLAUDE.md | 2 +-
PLAN.md | 35 +++++++++++++++++++++++++++--------
README.md | 2 +-
deploy/.env.example | 4 ++--
deploy/README.md | 4 ++--
deploy/caddy/Caddyfile | 2 +-
deploy/docker-compose.yml | 2 +-
docs/ARCHITECTURE.md | 2 +-
9 files changed, 38 insertions(+), 19 deletions(-)
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index a71229c..75228cb 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -9,7 +9,7 @@ name: CI
# `development` or `master` (the full test suite — the merge gate) and on a push
# to `development` (after a merge). The deploy job runs only for `development`
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
-# workflow (Stage 17).
+# workflow (Stage 18).
#
# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
# `--progress plain`) so the Gitea logs stay readable.
@@ -176,7 +176,7 @@ jobs:
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
# The test contour always uses Telegram's test environment — pinned here,
- # not an operator variable. Stage 17's prod workflow leaves it false.
+ # not an operator variable. Stage 18's prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
diff --git a/CLAUDE.md b/CLAUDE.md
index 69a6a6c..f798924 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,7 +61,7 @@ conversation memory — is the source of continuity. Keep it that way.
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into
`master` is test-only.
- Merge `development → master` only when CI is green; the **prod** deploy is then a
- **manual** workflow (Stage 17), never automatic. Secrets/variables are prefixed
+ **manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
- After any push, watch the run to green before declaring a stage done — use the
ready-made watcher, never an inline poll loop:
diff --git a/PLAN.md b/PLAN.md
index d59c49e..edbc419 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -50,7 +50,8 @@ 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 | Prod contour deploy (SSH export/import, manual after merge) | todo |
+| 17 | Test-contour verification & defect fixes | todo |
+| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
Scaffolding is incremental: `go.work` lists only existing modules; each stage
adds the modules it needs.
@@ -244,7 +245,7 @@ indices; the premiums.ts parity-test rework.
### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
-deploy + observability + the two-bots idea were split into **Stages 15–17** below and this
+deploy + observability + the two-bots idea were split into **Stages 15–18** below and this
stage took only the dependency/artifact split that everything else builds on. Scope: publish
`scrabble-solver` as a versioned Gitea module and split the dictionary build into a new
`scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume
@@ -297,7 +298,25 @@ 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 — Prod contour deploy
+### Stage 17 — Test-contour verification & defect fixes
+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
+console and Grafana sit behind the single `/_gm` Basic-Auth; the Telegram **bots** start (test
+environment) and the Mini App launches/authenticates; a game can be created and played through (web
++ Mini App); the **observability** stack receives data (Prometheus targets up, the dashboards
+populate incl. `accounts_created_total`/`active_users`, traces reach Tempo); the out-of-app push
+works. Fix the defects found and harden where the run exposes gaps — notably a CI **connector
+liveness check** (the deploy probe only hits the gateway today, so a crash-looping connector is
+invisible — that is how the Stage 16 test-env miss went unnoticed) and **path-conditional CI** (skip
+the jobs whose code did not change, behind a single always-running gate job so branch-protection
+required checks stay satisfiable — a skipped required check otherwise blocks the merge).
+Open details (interview at start): the verification checklist + pass bar; which discovered defects
+are in-scope vs deferred; the changed-paths design + the aggregate gate job; the connector
+liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before
+it settles).
+
+### 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
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master`
@@ -905,7 +924,7 @@ provided cert) at the contour caddy; prod VPN; rollback.
CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written
into the plan now as the agreed baseline (each still re-interviews at its own start).
(Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
- observability + the dual-bot idea split into Stages 15–17.)
+ observability + the dual-bot idea split into Stages 15–18.)
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
@@ -985,7 +1004,7 @@ provided cert) at the contour caddy; prod VPN; rollback.
- **Stage 14** (interview + implementation, re-scoped + discharges TODO-1/TODO-2):
- **Re-scoped to the split** (interview): the original "CI & deploy" was several sessions of work,
so it was cut to the **solver/dictionary split** (the dependency foundation) and the deploy +
- observability + the dual-bot idea were written into the plan as new **Stages 15–17**. The deploy
+ observability + the dual-bot idea were written into the plan as new **Stages 15–18**. The deploy
decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
full Collector+Prometheus+Tempo+Grafana stack; **two contours** — test = auto on feature-branch
push on the local host, prod = manual SSH `docker save`/`load` after merge; `TEST_`/`PROD_` secret
@@ -1047,7 +1066,7 @@ provided cert) at the contour caddy; prod VPN; rollback.
`.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs `unit`+`integration`+`ui` on a PR
into `development`/`master` and a **gated `deploy`** job (`needs` the three) that auto-rolls the test
contour **on a PR into — or a push to — `development`** (owner's "и PR, и push"). A PR into `master` is
- test-only; prod is the manual Stage 17. The former `go-unit`/`integration`/`ui-test` workflows were
+ test-only; prod is the manual Stage 18. The former `go-unit`/`integration`/`ui-test` workflows were
folded in (no path filters — full CI on every PR, per the owner). Console kept plain (`NO_COLOR`,
`docker compose --ansi never`, `--progress plain`).
- **Gateway serves the UI** (interview, the §13 single-origin): a new `gateway/internal/webui` embeds
@@ -1066,7 +1085,7 @@ provided cert) at the contour caddy; prod VPN; rollback.
**supersedes Stage 10's** gateway-fronts-`/_gm` model **in the deploy topology** (the gateway's own
`/_gm` proxy stays for a local non-caddy run). TLS: the **host caddy** terminates it for the test
contour and forwards to `scrabble:80`; the in-compose caddy is parameterised (`CADDY_SITE_ADDRESS`) to
- own ACME on prod (Stage 17) where there is no host caddy.
+ own ACME on prod (Stage 18) where there is no host caddy.
- **Networks** (engineering): inter-service traffic on a private `internal` network (project-scoped DNS,
no name collisions on the shared `edge`); only caddy joins the external `edge` (alias `scrabble`). The
connector keeps its VPN sidecar (the only egress that needs the tunnel). The connector-scoped
@@ -1094,7 +1113,7 @@ provided cert) at the contour caddy; prod VPN; rollback.
verified, but the option is idiomatic and now has a `bot` test asserting the `/bot/test/getMe`
path). The test contour **pins `TELEGRAM_TEST_ENV=true` in `ci.yaml`** (the contour is the test
environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
- knob and the secret-vs-variable footgun; prod (Stage 17) leaves it `false`.
+ knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`.
## Deferred TODOs (cross-stage)
diff --git a/README.md b/README.md
index b615c32..b307133 100644
--- a/README.md
+++ b/README.md
@@ -98,6 +98,6 @@ docker compose -f deploy/docker-compose.yml config # validate (needs the
CI auto-deploys the **test contour** on a PR into — or push to — `development`
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
-`development → master` (Stage 17). Env reference: [`deploy/.env.example`](deploy/.env.example);
+`development → master` (Stage 18). Env reference: [`deploy/.env.example`](deploy/.env.example);
the topology and the two-contour model are in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
diff --git a/deploy/.env.example b/deploy/.env.example
index 55bbd18..7edeb9f 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -1,5 +1,5 @@
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
-# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; Stage 17
+# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; Stage 18
# maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
#
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
@@ -17,7 +17,7 @@ LOG_LEVEL=info
# --- Edge / caddy -----------------------------------------------------------
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
-# external `edge` network). Prod (Stage 17): a domain so caddy does its own ACME.
+# external `edge` network). Prod (Stage 18): a domain so caddy does its own ACME.
CADDY_SITE_ADDRESS=:80
GM_BASICAUTH_USER=gm
GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash
diff --git a/deploy/README.md b/deploy/README.md
index ba4267e..8797966 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -38,7 +38,7 @@ cd deploy && docker compose up -d --build
**In CI** (the test contour) — `.gitea/workflows/ci.yaml`'s `deploy` job maps the
Gitea **`TEST_`-prefixed** secrets/variables onto the unprefixed names below and
-runs `docker compose up -d --build` on the runner host. Stage 17 (prod) maps the
+runs `docker compose up -d --build` on the runner host. Stage 18 (prod) maps the
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
feeds the compose's `POSTGRES_PASSWORD`, etc.
@@ -71,7 +71,7 @@ connector **fails at boot** if both are empty.
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
| `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English game-channel id; empty/`0` disables channel posts. |
| `TELEGRAM_GAME_CHANNEL_ID_RU` | variable | _(empty)_ | Russian game-channel id; empty/`0` disables channel posts. |
-| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod (Stage 17) leaves it `false`. |
+| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod (Stage 18) leaves it `false`. |
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile
index 880ee9a..25a4e20 100644
--- a/deploy/caddy/Caddyfile
+++ b/deploy/caddy/Caddyfile
@@ -4,7 +4,7 @@
# Connect edge) goes to the gateway. Mirrors ../galaxy-game's /_gm model.
#
# CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS
-# and forwards); set it to a domain in prod (Stage 17) so this caddy does its own
+# and forwards); set it to a domain in prod (Stage 18) so this caddy does its own
# ACME and the contour is self-contained.
{
admin off
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 9d88a89..6868737 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -11,7 +11,7 @@
# - `edge` (external): the host caddy reaches this contour at `scrabble:80`
# (the in-compose caddy's alias). The in-compose caddy terminates only HTTP in
# the test contour; the host caddy terminates TLS and forwards. For prod
-# (Stage 17, no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
+# (Stage 18, no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
# does its own ACME — the contour is then self-contained.
# - The connector egresses to api.telegram.org through the `vpn` sidecar
# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 1674fdd..f172a9f 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -569,7 +569,7 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
host, then a `GET /` probe through caddy). The host caddy terminates TLS and
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
(`CADDY_SITE_ADDRESS=:80`).
-- **Prod** (Stage 17): a manual SSH deploy after `development → master`. There is no
+- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no
host caddy, so the contour ships its own caddy terminating TLS — set
`CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
--
2.52.0
From 4a07d48a7bf126a28473203bb618194f253d7142 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Fri, 5 Jun 2026 17:34:33 +0200
Subject: [PATCH 005/223] Fix Grafana dashboards mount; keep connector OTLP
(AWG_CONF must omit DNS=)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- deploy/docker-compose.yml: mount the provisioned dashboards at
/etc/grafana/dashboards, not /var/lib/grafana/dashboards — the grafana-data
volume mounts over the latter and shadows the nested bind, so the provider
logged "readdirent /var/lib/grafana/dashboards: no such file or directory".
dashboards.yaml provider path updated to match.
- Connector telemetry stays OTLP. The VPN sidecar's netns reaches the collector's
internal IP fine (connected route, off-tunnel), but the sidecar's DNS hijacks
name resolution: AWG_CONF must NOT carry a DNS= directive, else otelcol won't
resolve ("produced zero addresses"). Without DNS= the netns uses Docker's
resolver (resolves both otelcol and api.telegram.org). Documented in
deploy/README.md (AWG_CONF row + wiring note), ARCHITECTURE §13, compose comment.
---
deploy/README.md | 14 +++++++++-----
deploy/docker-compose.yml | 11 ++++++++++-
.../provisioning/dashboards/dashboards.yaml | 2 +-
docs/ARCHITECTURE.md | 10 +++++++---
4 files changed, 27 insertions(+), 10 deletions(-)
diff --git a/deploy/README.md b/deploy/README.md
index 8797966..0b28545 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -49,7 +49,7 @@ feeds the compose's `POSTGRES_PASSWORD`, etc.
| Variable | Gitea kind | Purpose |
| --- | --- | --- |
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
-| `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the connector's only egress). |
+| `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the connector's only egress). **Must not contain a `DNS=` line** — it hijacks the shared netns's resolv.conf and breaks the connector resolving `otelcol` (telemetry export). Without it, Docker's resolver handles both `otelcol` and `api.telegram.org`. |
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext ''`. |
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the connector hands out in deep links / buttons. |
@@ -87,10 +87,14 @@ These are hard-wired in `docker-compose.yml` (no `${...}`), pointing the service
at each other on the `internal` network — listed here so they are not mistaken for
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
-`GATEWAY_CONNECTOR_ADDR`/`BACKEND_CONNECTOR_ADDR` (→ `telegram:9091`), the three
-services' `*_OTEL_*_EXPORTER=otlp` + `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
-(`_INSECURE=true`). `GATEWAY_ADMIN_*` is intentionally **unset** — caddy owns `/_gm`
-in the contour.
+`GATEWAY_CONNECTOR_ADDR`/`BACKEND_CONNECTOR_ADDR` (→ `telegram:9091`), and all three
+services' `*_OTEL_*_EXPORTER=otlp` → `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
+(`_INSECURE=true`). The connector shares the VPN sidecar's netns: routing to the
+collector's internal IP is fine (connected route), but its `AWG_CONF` must **not**
+set a `DNS=` directive — that hijacks resolv.conf and breaks resolving `otelcol`
+("produced zero addresses"); without it the netns uses Docker's resolver, which
+resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentionally
+**unset** — caddy owns `/_gm` in the contour.
## Host-side setup (outside this repo)
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 6868737..f36a531 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -125,6 +125,12 @@ services:
TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram
+ # The connector shares the VPN sidecar's netns. Routing to the collector's
+ # internal IP stays off the tunnel (connected route), but the sidecar's DNS
+ # hijacks name resolution: AWG_CONF must NOT carry a `DNS=` directive, else
+ # `otelcol` won't resolve ("produced zero addresses"). Without DNS= the netns
+ # uses Docker's resolver, which resolves both otelcol and api.telegram.org
+ # (see deploy/README.md).
TELEGRAM_OTEL_TRACES_EXPORTER: otlp
TELEGRAM_OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
@@ -199,7 +205,10 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
+ # Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
+ # grafana-data volume mounts over — a nested bind there is shadowed and the
+ # provider logs "no such file or directory").
+ - ./grafana/dashboards:/etc/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
networks: [internal]
diff --git a/deploy/grafana/provisioning/dashboards/dashboards.yaml b/deploy/grafana/provisioning/dashboards/dashboards.yaml
index 3772be2..8b92fd6 100644
--- a/deploy/grafana/provisioning/dashboards/dashboards.yaml
+++ b/deploy/grafana/provisioning/dashboards/dashboards.yaml
@@ -11,5 +11,5 @@ providers:
editable: true
allowUiUpdates: true
options:
- path: /var/lib/grafana/dashboards
+ path: /etc/grafana/dashboards
foldersFromFilesStructure: false
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index f172a9f..6f369f0 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -559,9 +559,13 @@ long-polls Telegram and egresses through a VPN sidecar, answering only internal
The full contour (`deploy/docker-compose.yml`) runs one `gateway`, one `backend`,
one Postgres, the connector (+ its VPN sidecar) and the **observability stack** —
OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces) and Grafana
-with provisioned datasources and dashboards. Inter-service traffic uses a private
-`internal` network (project-scoped DNS); only caddy joins the shared external `edge`
-network (alias `scrabble`).
+with provisioned datasources and dashboards. All three services export OTLP to the
+collector; the connector shares the VPN sidecar's netns, so its `AWG_CONF` must not
+carry a `DNS=` directive (that would hijack resolv.conf and stop it resolving
+`otelcol`; without it the netns uses Docker's resolver, which resolves both
+`otelcol` and `api.telegram.org`). Inter-service traffic uses a private `internal`
+network (project-scoped DNS); only caddy joins the shared external `edge` network
+(alias `scrabble`).
Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
- **Test** (Stage 16): auto-deploys on a PR into — or a push to — `development`
--
2.52.0
From 831ecd0cab9efc01d8baff1d622daa3ad15efa17 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Fri, 5 Jun 2026 17:42:21 +0200
Subject: [PATCH 006/223] Fix dangling config binds: seed configs to a stable
host path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root cause of the Grafana "readdirent /etc/grafana/dashboards: no such file or
directory": the CI runner checks out into an ephemeral act workspace that is
removed after the job, so binding the compose config files straight from it
dangles the mounts in the long-lived containers (verified the act source dir is
emptied after the job). caddy/otelcol/prometheus/tempo read their config once at
startup so they survive, but would break on a restart — same latent bug.
Fix (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring): the deploy job seeds
the config dirs to a stable $HOME/.scrabble-deploy and the compose binds them via
${SCRABBLE_CONFIG_DIR:-.} (local runs keep "."). Documented in the compose header,
deploy/README.md and the ci.yaml step.
---
.gitea/workflows/ci.yaml | 10 ++++++++++
deploy/README.md | 8 ++++++++
deploy/docker-compose.yml | 18 ++++++++++++------
3 files changed, 30 insertions(+), 6 deletions(-)
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 75228cb..bfb321b 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -188,6 +188,16 @@ jobs:
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
run: |
+ # Seed the config files to a stable host path. The runner checks out into
+ # an ephemeral act workspace that is removed after the job, which would
+ # dangle the compose config bind mounts in the long-lived containers
+ # (e.g. Grafana then logs "no such file or directory"). Bind from a stable
+ # dir instead (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring).
+ conf="$HOME/.scrabble-deploy"
+ rm -rf "$conf"
+ mkdir -p "$conf"
+ cp -r caddy otelcol prometheus tempo grafana "$conf"/
+ export SCRABBLE_CONFIG_DIR="$conf"
docker compose --ansi never build --progress plain
docker compose --ansi never up -d --remove-orphans
diff --git a/deploy/README.md b/deploy/README.md
index 0b28545..62ab89d 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -42,6 +42,14 @@ runs `docker compose up -d --build` on the runner host. Stage 18 (prod) maps the
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
feeds the compose's `POSTGRES_PASSWORD`, etc.
+The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
+`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
+`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
+workspace that is removed after the job — binding config straight from it would
+dangle the mounts in the long-lived containers (Grafana would log
+`no such file or directory`). Locally `SCRABBLE_CONFIG_DIR` defaults to `.`, so the
+compose binds from this directory.
+
## Required variables
`docker compose` aborts immediately if any of these is unset (they use `:?`):
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index f36a531..09cb03d 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -5,6 +5,12 @@
# interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy
# job (see deploy/.env.example for the unprefixed names).
#
+# Config bind sources are prefixed with ${SCRABBLE_CONFIG_DIR:-.}: locally they bind
+# straight from this directory, but CI seeds them to a stable host path and sets
+# SCRABBLE_CONFIG_DIR to it, because the runner's checkout is ephemeral (act removes
+# it after the job) and the bind mounts must outlive the job in the long-running
+# containers (see .gitea/workflows/ci.yaml + deploy/README.md).
+#
# Networking (mirrors ../galaxy-game):
# - `internal` (scrabble-internal): all inter-service traffic, project-private
# DNS so service names never collide on the shared `edge` network.
@@ -148,7 +154,7 @@ services:
GM_BASICAUTH_USER: ${GM_BASICAUTH_USER:-gm}
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
volumes:
- - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
+ - ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
networks:
internal: {}
@@ -162,7 +168,7 @@ services:
restart: unless-stopped
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- - ./otelcol/config.yaml:/etc/otelcol/config.yaml:ro
+ - ${SCRABBLE_CONFIG_DIR:-.}/otelcol/config.yaml:/etc/otelcol/config.yaml:ro
networks: [internal]
prometheus:
@@ -173,7 +179,7 @@ services:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=15d
volumes:
- - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - ${SCRABBLE_CONFIG_DIR:-.}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
networks: [internal]
@@ -183,7 +189,7 @@ services:
restart: unless-stopped
command: ["-config.file=/etc/tempo/tempo.yaml"]
volumes:
- - ./tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
+ - ${SCRABBLE_CONFIG_DIR:-.}/tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
- tempo-data:/var/tempo
networks: [internal]
@@ -204,11 +210,11 @@ services:
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
volumes:
- - ./grafana/provisioning:/etc/grafana/provisioning:ro
+ - ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
# grafana-data volume mounts over — a nested bind there is shadowed and the
# provider logs "no such file or directory").
- - ./grafana/dashboards:/etc/grafana/dashboards:ro
+ - ${SCRABBLE_CONFIG_DIR:-.}/grafana/dashboards:/etc/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
networks: [internal]
--
2.52.0
From 635f2fd9fc52b61a850ddbc3a26217e47f77a2b5 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 09:59:12 +0200
Subject: [PATCH 007/223] Stage 17: backend defect fixes (nudge code, TG name,
robot names/timing, multi-device push, move-duration metric + admin
analytics)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn')
- #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback
- #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin)
- #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4)
- #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only
- #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal)
- ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
---
backend/internal/account/account.go | 48 +++++-
backend/internal/account/profile.go | 35 +++++
backend/internal/account/provision_test.go | 47 ++++--
.../internal/adminconsole/assets/console.css | 14 ++
backend/internal/adminconsole/chart.go | 108 +++++++++++++
backend/internal/adminconsole/chart_test.go | 51 ++++++
.../templates/pages/user_detail.gohtml | 6 +
.../adminconsole/templates/pages/users.gohtml | 5 +-
backend/internal/adminconsole/views.go | 24 ++-
backend/internal/game/analytics.go | 116 ++++++++++++++
backend/internal/game/emit_test.go | 52 +++++++
backend/internal/game/metrics.go | 26 ++++
backend/internal/game/metrics_test.go | 15 ++
backend/internal/game/service.go | 14 +-
backend/internal/inttest/analytics_test.go | 81 ++++++++++
backend/internal/inttest/robot_test.go | 9 +-
backend/internal/inttest/stage6_test.go | 2 +-
backend/internal/lobby/lobby.go | 3 +-
backend/internal/lobby/matchmaker.go | 4 +-
backend/internal/lobby/matchmaker_test.go | 10 +-
backend/internal/robot/names.go | 146 ++++++++++++++++++
backend/internal/robot/names_test.go | 119 ++++++++++++++
backend/internal/robot/robot.go | 109 +++++++------
backend/internal/robot/strategy.go | 71 +++++++--
backend/internal/robot/strategy_test.go | 47 ++++--
backend/internal/server/dto_test.go | 1 +
backend/internal/server/handlers.go | 4 +-
.../internal/server/handlers_admin_console.go | 19 +++
ui/src/lib/i18n/en.ts | 1 +
ui/src/lib/i18n/ru.ts | 1 +
30 files changed, 1068 insertions(+), 120 deletions(-)
create mode 100644 backend/internal/adminconsole/chart.go
create mode 100644 backend/internal/adminconsole/chart_test.go
create mode 100644 backend/internal/game/analytics.go
create mode 100644 backend/internal/game/emit_test.go
create mode 100644 backend/internal/inttest/analytics_test.go
create mode 100644 backend/internal/robot/names.go
create mode 100644 backend/internal/robot/names_test.go
diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go
index ae6046c..515294d 100644
--- a/backend/internal/account/account.go
+++ b/backend/internal/account/account.go
@@ -12,7 +12,6 @@ import (
"fmt"
"strings"
"time"
- "unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
@@ -112,10 +111,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
diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go
index 03f6150..daf78b5 100644
--- a/backend/internal/account/profile.go
+++ b/backend/internal/account/profile.go
@@ -4,9 +4,11 @@ import (
"context"
"errors"
"fmt"
+ "math/rand/v2"
"regexp"
"strings"
"time"
+ "unicode"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
@@ -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.
diff --git a/backend/internal/account/provision_test.go b/backend/internal/account/provision_test.go
index 5417f13..afa08a9 100644
--- a/backend/internal/account/provision_test.go
+++ b/backend/internal/account/provision_test.go
@@ -1,6 +1,7 @@
package account
import (
+ "regexp"
"strings"
"testing"
"unicode/utf8"
@@ -8,21 +9,25 @@ import (
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged),
-// the first-name / username display-name precedence, and trimming.
+// the first-name / username display-name precedence, and the sanitization that
+// strips disallowed characters (emoji, digits, punctuation) to the editable format.
func TestTelegramSeed(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
wantLang, wantName string
}{
- "ru bare": {"ru", "user", "Иван", "ru", "Иван"},
- "en region-tagged": {"en-US", "user", "John", "en", "John"},
- "ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
- "unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
- "empty language": {"", "neo", "Neo", "", "Neo"},
- "first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
- "username fallback": {"en", "handle", "", "en", "handle"},
- "both empty": {"en", "", "", "en", ""},
- "trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
+ "ru bare": {"ru", "user", "Иван", "ru", "Иван"},
+ "en region-tagged": {"en-US", "user", "John", "en", "John"},
+ "ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
+ "unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
+ "empty language": {"", "neo", "Neo", "", "Neo"},
+ "first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
+ "username fallback": {"en", "handle", "", "en", "handle"},
+ "trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
+ "emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"},
+ "punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
+ "digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
+ "garbage to username": {"en", "good", "123!@#", "en", "good"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
@@ -37,6 +42,28 @@ func TestTelegramSeed(t *testing.T) {
}
}
+// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to
+// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
+func TestTelegramSeedPlaceholder(t *testing.T) {
+ cases := map[string]struct {
+ languageCode, username, firstName string
+ wantRe string
+ }{
+ "en empty": {"en", "", "", `^Player-\d{5}$`},
+ "ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
+ "default en": {"fr", "", "", `^Player-\d{5}$`},
+ "both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName
+ if !regexp.MustCompile(tc.wantRe).MatchString(got) {
+ t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
+ }
+ })
+ }
+}
+
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
// maxDisplayName runes (counted in runes, not bytes).
func TestTelegramSeedTruncatesLongName(t *testing.T) {
diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css
index 780d1cd..8386ee3 100644
--- a/backend/internal/adminconsole/assets/console.css
+++ b/backend/internal/adminconsole/assets/console.css
@@ -101,3 +101,17 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
.actions form { margin: 0; }
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
+
+/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */
+.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; }
+.chart .axis { stroke: var(--line); stroke-width: 1; }
+.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; }
+.chart .lbl { fill: var(--ink-dim); font-size: 11px; }
+.chart .ln { fill: none; stroke-width: 1.5; }
+.chart .ln-min { stroke: var(--ok); }
+.chart .ln-avg { stroke: var(--accent); }
+.chart .ln-max { stroke: var(--danger); }
+.lg { font-weight: 600; }
+.lg-min { color: var(--ok); }
+.lg-avg { color: var(--accent); }
+.lg-max { color: var(--danger); }
diff --git a/backend/internal/adminconsole/chart.go b/backend/internal/adminconsole/chart.go
new file mode 100644
index 0000000..bfa7b39
--- /dev/null
+++ b/backend/internal/adminconsole/chart.go
@@ -0,0 +1,108 @@
+package adminconsole
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+)
+
+// ChartPoint is one move-number sample of the move-duration chart: the min, mean and
+// max think time (seconds) the account took on its Ordinal-th move across its games.
+type ChartPoint struct {
+ Ordinal int
+ Min float64
+ Max float64
+ Avg float64
+}
+
+// FormatDuration renders a think-time in seconds as a compact human string
+// ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels.
+func FormatDuration(secs float64) string {
+ d := time.Duration(secs * float64(time.Second))
+ switch {
+ case d < time.Minute:
+ return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm", int(d.Minutes()+0.5))
+ default:
+ h := int(d.Hours())
+ if m := int(d.Minutes()) - h*60; m > 0 {
+ return fmt.Sprintf("%dh%dm", h, m)
+ }
+ return fmt.Sprintf("%dh", h)
+ }
+}
+
+// MoveDurationChart renders the per-move-number think-time chart as a self-contained,
+// script-free inline SVG with three series (min, mean, max). The coordinates and
+// labels are all derived from numeric data, so the result is safe template.HTML.
+// An empty series renders nothing.
+func MoveDurationChart(points []ChartPoint) template.HTML {
+ if len(points) == 0 {
+ return ""
+ }
+ const (
+ w, h = 640, 240
+ padL = 46
+ padR = 12
+ padT = 10
+ padB = 28
+ )
+ maxOrd := points[len(points)-1].Ordinal
+ if maxOrd < 1 {
+ maxOrd = 1
+ }
+ var maxY float64
+ for _, p := range points {
+ maxY = max(maxY, p.Max)
+ }
+ if maxY <= 0 {
+ maxY = 1
+ }
+ xOf := func(ord int) float64 {
+ if maxOrd == 1 {
+ return padL
+ }
+ return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR)
+ }
+ yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) }
+ line := func(get func(ChartPoint) float64) string {
+ pts := make([]string, len(points))
+ for i, p := range points {
+ pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p)))
+ }
+ return strings.Join(pts, " ")
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, ``)
+ return template.HTML(b.String())
+}
+
+// xTicks returns up to three distinct ordinal labels for the chart's X axis.
+func xTicks(maxOrd int) []int {
+ if maxOrd <= 2 {
+ out := make([]int, 0, maxOrd)
+ for i := 1; i <= maxOrd; i++ {
+ out = append(out, i)
+ }
+ return out
+ }
+ return []int{1, (maxOrd + 1) / 2, maxOrd}
+}
diff --git a/backend/internal/adminconsole/chart_test.go b/backend/internal/adminconsole/chart_test.go
new file mode 100644
index 0000000..5a53fa9
--- /dev/null
+++ b/backend/internal/adminconsole/chart_test.go
@@ -0,0 +1,51 @@
+package adminconsole
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestFormatDuration(t *testing.T) {
+ cases := map[float64]string{
+ 0: "0s", 30: "30s", 59: "59s", 60: "1m", 150: "3m", 3600: "1h", 3660: "1h1m", 7800: "2h10m",
+ }
+ for secs, want := range cases {
+ if got := FormatDuration(secs); got != want {
+ t.Errorf("FormatDuration(%v) = %q, want %q", secs, got, want)
+ }
+ }
+}
+
+func TestMoveDurationChartEmpty(t *testing.T) {
+ if got := MoveDurationChart(nil); got != "" {
+ t.Errorf("empty chart = %q, want empty", got)
+ }
+}
+
+func TestMoveDurationChart(t *testing.T) {
+ pts := []ChartPoint{{Ordinal: 1, Min: 5, Max: 20, Avg: 10}, {Ordinal: 2, Min: 8, Max: 40, Avg: 18}, {Ordinal: 3, Min: 12, Max: 90, Avg: 30}}
+ svg := string(MoveDurationChart(pts))
+ for _, want := range []string{"
{{end}}
+{{if .MoveChart}}
+
Move timing
+
Think time per move number across all games — min · mean · max.
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index d05ddc6..9874d50 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -1,5 +1,7 @@
package adminconsole
+import "html/template"
+
// The *View types are the display models the gin handlers fill and the templates
// render. Time values are pre-formatted to strings by the handlers so the
// templates stay logic-free.
@@ -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.
diff --git a/backend/internal/game/analytics.go b/backend/internal/game/analytics.go
new file mode 100644
index 0000000..3b0a301
--- /dev/null
+++ b/backend/internal/game/analytics.go
@@ -0,0 +1,116 @@
+package game
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+// A move's "duration" is the think time from the previous move's commit (the moment
+// the turn started) to this move's commit. Only play/pass/exchange moves count;
+// timeouts and resignations are not think time. The very first move of a game has no
+// previous move, so its baseline is the game's creation time. The figures are derived
+// from the move journal (game_moves.created_at), so no schema change is needed.
+//
+// timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for
+// every timed move; the two reports aggregate it differently.
+const timedMovesCTE = `
+ SELECT gp.account_id AS aid,
+ m.game_id AS gid,
+ ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord,
+ EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs
+ FROM backend.game_moves m
+ JOIN backend.games g ON g.game_id = m.game_id
+ LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1
+ JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat
+ WHERE m.action IN ('play', 'pass', 'exchange')`
+
+// MoveDurationStat is the min, max and mean per-move think time (in seconds) for an
+// account across all its games, with the number of timed moves counted.
+type MoveDurationStat struct {
+ MinSecs float64
+ MaxSecs float64
+ AvgSecs float64
+ Moves int
+}
+
+// MoveDurationStats returns the move-duration summary for each of accountIDs that has
+// at least one timed move; accounts with none are absent from the map. It powers the
+// admin user-list columns. The scan over the journal is acceptable for the low-traffic
+// console; per-human analysis is the authoritative use (the live metric aggregates all
+// seats including robots).
+func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
+ if len(accountIDs) == 0 {
+ return map[uuid.UUID]MoveDurationStat{}, nil
+ }
+ q := `WITH d AS (` + timedMovesCTE + `)
+SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid`
+ rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs))
+ if err != nil {
+ return nil, fmt.Errorf("game: move-duration stats: %w", err)
+ }
+ defer rows.Close()
+ out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs))
+ for rows.Next() {
+ var id uuid.UUID
+ var st MoveDurationStat
+ if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil {
+ return nil, fmt.Errorf("game: scan move-duration stat: %w", err)
+ }
+ out[id] = st
+ }
+ return out, rows.Err()
+}
+
+// OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move
+// (Ordinal) across all its games.
+type OrdinalDuration struct {
+ Ordinal int
+ MinSecs float64
+ MaxSecs float64
+ AvgSecs float64
+}
+
+// MoveDurationByOrdinal returns the account's per-move-number think-time summary,
+// ordered by move number, for the admin user-detail chart. The ordinal counts the
+// account's own moves within each game (its 1st, 2nd, … move).
+func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
+ q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1)
+SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord`
+ rows, err := s.db.QueryContext(ctx, q, accountID)
+ if err != nil {
+ return nil, fmt.Errorf("game: move-duration by ordinal: %w", err)
+ }
+ defer rows.Close()
+ var out []OrdinalDuration
+ for rows.Next() {
+ var od OrdinalDuration
+ if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil {
+ return nil, fmt.Errorf("game: scan ordinal duration: %w", err)
+ }
+ out = append(out, od)
+ }
+ return out, rows.Err()
+}
+
+// uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an
+// ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe.
+func uuidArrayLiteral(ids []uuid.UUID) string {
+ ss := make([]string, len(ids))
+ for i, id := range ids {
+ ss[i] = id.String()
+ }
+ return "{" + strings.Join(ss, ",") + "}"
+}
+
+// MoveDurationStats exposes the store report to the admin console handlers.
+func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
+ return svc.store.MoveDurationStats(ctx, accountIDs)
+}
+
+// MoveDurationByOrdinal exposes the per-move-number report to the admin console.
+func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
+ return svc.store.MoveDurationByOrdinal(ctx, accountID)
+}
diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go
new file mode 100644
index 0000000..03f6509
--- /dev/null
+++ b/backend/internal/game/emit_test.go
@@ -0,0 +1,52 @@
+package game
+
+import (
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/engine"
+ "scrabble/backend/internal/notify"
+)
+
+// recordingPublisher captures every published intent for assertions.
+type recordingPublisher struct{ intents []notify.Intent }
+
+func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) }
+
+// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every
+// seat — including the actor's own account, so the mover's other devices refresh —
+// and your_turn only to the next mover.
+func TestEmitMoveNotifiesActor(t *testing.T) {
+ actor, opp := uuid.New(), uuid.New()
+ pub := &recordingPublisher{}
+ svc := &Service{pub: pub}
+ g := Game{
+ ID: uuid.New(),
+ Status: StatusActive,
+ ToMove: 1,
+ TurnStartedAt: time.Now(),
+ TurnTimeout: time.Hour,
+ Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}},
+ }
+ svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10})
+
+ kinds := map[uuid.UUID][]string{}
+ for _, in := range pub.intents {
+ kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
+ }
+ if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
+ t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
+ }
+ if !slices.Contains(kinds[opp], notify.KindOpponentMoved) {
+ t.Errorf("opponent should get opponent_moved, got %v", kinds[opp])
+ }
+ if !slices.Contains(kinds[opp], notify.KindYourTurn) {
+ t.Errorf("next mover should get your_turn, got %v", kinds[opp])
+ }
+ if slices.Contains(kinds[actor], notify.KindYourTurn) {
+ t.Errorf("actor is not next to move, should not get your_turn")
+ }
+}
diff --git a/backend/internal/game/metrics.go b/backend/internal/game/metrics.go
index 8a59185..b9596d3 100644
--- a/backend/internal/game/metrics.go
+++ b/backend/internal/game/metrics.go
@@ -22,6 +22,7 @@ const meterName = "scrabble/backend/game"
type gameMetrics struct {
replay metric.Float64Histogram
validate metric.Float64Histogram
+ moveDur metric.Float64Histogram
started metric.Int64Counter
abandoned metric.Int64Counter
}
@@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics {
return &gameMetrics{
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
+ moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."),
started: counter(meter, "games_started_total", "Games created and started."),
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
}
@@ -75,6 +77,30 @@ func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, star
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
}
+// recordMoveDuration records how long a seat spent on a committed move, attributed by
+// variant and the game phase derived from moveCount. A non-positive duration (a clock
+// skew or a move with no recorded turn start) is dropped.
+func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) {
+ if d <= 0 {
+ return
+ }
+ m.moveDur.Record(ctx, d.Seconds(),
+ metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount))))
+}
+
+// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The
+// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7).
+func phaseOf(moveCount int) string {
+ switch {
+ case moveCount <= 8:
+ return "opening"
+ case moveCount <= 20:
+ return "middle"
+ default:
+ return "endgame"
+ }
+}
+
// recordStarted counts one started game of variant.
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
m.started.Add(ctx, 1, variantAttr(v))
diff --git a/backend/internal/game/metrics_test.go b/backend/internal/game/metrics_test.go
index dad8b97..cd1d5c5 100644
--- a/backend/internal/game/metrics_test.go
+++ b/backend/internal/game/metrics_test.go
@@ -26,6 +26,8 @@ func TestGameMetrics(t *testing.T) {
m.recordAbandoned(ctx, engine.VariantErudit)
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
+ m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
+ m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
@@ -45,6 +47,19 @@ func TestGameMetrics(t *testing.T) {
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
}
+ if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
+ t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
+ }
+}
+
+// TestPhaseOf checks the move-ordinal to phase bucketing.
+func TestPhaseOf(t *testing.T) {
+ cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
+ for mc, want := range cases {
+ if got := phaseOf(mc); got != want {
+ t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
+ }
+ }
}
// counterByAttr sums the int64 counter named name, grouped by the value of the
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index 0b220aa..780bf8e 100644
--- a/backend/internal/game/service.go
+++ b/backend/internal/game/service.go
@@ -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 {
diff --git a/backend/internal/inttest/analytics_test.go b/backend/internal/inttest/analytics_test.go
new file mode 100644
index 0000000..4697dd8
--- /dev/null
+++ b/backend/internal/inttest/analytics_test.go
@@ -0,0 +1,81 @@
+//go:build integration
+
+package inttest
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/account"
+ "scrabble/backend/internal/game"
+)
+
+// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the
+// admin-console move-duration reports compute the think time (gap to the previous
+// move, the first move measured from game creation) correctly, per account and per
+// the account's move ordinal.
+func TestMoveDurationAnalytics(t *testing.T) {
+ ctx := context.Background()
+ accounts := account.NewStore(testDB)
+ a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
+ if err != nil {
+ t.Fatalf("provision A: %v", err)
+ }
+ b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
+ if err != nil {
+ t.Fatalf("provision B: %v", err)
+ }
+
+ gid := uuid.New()
+ t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+ if _, err := testDB.ExecContext(ctx,
+ `INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
+ VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
+ t.Fatalf("insert game: %v", err)
+ }
+ if _, err := testDB.ExecContext(ctx,
+ `INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil {
+ t.Fatalf("insert seats: %v", err)
+ }
+ // seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200.
+ moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}}
+ for _, m := range moves {
+ if _, err := testDB.ExecContext(ctx,
+ `INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`,
+ gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil {
+ t.Fatalf("insert move %d: %v", m.seq, err)
+ }
+ }
+
+ store := game.NewStore(testDB)
+ stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID})
+ if err != nil {
+ t.Fatalf("stats: %v", err)
+ }
+ if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 {
+ t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa)
+ }
+ if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 {
+ t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb)
+ }
+
+ byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID)
+ if err != nil {
+ t.Fatalf("by ordinal: %v", err)
+ }
+ want := []game.OrdinalDuration{
+ {Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60},
+ {Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50},
+ }
+ if len(byOrd) != len(want) {
+ t.Fatalf("by ordinal = %+v, want %+v", byOrd, want)
+ }
+ for i, w := range want {
+ if byOrd[i] != w {
+ t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w)
+ }
+ }
+}
diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go
index a212484..33efe14 100644
--- a/backend/internal/inttest/robot_test.go
+++ b/backend/internal/inttest/robot_test.go
@@ -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)
}
diff --git a/backend/internal/inttest/stage6_test.go b/backend/internal/inttest/stage6_test.go
index bade281..6c3ea48 100644
--- a/backend/internal/inttest/stage6_test.go
+++ b/backend/internal/inttest/stage6_test.go
@@ -38,7 +38,7 @@ func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
- robotID, err := robots.Pick()
+ robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go
index 80395a8..8908a38 100644
--- a/backend/internal/lobby/lobby.go
+++ b/backend/internal/lobby/lobby.go
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
+ "scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
@@ -25,7 +26,7 @@ type GameCreator interface {
// auto-match. robot.Service satisfies it; it returns an error when no robot is
// available so the matchmaker can defer substitution.
type RobotProvider interface {
- Pick() (uuid.UUID, error)
+ Pick(variant engine.Variant) (uuid.UUID, error)
}
// Blocker reports whether two accounts have a block between them (either
diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go
index 649e98f..48da0dd 100644
--- a/backend/internal/lobby/matchmaker.go
+++ b/backend/internal/lobby/matchmaker.go
@@ -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 {
diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go
index 6d59772..e2d145c 100644
--- a/backend/internal/lobby/matchmaker_test.go
+++ b/backend/internal/lobby/matchmaker_test.go
@@ -28,13 +28,15 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
}
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
-// an empty pool.
+// an empty pool. It records the variant of the last substitution request.
type fakeRobots struct {
- id uuid.UUID
- err error
+ id uuid.UUID
+ err error
+ lastVariant engine.Variant
}
-func (f *fakeRobots) Pick() (uuid.UUID, error) {
+func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
+ f.lastVariant = variant
if f.err != nil {
return uuid.Nil, f.err
}
diff --git a/backend/internal/robot/names.go b/backend/internal/robot/names.go
new file mode 100644
index 0000000..a49d2fe
--- /dev/null
+++ b/backend/internal/robot/names.go
@@ -0,0 +1,146 @@
+package robot
+
+// Robot display names are composed, not hand-listed. Per language there is a pool of
+// 32 full first names and a paired pool of 32 colloquial forms (William/Bill,
+// Анастасия/Настя), a surname pool, and three rendering forms: first name only;
+// first name plus a surname initial; first name plus full surname. Because robots are
+// durable accounts whose name must stay stable across restarts (a player's opponent
+// must not rename itself on every deploy, nor mid-game), the composition is
+// deterministic per pool slot — seeded by the slot index through mix — rather than
+// re-randomised each boot. Russian surnames are gender-agreed with the first name.
+
+// robotPoolSize is the number of robot accounts provisioned per language. It equals
+// the first-name pool size, so each slot draws a distinct person.
+const robotPoolSize = 32
+
+// latinShareInRussian is the approximate percentage of Russian-variant games that
+// draw a Latin-named robot rather than a Russian-named one (the owner's "≤20%").
+const latinShareInRussian = 20
+
+// name composition forms.
+const (
+ nameFormFirstOnly = iota // "Anna"
+ nameFormInitial // "Anna C."
+ nameFormFull // "Anna Carter"
+)
+
+// genderedName is a Russian first name tagged by grammatical gender so the surname
+// form (masculine vs feminine) can agree with it.
+type genderedName struct {
+ name string
+ female bool
+}
+
+// surnamePair holds a Russian surname's masculine and feminine forms.
+type surnamePair struct{ m, f string }
+
+// firstNamesFullEN and firstNamesShortEN are paired by index: the same person's
+// official and colloquial English first name (William/Bill).
+var firstNamesFullEN = []string{
+ "William", "Robert", "Thomas", "Nicholas", "Joseph", "Edward", "Charles", "Margaret",
+ "Elizabeth", "Katherine", "Alexander", "Samuel", "Andrew", "Christopher", "Benjamin", "Daniel",
+ "Matthew", "Anthony", "Michael", "Richard", "Jonathan", "Patricia", "Jennifer", "Jessica",
+ "Stephanie", "Victoria", "Theodore", "Frederick", "Gabriel", "Vincent", "Eleanor", "Josephine",
+}
+
+var firstNamesShortEN = []string{
+ "Will", "Bob", "Tom", "Nick", "Joe", "Eddie", "Charlie", "Maggie",
+ "Liz", "Kate", "Alex", "Sam", "Drew", "Chris", "Ben", "Dan",
+ "Matt", "Tony", "Mike", "Rick", "Jon", "Pat", "Jen", "Jess",
+ "Steph", "Vicky", "Ted", "Fred", "Gabe", "Vince", "Ellie", "Josie",
+}
+
+// surnamesEN is a pool of gender-neutral English surnames.
+var surnamesEN = []string{
+ "Carter", "Hayes", "Brooks", "Reed", "Cole", "Lane", "Bishop", "Hart",
+ "Shaw", "Wells", "Pierce", "Wade", "Frost", "Doyle", "Boyd", "Marsh",
+ "Hale", "Nash", "Webb", "Dale", "Park", "Lyon", "Snow", "Cross",
+ "Vance", "Knox", "Page", "Ford", "Banks", "Foster", "Greer", "Mills",
+}
+
+// firstNamesFullRU and firstNamesShortRU are paired by index: the same person's
+// official and colloquial Russian first name (Анастасия/Настя), gender-tagged.
+var firstNamesFullRU = []genderedName{
+ {"Александр", false}, {"Дмитрий", false}, {"Сергей", false}, {"Алексей", false},
+ {"Михаил", false}, {"Николай", false}, {"Владимир", false}, {"Константин", false},
+ {"Павел", false}, {"Григорий", false}, {"Андрей", false}, {"Иван", false},
+ {"Пётр", false}, {"Роман", false}, {"Антон", false}, {"Евгений", false},
+ {"Анна", true}, {"Мария", true}, {"Анастасия", true}, {"Наталья", true},
+ {"Татьяна", true}, {"Екатерина", true}, {"Юлия", true}, {"Ольга", true},
+ {"Елена", true}, {"Ирина", true}, {"Светлана", true}, {"Полина", true},
+ {"Дарья", true}, {"София", true}, {"Алина", true}, {"Вера", true},
+}
+
+var firstNamesShortRU = []genderedName{
+ {"Саша", false}, {"Дима", false}, {"Серёжа", false}, {"Лёша", false},
+ {"Миша", false}, {"Коля", false}, {"Володя", false}, {"Костя", false},
+ {"Паша", false}, {"Гриша", false}, {"Андрюша", false}, {"Ваня", false},
+ {"Петя", false}, {"Рома", false}, {"Антоша", false}, {"Женя", false},
+ {"Аня", true}, {"Маша", true}, {"Настя", true}, {"Наташа", true},
+ {"Таня", true}, {"Катя", true}, {"Юля", true}, {"Оля", true},
+ {"Лена", true}, {"Ира", true}, {"Света", true}, {"Поля", true},
+ {"Даша", true}, {"Соня", true}, {"Аля", true}, {"Верочка", true},
+}
+
+// surnamesRU is a pool of common Russian surnames in masculine and feminine forms.
+var surnamesRU = []surnamePair{
+ {"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
+ {"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
+ {"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
+ {"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
+ {"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
+ {"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
+ {"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
+ {"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
+ {"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
+ {"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
+ {"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"},
+}
+
+// robotDisplayNamesEN builds the English robot display-name pool, one per slot. Each
+// slot draws its paired full or colloquial first name, a surname, and a form.
+func robotDisplayNamesEN() []string {
+ out := make([]string, robotPoolSize)
+ for i := range out {
+ h := mix(int64(i), "robot-en")
+ first := firstNamesFullEN[i%len(firstNamesFullEN)]
+ if (h>>16)&1 == 1 {
+ first = firstNamesShortEN[i%len(firstNamesShortEN)]
+ }
+ surname := surnamesEN[h%uint64(len(surnamesEN))]
+ out[i] = composeName(first, surname, int((h>>8)%3))
+ }
+ return out
+}
+
+// robotDisplayNamesRU builds the Russian robot display-name pool, one per slot, with
+// the surname form agreeing with the first name's gender.
+func robotDisplayNamesRU() []string {
+ out := make([]string, robotPoolSize)
+ for i := range out {
+ h := mix(int64(i), "robot-ru")
+ fn := firstNamesFullRU[i%len(firstNamesFullRU)]
+ if (h>>16)&1 == 1 {
+ fn = firstNamesShortRU[i%len(firstNamesShortRU)]
+ }
+ sp := surnamesRU[h%uint64(len(surnamesRU))]
+ surname := sp.m
+ if fn.female {
+ surname = sp.f
+ }
+ out[i] = composeName(fn.name, surname, int((h>>8)%3))
+ }
+ return out
+}
+
+// composeName renders one of the three name forms from a first name and a surname.
+func composeName(first, surname string, form int) string {
+ switch form {
+ case nameFormInitial:
+ return first + " " + string([]rune(surname)[:1]) + "."
+ case nameFormFull:
+ return first + " " + surname
+ default:
+ return first
+ }
+}
diff --git a/backend/internal/robot/names_test.go b/backend/internal/robot/names_test.go
new file mode 100644
index 0000000..7925b8c
--- /dev/null
+++ b/backend/internal/robot/names_test.go
@@ -0,0 +1,119 @@
+package robot
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "scrabble/backend/internal/engine"
+)
+
+// TestComposeName covers the three rendering forms, including a Cyrillic initial.
+func TestComposeName(t *testing.T) {
+ cases := []struct {
+ first, surname string
+ form int
+ want string
+ }{
+ {"Anna", "Carter", nameFormFirstOnly, "Anna"},
+ {"Anna", "Carter", nameFormInitial, "Anna C."},
+ {"Anna", "Carter", nameFormFull, "Anna Carter"},
+ {"Маша", "Суханова", nameFormInitial, "Маша С."},
+ {"Маша", "Суханова", nameFormFull, "Маша Суханова"},
+ }
+ for _, c := range cases {
+ if got := composeName(c.first, c.surname, c.form); got != c.want {
+ t.Errorf("composeName(%q,%q,%d) = %q, want %q", c.first, c.surname, c.form, got, c.want)
+ }
+ }
+}
+
+// TestNamePoolsPaired checks the full and colloquial first-name pools line up by
+// index (so a slot's gender and person are consistent) and the surname forms differ.
+func TestNamePoolsPaired(t *testing.T) {
+ if len(firstNamesFullEN) != robotPoolSize || len(firstNamesShortEN) != robotPoolSize {
+ t.Fatalf("EN first-name pools must each hold %d names", robotPoolSize)
+ }
+ if len(firstNamesFullRU) != robotPoolSize || len(firstNamesShortRU) != robotPoolSize {
+ t.Fatalf("RU first-name pools must each hold %d names", robotPoolSize)
+ }
+ for i := range firstNamesFullRU {
+ if firstNamesFullRU[i].female != firstNamesShortRU[i].female {
+ t.Errorf("RU pair %d disagrees on gender: %q vs %q", i, firstNamesFullRU[i].name, firstNamesShortRU[i].name)
+ }
+ }
+ for _, sp := range surnamesRU {
+ if sp.m == sp.f {
+ t.Errorf("RU surname forms should differ: %q", sp.m)
+ }
+ }
+}
+
+// TestRobotDisplayNames checks the generated pools are the right size, non-empty and
+// deterministic — durable robot accounts must keep a stable name across restarts.
+func TestRobotDisplayNames(t *testing.T) {
+ en1, en2 := robotDisplayNamesEN(), robotDisplayNamesEN()
+ ru1, ru2 := robotDisplayNamesRU(), robotDisplayNamesRU()
+ if len(en1) != robotPoolSize || len(ru1) != robotPoolSize {
+ t.Fatalf("pool sizes en=%d ru=%d, want %d", len(en1), len(ru1), robotPoolSize)
+ }
+ for i := range en1 {
+ if en1[i] != en2[i] || ru1[i] != ru2[i] {
+ t.Fatalf("pool not deterministic at %d: en %q/%q ru %q/%q", i, en1[i], en2[i], ru1[i], ru2[i])
+ }
+ if en1[i] == "" || ru1[i] == "" {
+ t.Fatalf("empty composed name at index %d", i)
+ }
+ }
+}
+
+// TestPickVariantRouting checks English games draw the Latin pool and Russian games
+// draw mostly Russian names with a Latin minority.
+func TestPickVariantRouting(t *testing.T) {
+ enID, ruID := uuid.New(), uuid.New()
+ s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
+ for i := 0; i < 200; i++ {
+ if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
+ t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
+ }
+ }
+ var en, ru int
+ for i := 0; i < 4000; i++ {
+ got, err := s.Pick(engine.VariantRussianScrabble)
+ if err != nil {
+ t.Fatalf("russian Pick: %v", err)
+ }
+ switch got {
+ case enID:
+ en++
+ case ruID:
+ ru++
+ }
+ }
+ if ru <= en {
+ t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
+ }
+ if en == 0 {
+ t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
+ }
+ // Эрудит routes like Russian Scrabble.
+ if _, err := s.Pick(engine.VariantErudit); err != nil {
+ t.Errorf("erudit Pick: %v", err)
+ }
+}
+
+// TestPickFallback checks an empty side falls back to the other pool and an empty pool
+// errors.
+func TestPickFallback(t *testing.T) {
+ id := uuid.New()
+ if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
+ t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
+ }
+ if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
+ t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
+ }
+ if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
+ t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
+ }
+}
diff --git a/backend/internal/robot/robot.go b/backend/internal/robot/robot.go
index f4ef5cc..e6aa40d 100644
--- a/backend/internal/robot/robot.go
+++ b/backend/internal/robot/robot.go
@@ -55,13 +55,6 @@ type Nudger interface {
LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error)
}
-// robotNames is the curated, human-like name pool. Each name backs one durable
-// robot account, addressed by a stable robot identity (its lower-cased name).
-var robotNames = []string{
- "Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan",
- "Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage",
-}
-
// Config configures the robot subsystem.
type Config struct {
// DriveInterval is how often the driver scans for robot turns. Sourced from
@@ -91,8 +84,9 @@ type Service struct {
clock func() time.Time
log *zap.Logger
- mu sync.RWMutex
- pool []uuid.UUID
+ mu sync.RWMutex
+ poolEN []uuid.UUID
+ poolRU []uuid.UUID
}
// NewService constructs a robot Service. games and social are the domain seams it
@@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met
}
}
-// EnsurePool idempotently provisions the named robot accounts and records their
-// ids as the pool. Each robot is a durable account bound to a robot identity,
-// with chat and friend requests blocked so it never engages socially
-// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary
-// registry: a failure fails the boot.
+// EnsurePool idempotently provisions the robot accounts (one per slot of each
+// language's composed name pool) and records their ids. Each robot is a durable
+// account bound to a stable, index-keyed robot identity, with chat and friend
+// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a
+// startup dependency, like the dictionary registry: a failure fails the boot.
func (s *Service) EnsurePool(ctx context.Context) error {
- ids := make([]uuid.UUID, 0, len(robotNames))
- for _, name := range robotNames {
- acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name))
- if err != nil {
- return fmt.Errorf("robot: provision %q: %w", name, err)
- }
- if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests {
- if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
- DisplayName: name,
- PreferredLanguage: acc.PreferredLanguage,
- TimeZone: acc.TimeZone,
- AwayStart: acc.AwayStart,
- AwayEnd: acc.AwayEnd,
- BlockChat: true,
- BlockFriendRequests: true,
- }); err != nil {
- return fmt.Errorf("robot: profile %q: %w", name, err)
- }
- }
- ids = append(ids, acc.ID)
+ en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN())
+ if err != nil {
+ return err
+ }
+ ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU())
+ if err != nil {
+ return err
}
s.mu.Lock()
- s.pool = ids
+ s.poolEN, s.poolRU = en, ru
s.mu.Unlock()
return nil
}
-// Pick returns a random robot account from the pool, for the matchmaker to
-// substitute into an auto-match. It satisfies lobby.RobotProvider.
-func (s *Service) Pick() (uuid.UUID, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- if len(s.pool) == 0 {
- return uuid.Nil, ErrNoRobotAvailable
+// provisionPool provisions one durable robot account per name and returns their ids
+// in order. The identity is keyed by language and slot index (stable across restarts
+// and independent of the composed display name); account.ProvisionRobot sets the
+// display name and social blocks and is idempotent, so EnsurePool can run every boot.
+func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) {
+ ids := make([]uuid.UUID, 0, len(names))
+ for i, name := range names {
+ acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name)
+ if err != nil {
+ return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err)
+ }
+ ids = append(ids, acc.ID)
}
- return s.pool[rand.IntN(len(s.pool))], nil
+ return ids, nil
}
-// poolIDs returns a snapshot of the pool for the driver scan.
+// Pick returns a random robot account for the matchmaker to substitute into an
+// auto-match of the given variant. An English game draws from the Latin pool; a
+// Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a
+// Latin name about latinShareInRussian% of the time; either side falls back to the
+// other when its pool is empty. It satisfies lobby.RobotProvider.
+func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ primary, secondary := s.poolEN, s.poolRU
+ if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit {
+ primary, secondary = s.poolRU, s.poolEN
+ if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian {
+ primary, secondary = secondary, primary
+ }
+ }
+ if len(primary) == 0 {
+ primary = secondary
+ }
+ if len(primary) == 0 {
+ return uuid.Nil, ErrNoRobotAvailable
+ }
+ return primary[rand.IntN(len(primary))], nil
+}
+
+// poolIDs returns a snapshot of the whole pool (both languages) for the driver scan,
+// which is variant-agnostic — it acts on every robot's active games.
func (s *Service) poolIDs() []uuid.UUID {
s.mu.RLock()
defer s.mu.RUnlock()
- return append([]uuid.UUID(nil), s.pool...)
-}
-
-// externalID is the stable robot identity for a pool name.
-func externalID(name string) string {
- return "robot-" + name
+ ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU))
+ ids = append(ids, s.poolEN...)
+ ids = append(ids, s.poolRU...)
+ return ids
}
diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go
index 7301670..633dd98 100644
--- a/backend/internal/robot/strategy.go
+++ b/backend/internal/robot/strategy.go
@@ -23,17 +23,27 @@ const (
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
playToWinPercent = 40
- // delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the
- // right-skewed distribution (short delays frequent). With skew 3.5 the median
- // is about 10 minutes and the mean about 20, with a tail out to the maximum.
- delayMinMinutes = 2.0
- delayMaxMinutes = 90.0
- delaySkew = 3.5
+ // The robot's think time depends on how far the game has progressed: early moves
+ // are quick and late moves can be long (endgame deliberation). The delay is drawn
+ // from a band that interpolates with the move count from [delayEarlyLoMinutes,
+ // delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes]
+ // by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates
+ // delays near the band's floor — an active player). The result is clamped to
+ // [delayHardMinMinutes, delayHardMaxMinutes]. The numbers are deliberate estimates,
+ // to be retuned once real play statistics arrive (docs/ARCHITECTURE.md §7).
+ delayEarlyLoMinutes = 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))
}
diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go
index 3161e4b..e230ea6 100644
--- a/backend/internal/robot/strategy_test.go
+++ b/backend/internal/robot/strategy_test.go
@@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) {
}
}
-// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in
-// [2min, 90min) and is reproducible for a (seed, moveCount).
+// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard
+// bounds [1min, 90min] and is reproducible for a (seed, moveCount).
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
for seed := int64(1); seed <= 200; seed++ {
for mc := 0; mc < 50; mc++ {
d := moveDelay(seed, mc)
- if d < 2*time.Minute || d >= 90*time.Minute {
- t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc)
+ if d < 1*time.Minute || d > 90*time.Minute {
+ t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc)
}
if moveDelay(seed, mc) != d {
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
@@ -43,22 +43,49 @@ func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
}
}
-// TestMoveDelaySkew checks the distribution is right-skewed with the intended
-// ~10-minute median: most delays are short, the mean sits above the median.
+// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the
+// first move lives in the short [1,5]min band, a late move in the long [10,90]min
+// band, so the median think time rises with the move count.
+func TestMoveDelayGrowsWithMoveCount(t *testing.T) {
+ median := func(mc int) float64 {
+ const n = 4000
+ xs := make([]float64, n)
+ for s := 0; s < n; s++ {
+ xs[s] = moveDelay(int64(s+1), mc).Minutes()
+ }
+ sort.Float64s(xs)
+ return xs[n/2]
+ }
+ for s := int64(1); s <= 500; s++ {
+ if d := moveDelay(s, 0).Minutes(); d < 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)
diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go
index 7f0ae7a..517c8fc 100644
--- a/backend/internal/server/dto_test.go
+++ b/backend/internal/server/dto_test.go
@@ -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"},
diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go
index dbbe120..f4e64f9 100644
--- a/backend/internal/server/handlers.go
+++ b/backend/internal/server/handlers.go
@@ -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):
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index d8e2265..5fdf3aa 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -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)
}
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 6cf7c09..99b53b5 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -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.',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index 7034bd2..1ffcbf3 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -149,6 +149,7 @@ export const ru: Record = {
'lang.ru': 'Русский',
'error.not_your_turn': 'Сейчас не ваш ход.',
+ 'error.nudge_own_turn': 'Сейчас ваш ход — некого торопить.',
'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
--
2.52.0
From c0b46a7ca6a012b3f5393bd76091790e0aba83c3 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 10:05:01 +0200
Subject: [PATCH 008/223] Stage 17: path-conditional CI behind an aggregate
gate + connector liveness probe; Grafana move-duration panel
- #10 a `changes` job path-filters unit/integration/ui; an always-running `gate` job aggregates them (success-or-skipped) and becomes the only required check
- #9 deploy adds a Telegram-connector liveness probe (docker inspect: running, not restarting, stable restart count) with a VPN-handshake grace period
- #1a Game-domain dashboard gains a 'Move think-time by phase (p50/p95)' panel
- deploy README: branch protection now requires only CI / gate
---
.gitea/workflows/ci.yaml | 119 ++++++++++++++++++++-
deploy/README.md | 7 +-
deploy/grafana/dashboards/game-domain.json | 14 ++-
3 files changed, 135 insertions(+), 5 deletions(-)
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index bfb321b..67d46a9 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -1,6 +1,6 @@
name: CI
-# Single gated pipeline for the test contour (Stage 16). Gitea cannot express
+# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
# one workflow.
#
@@ -11,6 +11,12 @@ name: CI
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
# workflow (Stage 18).
#
+# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their
+# code changed (the `changes` job decides). Because a skipped required check would
+# block a merge under branch protection, the always-running `gate` job aggregates
+# their results and is the ONLY required status check; it passes when every
+# upstream job either succeeded or was skipped.
+#
# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
# `--progress plain`) so the Gitea logs stay readable.
@@ -21,7 +27,57 @@ on:
branches: [development]
jobs:
+ # changes detects which areas a PR/push touched, so the test jobs can skip when
+ # irrelevant. It defaults to running everything when the diff cannot be computed.
+ changes:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash
+ outputs:
+ go: ${{ steps.filter.outputs.go }}
+ ui: ${{ steps.filter.outputs.ui }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Detect changed paths
+ id: filter
+ run: |
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ git fetch -q origin "${{ github.base_ref }}" || true
+ range="origin/${{ github.base_ref }}...HEAD"
+ else
+ before="${{ github.event.before }}"
+ if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
+ range="HEAD~1...HEAD"
+ else
+ range="${before}...HEAD"
+ fi
+ fi
+ echo "comparison range: $range"
+ # Default to running everything; narrow only when the diff is computable.
+ go=true; ui=true
+ files="$(git diff --name-only "$range" 2>/dev/null || echo __DIFF_FAILED__)"
+ if [ "$files" != "__DIFF_FAILED__" ]; then
+ echo "changed files:"; echo "$files"
+ go=false; ui=false
+ if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|go\.work)'; then go=true; fi
+ if echo "$files" | grep -qE '^ui/'; then ui=true; fi
+ # A workflow or deploy change re-runs everything as a safety net.
+ if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
+ else
+ echo "diff failed; running all jobs"
+ fi
+ echo "selected: go=$go ui=$ui"
+ echo "go=$go" >> "$GITHUB_OUTPUT"
+ echo "ui=$ui" >> "$GITHUB_OUTPUT"
+
unit:
+ needs: changes
+ if: ${{ needs.changes.outputs.go == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
@@ -67,6 +123,8 @@ jobs:
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
integration:
+ needs: changes
+ if: ${{ needs.changes.outputs.go == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
@@ -102,6 +160,8 @@ jobs:
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
ui:
+ needs: changes
+ if: ${{ needs.changes.outputs.ui == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
@@ -142,10 +202,37 @@ jobs:
run: pnpm run test:e2e
timeout-minutes: 5
+ # gate is the single branch-protection required check. It always runs and passes
+ # only when each upstream job succeeded or was skipped (a path-filtered no-op),
+ # failing the merge if any actually failed or was cancelled.
+ gate:
+ needs: [unit, integration, ui]
+ if: always()
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Aggregate required checks
+ run: |
+ fail=
+ for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do
+ name="${r%%:*}"; res="${r#*:}"
+ echo "$name = $res"
+ case "$res" in
+ success|skipped) ;;
+ *) echo "::error::$name=$res"; fail=1 ;;
+ esac
+ done
+ [ -z "$fail" ] || { echo "one or more required jobs failed"; exit 1; }
+ echo "all required jobs passed or were skipped"
+
deploy:
# Auto test-deploy on a PR into development and on the push that merges it.
# A PR into master is test-only (this job is skipped); prod deploy is manual.
- needs: [unit, integration, ui]
+ # Gates on `gate` (so a real test failure blocks the deploy) but runs even when
+ # some test jobs were path-skipped.
+ needs: [gate]
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
runs-on: ubuntu-latest
defaults:
@@ -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
diff --git a/deploy/README.md b/deploy/README.md
index 62ab89d..b5778f4 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -110,5 +110,8 @@ resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentional
- **Host caddy** route ` → scrabble:80` (the in-compose caddy serves HTTP
in the test contour; the host caddy terminates TLS). Not needed on prod, where the
contour caddy owns TLS (set `CADDY_SITE_ADDRESS` to the domain).
-- **Branch protection** required-status-check names are `CI / unit`,
- `CI / integration`, `CI / ui` (see [`../CLAUDE.md`](../CLAUDE.md) "Branching & CI").
+- **Branch protection** requires the single status check `CI / gate` (Stage 17).
+ The `unit` / `integration` / `ui` jobs are path-conditional (they skip when their
+ code did not change), and the always-running `gate` job aggregates them (passing
+ when each succeeded or was skipped), so a skipped job never blocks a merge. See
+ [`../CLAUDE.md`](../CLAUDE.md) "Branching & CI".
diff --git a/deploy/grafana/dashboards/game-domain.json b/deploy/grafana/dashboards/game-domain.json
index 90d76f9..53594c2 100644
--- a/deploy/grafana/dashboards/game-domain.json
+++ b/deploy/grafana/dashboards/game-domain.json
@@ -4,7 +4,7 @@
"tags": ["scrabble"],
"timezone": "",
"schemaVersion": 39,
- "version": 1,
+ "version": 2,
"refresh": "30s",
"time": { "from": "now-24h", "to": "now" },
"panels": [
@@ -54,6 +54,18 @@
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(game_move_validate_duration_bucket[5m])) by (le, variant))", "legendFormat": "{{variant}}" }]
+ },
+ {
+ "type": "timeseries",
+ "title": "Move think-time by phase (p50 / p95)",
+ "description": "Seconds a seat spent on a committed move, by game phase. Aggregates all seats including robots; per-human analysis is in the admin console.",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "refId": "A", "expr": "histogram_quantile(0.5, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p50 {{phase}}" },
+ { "refId": "B", "expr": "histogram_quantile(0.95, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p95 {{phase}}" }
+ ]
}
]
}
--
2.52.0
From 1d0bafaabb697ba18cdd2c056e727d0a360c456d Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 10:23:42 +0200
Subject: [PATCH 009/223] Stage 17: UI defect fixes (russian variant, Telegram
theme/nav/banner, reconnect, hint zoom, plaque, history, transitions,
per-game cache)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400
- #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher
- #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token
- #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground
- #17 hint zoom scrolls to the placement's bounding box, not the top-left
- #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history
- #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board
- #3 (UI) disable nudge on the player's own turn
- #18a directional screen slide transitions (forward in from the right, back reveals the lobby)
- #13 per-game in-memory cache: instant render on re-entry + background refresh
- e2e: openGame waits for the slide transition to settle
---
ui/e2e/game.spec.ts | 4 ++
ui/src/App.svelte | 73 ++++++++++++++++++++++++-------
ui/src/app.css | 3 ++
ui/src/components/AdBanner.svelte | 2 +-
ui/src/game/Chat.svelte | 6 ++-
ui/src/game/Game.svelte | 61 ++++++++++++++++++++++----
ui/src/lib/app.svelte.ts | 53 +++++++++++++++++++---
ui/src/lib/gamecache.ts | 30 +++++++++++++
ui/src/lib/mock/alphabet.ts | 2 +-
ui/src/lib/mock/client.ts | 2 +-
ui/src/lib/mock/data.ts | 2 +-
ui/src/lib/model.ts | 2 +-
ui/src/lib/premiums.test.ts | 2 +-
ui/src/lib/telegram.ts | 12 +++++
ui/src/lib/theme.ts | 5 +++
ui/src/lib/variants.test.ts | 4 +-
ui/src/lib/variants.ts | 4 +-
ui/src/screens/Lobby.svelte | 2 +-
ui/src/screens/Settings.svelte | 23 +++++-----
19 files changed, 239 insertions(+), 53 deletions(-)
create mode 100644 ui/src/lib/gamecache.ts
diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts
index b38d17f..cce90f7 100644
--- a/ui/e2e/game.spec.ts
+++ b/ui/e2e/game.spec.ts
@@ -10,6 +10,10 @@ async function openGame(page: Page): Promise {
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
await expect(page.locator('[data-cell]').first()).toBeVisible();
+ // Wait for the screen-slide transition to settle so only the game pane remains;
+ // until it does, the leaving lobby pane's header (its menu button) is also in the
+ // DOM, which would make shared locators like .burger ambiguous.
+ await expect(page.locator('.pane')).toHaveCount(1);
}
test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => {
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 1acb335..6247051 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -1,5 +1,6 @@
{#if !app.ready}
{t('common.loading')}
-{:else if router.route.name === 'login'}
-
-{:else if router.route.name === 'new'}
-
-{:else if router.route.name === 'game'}
-
-{:else if router.route.name === 'profile'}
-
-{:else if router.route.name === 'settings'}
-
-{:else if router.route.name === 'about'}
-
-{:else if router.route.name === 'friends'}
-
-{:else if router.route.name === 'stats'}
-
{:else}
-
+
+ {#key routeKey}
+
+ {#if router.route.name === 'login'}
+
+ {:else if router.route.name === 'new'}
+
+ {:else if router.route.name === 'game'}
+
+ {:else if router.route.name === 'profile'}
+
+ {:else if router.route.name === 'settings'}
+
+ {:else if router.route.name === 'about'}
+
+ {:else if router.route.name === 'friends'}
+
+ {:else if router.route.name === 'stats'}
+
+ {:else}
+
+ {/if}
+
+ {/key}
+
{/if}
@@ -50,4 +80,13 @@
place-items: center;
color: var(--text-muted);
}
+ .router {
+ position: relative;
+ height: 100%;
+ overflow: hidden;
+ }
+ .pane {
+ position: absolute;
+ inset: 0;
+ }
diff --git a/ui/src/app.css b/ui/src/app.css
index 2bde4aa..ff1ed3e 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -11,6 +11,7 @@
--bg-elev: #ffffff;
--surface: #ffffff;
--surface-2: #eef0f3;
+ --ad-bg: #e3e7ee; /* announcement banner: a subtle accent, darker in light theme */
--text: #14181f;
--text-muted: #6b7280;
--border: #d8dce2;
@@ -51,6 +52,7 @@
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
+ --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
@@ -82,6 +84,7 @@
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
+ --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte
index 843d2b0..e5d2e04 100644
--- a/ui/src/components/AdBanner.svelte
+++ b/ui/src/components/AdBanner.svelte
@@ -57,7 +57,7 @@
overflow: hidden;
white-space: nowrap;
padding: 6px 0;
- background: var(--surface-2);
+ background: var(--ad-bg);
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.2;
diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte
index 638f6b5..1cad5c0 100644
--- a/ui/src/game/Chat.svelte
+++ b/ui/src/game/Chat.svelte
@@ -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()}
/>
-
+
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index 2aebef7..dd82a80 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -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}
-
+
+
+
(historyOpen = !historyOpen)}>
{#each view.game.seats as s (s.seat)}
{:else}
@@ -545,16 +543,22 @@
{#snippet trigger()}🥺{t('game.skip')}{/snippet}
{#snippet popover(close)}{/snippet}
-
+
{#snippet trigger()}
🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if}{t('game.hint')}
{/snippet}
{#snippet popover(close)}{/snippet}
-
+ {#if placement.pending.length > 0}
+
+ {:else}
+
+ {/if}
{/if}
{/snippet}
@@ -641,15 +645,18 @@
text-align: center;
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);
+ /* inactive seats recede: they blend into the bar, slightly sunk */
+ background: transparent;
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
+ }
+ .seat .nm {
+ color: var(--text-muted);
}
.seat.turn {
- /* the active seat is "raised": lifted clear of the others with side shadows */
- background: var(--bg-elev);
+ /* the active seat pops: a raised, accented chip lifted clear of the bar */
+ background: var(--surface-2);
box-shadow:
- 0 1px 2px rgba(0, 0, 0, 0.16),
+ 0 2px 6px rgba(0, 0, 0, 0.3),
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
3px 0 6px -2px rgba(0, 0, 0, 0.26);
position: relative;
@@ -767,16 +774,18 @@
flex: 1;
min-width: 0;
}
- .flag {
- font-size: 1.6rem;
- }
- :global(.make) {
+ .make {
min-width: 56px;
background: var(--accent);
color: var(--accent-text);
+ border: none;
border-radius: var(--radius-sm);
display: grid;
place-items: center;
+ font-size: 1.6rem;
+ }
+ .make:disabled {
+ opacity: 0.55;
}
.pop {
padding: 9px 14px;
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index 926b921..848bbbd 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -9,7 +9,7 @@ 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, telegramColorScheme, telegramLaunch } from './telegram';
+import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
@@ -52,11 +52,38 @@ let streamAlive = false;
let reconnectTimer: ReturnType | null = null;
let toastTimer: ReturnType | null = null;
-/** documentHidden reports whether the app is currently backgrounded. */
+// Background/foreground tracking, to silence the reconnect banner during a normal app
+// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
+let backgrounded = false;
+let foregroundedAt = 0;
+const reconnectGraceMs = 4000;
+
+/** documentHidden reports whether the page is currently hidden. */
function documentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
+/**
+ * bannerSuppressed reports whether the connection banner should stay hidden: while
+ * backgrounded, and for a short grace after returning to the foreground — a connection
+ * dropped while suspended surfaces its error on resume, before the silent reconnect lands.
+ */
+function bannerSuppressed(): boolean {
+ return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs;
+}
+
+function goBackground(): void {
+ backgrounded = true;
+}
+
+function goForeground(): void {
+ backgrounded = false;
+ foregroundedAt = Date.now();
+ if (!app.session) return;
+ if (!streamAlive) openStream(); // silently re-establish a stream dropped while away
+ void refreshNotifications();
+}
+
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
@@ -96,14 +123,10 @@ function openStream(): void {
},
() => {
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();
- }
+ // A background suspend drops the single-shot stream. Keep the banner hidden while
+ // backgrounded or just-resumed (bannerSuppressed); always schedule a retry.
+ if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error');
+ scheduleReconnect();
},
);
}
@@ -114,7 +137,7 @@ function scheduleReconnect(): void {
if (reconnectTimer || !app.session) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
- if (app.session && !streamAlive && !documentHidden()) openStream();
+ if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream();
}, 4000);
}
@@ -353,14 +376,18 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs();
}
-// 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).
+// Background/foreground lifecycle: silence the reconnect banner during a suspend and
+// reconnect quietly on return (and refresh the lobby badge for any push missed while
+// hidden, §10). Several signals cover the platforms: the page Visibility API, the
+// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0).
if (typeof document !== 'undefined') {
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible' && app.session) {
- if (!streamAlive) openStream();
- void refreshNotifications();
- }
- });
+ document.addEventListener('visibilitychange', () =>
+ document.visibilityState === 'visible' ? goForeground() : goBackground(),
+ );
}
+if (typeof window !== 'undefined') {
+ window.addEventListener('pageshow', goForeground);
+ window.addEventListener('pagehide', goBackground);
+}
+telegramOnEvent('activated', goForeground);
+telegramOnEvent('deactivated', goBackground);
diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts
index da5455d..5440086 100644
--- a/ui/src/lib/telegram.ts
+++ b/ui/src/lib/telegram.ts
@@ -12,6 +12,7 @@ interface TelegramWebApp {
colorScheme?: 'light' | 'dark';
ready?: () => void;
expand?: () => void;
+ onEvent?: (event: string, handler: () => void) => void;
}
function webApp(): TelegramWebApp | undefined {
@@ -49,6 +50,15 @@ export function telegramLaunch(): TelegramLaunch {
return { initData: w.initData, startParam, theme: w.themeParams };
}
+/**
+ * telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' /
+ * 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client
+ * that predates the event, so callers can register defensively.
+ */
+export function telegramOnEvent(event: string, handler: () => void): void {
+ webApp()?.onEvent?.(event, handler);
+}
+
/**
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
* or undefined outside Telegram. Inside the Mini App this — not the OS
--
2.52.0
From 645a50353234fd4e8a8254724043ff0b8c5ec044 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 12:38:04 +0200
Subject: [PATCH 012/223] =?UTF-8?q?Stage=2017=20(#4):=20in-memory=20lobby?=
=?UTF-8?q?=20cache=20=E2=80=94=20render=20instantly=20on=20the=20back-sli?=
=?UTF-8?q?de,=20refresh=20in=20background?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ui/src/lib/app.svelte.ts | 2 ++
ui/src/lib/lobbycache.ts | 30 ++++++++++++++++++++++++++++++
ui/src/screens/Lobby.svelte | 14 +++++++++++++-
3 files changed, 45 insertions(+), 1 deletion(-)
create mode 100644 ui/src/lib/lobbycache.ts
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index 848bbbd..e001ba9 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -13,6 +13,7 @@ import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch, te
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
+import { clearLobby } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -311,6 +312,7 @@ export async function loginEmail(email: string, code: string): Promise {
export async function logout(): Promise {
closeStream();
clearGameCache();
+ clearLobby();
gateway.setToken(null);
await clearSession();
app.session = null;
diff --git a/ui/src/lib/lobbycache.ts b/ui/src/lib/lobbycache.ts
new file mode 100644
index 0000000..5184bf8
--- /dev/null
+++ b/ui/src/lib/lobbycache.ts
@@ -0,0 +1,30 @@
+// In-memory lobby snapshot, the lobby counterpart of gamecache.ts. The lobby re-fetches
+// its lists on every entry, so without a cache the screen renders blank and "draws in"
+// during the back-slide from a game. Caching the last lists lets the lobby render
+// instantly (before/under the transition) and refresh in the background. Process-memory
+// only; cleared on logout.
+
+import type { AccountRef, GameView, Invitation } from './model';
+
+interface LobbySnapshot {
+ games: GameView[];
+ invitations: Invitation[];
+ incoming: AccountRef[];
+}
+
+let snapshot: LobbySnapshot | null = null;
+
+/** getLobby returns the last lobby lists, or null before the first load. */
+export function getLobby(): LobbySnapshot | null {
+ return snapshot;
+}
+
+/** setLobby stores the latest lobby lists. */
+export function setLobby(s: LobbySnapshot): void {
+ snapshot = s;
+}
+
+/** clearLobby drops the cached lobby (called on logout). */
+export function clearLobby(): void {
+ snapshot = null;
+}
diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte
index 685a983..daf61a4 100644
--- a/ui/src/screens/Lobby.svelte
+++ b/ui/src/screens/Lobby.svelte
@@ -8,6 +8,7 @@
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
+ import { getLobby, setLobby } from '../lib/lobbycache';
import type { AccountRef, GameView, Invitation } from '../lib/model';
let games = $state([]);
@@ -23,12 +24,23 @@
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
app.notifications = invitations.length + incoming.length;
}
+ setLobby({ games, invitations, incoming });
} catch (e) {
handleError(e);
}
}
- onMount(load);
+ onMount(() => {
+ // Render instantly from the cached lists (so the screen does not "draw in" during
+ // the back-slide), then refresh in the background.
+ const cached = getLobby();
+ if (cached) {
+ games = cached.games;
+ invitations = cached.invitations;
+ incoming = cached.incoming;
+ }
+ void load();
+ });
$effect(() => {
if (app.lastEvent) void load();
});
--
2.52.0
From f6bffd1f5736c8c2a76e51357d15f9dde719ccf8 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 12:55:46 +0200
Subject: [PATCH 013/223] Stage 17 (contour round 3): Telegram Mini Apps
polish, board scroll, keyboard overlay
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Telegram (lib/telegram.ts): chrome colours (setHeaderColor/setBackgroundColor/setBottomBarColor) match Telegram's header/bg/bottom bar to the app; native BackButton on sub-screens (app chevron hidden in TG); HapticFeedback on tile place/commit/error; enableClosingConfirmation while a game is open; disableVerticalSwipes so swipe-to-minimise doesn't fight tile drag / board scroll
- #9 board-only vertical scroll: Screen 'column' mode lets the board area scroll while score/status/rack/tab bar stay fixed (zoom keeps its own scroll)
- #10 check-word dialog opens in Modal keyboard-overlay mode (top-anchored, keyboard overlays the empty area) — no resize/relayout jank; other modals stay keyboard-aware
- docs: UI_DESIGN Telegram integration + vertical fit/keyboard; PLAN round 2-3 follow-ups
---
PLAN.md | 15 ++++++
docs/UI_DESIGN.md | 31 +++++++++----
ui/src/App.svelte | 12 ++++-
ui/src/components/Header.svelte | 7 ++-
ui/src/components/Modal.svelte | 14 +++++-
ui/src/components/Screen.svelte | 10 +++-
ui/src/game/Game.svelte | 21 +++++++--
ui/src/lib/app.svelte.ts | 30 +++++++++++-
ui/src/lib/telegram.ts | 82 +++++++++++++++++++++++++++++++++
9 files changed, 204 insertions(+), 18 deletions(-)
diff --git a/PLAN.md b/PLAN.md
index fd0b767..7ae8a4a 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1254,6 +1254,21 @@ provided cert) at the contour caddy; prod VPN; rollback.
and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a
browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify,
no server change. **Multi-word history (#22)** was already implemented (all formed words shown).
+ - **Contour-verification follow-ups** (rounds 2–3, from live testing): the Grafana
+ double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is
+ disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the
+ move-duration panel was invisible because the deploy reseed (`rm -rf`) left the
+ config-only services on a stale bind mount — the deploy now **force-recreates**
+ caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 →
+ 300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram
+ reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide
+ + Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted —
+ chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native
+ **BackButton**, **HapticFeedback**, **closing confirmation** in a game,
+ **disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat
+ pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
+ button disables at zero hints; plus **board-only vertical scroll** (#9) and a
+ **keyboard-overlay** check-word dialog (#10).
## Deferred TODOs (cross-stage)
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index cd05eae..2df7f8c 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -36,15 +36,22 @@ Login uses `Screen`.
- **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).
+ collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
+ (`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
+ and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
+ on lobby ↔ game navigation.
+- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
+ scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
+ which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
+ theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
+ `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
+ native header **BackButton** drives back-navigation (the app's chevron is hidden in
+ Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing
+ confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise)
+ are disabled so they don't fight tile drag or the board scroll; and a live stream dropped
+ by a background suspend reconnects silently on return — the connection banner is
+ suppressed while hidden and for a short grace after resume (visibilitychange +
+ pageshow/pagehide + Telegram `activated`/`deactivated`).
## Tiles & board
@@ -66,6 +73,12 @@ Login uses `Screen`.
shadow) pins to the board as the board slides down, instead of tracking the table as
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
jitter. A move's row lists every word it formed (the main word first).
+- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the
+ board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
+ bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
+ `Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
+ overlays the empty area below, so the layout doesn't resize/jank; other modals stay
+ keyboard-aware (they size to the area above the keyboard).
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
last completed word gets a dark tile background — static while it is the opponent's
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 6247051..f58157f 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -2,8 +2,9 @@
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
- import { router } from './lib/router.svelte';
+ import { navigate, router } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
+ import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
@@ -19,6 +20,15 @@
void bootstrap();
});
+ // Inside Telegram, drive its native header back button: show it on any sub-screen
+ // (everything returns to the lobby root), hide it on the lobby/login. The app's own
+ // back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
+ $effect(() => {
+ if (!insideTelegram()) return;
+ const name = router.route.name;
+ telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/'));
+ });
+
// Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so
diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte
index f95ff0c..e39b0a6 100644
--- a/ui/src/components/Header.svelte
+++ b/ui/src/components/Header.svelte
@@ -1,14 +1,19 @@
- {#if back}
+ {#if showBack}
diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte
index acd189d..7fa7379 100644
--- a/ui/src/components/Modal.svelte
+++ b/ui/src/components/Modal.svelte
@@ -4,18 +4,21 @@
let {
title = '',
onclose,
+ overlayKeyboard = false,
children,
- }: { title?: string; onclose?: () => void; children?: Snippet } = $props();
+ }: { title?: string; onclose?: () => void; overlayKeyboard?: boolean; children?: Snippet } = $props();
// Track the visual viewport so the backdrop covers only the area above an open
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
// backdrop still centres it behind the keyboard. Sizing the backdrop to
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
+ // overlayKeyboard opts out: the sheet is small and top-anchored, so the keyboard
+ // simply overlays the empty lower area — no resize, no relayout jank (e.g. check word).
let vh = $state(0);
let top = $state(0);
$effect(() => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
- if (!vv) return;
+ if (!vv || overlayKeyboard) return;
const update = () => {
vh = vv.height;
top = vv.offsetTop;
@@ -34,6 +37,7 @@
onclose?.()}
@@ -61,6 +65,12 @@
padding: 16px;
z-index: 40;
}
+ /* Overlay mode: top-anchor the (small) sheet and don't track the keyboard, so the
+ soft keyboard overlays the empty lower area without resizing/relaying out. */
+ .backdrop.overlay {
+ align-items: flex-start;
+ padding-top: 12vh;
+ }
.sheet {
background: var(--surface);
color: var(--text);
diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte
index 12a2b93..6ebe39b 100644
--- a/ui/src/components/Screen.svelte
+++ b/ui/src/components/Screen.svelte
@@ -14,6 +14,7 @@
children,
scroll = true,
growNav = false,
+ column = false,
}: {
title: string;
back?: string;
@@ -22,13 +23,16 @@
children?: Snippet;
scroll?: boolean;
growNav?: boolean;
+ // column lays the content out as a flex column so a child can own the vertical fit
+ // (the game makes only its board scroll while the score/rack/tab bar stay put).
+ column?: boolean;
} = $props();
- {@render children?.()}
+ {@render children?.()}
{#if tabbar}
{/if}
@@ -50,6 +54,10 @@
.content.scroll {
overflow-y: auto;
}
+ .content.column {
+ display: flex;
+ flex-direction: column;
+ }
.tabbar {
flex: 0 0 auto;
}
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index bc258cc..5d1272e 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -19,6 +19,7 @@
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
+ import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
newPlacement,
@@ -112,6 +113,8 @@
}
}
onMount(() => {
+ // Guard against an accidental swipe-close losing the open game (Telegram).
+ telegramClosingConfirmation(true);
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
@@ -185,6 +188,7 @@
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
+ telegramClosingConfirmation(false);
});
function onCell(row: number, col: number) {
@@ -212,12 +216,14 @@
return;
}
placement = place(placement, index, row, col);
+ telegramHaptic('select');
recompute();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
blankPrompt = null;
+ telegramHaptic('select');
recompute();
}
@@ -242,6 +248,7 @@
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
+ telegramHaptic('success');
zoomed = false;
await load();
} catch (e) {
@@ -449,7 +456,7 @@
]);
-
+
{#snippet menu()}
{/snippet}
@@ -596,7 +603,7 @@
{/if}
{#if checkOpen}
- (checkOpen = false)}>
+ (checkOpen = false)}>
.scoreboard {
display: flex;
+ flex: none;
gap: 6px;
padding: 8px var(--pad);
background: var(--bg-elev);
@@ -681,7 +689,12 @@
}
.stage {
position: relative;
- overflow: hidden;
+ /* The board is the only part that scrolls vertically when the game does not fit;
+ the score bar, status, rack and tab bar stay put (#9). */
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
}
.history {
position: absolute;
@@ -741,6 +754,7 @@
}
.status {
display: flex;
+ flex: none;
align-items: center;
justify-content: space-between;
padding: 2px var(--pad) 6px;
@@ -762,6 +776,7 @@
}
.rack-row {
display: flex;
+ flex: none;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad) 6px;
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index e001ba9..0af18fa 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -9,7 +9,16 @@ 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, telegramColorScheme, telegramLaunch, telegramOnEvent } from './telegram';
+import {
+ insideTelegram,
+ onTelegramPath,
+ telegramColorScheme,
+ telegramDisableVerticalSwipes,
+ telegramHaptic,
+ telegramLaunch,
+ telegramOnEvent,
+ telegramSetChrome,
+} from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
@@ -93,6 +102,7 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
+ telegramHaptic('error');
if (err instanceof GatewayError) {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout();
@@ -200,6 +210,20 @@ export async function applyLinkResult(r: LinkResult): Promise {
app.profile = await gateway.profileGet();
}
+/**
+ * syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
+ * theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
+ */
+function syncTelegramChrome(): void {
+ if (typeof document === 'undefined') return;
+ const cs = getComputedStyle(document.documentElement);
+ telegramSetChrome(
+ cs.getPropertyValue('--bg-elev').trim(),
+ cs.getPropertyValue('--bg').trim(),
+ cs.getPropertyValue('--bg-elev').trim(),
+ );
+}
+
export async function bootstrap(): Promise {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
@@ -232,6 +256,10 @@ export async function bootstrap(): Promise {
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
+ // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
+ // fighting tile drag / board scroll.
+ syncTelegramChrome();
+ telegramDisableVerticalSwipes();
try {
await adoptSession(await gateway.authTelegram(launch.initData));
await routeStartParam(launch.startParam);
diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts
index 5440086..1ed3a63 100644
--- a/ui/src/lib/telegram.ts
+++ b/ui/src/lib/telegram.ts
@@ -13,6 +13,23 @@ interface TelegramWebApp {
ready?: () => void;
expand?: () => void;
onEvent?: (event: string, handler: () => void) => void;
+ setHeaderColor?: (color: string) => void;
+ setBackgroundColor?: (color: string) => void;
+ setBottomBarColor?: (color: string) => void;
+ disableVerticalSwipes?: () => void;
+ enableClosingConfirmation?: () => void;
+ disableClosingConfirmation?: () => void;
+ HapticFeedback?: {
+ impactOccurred?: (style: string) => void;
+ notificationOccurred?: (type: string) => void;
+ selectionChanged?: () => void;
+ };
+ BackButton?: {
+ show?: () => void;
+ hide?: () => void;
+ onClick?: (cb: () => void) => void;
+ offClick?: (cb: () => void) => void;
+ };
}
function webApp(): TelegramWebApp | undefined {
@@ -70,6 +87,71 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
return webApp()?.colorScheme;
}
+/**
+ * telegramSetChrome paints Telegram's own header, background and bottom bar to match the
+ * app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
+ * outside Telegram or on a client predating a given setter.
+ */
+export function telegramSetChrome(header: string, background: string, bottom: string): void {
+ const w = webApp();
+ if (header) w?.setHeaderColor?.(header);
+ if (background) w?.setBackgroundColor?.(background);
+ if (bottom) w?.setBottomBarColor?.(bottom);
+}
+
+/**
+ * telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
+ * it does not fight tile drag-and-drop or the board's vertical scroll.
+ */
+export function telegramDisableVerticalSwipes(): void {
+ webApp()?.disableVerticalSwipes?.();
+}
+
+/** Haptic is the set of feedbacks the app triggers. */
+export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
+
+/** telegramHaptic fires a Telegram haptic; a no-op outside Telegram or on older clients. */
+export function telegramHaptic(kind: Haptic): void {
+ const h = webApp()?.HapticFeedback;
+ if (!h) return;
+ if (kind === 'select') h.selectionChanged?.();
+ else if (kind === 'success' || kind === 'error' || kind === 'warning') h.notificationOccurred?.(kind);
+ else h.impactOccurred?.(kind);
+}
+
+/**
+ * telegramClosingConfirmation toggles the confirmation Telegram shows when the user
+ * swipes the Mini App closed — enabled during an active game so it is not lost by accident.
+ */
+export function telegramClosingConfirmation(on: boolean): void {
+ const w = webApp();
+ if (on) w?.enableClosingConfirmation?.();
+ else w?.disableClosingConfirmation?.();
+}
+
+let backHandler: (() => void) | null = null;
+
+/**
+ * telegramBackButton shows or hides Telegram's native header back button, wiring its
+ * click to onClick (replacing any previous handler). The app hides its own back chevron
+ * inside Telegram so only the native control shows.
+ */
+export function telegramBackButton(show: boolean, onClick?: () => void): void {
+ const b = webApp()?.BackButton;
+ if (!b) return;
+ if (backHandler) b.offClick?.(backHandler);
+ backHandler = null;
+ if (show) {
+ if (onClick) {
+ backHandler = onClick;
+ b.onClick?.(onClick);
+ }
+ b.show?.();
+ } else {
+ b.hide?.();
+ }
+}
+
/**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe.
--
2.52.0
From b15fd30c4fb378623119353c50bf2a6b468639c9 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 14:08:40 +0200
Subject: [PATCH 014/223] Stage 17 (contour round 4a): quick fixes
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
---
backend/internal/account/profile.go | 7 +++--
backend/internal/account/validate_test.go | 28 ++++++++++---------
.../templates/pages/game_detail.gohtml | 4 +--
ui/src/components/Toast.svelte | 13 ++++++++-
ui/src/game/Board.svelte | 9 ++++--
ui/src/game/Game.svelte | 6 ++--
ui/src/lib/i18n.test.ts | 4 +--
ui/src/lib/i18n/en.ts | 3 +-
ui/src/lib/i18n/ru.ts | 3 +-
ui/src/lib/profileValidation.test.ts | 5 +++-
ui/src/lib/profileValidation.ts | 7 +++--
ui/src/screens/Profile.svelte | 4 ++-
12 files changed, 61 insertions(+), 32 deletions(-)
diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go
index daf78b5..8aa7985 100644
--- a/backend/internal/account/profile.go
+++ b/backend/internal/account/profile.go
@@ -28,9 +28,10 @@ const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
-// by a single space. No leading or trailing separator and no two adjacent separators,
-// except "". So "Name_P. Last" is valid, "Name P._Last" is not.
-var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
+// by a single space. No leading separator and no two adjacent separators (except
+// ""); a single trailing "." is allowed (Stage 17), so
+// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
+var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
// ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name).
diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go
index 1e8c978..0bdee79 100644
--- a/backend/internal/account/validate_test.go
+++ b/backend/internal/account/validate_test.go
@@ -12,19 +12,21 @@ func TestValidateDisplayName(t *testing.T) {
want string
ok bool
}{
- "plain": {"Kaya", "Kaya", true},
- "cyrillic": {"Кая", "Кая", true},
- "dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
- "single dot": {"Mr.Smith", "Mr.Smith", true},
- "dot then space": {"Mr. Smith", "Mr. Smith", true},
- "trim surrounding": {" Kaya ", "Kaya", true},
- "adjacent specials": {"Name P._Last", "", false},
- "two spaces": {"Name Last", "", false},
- "leading special": {"_Name", "", false},
- "trailing special": {"Name.", "", false},
- "digit rejected": {"Name2", "", false},
- "blank": {" ", "", false},
- "too long": {strings.Repeat("a", 33), "", false},
+ "plain": {"Kaya", "Kaya", true},
+ "cyrillic": {"Кая", "Кая", true},
+ "dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
+ "single dot": {"Mr.Smith", "Mr.Smith", true},
+ "dot then space": {"Mr. Smith", "Mr. Smith", true},
+ "trim surrounding": {" Kaya ", "Kaya", true},
+ "adjacent specials": {"Name P._Last", "", false},
+ "two spaces": {"Name Last", "", false},
+ "leading special": {"_Name", "", false},
+ "trailing underscore": {"Name_", "", false},
+ "trailing dot ok": {"Anna B.", "Anna B.", true},
+ "double trailing dot": {"Name..", "", false},
+ "digit rejected": {"Name2", "", false},
+ "blank": {" ", "", false},
+ "too long": {strings.Repeat("a", 33), "", false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
index 313b31f..436fbb2 100644
--- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
@@ -17,10 +17,10 @@
{/if}
--
2.52.0
From 3856b34f8a177bcbf402fd37f21ca36c3c8f1d07 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sat, 6 Jun 2026 14:55:17 +0200
Subject: [PATCH 020/223] Stage 17 docs: round-4 UI (inline profile,
double-tap/drag recall, hover-zoom, animated shuffle, lines-off board)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- UI_DESIGN: double-tap recall vs zoom, hover-hold drag auto-zoom, placing & recall
rules, grid-lines toggle (gapless checkerboard default), animated shuffle; fix the
stale MakeMove/Reset description (direct ✅ button + ↩️ Reset tab, no popover).
- FUNCTIONAL (+ru): optional trailing '.' in display names; profile edited inline.
- PLAN: robot early band [1,5]→[3,10] (#14); round-4 refinements + deferred #2/#16.
---
PLAN.md | 18 +++++++++++++++---
docs/FUNCTIONAL.md | 7 ++++---
docs/FUNCTIONAL_ru.md | 11 ++++++-----
docs/UI_DESIGN.md | 37 ++++++++++++++++++++++++-------------
4 files changed, 49 insertions(+), 24 deletions(-)
diff --git a/PLAN.md b/PLAN.md
index 7ae8a4a..dfb8a44 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1224,9 +1224,9 @@ provided cert) at the contour caddy; prod VPN; rollback.
across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20%
Latin, an English game the Latin pool. Robot identities are keyed `robot--`.
- **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the
- band interpolates from [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.
+ band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min
+ by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime
+ nudge pulls the reply toward the move's lower band.
- **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own
other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the
actor), and the gateway already fans each event out to all of a user's live streams.
@@ -1269,6 +1269,18 @@ provided cert) at the contour caddy; prod VPN; rollback.
pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
button disables at zero hints; plus **board-only vertical scroll** (#9) and a
**keyboard-overlay** check-word dialog (#10).
+ - **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised
+ [1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel
+ toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a
+ single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap**
+ or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over
+ a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to
+ their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings
+ toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming
+ ~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native
+ scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
+ **robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
+ across the game/robot package boundary, to be picked up when that seam is added.
## Deferred TODOs (cross-stage)
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 432eb39..9a3936d 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -109,9 +109,10 @@ even disguised. Nudge the player whose turn is awaited at most once per hour (th
nudge is part of the game chat); the out-of-app push is delivered via the platform.
### Profile & settings *(Stage 4 / 8)*
-Edit the display name (letters joined by single space / "." / "_" separators, up to
-32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
-10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking
+Edit the display name (letters joined by a single space / "." / "_" separator, with an
+optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
+daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
+block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge" (Stage 11).
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index 4f1a05b..bffeb6f 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -112,11 +112,12 @@ Mini App** авторизует по подписанным `initData` плат
push доставляется через платформу.
### Профиль и настройки *(Stage 4 / 8)*
-Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
-«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
-(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
-переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов
-вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
+Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
+«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от
+UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
+переходом через полночь) и переключателей блокировок. Форма профиля редактируется
+сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
+слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
### История и статистика *(Stage 3 / 8)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index 2df7f8c..e480580 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -62,10 +62,17 @@ Login uses `Screen`.
that works consistently across browsers; no `transform`, which broke scrolling
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
they stay a constant size as the cells grow (relatively smaller at higher zoom).
- **Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
- target; the custom pinch and swipe-to-open-history gestures were dropped because they
- fight native scroll — history opens from the menu or a tap on the players plaque (below).
- A **hint** auto-zooms centred on the hint's placement, not the top-left (Stage 17).
+ **Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending**
+ tile recalls it. On touch, placing a tile auto-zooms in centred on the target, and
+ **holding a dragged tile over a cell for ~1 s** auto-zooms there (Stage 17). The custom
+ pinch and swipe-to-open-history gestures stay dropped — they fight both native scroll and
+ the one-finger drag-back gesture; history opens from the menu or a tap on the players
+ plaque (below). A **hint** auto-zooms centred on the hint's placement, not the top-left.
+- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
+ dragging it onto a cell; a pending tile is taken back by a **double-tap** or by **dragging
+ it back onto the rack** (unzoomed board only — when zoomed the one-finger gesture scrolls).
+ A single tap no longer recalls (too easy to trigger); a recalled tile returns to its
+ original rack slot (Stage 17).
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
@@ -86,20 +93,24 @@ Login uses `Screen`.
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
-- **Grid lines**: the inter-cell gap shows a contrasting `--cell-line` (darker in light,
- lighter in dark) to avoid a wavy-line optical illusion.
+- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless
+ checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
+ soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
+ board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
+ `--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion.
## Controls
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
- immediately. Reused by:
- - **MakeMove** (appears when ≥1 tile is pending; the rack collapses its used slots and
- shifts left to free room): a **🏁** button whose popover offers **Make move ✅** /
- **Reset ❌**.
- - **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
- remaining-count badge) — each confirmed by an **Ok ✅** popover; 🔀 Shuffle has no
- label and no confirm. The under-board slot shows the **Scores: N** preview.
+ immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
+- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots
+ and shifts left, a direct **✅** button beside the rack commits the move (no popover), and
+ the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
+- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
+ remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which
+ **animates** — tiles hop along a low parabola to their new slots (duration scaled by the
+ distance) with a short haptic shake. The under-board slot shows the **Scores: N** preview.
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
--
2.52.0
From 10412fee8e8de38e7abacc19ef94f09cb59500f0 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 09:17:35 +0200
Subject: [PATCH 021/223] =?UTF-8?q?Stage=2017=20round=205=20=E2=80=94=20ba?=
=?UTF-8?q?ckend/correctness=20bug=20fixes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat
(not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat.
- Quick-match cancel was a UI no-op (only stopped polling): add the full path
(REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's
pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no
later robot-substituted game). NewGame dequeues on cancel and on abandon.
- Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win.
The winner now takes rank 1 and the viewer is placed from rank 2 — matching the
game-detail screen.
- Friend request to a robot: robots no longer block requests; the request stays
pending and expires (friendRequestTTL), mirroring a human who ignores it.
- Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a
correct message; the chat nudge button disables during the hourly cooldown; the
nudge note reads 'Waiting for your move!' (button keeps the Nudge action label).
Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot
inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
---
backend/internal/account/account.go | 16 +++++----
backend/internal/engine/game.go | 22 +++++++++---
backend/internal/engine/resign_test.go | 33 +++++++++++++++++
backend/internal/game/service.go | 43 ++++++++++++++++++++---
backend/internal/inttest/game_test.go | 36 +++++++++++++++++++
backend/internal/inttest/social_test.go | 32 +++++++++++++++++
backend/internal/lobby/matchmaker.go | 7 ++--
backend/internal/lobby/matchmaker_test.go | 21 +++++++++++
backend/internal/server/dto_test.go | 1 +
backend/internal/server/handlers.go | 8 +++--
backend/internal/server/handlers_user.go | 14 ++++++++
gateway/internal/backendclient/api.go | 5 +++
gateway/internal/transcode/transcode.go | 13 +++++++
ui/src/game/Chat.svelte | 2 +-
ui/src/game/Game.svelte | 23 +++++++++++-
ui/src/lib/client.ts | 2 ++
ui/src/lib/i18n/en.ts | 4 ++-
ui/src/lib/i18n/ru.ts | 4 ++-
ui/src/lib/mock/client.ts | 5 +++
ui/src/lib/result.test.ts | 9 +++++
ui/src/lib/result.ts | 8 +++--
ui/src/lib/transport.ts | 3 ++
ui/src/screens/NewGame.svelte | 19 ++++++++--
23 files changed, 301 insertions(+), 29 deletions(-)
diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go
index 515294d..8fee67a 100644
--- a/backend/internal/account/account.go
+++ b/backend/internal/account/account.go
@@ -112,17 +112,19 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
}
// 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.
+// member: a KindRobot identity carrying displayName, with chat blocked but friend
+// requests NOT blocked — a request to a robot is accepted as pending and, since the
+// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human
+// who ignores the request. Robot names are system-generated, not player-edited, so they
+// bypass the editable display-name validation and may carry forms the editor rejects (an
+// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the
+// display name and both block flags.
func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) {
acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName})
if err != nil {
return Account{}, err
}
- if acc.DisplayName == displayName && acc.BlockChat && acc.BlockFriendRequests {
+ if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests {
return acc, nil
}
stmt := table.Accounts.UPDATE(
@@ -130,7 +132,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
).SET(
postgres.String(displayName), postgres.Bool(true),
- postgres.Bool(true), postgres.TimestampzT(time.Now().UTC()),
+ postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))).
RETURNING(table.Accounts.AllColumns)
diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go
index 4b55e5c..ecbe5d0 100644
--- a/backend/internal/engine/game.go
+++ b/backend/internal/engine/game.go
@@ -248,17 +248,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
// winning regardless of score. A missed-turn timeout reuses Resign in the game
// domain, so it inherits this win/loss.
func (g *Game) Resign() (MoveRecord, error) {
+ return g.ResignSeat(g.toMove)
+}
+
+// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
+// may forfeit on the opponent's turn. The resigning seat always loses (winner()
+// skips resigned seats). The turn cursor only advances when the seat that resigned
+// was the one to move; resigning an off-turn seat leaves the current player's turn
+// intact. It returns ErrGameOver on a finished game or for an out-of-range or
+// already-resigned seat.
+func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
- player := g.toMove
- g.resigned[player] = true
- g.disposeHand(player)
- rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
+ if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
+ return MoveRecord{}, ErrGameOver
+ }
+ g.resigned[seat] = true
+ g.disposeHand(seat)
+ rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
g.log = append(g.log, rec)
if g.activeCount() <= 1 {
g.finish(EndResign)
- } else {
+ } else if seat == g.toMove {
g.advance()
}
return rec, nil
diff --git a/backend/internal/engine/resign_test.go b/backend/internal/engine/resign_test.go
index 1df334a..8c08b44 100644
--- a/backend/internal/engine/resign_test.go
+++ b/backend/internal/engine/resign_test.go
@@ -69,6 +69,39 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
}
}
+// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0
+// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner
+// loses, the opponent wins, and the game ends.
+func TestResignSeatOffTurn(t *testing.T) {
+ g := openingGame(t)
+
+ hint, ok := g.HintView()
+ if !ok {
+ t.Fatal("opening game has no hint")
+ }
+ if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves
+ t.Fatalf("player 0 play: %v", err)
+ }
+ if g.ToMove() != 1 {
+ t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove())
+ }
+
+ // Player 0 resigns although it is player 1's turn.
+ rec, err := g.ResignSeat(0)
+ if err != nil {
+ t.Fatalf("player 0 off-turn resign: %v", err)
+ }
+ if rec.Player != 0 || rec.Action != ActionResign {
+ t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action)
+ }
+ if !g.Over() || g.Reason() != EndResign {
+ t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
+ }
+ if res := g.Result(); res.Winner != 1 {
+ t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner)
+ }
+}
+
// TestResignOnFinishedGame rejects a second transition.
func TestResignOnFinishedGame(t *testing.T) {
g := newEnglishGame(t, 1)
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index 780bf8e..e109929 100644
--- a/backend/internal/game/service.go
+++ b/backend/internal/game/service.go
@@ -171,11 +171,46 @@ func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, t
}
// Resign ends the game on the player's turn; the remaining player wins.
+// Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is
+// allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does
+// not go through transition's turn check: it resigns the actor's own seat, whoever is to
+// move. The resigning seat always loses (docs/ARCHITECTURE.md §7).
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
- return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
- rec, err := g.Resign()
- return rec, nil, err
- })
+ pre, err := svc.store.GetGame(ctx, gameID)
+ if err != nil {
+ return MoveResult{}, err
+ }
+ seat, ok := pre.seatOf(accountID)
+ if !ok {
+ return MoveResult{}, ErrNotAPlayer
+ }
+ if pre.Status != StatusActive {
+ return MoveResult{}, ErrFinished
+ }
+
+ unlock := svc.locks.lock(gameID)
+ defer unlock()
+
+ g, err := svc.liveGame(ctx, pre)
+ if err != nil {
+ return MoveResult{}, err
+ }
+ if g.Over() {
+ return MoveResult{}, ErrFinished
+ }
+
+ rackBefore := g.Hand(seat)
+ rec, err := g.ResignSeat(seat)
+ if err != nil {
+ return MoveResult{}, err
+ }
+ post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats)
+ if err != nil {
+ return MoveResult{}, err
+ }
+ // A resignation carries no think time (it can happen on the opponent's turn), so it
+ // is intentionally excluded from the move-duration metric.
+ return MoveResult{Move: rec, Game: post}, nil
}
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go
index 0ce8914..9706f13 100644
--- a/backend/internal/inttest/game_test.go
+++ b/backend/internal/inttest/game_test.go
@@ -299,6 +299,42 @@ func TestResignWinnerAndStats(t *testing.T) {
}
}
+// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
+// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
+// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
+// despite leading on score.
+func TestResignOnOpponentTurn(t *testing.T) {
+ ctx := context.Background()
+ svc := newGameService()
+ seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
+ seed := openingSeed(t)
+ g, err := svc.Create(ctx, game.CreateParams{
+ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ hint, ok := newMirror(t, seed, 2).HintView()
+ if !ok {
+ t.Fatal("no opening move")
+ }
+ if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
+ t.Fatalf("p0 play: %v", err)
+ }
+
+ res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
+ if err != nil {
+ t.Fatalf("off-turn resign = %v, want nil", err)
+ }
+ if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
+ t.Fatalf("after off-turn resign: %+v", res.Game)
+ }
+ if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
+ t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
+ }
+}
+
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
func TestTimeoutSweep(t *testing.T) {
ctx := context.Background()
diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go
index 5de9c3d..0317996 100644
--- a/backend/internal/inttest/social_test.go
+++ b/backend/internal/inttest/social_test.go
@@ -40,6 +40,38 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
return g.ID, seats
}
+// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
+// pending rather than blocked: robots no longer block friend requests, so the request
+// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17).
+func TestFriendRequestToRobotStaysPending(t *testing.T) {
+ ctx := context.Background()
+ svc := newSocialService()
+ accs := account.NewStore(testDB)
+
+ human := provisionAccount(t)
+ robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie")
+ if err != nil {
+ t.Fatalf("provision robot: %v", err)
+ }
+ if robot.BlockFriendRequests {
+ t.Fatal("robot must not block friend requests")
+ }
+ // A request is only allowed between players who share a game.
+ if _, err := newGameService().Create(ctx, game.CreateParams{
+ Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
+ TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
+ }); err != nil {
+ t.Fatalf("create game: %v", err)
+ }
+
+ if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil {
+ t.Fatalf("request to robot = %v, want nil (accepted as pending)", err)
+ }
+ if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human {
+ t.Fatalf("robot incoming = %v, want [human]", got)
+ }
+}
+
func TestFriendRequestLifecycle(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go
index 48da0dd..2342291 100644
--- a/backend/internal/lobby/matchmaker.go
+++ b/backend/internal/lobby/matchmaker.go
@@ -142,11 +142,14 @@ func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult
return EnqueueResult{}, nil
}
-// Cancel removes accountID from whatever pool it waits in, reporting whether it
-// was queued.
+// Cancel removes accountID from whatever pool it waits in and drops any pending
+// matched result, reporting whether it was queued. Clearing the result closes the
+// race where the reaper substituted a robot just before the player cancelled: the
+// stale game must not later surface through Poll as a game the player did not want.
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock()
defer m.mu.Unlock()
+ delete(m.results, accountID)
variant, ok := m.queued[accountID]
if !ok {
return false
diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go
index e2d145c..4092e57 100644
--- a/backend/internal/lobby/matchmaker_test.go
+++ b/backend/internal/lobby/matchmaker_test.go
@@ -240,6 +240,27 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
}
}
+// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
+// robot just before the player cancels: Cancel must drop the pending result so the
+// abandoned game never surfaces through Poll (Stage 17).
+func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
+ creator := &fakeCreator{}
+ mm := newTestMatchmaker(creator, uuid.New())
+ base := time.Now()
+ mm.clock = func() time.Time { return base }
+ ctx := context.Background()
+ a := uuid.New()
+
+ if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
+ t.Fatalf("enqueue: %v", err)
+ }
+ mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
+ mm.Cancel(ctx, a) // ... then the player cancels
+ if got, _ := mm.Poll(ctx, a); got.Matched {
+ t.Error("cancel must drop the pending substituted game; Poll still matched")
+ }
+}
+
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go
index 517c8fc..619a3dc 100644
--- a/backend/internal/server/dto_test.go
+++ b/backend/internal/server/dto_test.go
@@ -47,6 +47,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"},
+ "nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"},
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go
index f4e64f9..e4238f3 100644
--- a/backend/internal/server/handlers.go
+++ b/backend/internal/server/handlers.go
@@ -69,6 +69,7 @@ func (s *Server) registerRoutes() {
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
+ u.POST("/lobby/cancel", s.handleCancel)
u.GET("/lobby/poll", s.handlePoll)
}
if s.invitations != nil {
@@ -200,9 +201,12 @@ func statusForError(err error) (int, string) {
case errors.Is(err, session.ErrNotFound):
return http.StatusUnauthorized, "session_invalid"
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
- errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
- errors.Is(err, social.ErrNudgeTooSoon):
+ errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent):
return http.StatusUnprocessableEntity, "chat_rejected"
+ case errors.Is(err, social.ErrNudgeTooSoon):
+ // A too-frequent nudge is a distinct, non-content rejection — the UI must say
+ // "don't rush the player so often", not the chat content-rejection message.
+ return http.StatusConflict, "nudge_too_soon"
case errors.Is(err, social.ErrSelfRelation):
return http.StatusBadRequest, "self_relation"
case errors.Is(err, social.ErrRequestExists):
diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go
index 7e181fc..180335a 100644
--- a/backend/internal/server/handlers_user.go
+++ b/backend/internal/server/handlers_user.go
@@ -153,6 +153,20 @@ func (s *Server) handleEnqueue(c *gin.Context) {
c.JSON(http.StatusOK, dto)
}
+// handleCancel removes the caller from the auto-match pool (and drops any pending
+// matched result), so a cancelled quick-match neither blocks a re-queue nor later
+// surfaces a robot-substituted game the player abandoned. It is idempotent: cancelling
+// when not queued is a no-op success.
+func (s *Server) handleCancel(c *gin.Context) {
+ uid, ok := userID(c)
+ if !ok {
+ abortBadRequest(c, "missing identity")
+ return
+ }
+ s.matchmaker.Cancel(c.Request.Context(), uid)
+ c.Status(http.StatusNoContent)
+}
+
// handlePoll reports whether the caller has been paired since queueing.
func (s *Server) handlePoll(c *gin.Context) {
uid, ok := userID(c)
diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go
index 5eb17e1..4320517 100644
--- a/gateway/internal/backendclient/api.go
+++ b/gateway/internal/backendclient/api.go
@@ -255,6 +255,11 @@ func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) {
return out, err
}
+// Cancel removes the caller from the auto-match pool (idempotent; 204 No Content).
+func (c *Client) Cancel(ctx context.Context, userID string) error {
+ return c.do(ctx, http.MethodPost, "/api/v1/user/lobby/cancel", userID, "", nil, nil)
+}
+
// ChatPost stores a chat message, forwarding the client IP for moderation.
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
var out ChatResp
diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go
index 6cf0ca4..60a77f2 100644
--- a/gateway/internal/transcode/transcode.go
+++ b/gateway/internal/transcode/transcode.go
@@ -24,6 +24,7 @@ const (
MsgGameSubmitPlay = "game.submit_play"
MsgGameState = "game.state"
MsgLobbyEnqueue = "lobby.enqueue"
+ MsgLobbyCancel = "lobby.cancel"
MsgLobbyPoll = "lobby.poll"
MsgChatPost = "chat.post"
MsgGamesList = "games.list"
@@ -93,6 +94,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
+ r.ops[MsgLobbyCancel] = Op{Handler: cancelHandler(backend), Auth: true}
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true}
@@ -233,6 +235,17 @@ func pollHandler(backend *backendclient.Client) Handler {
}
}
+// cancelHandler removes the caller from the auto-match pool. It carries no result;
+// it echoes an empty (unmatched) Match so the client has a well-formed payload.
+func cancelHandler(backend *backendclient.Client) Handler {
+ return func(ctx context.Context, req Request) ([]byte, error) {
+ if err := backend.Cancel(ctx, req.UserID); err != nil {
+ return nil, err
+ }
+ return encodeMatch(backendclient.MatchResp{}), nil
+ }
+}
+
func chatPostHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsChatPostRequest(req.Payload, 0)
diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte
index 0afbffe..7d12318 100644
--- a/ui/src/game/Chat.svelte
+++ b/ui/src/game/Chat.svelte
@@ -51,7 +51,7 @@
onkeydown={(e) => e.key === 'Enter' && send()}
/>
-
+
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index 6b438cc..2e21877 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -89,6 +89,20 @@
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
+ // Nudge cooldown (one per hour per game, mirrored from the backend): the control is
+ // disabled for an hour after the player's own last nudge. nudgeTick re-evaluates it on a
+ // timer while the chat is open, so it re-enables without waiting for a new message.
+ const nudgeCooldownSecs = 3600;
+ let nudgeTick = $state(0);
+ const nudgeOnCooldown = $derived.by(() => {
+ void nudgeTick;
+ const mine = app.session?.userId ?? '';
+ const last = messages.reduce(
+ (mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx),
+ 0,
+ );
+ return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs;
+ });
async function load() {
try {
@@ -145,6 +159,13 @@
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
});
+ // Tick the nudge cooldown while the chat is open so the control re-enables on time.
+ $effect(() => {
+ if (panel !== 'chat') return;
+ const h = setInterval(() => (nudgeTick += 1), 20000);
+ return () => clearInterval(h);
+ });
+
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
@@ -708,7 +729,7 @@
{#if panel === 'chat'}
(panel = 'none')}>
-
+
{/if}
diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts
index 49bfb9f..80f3543 100644
--- a/ui/src/lib/client.ts
+++ b/ui/src/lib/client.ts
@@ -65,6 +65,8 @@ export interface GatewayClient {
// --- lobby ---
lobbyEnqueue(variant: Variant): Promise;
lobbyPoll(): Promise;
+ /** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */
+ lobbyCancel(): Promise;
// --- game ---
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index dfa075f..63e67c5 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -94,7 +94,8 @@ export const en = {
'chat.placeholder': 'Quick message…',
'chat.send': 'Send',
- 'chat.nudge': 'Nudge',
+ 'chat.nudge': 'Waiting for your move!',
+ 'chat.nudgeAction': 'Nudge',
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
@@ -155,6 +156,7 @@ export const en = {
'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
+ 'error.nudge_too_soon': "Please don't rush your opponent so often.",
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
'error.already_queued': 'You are already in the queue.',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index ba15548..319c606 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -95,7 +95,8 @@ export const ru: Record = {
'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить',
- 'chat.nudge': 'Поторопить',
+ 'chat.nudge': 'Жду вашего хода!',
+ 'chat.nudgeAction': 'Поторопить',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
@@ -156,6 +157,7 @@ export const ru: Record = {
'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
+ 'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
'error.already_queued': 'Вы уже в очереди.',
diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts
index ef4a1c7..9de0d4d 100644
--- a/ui/src/lib/mock/client.ts
+++ b/ui/src/lib/mock/client.ts
@@ -180,6 +180,11 @@ export class MockGateway implements GatewayClient {
return { matched: false };
}
+ async lobbyCancel(): Promise {
+ // Dequeue: drop the pending substitution so a cancelled quick-match never arrives.
+ this.pendingMatch = null;
+ }
+
// --- game ---
async gameState(gameId: string, _includeAlphabet: boolean): Promise {
const g = this.game(gameId);
diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts
index f131354..3a92bb0 100644
--- a/ui/src/lib/result.test.ts
+++ b/ui/src/lib/result.test.ts
@@ -48,6 +48,15 @@ describe('resultBadge', () => {
});
});
+ it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
+ // The opponent won by resignation (isWinner) although neither side scored — the lobby
+ // must read this as a loss, matching the game-detail screen (Stage 17 regression).
+ expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
+ key: 'result.defeat',
+ emoji: '🥈',
+ });
+ });
+
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts
index 479db04..d177b94 100644
--- a/ui/src/lib/result.ts
+++ b/ui/src/lib/result.ts
@@ -21,9 +21,11 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
- // Someone else won — place the viewer by score (1 + number of higher scores).
- const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length;
- if (rank <= 1) return { key: 'result.victory', emoji: '🏆' };
+ // Someone else won and it is not me, so I did not win — even when scores are level (a
+ // win by resignation or timeout can leave the winner at or below my score). The winner
+ // takes rank 1; place me among the remaining seats by score, starting at rank 2.
+ const ahead = game.seats.filter((s) => !s.isWinner && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
+ const rank = 2 + ahead;
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
return { key: 'result.place4', emoji: '🏅' };
diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts
index ae40b6d..23cec7a 100644
--- a/ui/src/lib/transport.ts
+++ b/ui/src/lib/transport.ts
@@ -80,6 +80,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async lobbyPoll() {
return codec.decodeMatchResult(await exec('lobby.poll', codec.empty()));
},
+ async lobbyCancel() {
+ await exec('lobby.cancel', codec.empty());
+ },
async gameState(id, includeAlphabet) {
return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id, includeAlphabet)));
diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte
index 85ff550..0b64511 100644
--- a/ui/src/screens/NewGame.svelte
+++ b/ui/src/screens/NewGame.svelte
@@ -31,12 +31,22 @@
poll = null;
}
}
+ // cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
+ // is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
+ // reaper later substitutes a robot for a game the player abandoned (Stage 17 fix).
+ function cancelSearch() {
+ stop();
+ searching = false;
+ void gateway.lobbyCancel().catch(() => {});
+ navigate('/');
+ }
async function find(v: Variant) {
searching = true;
try {
const r = await gateway.lobbyEnqueue(v);
if (r.matched && r.game) {
+ searching = false;
navigate(`/game/${r.game.id}`);
return;
}
@@ -45,6 +55,7 @@
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
stop();
+ searching = false;
navigate(`/game/${p.game.id}`);
}
} catch (e) {
@@ -103,7 +114,11 @@
}
}
- onDestroy(stop);
+ onDestroy(() => {
+ stop();
+ // Abandoned mid-search (navigated away without Cancel): dequeue so we don't linger.
+ if (searching) void gateway.lobbyCancel().catch(() => {});
+ });
@@ -112,7 +127,7 @@
{t('new.searching')}
-
+
{:else}
{#if !guest}
--
2.52.0
From 3899ffda0ff48b868094908eda9dc529aab84bc6 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 09:21:22 +0200
Subject: [PATCH 022/223] Stage 17 round 5: fix robot-pool test for the new
friend-request policy
TestRobotPoolProvisionsRobotAccounts asserted robots block friend requests; they no
longer do (a request stays pending and expires like a human ignore). Assert chat is
blocked and friend requests are open. (Unblocks the integration job / contour deploy.)
---
backend/internal/inttest/robot_test.go | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go
index 33efe14..f8ef7fa 100644
--- a/backend/internal/inttest/robot_test.go
+++ b/backend/internal/inttest/robot_test.go
@@ -96,8 +96,11 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
if err != nil {
t.Fatalf("get robot account: %v", err)
}
- if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests {
- t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
+ // A robot blocks chat but NOT friend requests: a request to a robot stays pending and
+ // expires, mirroring a human who ignores it (Stage 17).
+ if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
+ t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
+ acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
}
}
--
2.52.0
From 29d1193a0a2a8cc6ebd65de125f3f79b15d16583 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 09:34:07 +0200
Subject: [PATCH 023/223] =?UTF-8?q?Stage=2017=20round=205=20=E2=80=94=20bo?=
=?UTF-8?q?ard=20interaction=20&=20UI=20polish?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Even zoom: interpolate the board scroll toward a pre-clamped target as the real width
grows/shrinks, so it magnifies A->B in one motion instead of lurching and snapping back
near the edges/centre. Recentre only on a zoom toggle, never on a focus change — so a
2nd+ placed tile and a hovered dragged tile no longer jump the board.
- Drag: highlight the aimed-at empty cell as a drop target; hover-hold auto-zoom now
fires only for the first (zoom-in) hold.
- Pinch zoom: two-finger spread/close toggles zoom toward the pinch midpoint (preventDefault
only for two touches, so one-finger scroll stays native); a second finger aborts a drag.
- Shuffle hop capped at 0.3s and disabled under reduce-motion.
- Make-move is a borderless icon button, disabled while the pending word is known illegal.
- Variant display names: english & russian_scrabble -> Scrabble/Скрэббл, erudit ->
Erudite/Эрудит; the in-game title shows the variant name (was always 'Scrabble').
---
ui/src/game/Board.svelte | 114 ++++++++++++++++++++++++++++++++++-----
ui/src/game/Game.svelte | 68 ++++++++++++++++-------
ui/src/game/Rack.svelte | 7 +--
ui/src/lib/i18n/en.ts | 6 +--
ui/src/lib/i18n/ru.ts | 4 +-
ui/src/lib/variants.ts | 11 +++-
6 files changed, 171 insertions(+), 39 deletions(-)
diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte
index 1a6df98..7f6637b 100644
--- a/ui/src/game/Board.svelte
+++ b/ui/src/game/Board.svelte
@@ -1,4 +1,5 @@
-
+
{#snippet menu()}
{/snippet}
@@ -600,6 +630,7 @@
lines={app.boardLines}
locale={app.locale}
{focus}
+ {dropTarget}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
@@ -624,10 +655,10 @@
a finished game shows the final rack greyed out and the controls disabled. -->
{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}} next move {{.NextMove}}{{end}}{{end}}
{{end}}
+{{if .HasRobot}}
Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.
{{end}}
{{end}}
{{- end}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 8293dd9..cb92d26 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -145,9 +145,15 @@ type GameDetailView struct {
UpdatedAt string
FinishedAt string
Seats []SeatRow
+ // HasRobot is true when any seat is a robot, gating the robot-target caption;
+ // RobotTargetPct is the configured global play-to-win rate, in percent.
+ HasRobot bool
+ RobotTargetPct int
}
-// SeatRow is one seat of a game.
+// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
+// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
+// scheduled next-move ETA shown only while it is that robot's turn in an active game.
type SeatRow struct {
Seat int
DisplayName string
@@ -155,6 +161,9 @@ type SeatRow struct {
Score int
HintsUsed int
Winner bool
+ IsRobot bool
+ RobotIntent string
+ NextMove string
}
// ComplaintsView is the paginated complaint review queue.
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index e109929..63c46c1 100644
--- a/backend/internal/game/service.go
+++ b/backend/internal/game/service.go
@@ -220,6 +220,12 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
return svc.store.GetGameVariant(ctx, gameID)
}
+// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
+// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
+func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
+ return svc.store.RobotSchedule(ctx, gameID)
+}
+
// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go
index c06c508..b09e5b4 100644
--- a/backend/internal/game/store.go
+++ b/backend/internal/game/store.go
@@ -651,6 +651,24 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
return row.Seed, nil
}
+// RobotSchedule returns a game's bag seed and current turn-start time. The admin console
+// combines them with the robot strategy to show a robot seat's play-to-win intent and its
+// next-move ETA. Both are server-only state, never part of the public game view.
+func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
+ stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt).
+ FROM(table.Games).
+ WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
+ LIMIT(1)
+ var row model.Games
+ if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
+ if errors.Is(err, qrm.ErrNoRows) {
+ return 0, time.Time{}, ErrNotFound
+ }
+ return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err)
+ }
+ return row.Seed, row.TurnStartedAt, nil
+}
+
// projectGame builds a Game from a games row and its ordered seat rows.
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
variant, err := engine.ParseVariant(g.Variant)
diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go
index 3059643..6b77c97 100644
--- a/backend/internal/inttest/admin_test.go
+++ b/backend/internal/inttest/admin_test.go
@@ -167,6 +167,45 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
}
}
+// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
+// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
+func TestConsoleGameDetailRobotSchedule(t *testing.T) {
+ ctx := context.Background()
+ svc := newGameService()
+ robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
+ if err != nil {
+ t.Fatalf("provision robot: %v", err)
+ }
+ human := provisionAccount(t)
+ // Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
+ g, err := svc.Create(ctx, game.CreateParams{
+ Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ srv := server.New(":0", server.Deps{
+ Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
+ })
+ code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
+ if code != http.StatusOK {
+ t.Fatalf("game detail = %d, want 200", code)
+ }
+ if !strings.Contains(body, "🤖") {
+ t.Error("robot seat is not marked in the game detail")
+ }
+ if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
+ t.Error("robot play-to-win intent missing")
+ }
+ if !strings.Contains(body, "next move") {
+ t.Error("robot is to move but the next-move ETA is missing")
+ }
+ if !strings.Contains(body, "~40%") {
+ t.Error("robot play-to-win target caption missing")
+ }
+}
+
// consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go
index 7c9b952..4219863 100644
--- a/backend/internal/robot/strategy.go
+++ b/backend/internal/robot/strategy.go
@@ -114,6 +114,40 @@ func playToWin(seed int64) bool {
return mix(seed, "win")%100 < playToWinPercent
}
+// PlayToWin exposes the once-per-game play-to-win decision for a game's bag seed, for the
+// admin console (it is deterministic and fixed for the whole game).
+func PlayToWin(seed int64) bool { return playToWin(seed) }
+
+// PlayToWinTargetPercent is the configured probability, in percent, that a robot plays to
+// win in any given game (the admin console shows it alongside the per-game decision).
+const PlayToWinTargetPercent = playToWinPercent
+
+// NextMoveAt is the deterministic instant the robot is scheduled to play the move at
+// moveCount, given when the turn started and the opponent's timezone (which anchors the
+// robot's sleep window). It is the sampled think-time delay, deferred to the end of the
+// sleep window when it would otherwise land while the robot is asleep. The driver acts on
+// a scan tick, so the real move lands at the first scan at or after this instant. It is
+// meaningful only on the robot's own turn; the admin console surfaces it as an ETA.
+func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time {
+ t := turnStartedAt.Add(moveDelay(seed, moveCount))
+ drift := sleepDrift(seed)
+ if asleep(opponentTZ, drift, t) {
+ t = wakeAfter(opponentTZ, drift, t)
+ }
+ return t
+}
+
+// wakeAfter returns the first instant at or after t when the robot is awake — the local
+// hour reaches sleepEndHour in the opponent's drifted timezone — converted back to UTC.
+func wakeAfter(opponentTZ string, drift time.Duration, t time.Time) time.Time {
+ local := t.In(loadLocation(opponentTZ)).Add(drift)
+ wake := time.Date(local.Year(), local.Month(), local.Day(), sleepEndHour, 0, 0, 0, local.Location())
+ if !wake.After(local) {
+ wake = wake.Add(24 * time.Hour)
+ }
+ return wake.Add(-drift).UTC()
+}
+
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
// for the move at moveCount. It interpolates linearly with game progress (the move
// count over avgGameMoves, capped at 1): early moves sit in a short band and late
diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go
index b728d00..91092bb 100644
--- a/backend/internal/robot/strategy_test.go
+++ b/backend/internal/robot/strategy_test.go
@@ -207,6 +207,37 @@ func TestMixDeterministic(t *testing.T) {
}
}
+// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never
+// earlier than the sampled think-time delay, and it never lands while the robot is asleep
+// (a delay that would fall in the sleep window is deferred to the wake time).
+func TestNextMoveAt(t *testing.T) {
+ base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for seed := int64(1); seed <= 500; seed++ {
+ for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day
+ start := base.Add(time.Duration(h) * time.Hour)
+ at := NextMoveAt(seed, 3, start, "UTC")
+ if at.Before(start.Add(moveDelay(seed, 3))) {
+ t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at)
+ }
+ if asleep("UTC", sleepDrift(seed), at) {
+ t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at)
+ }
+ }
+ }
+}
+
+// TestPlayToWinExport checks the exported decision matches the internal one and the target.
+func TestPlayToWinExport(t *testing.T) {
+ for seed := int64(1); seed <= 200; seed++ {
+ if PlayToWin(seed) != playToWin(seed) {
+ t.Fatalf("PlayToWin(%d) != playToWin", seed)
+ }
+ }
+ if PlayToWinTargetPercent != playToWinPercent {
+ t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent)
+ }
+}
+
// plays builds candidate plays carrying only the given scores (ranked as passed).
func plays(scores ...int) []engine.MoveRecord {
out := make([]engine.MoveRecord, len(scores))
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index 91da11c..5631c8b 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -18,6 +18,7 @@ import (
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
+ "scrabble/backend/internal/robot"
)
// adminPageSize is the page size of the admin console's paginated lists.
@@ -248,16 +249,58 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt),
}
+ // Resolve seats and detect robot seats; capture the human opponent's timezone, which
+ // anchors the robot's sleep window for the next-move ETA.
+ oppTZ := ""
for _, seat := range g.Seats {
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
- if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil {
+ acc, accErr := s.accounts.GetByID(ctx, seat.AccountID)
+ if accErr == nil {
row.DisplayName = acc.DisplayName
}
+ if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot {
+ row.IsRobot = true
+ view.HasRobot = true
+ } else if accErr == nil {
+ oppTZ = acc.TimeZone
+ }
view.Seats = append(view.Seats, row)
}
+ // For each robot seat, surface the game's deterministic play-to-win intent and — while
+ // it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed.
+ if view.HasRobot {
+ view.RobotTargetPct = robot.PlayToWinTargetPercent
+ if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil {
+ now := time.Now().UTC()
+ for i := range view.Seats {
+ if !view.Seats[i].IsRobot {
+ continue
+ }
+ if robot.PlayToWin(seed) {
+ view.Seats[i].RobotIntent = "play to win"
+ } else {
+ view.Seats[i].RobotIntent = "play to lose"
+ }
+ if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat {
+ view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now)
+ }
+ }
+ }
+ }
s.renderConsole(c, "game_detail", "games", "Game", view)
}
+// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a
+// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)".
+func robotETA(at, now time.Time) string {
+ mins := int(at.Sub(now).Round(time.Minute).Minutes())
+ rel := fmt.Sprintf("in ~%d min", mins)
+ if mins <= 0 {
+ rel = "due now"
+ }
+ return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel)
+}
+
// consoleComplaints renders the paginated complaint review queue.
func (s *Server) consoleComplaints(c *gin.Context) {
ctx := c.Request.Context()
--
2.52.0
From a420d6a2cd26ffb4ec799015429edcc7feddb639 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 09:48:08 +0200
Subject: [PATCH 025/223] Stage 17 round 5 docs: bake the bug fixes + UI polish
+ L2 into live docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ARCHITECTURE: resign on the opponent's turn (ResignSeat + turn-check bypass); robots
block chat but accept-and-ignore friend requests; quick-match /lobby/cancel; the admin
robot play-to-win intent + next-move ETA panel.
- UI_DESIGN: even A->B zoom (recentre only on zoom-in), pinch, drop-target highlight,
shuffle ≤0.3s + reduce-motion, borderless make-move disabled on illegal, variant title.
- FUNCTIONAL (+ru): variant display names (Scrabble/Erudite); robot ignores friend requests.
- PLAN: round-5 refinements bullet (+ the bilingual two-Scrabble open edge).
---
PLAN.md | 19 +++++++++++++++++++
docs/ARCHITECTURE.md | 17 +++++++++++++----
docs/FUNCTIONAL.md | 11 +++++++----
docs/FUNCTIONAL_ru.md | 13 ++++++++-----
docs/UI_DESIGN.md | 33 +++++++++++++++++++++------------
5 files changed, 68 insertions(+), 25 deletions(-)
diff --git a/PLAN.md b/PLAN.md
index dfb8a44..b8b28de 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1281,6 +1281,25 @@ provided cert) at the contour caddy; prod VPN; rollback.
scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
**robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
across the game/robot package boundary, to be picked up when that seam is added.
+ - **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn**
+ now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit
+ the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op
+ (only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the
+ matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later
+ robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win —
+ `result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend
+ request to a robot** is accepted as pending and expires like a human ignore (robots no longer set
+ `BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the
+ chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish:
+ **even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no
+ lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch
+ zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop
+ capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known-
+ illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit
+ → Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game
+ card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's
+ turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would
+ show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
## Deferred TODOs (cross-stage)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 47aa02d..46045bb 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -235,7 +235,10 @@ Key points:
applying the end-game rack-value adjustment, or a resignation. On a
**resignation the resigner keeps their accumulated score (no rack adjustment)
and never wins**: the win goes to the highest score among the remaining seats,
- unconditionally the other player in a two-player game. The engine exposes a
+ unconditionally the other player in a two-player game. A player may resign **on the
+ opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)`
+ resigns that player's own seat whoever is to move, and the game domain skips the turn
+ check for resign (Stage 17). The engine exposes a
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
- The **game domain** (`internal/game`) owns everything the engine does not —
@@ -301,9 +304,10 @@ 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), keyed
-`robot--` 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
+`robot--` and provisioned at startup with **chat blocked but friend
+requests open** — a request to a robot is accepted as pending and expires unanswered
+(the robot never responds), mirroring a human who ignores it (Stage 17); the chat
+block backs the human-like names (there is no DM surface; chat is per-game). Names are
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
a surname pool (gender-agreed for Russian) in one of three forms (first only /
first + surname initial / first + full surname), deterministically per pool slot so
@@ -331,6 +335,8 @@ English game the Latin pool.
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
+ The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
+ the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17).
## 8. Lobby & social
@@ -342,6 +348,9 @@ English game the Latin pool.
robot (§7) and starts the game. On a pairing or substitution the matchmaker
emits a **match-found** notification (§10), delivered over the live stream;
`Poll` remains as a fallback for a client that is not currently streaming.
+ **Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
+ pending matched result, so a cancelled quick-match is dequeued rather than left for
+ the reaper to robot-substitute (Stage 17).
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 9a3936d..771ae57 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -55,9 +55,11 @@ two accounts share a game still in progress.
### Lobby & matchmaking *(Stage 4 / 15)*
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
-limited to the languages the player's sign-in service supports (English → English;
-Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is
-unrestricted). This gates only **starting** a new game — both auto-match and a friend
+limited to the languages the player's sign-in service supports (English → Scrabble;
+Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
+unrestricted). Variants are shown by their **display name** — both Scrabble variants read
+"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
+the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
@@ -92,7 +94,8 @@ and plays at a human pace — short thinking times for most moves, the occasiona
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, language-appropriate name (a Russian game draws mostly Russian
-names) and neither chats nor accepts friend requests.
+names); it does not chat, and **silently ignores friend requests** — a request to a
+robot stays pending and expires, exactly like a human who never responds.
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
Become friends in two ways: redeem a **one-time code** the other player issues (six
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index bffeb6f..cbbd2aa 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -56,9 +56,11 @@ Mini App** авторизует по подписанным `initData` плат
### Лобби и подбор *(Stage 4 / 15)*
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
-ограничены языками, которые поддерживает сервис входа игрока (английский → English;
-русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не
-ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и
+ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
+русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
+ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
+читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
+и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
@@ -93,8 +95,9 @@ Mini App** авторизует по подписанным `initData` плат
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
-языку партии (в русской партии — в основном русские имена), не общается в чате и не
-принимает заявки в друзья.
+языку партии (в русской партии — в основном русские имена); не общается в чате и
+**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
+ровно как у человека, который не отвечает.
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index e480580..3ba425a 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -63,16 +63,22 @@ Login uses `Screen`.
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
they stay a constant size as the cells grow (relatively smaller at higher zoom).
**Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending**
- tile recalls it. On touch, placing a tile auto-zooms in centred on the target, and
- **holding a dragged tile over a cell for ~1 s** auto-zooms there (Stage 17). The custom
- pinch and swipe-to-open-history gestures stay dropped — they fight both native scroll and
- the one-finger drag-back gesture; 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.
+ tile recalls it. **Pinch** zooms toward the pinch midpoint (a two-finger gesture;
+ preventDefault fires only for two touches, so one-finger scroll stays native, and a second
+ finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
+ as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
+ back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or
+ hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
+ in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
+ the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
+ history opens from the menu or a tap on the players plaque (below). A **hint** auto-zooms
+ centred on the hint's placement, not the top-left.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
- dragging it onto a cell; a pending tile is taken back by a **double-tap** or by **dragging
- it back onto the rack** (unzoomed board only — when zoomed the one-finger gesture scrolls).
- A single tap no longer recalls (too easy to trigger); a recalled tile returns to its
- original rack slot (Stage 17).
+ dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
+ cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
+ **double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
+ the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
+ recalled tile returns to its original rack slot (Stage 17).
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
@@ -105,12 +111,15 @@ Login uses `Screen`.
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots
- and shifts left, a direct **✅** button beside the rack commits the move (no popover), and
- the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
+ and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
+ button) beside the rack commits the move — no popover, and disabled while the pending word
+ is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
- distance) with a short haptic shake. The under-board slot shows the **Scores: N** preview.
+ distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
+ under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
+ display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
--
2.52.0
From 512ad4dfb97bc62c33d8d6cf0d7a2b41952c6446 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 11:18:25 +0200
Subject: [PATCH 026/223] Stage 17 round 6 (cluster 1): profile, tap flash,
variant naming, chat/nudge by turn
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Profile: drop the hint-balance line.
- Board: no mobile tap flash on a cell tap (-webkit-tap-highlight-color: transparent),
matching the web click; the only intentional cell animation stays the last-word flash.
- Variant names keyed by the game's alphabet, not the UI language: english -> Scrabble
always, russian_scrabble -> Скрэббл always (unlocalized, never collide), erudit localized.
- Chat/nudge are mutually exclusive by turn: the message field + Send show on your turn,
the nudge replaces them on the opponent's turn; while the nudge cooldown is active the
button is disabled with a grey 'awaiting reply' caption to its left.
---
ui/e2e/social.spec.ts | 10 +++++----
ui/src/game/Board.svelte | 3 +++
ui/src/game/Chat.svelte | 40 ++++++++++++++++++++++++-----------
ui/src/game/Game.svelte | 2 +-
ui/src/lib/i18n/en.ts | 3 ++-
ui/src/lib/i18n/ru.ts | 3 ++-
ui/src/lib/variants.ts | 9 ++++----
ui/src/screens/Profile.svelte | 11 ----------
8 files changed, 47 insertions(+), 34 deletions(-)
diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts
index 331df8b..33cdd70 100644
--- a/ui/e2e/social.spec.ts
+++ b/ui/e2e/social.spec.ts
@@ -165,12 +165,14 @@ test('link account: the Telegram web sign-in control is offered in a browser', a
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
});
-test('chat send and nudge are icon buttons', async ({ page }) => {
+test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => {
await loginLobby(page);
- await page.getByRole('button', { name: /Ann/ }).click();
+ await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
await page.locator('.burger').first().click();
await page.getByRole('button', { name: 'Chat' }).click();
- // Icon-only controls expose their action through the aria-label.
+ // On your turn the message field + Send are shown and the nudge is hidden (Stage 17);
+ // chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
+ // through the aria-label.
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
- await expect(page.getByRole('button', { name: 'Nudge' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
});
diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte
index 7f6637b..a434519 100644
--- a/ui/src/game/Board.svelte
+++ b/ui/src/game/Board.svelte
@@ -250,6 +250,9 @@
border-radius: 1px;
background: var(--cell-bg);
color: var(--prem-text);
+ /* No mobile tap flash on a cell tap (parity with the web click; the only intentional
+ cell animation is the last-word .flash highlight). */
+ -webkit-tap-highlight-color: transparent;
padding: 0;
overflow: hidden;
font-size: 0;
diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte
index 7d12318..fb1ddb9 100644
--- a/ui/src/game/Chat.svelte
+++ b/ui/src/game/Chat.svelte
@@ -6,16 +6,20 @@
messages,
myId,
busy,
- canNudge = true,
+ myTurn = false,
+ nudgeOnCooldown = false,
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;
+ // Chat and nudge are mutually exclusive by turn (Stage 17): on the player's own turn the
+ // message field + send are shown (and nudging makes no sense — there is no one to
+ // hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
+ // cooldown is active the nudge is disabled with an "awaiting reply" caption.
+ myTurn?: boolean;
+ nudgeOnCooldown?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
@@ -44,14 +48,18 @@
{/each}
@@ -99,6 +107,14 @@
.input {
display: flex;
gap: 6px;
+ align-items: center;
+ }
+ /* The cooldown caption sits to the left of the disabled nudge button. */
+ .cooldown {
+ flex: 1;
+ text-align: right;
+ color: var(--text-muted);
+ font-size: 0.85rem;
}
.input input {
flex: 1;
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index 9fa01c9..7c07d2a 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -760,7 +760,7 @@
{#if panel === 'chat'}
(panel = 'none')}>
-
+
{/if}
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index c3f0b55..3261d38 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -41,7 +41,7 @@ export const en = {
'new.title': 'New game',
'new.subtitle': 'Auto-match with another player',
'new.english': 'Scrabble',
- 'new.russian': 'Scrabble',
+ 'new.russian': 'Скрэббл',
'new.erudit': 'Erudite',
'new.find': 'Find a game',
'new.searching': 'Looking for an opponent…',
@@ -96,6 +96,7 @@ export const en = {
'chat.send': 'Send',
'chat.nudge': 'Waiting for your move!',
'chat.nudgeAction': 'Nudge',
+ 'chat.awaitingReply': "Waiting for the opponent's reply",
'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index f7fdeac..10846f5 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -41,7 +41,7 @@ export const ru: Record = {
'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника',
- 'new.english': 'Скрэббл',
+ 'new.english': 'Scrabble',
'new.russian': 'Скрэббл',
'new.erudit': 'Эрудит',
'new.find': 'Найти игру',
@@ -97,6 +97,7 @@ export const ru: Record = {
'chat.send': 'Отправить',
'chat.nudge': 'Жду вашего хода!',
'chat.nudgeAction': 'Поторопить',
+ 'chat.awaitingReply': 'Ждём реакцию соперника',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts
index 1f21dfd..a622c2b 100644
--- a/ui/src/lib/variants.ts
+++ b/ui/src/lib/variants.ts
@@ -11,10 +11,11 @@ export interface VariantOption {
label: MessageKey;
}
-// ALL_VARIANTS lists every variant in display order. The labels are display names, not
-// language names: both Scrabble variants render as "Scrabble"/"Скрэббл" and Erudit as
-// "Erudite"/"Эрудит" (Stage 17) — the offered list is language-gated, so within one
-// language the names stay distinct.
+// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by
+// the game's alphabet, not the interface language: the English-alphabet game is always
+// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
+// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
+// (Stage 17).
export const ALL_VARIANTS: VariantOption[] = [
{ id: 'english', label: 'new.english' },
{ id: 'russian_scrabble', label: 'new.russian' },
diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte
index 999e10e..8cc48d2 100644
--- a/ui/src/screens/Profile.svelte
+++ b/ui/src/screens/Profile.svelte
@@ -166,8 +166,6 @@
{p.displayName}
{#if p.isGuest}{t('profile.guest')}{/if}
-
{t('profile.hintBalance')}{p.hintBalance}
-
{#if p.isGuest}
{t('profile.guestLocked')}
{:else}
@@ -284,15 +282,6 @@
color: var(--text-muted);
font-size: 0.8rem;
}
- .hintbal {
- display: flex;
- justify-content: space-between;
- color: var(--text-muted);
- }
- .hintbal b {
- color: var(--text);
- font-weight: 600;
- }
.muted {
color: var(--text-muted);
font-size: 0.9rem;
--
2.52.0
From 2cb2b57cdbae15006d5e6704ba04df5e9e775303 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 11:23:43 +0200
Subject: [PATCH 027/223] Stage 17 round 6 (#10 backend): enforce chat only on
your turn
PostMessage now rejects a chat sent on a finished game or when it is not the sender's
turn (ErrChatNotYourTurn -> 409 chat_not_your_turn), matching the UI where the message
field is hidden off-turn and only the nudge shows. Existing chat tests post on the
to-move seat and are unaffected; adds an off-turn-rejection integration test + the dto
mapping case + the UI error message.
---
backend/internal/inttest/social_test.go | 14 ++++++++++++++
backend/internal/server/dto_test.go | 1 +
backend/internal/server/handlers.go | 2 ++
backend/internal/social/chat.go | 13 +++++++++++--
backend/internal/social/social.go | 4 ++++
ui/src/lib/i18n/en.ts | 1 +
ui/src/lib/i18n/ru.ts | 1 +
7 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go
index 0317996..ac44613 100644
--- a/backend/internal/inttest/social_test.go
+++ b/backend/internal/inttest/social_test.go
@@ -314,6 +314,20 @@ func TestChatRejectsBadContent(t *testing.T) {
}
}
+// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17):
+// the player to move can post, the waiting player gets ErrChatNotYourTurn.
+func TestChatOnlyOnYourTurn(t *testing.T) {
+ ctx := context.Background()
+ svc := newSocialService()
+ gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening
+ if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) {
+ t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err)
+ }
+ if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil {
+ t.Fatalf("on-turn chat = %v, want nil", err)
+ }
+}
+
func TestNudgeRulesAndRateLimit(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go
index 619a3dc..a5d2c66 100644
--- a/backend/internal/server/dto_test.go
+++ b/backend/internal/server/dto_test.go
@@ -48,6 +48,7 @@ func TestStatusForError(t *testing.T) {
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
"nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"},
+ "chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"},
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go
index e4238f3..c4104b5 100644
--- a/backend/internal/server/handlers.go
+++ b/backend/internal/server/handlers.go
@@ -207,6 +207,8 @@ func statusForError(err error) (int, string) {
// A too-frequent nudge is a distinct, non-content rejection — the UI must say
// "don't rush the player so often", not the chat content-rejection message.
return http.StatusConflict, "nudge_too_soon"
+ case errors.Is(err, social.ErrChatNotYourTurn):
+ return http.StatusConflict, "chat_not_your_turn"
case errors.Is(err, social.ErrSelfRelation):
return http.StatusBadRequest, "self_relation"
case errors.Is(err, social.ErrRequestExists):
diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go
index 387440e..78e6f24 100644
--- a/backend/internal/social/chat.go
+++ b/backend/internal/social/chat.go
@@ -49,13 +49,22 @@ type Message struct {
// rune limit, and free of links/emails/phone numbers (the content filter). The
// gateway-forwarded senderIP is validated and stored for moderation.
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
- seats, _, _, err := svc.games.Participants(ctx, gameID)
+ seats, toMove, status, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
- if !slices.Contains(seats, senderID) {
+ idx := slices.Index(seats, senderID)
+ if idx < 0 {
return Message{}, ErrNotParticipant
}
+ // Chat is allowed only on the sender's own turn in an active game; the opponent's-turn
+ // control is the nudge (Stage 17).
+ if status != statusActive {
+ return Message{}, ErrGameNotActive
+ }
+ if idx != toMove {
+ return Message{}, ErrChatNotYourTurn
+ }
sender, err := svc.accounts.GetByID(ctx, senderID)
if err != nil {
return Message{}, err
diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go
index 43002c2..3ea5c28 100644
--- a/backend/internal/social/social.go
+++ b/backend/internal/social/social.go
@@ -67,6 +67,10 @@ var (
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
ErrGameNotActive = errors.New("social: game is not active")
+ // ErrChatNotYourTurn is returned when a chat message is sent while it is not the
+ // sender's turn — chat is allowed only on your own turn (the opponent's-turn control
+ // is the nudge, Stage 17).
+ ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn")
)
// Service is the social domain. It is the only writer of the friendships, blocks
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 3261d38..03189ce 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -158,6 +158,7 @@ export const en = {
'error.no_hint_available': 'No options with your letters.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.",
+ 'error.chat_not_your_turn': 'You can chat only on your turn.',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
'error.already_queued': 'You are already in the queue.',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index 10846f5..c7cec61 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -159,6 +159,7 @@ export const ru: Record = {
'error.no_hint_available': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
+ 'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
'error.already_queued': 'Вы уже в очереди.',
--
2.52.0
From cdf616d6c480197d10d1cd83064782198765a7bd Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 11:32:08 +0200
Subject: [PATCH 028/223] Stage 17 round 6 (#7): reset the nudge cooldown once
the player acts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hourly nudge cooldown now clears as soon as the sender has moved or posted a chat
since their last nudge — engagement lifts the 'don't spam' limit. Backend: Nudge checks
game.LastMoveAt + the sender's last non-nudge chat against the last nudge time
(GameReader gains LastMoveAt). UI: nudgeOnCooldown mirrors it — a chat reset is read from
the message list, a move is tracked client-side (lastActedAt on commit/pass/exchange; the
backend stays authoritative across a reload). Integration test covers the reset.
---
backend/internal/game/service.go | 7 ++++
backend/internal/game/store.go | 19 ++++++++++
backend/internal/inttest/social_test.go | 30 ++++++++++++++++
backend/internal/social/chat.go | 47 ++++++++++++++++++++++++-
backend/internal/social/social.go | 3 ++
ui/src/game/Game.svelte | 22 +++++++++---
6 files changed, 122 insertions(+), 6 deletions(-)
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index 63c46c1..0010f29 100644
--- a/backend/internal/game/service.go
+++ b/backend/internal/game/service.go
@@ -226,6 +226,13 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i
return svc.store.RobotSchedule(ctx, gameID)
}
+// LastMoveAt returns the time of an account's most recent move in a game (and whether it
+// has moved). The social service uses it to reset the nudge cooldown once a player has
+// taken a turn (Stage 17).
+func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
+ return svc.store.LastMoveAt(ctx, gameID, accountID)
+}
+
// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go
index b09e5b4..d97a2ea 100644
--- a/backend/internal/game/store.go
+++ b/backend/internal/game/store.go
@@ -651,6 +651,25 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
return row.Seed, nil
}
+// LastMoveAt returns the time of the account's most recent move in the game and true, or
+// the zero time and false when it has not moved. The social service uses it to reset the
+// nudge cooldown once the player has taken a turn (Stage 17).
+func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
+ var at sql.NullTime
+ err := s.db.QueryRowContext(ctx,
+ `SELECT MAX(m.created_at) FROM backend.game_moves m
+ JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat
+ WHERE m.game_id = $1 AND p.account_id = $2`,
+ gameID, accountID).Scan(&at)
+ if err != nil {
+ return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err)
+ }
+ if !at.Valid {
+ return time.Time{}, false, nil
+ }
+ return at.Time, true, nil
+}
+
// RobotSchedule returns a game's bag seed and current turn-start time. The admin console
// combines them with the robot strategy to show a robot seat's play-to-win intent and its
// next-move ETA. Both are server-only state, never part of the public game view.
diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go
index ac44613..33908fa 100644
--- a/backend/internal/inttest/social_test.go
+++ b/backend/internal/inttest/social_test.go
@@ -353,3 +353,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
t.Fatalf("nudge after window: %v", err)
}
}
+
+// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
+// acted (moved or chatted) since their last nudge, even within the hour (Stage 17).
+func TestNudgeCooldownResetsOnAction(t *testing.T) {
+ ctx := context.Background()
+ svc := newSocialService()
+ gsvc := newGameService()
+ gameID, seats := newGameWithSeats(t, 2) // seat 0 to move
+
+ if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
+ t.Fatalf("nudge: %v", err)
+ }
+ if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
+ t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
+ }
+ // Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes.
+ if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil {
+ t.Fatalf("seat0 pass: %v", err)
+ }
+ if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil {
+ t.Fatalf("seat1 chat: %v", err)
+ }
+ if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil {
+ t.Fatalf("seat1 pass: %v", err)
+ }
+ // Back on the opponent's turn, the cooldown is reset by the action since the nudge.
+ if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
+ t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
+ }
+}
diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go
index 78e6f24..0d9edc8 100644
--- a/backend/internal/social/chat.go
+++ b/backend/internal/social/chat.go
@@ -114,7 +114,15 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return Message{}, err
}
if ok && svc.now().Sub(last) < nudgeInterval {
- return Message{}, ErrNudgeTooSoon
+ // The cooldown resets once the sender has acted (moved or chatted) since the last
+ // nudge — engagement clears the "don't spam" limit (Stage 17).
+ acted, err := svc.actedSince(ctx, gameID, senderID, last)
+ if err != nil {
+ return Message{}, err
+ }
+ if !acted {
+ return Message{}, ErrNudgeTooSoon
+ }
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
if err != nil {
@@ -127,6 +135,22 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return msg, nil
}
+// actedSince reports whether senderID made a move or posted a chat message in the game
+// after t — the events that reset the nudge cooldown (Stage 17).
+func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) {
+ if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
+ return false, err
+ } else if ok && mv.After(t) {
+ return true, nil
+ }
+ if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil {
+ return false, err
+ } else if ok && msg.After(t) {
+ return true, nil
+ }
+ return false, nil
+}
+
// emitChat pushes a chat message to every seated player except the sender
// (best-effort live delivery; the recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
@@ -261,6 +285,27 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti
return row.CreatedAt, true, nil
}
+// lastMessageAt returns the time of senderID's most recent non-nudge chat message in
+// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale
+// nudge no longer blocks a new one (Stage 17).
+func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
+ stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
+ FROM(table.ChatMessages).
+ WHERE(
+ table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
+ AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
+ AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))),
+ ).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
+ var row model.ChatMessages
+ if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
+ if errors.Is(err, qrm.ErrNoRows) {
+ return time.Time{}, false, nil
+ }
+ return time.Time{}, false, fmt.Errorf("social: last message: %w", err)
+ }
+ return row.CreatedAt, true, nil
+}
+
// messageFromRow projects a generated row into the public Message.
func messageFromRow(r model.ChatMessages) Message {
m := Message{
diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go
index 3ea5c28..1f0aaa2 100644
--- a/backend/internal/social/social.go
+++ b/backend/internal/social/social.go
@@ -28,6 +28,9 @@ type GameReader interface {
// SharedGame reports whether two accounts are seated together in any game
// (active or finished); it gates the "befriend an opponent" request path.
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
+ // LastMoveAt is the time of an account's most recent move in a game (and whether it
+ // has moved); the nudge cooldown resets once the player has taken a turn.
+ LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error)
}
// Sentinel errors returned by the service.
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index 7c07d2a..beafaac 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -95,14 +95,23 @@
// timer while the chat is open, so it re-enables without waiting for a new message.
const nudgeCooldownSecs = 3600;
let nudgeTick = $state(0);
+ // Unix seconds of the player's own last move, which resets the nudge cooldown (mirrors the
+ // backend, Stage 17). A chat reset is read from `messages`; a move is tracked client-side
+ // (the backend stays authoritative across a reload).
+ let lastActedAt = $state(0);
const nudgeOnCooldown = $derived.by(() => {
void nudgeTick;
const mine = app.session?.userId ?? '';
- const last = messages.reduce(
- (mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx),
- 0,
- );
- return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs;
+ let lastNudge = 0;
+ let lastChat = 0;
+ for (const m of messages) {
+ if (m.senderId !== mine) continue;
+ if (m.kind === 'nudge') lastNudge = Math.max(lastNudge, m.createdAtUnix);
+ else lastChat = Math.max(lastChat, m.createdAtUnix);
+ }
+ if (lastNudge === 0 || Date.now() / 1000 - lastNudge >= nudgeCooldownSecs) return false;
+ // Engagement since the nudge clears the cooldown: a chat or a move.
+ return lastChat <= lastNudge && lastActedAt <= lastNudge;
});
async function load() {
@@ -361,6 +370,7 @@
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
+ lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
telegramHaptic('success');
zoomed = false;
await load();
@@ -381,6 +391,7 @@
busy = true;
try {
await gateway.pass(id);
+ lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
@@ -461,6 +472,7 @@
busy = true;
try {
await gateway.exchange(id, tiles, variant);
+ lastActedAt = Date.now() / 1000; // a move resets the nudge cooldown
await load();
} catch (e) {
handleError(e);
--
2.52.0
From 74683f294f2666627eb17c9bb368b9a89c73815f Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 11:39:31 +0200
Subject: [PATCH 029/223] Stage 17 round 6 (#13/About): About screen content +
app version from git describe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- About screen: prominent localized title (Scrabble / Эрудит (Скрэббл)), a rules link
(en/ru Wikipedia), and the Random-game / Game-with-friends sections; copy lives in a
shared aboutContent module (the landing will reuse it). The random-game move limit
inlines the 24h auto-match clock.
- App version: Vite define __APP_VERSION__ from VITE_APP_VERSION (default 'dev'), wired as
a Docker build-arg sourced from `git describe --tags --always` in the deploy step — no
manual version bumps. The fallback keeps a plain/local build working.
---
.gitea/workflows/ci.yaml | 3 ++
deploy/docker-compose.yml | 1 +
gateway/Dockerfile | 5 ++-
ui/src/lib/aboutContent.ts | 62 +++++++++++++++++++++++++++++++++++++
ui/src/screens/About.svelte | 62 +++++++++++++++++++++++++++++++++++--
ui/src/vite-env.d.ts | 3 ++
ui/vite.config.ts | 6 ++++
7 files changed, 138 insertions(+), 4 deletions(-)
create mode 100644 ui/src/lib/aboutContent.ts
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 42cb76a..c5e381d 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -285,6 +285,9 @@ jobs:
mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/
export SCRABBLE_CONFIG_DIR="$conf"
+ # App version for the About screen: the git tag if present, else the short SHA
+ # (the test checkout is shallow/untagged, so this is the SHA here — fine).
+ export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
docker compose --ansi never build --progress plain
docker compose --ansi never up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 66c96f3..7836d94 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -79,6 +79,7 @@ services:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
+ VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
depends_on: [backend]
environment:
diff --git a/gateway/Dockerfile b/gateway/Dockerfile
index bb0dd60..4a22bad 100644
--- a/gateway/Dockerfile
+++ b/gateway/Dockerfile
@@ -17,12 +17,15 @@ WORKDIR /ui
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
# Prod UI build vars (Vite reads VITE_-prefixed env at build; baked into the bundle).
+# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK=
ARG VITE_GATEWAY_URL=
+ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
- VITE_GATEWAY_URL=$VITE_GATEWAY_URL
+ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
+ VITE_APP_VERSION=$VITE_APP_VERSION
# Install with the lockfile first (the workspace file carries pnpm's build-script
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
diff --git a/ui/src/lib/aboutContent.ts b/ui/src/lib/aboutContent.ts
new file mode 100644
index 0000000..dd43297
--- /dev/null
+++ b/ui/src/lib/aboutContent.ts
@@ -0,0 +1,62 @@
+// Localised "About" / landing copy, shared by the About screen and the public landing
+// page (Stage 17). Kept out of the flat i18n catalog because it is structured (a heading,
+// a rules link, two bulleted sections) and only used in these two long-form places.
+
+import type { Locale } from './i18n/index.svelte';
+
+export interface AboutContent {
+ /** Prominent heading: "Scrabble" / "Эрудит (Скрэббл)". */
+ title: string;
+ rulesUrl: string;
+ /** Text before the rules link. */
+ rulesPrefix: string;
+ /** The rules link label. */
+ rulesLink: string;
+ randomTitle: string;
+ /** The "respect the opponent's time" note (rendered with a ❗️ prefix). */
+ randomRespect: string;
+ random: string[];
+ friendsTitle: string;
+ friends: string[];
+}
+
+/**
+ * aboutContent returns the localised About/landing copy. hours is the auto-match move clock
+ * (backend game.DefaultTurnTimeout), inlined into the random-game time-limit bullet.
+ */
+export function aboutContent(locale: Locale, hours: number): AboutContent {
+ if (locale === 'ru') {
+ return {
+ title: 'Эрудит (Скрэббл)',
+ rulesUrl: 'https://ru.wikipedia.org/wiki/Скрэббл',
+ rulesPrefix: 'Основные ',
+ rulesLink: 'правила игры',
+ randomTitle: 'Случайная игра',
+ randomRespect: 'Уважайте личное время соперника, будьте терпеливы.',
+ random: [
+ 'В игре двое соперников.',
+ 'Каждому доступна 1 подсказка в новой партии.',
+ `Лимит времени на ход: ${hours} ч. 00 минут.`,
+ 'Время отсутствия задаётся в профиле и продлевает лимит.',
+ ],
+ friendsTitle: 'Игра с друзьями',
+ friends: ['До 4-х участников.', 'Количество подсказок регулируется.', 'Произвольный лимит времени.'],
+ };
+ }
+ return {
+ title: 'Scrabble',
+ rulesUrl: 'https://en.wikipedia.org/wiki/Scrabble',
+ rulesPrefix: 'Basic ',
+ rulesLink: 'game rules',
+ randomTitle: 'Random game',
+ randomRespect: "Respect your opponent's time, be patient.",
+ random: [
+ 'Two opponents per game.',
+ 'Each player gets 1 hint per new game.',
+ `Move time limit: ${hours} h 00 min.`,
+ 'An away window set in your profile extends the limit.',
+ ],
+ friendsTitle: 'Game with friends',
+ friends: ['Up to 4 players.', 'The number of hints is configurable.', 'A custom time limit.'],
+ };
+}
diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte
index 874f0da..786fb91 100644
--- a/ui/src/screens/About.svelte
+++ b/ui/src/screens/About.svelte
@@ -1,14 +1,37 @@
@@ -16,8 +39,41 @@
diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts
index 78ed9ca..02e18e8 100644
--- a/ui/src/vite-env.d.ts
+++ b/ui/src/vite-env.d.ts
@@ -9,3 +9,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+
+/** App version string, injected by Vite's define from `git describe` at build time. */
+declare const __APP_VERSION__: string;
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 58e463f..b15496d 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -12,6 +12,12 @@ export default defineConfig(({ mode }) => ({
// Relative asset base so the one build serves under any path — the gateway maps the
// Telegram Mini App to /telegram/ (the hash router is path-agnostic).
base: './',
+ define: {
+ // App version shown on the About screen, injected at build time from `git describe`
+ // via a Docker build-arg (Stage 17). Falls back to "dev" for a plain local/mock build,
+ // so a missing build-arg never breaks the build.
+ __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
+ },
plugins: [svelte()],
server: {
port: 5173,
--
2.52.0
From d3657fdf5c402456365570a0ae6886d8a667a84a Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 11:48:19 +0200
Subject: [PATCH 030/223] Stage 17 round 6 (#11/#12): quick-game variant
plaques with rules, flag, and move-limit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Each auto-match variant is now a lobby-style plaque: the display name with a flag on the
right (🇺🇸 / 🇷🇺; Erudit uses a bundled minimalist USSR flag SVG) and a one-line rules
summary below — bag size, the ё rule, and bonus differences, sourced from the engine
rulesets (Scrabble 100 · Скрэббл 104, ё a letter · Эрудит 131, ё=е, no centre ×2, +15).
The move-time limit (24h auto-match clock) is shown under the buttons. e2e locks it.
(Multiple-words-per-move is the same for every variant, so it is described in About/landing
rather than repeated on each button.)
---
ui/e2e/game.spec.ts | 8 +++++
ui/public/flag-ussr.svg | 11 +++++++
ui/src/lib/i18n/en.ts | 4 +++
ui/src/lib/i18n/ru.ts | 4 +++
ui/src/lib/variants.ts | 16 ++++++++++
ui/src/screens/NewGame.svelte | 55 ++++++++++++++++++++++++++++++++---
6 files changed, 94 insertions(+), 4 deletions(-)
create mode 100644 ui/public/flag-ussr.svg
diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts
index 0c96883..f6e77e4 100644
--- a/ui/e2e/game.spec.ts
+++ b/ui/e2e/game.spec.ts
@@ -29,6 +29,14 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page })
await expect(page.locator('.make')).toBeHidden();
});
+test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: /guest/i }).click();
+ await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
+ await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary
+ await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
+});
+
test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg
new file mode 100644
index 0000000..90b07b7
--- /dev/null
+++ b/ui/public/flag-ussr.svg
@@ -0,0 +1,11 @@
+
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 03189ce..3a17859 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -45,6 +45,10 @@ export const en = {
'new.erudit': 'Erudite',
'new.find': 'Find a game',
'new.searching': 'Looking for an opponent…',
+ 'new.rulesEnglish': '100 tiles · bingo +50',
+ 'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
+ 'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
+ 'new.moveLimit': 'Move time: {n} h 00 min',
'game.bag': '{n} in the bag',
'game.bagEmpty': 'Bag is empty',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index c7cec61..b8a2614 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -46,6 +46,10 @@ export const ru: Record = {
'new.erudit': 'Эрудит',
'new.find': 'Найти игру',
'new.searching': 'Ищем соперника…',
+ 'new.rulesEnglish': '100 фишек · бинго +50',
+ 'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
+ 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
+ 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts
index a622c2b..62e3144 100644
--- a/ui/src/lib/variants.ts
+++ b/ui/src/lib/variants.ts
@@ -28,6 +28,22 @@ export function variantNameKey(v: Variant): MessageKey {
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
}
+// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
+// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
+export const VARIANT_RULES: Record = {
+ english: 'new.rulesEnglish',
+ russian_scrabble: 'new.rulesRussian',
+ erudit: 'new.rulesErudit',
+};
+
+// VARIANT_FLAG is the flag shown on a variant button: an emoji for the Scrabble variants;
+// Erudit uses the bundled USSR flag SVG (public/flag-ussr.svg), so its entry is empty.
+export const VARIANT_FLAG: Record = {
+ english: '🇺🇸',
+ russian_scrabble: '🇷🇺',
+ erudit: '',
+};
+
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte
index 0b64511..caa27ee 100644
--- a/ui/src/screens/NewGame.svelte
+++ b/ui/src/screens/NewGame.svelte
@@ -6,7 +6,10 @@
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { AccountRef, Variant } from '../lib/model';
- import { availableVariants } from '../lib/variants';
+ import { availableVariants, VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
+
+ // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
+ const AUTO_MATCH_HOURS = 24;
// The offered variants are gated by the languages the sign-in service supports
// (Stage 15); the auto-match list and the friend-invite picker both use this.
@@ -141,9 +144,20 @@
{t('new.subtitle')}
{#each variants as v (v.id)}
-
+
{/each}
+
{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}
{:else if friends.length === 0}
{t('new.noFriends')}
{:else}
@@ -207,15 +221,48 @@
flex-direction: column;
gap: 10px;
}
+ /* A plaque per variant (like the lobby game cards): the name with its flag on the right,
+ and a one-line rules summary below. */
.variant {
- padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 12px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
+ text-align: left;
+ user-select: none;
+ }
+ .vmain {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+ .vname {
font-size: 1.05rem;
font-weight: 600;
- user-select: none;
+ }
+ .vflag {
+ font-size: 1.3rem;
+ line-height: 1;
+ }
+ .vflag-img {
+ width: 1.6rem;
+ height: auto;
+ border-radius: 2px;
+ }
+ .vrules {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ }
+ .movelimit {
+ margin: 0;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.85rem;
}
.seg {
display: flex;
--
2.52.0
From 35666e17052fb5dc763329f9081bd6d6578058d8 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 12:10:52 +0200
Subject: [PATCH 031/223] Stage 17 round 6 fixes: pin the nudge button right;
schematic USSR flag emblem
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Chat: always render the (possibly empty) flex:1 caption before the nudge button, so the
nudge stays pinned right whether or not the cooldown text shows (it drifted left when
available).
- USSR flag: redraw the hammer & sickle as a thin schematic sketch — an elongated
semicircle sickle with a handle, crossed by a T-shaped hammer (per the original's
structure), instead of the bold over-filled emblem; the star is a touch smaller.
---
ui/public/flag-ussr.svg | 17 ++++++++++-------
ui/src/game/Chat.svelte | 3 ++-
2 files changed, 12 insertions(+), 8 deletions(-)
diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg
index 90b07b7..0bdc2ea 100644
--- a/ui/public/flag-ussr.svg
+++ b/ui/public/flag-ussr.svg
@@ -1,11 +1,14 @@
diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte
index fb1ddb9..039d22d 100644
--- a/ui/src/game/Chat.svelte
+++ b/ui/src/game/Chat.svelte
@@ -57,7 +57,8 @@
/>
{:else}
- {#if nudgeOnCooldown}{t('chat.awaitingReply')}{/if}
+
+ {nudgeOnCooldown ? t('chat.awaitingReply') : ''}
{/if}
--
2.52.0
From 2b0b1c0035335100501ab7d237fbc7b29b2815f0 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Sun, 7 Jun 2026 12:21:09 +0200
Subject: [PATCH 032/223] Stage 17 round 6 (#3): drag-reorder rack tiles with a
visual gap
Dragging a rack tile and dropping it back on the rack reorders it: the dragged tile is
lifted out (the drag ghost stands in) and the tiles at/after the pointer's drop slot slide
right to open a gap there, so the drop position is visible. On drop the rack and its stable
ids are permuted (reorderIndices, unit-tested). Reorder applies only with no pending tiles,
so it stays a clean permutation; dropping on a board cell still places as before. Server
persistence of the order follows (#4).
---
ui/src/game/Game.svelte | 67 +++++++++++++++++++++++++++++++++---
ui/src/game/Rack.svelte | 23 +++++++++++--
ui/src/lib/placement.test.ts | 10 ++++++
ui/src/lib/placement.ts | 13 +++++++
4 files changed, 106 insertions(+), 7 deletions(-)
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index beafaac..36db4ff 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -28,6 +28,7 @@
placementFromHint,
rackView,
recallAt,
+ reorderIndices,
reset,
toSubmit,
type Placement,
@@ -193,6 +194,11 @@
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
let dropTarget = $state<{ row: number; col: number } | null>(null);
+ // Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id
+ // (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
+ // (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
+ let reorderDragId = $state(null);
+ let reorderTo = $state(null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -214,6 +220,7 @@
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
+ clearReorder();
downInfo = null;
dragMoved = false;
drag = null;
@@ -241,6 +248,33 @@
hoverKey = '';
dropTarget = null;
}
+ function clearReorder() {
+ reorderDragId = null;
+ reorderTo = null;
+ }
+ // overRack reports whether y is within the rack's row (a small margin makes the target
+ // forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
+ function overRack(y: number): boolean {
+ const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect();
+ return !!r && y >= r.top - 24 && y <= r.bottom + 24;
+ }
+ function dropSlotAt(x: number): number {
+ const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[];
+ for (let i = 0; i < tiles.length; i++) {
+ const r = tiles[i].getBoundingClientRect();
+ if (x < r.left + r.width / 2) return i;
+ }
+ return tiles.length;
+ }
+ // reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and
+ // its stable ids. Only valid with no pending tiles (the rack is then a clean permutation).
+ function reorderRack(fromIndex: number, toSlot: number) {
+ if (placement.pending.length > 0) return;
+ const order = reorderIndices(placement.rack.length, fromIndex, toSlot);
+ rackIds = order.map((i) => rackIds[i] ?? i);
+ placement = newPlacement(order.map((i) => placement.rack[i]));
+ selected = null;
+ }
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
@@ -249,15 +283,26 @@
const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
+ // A rack tile is lifted out of the rack while dragged (the ghost stands in for it).
+ reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
// No zoom on drag start: the player may still change their mind. Holding the tile
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
}
if (!drag) return;
drag = { ...drag, x: e.clientX, y: e.clientY };
const c = cellUnder(e.clientX, e.clientY);
- // Highlight the aimed-at cell as a drop target, but only when it is free (no committed
- // or pending tile there).
- dropTarget = c && !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
+ // Preview where the drop lands: a drop-target ring on a free board cell, or — for a
+ // rack-source drag over the rack with no pending tiles — a reorder gap at that slot.
+ if (c) {
+ dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
+ reorderTo = null;
+ } else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) {
+ reorderTo = dropSlotAt(e.clientX);
+ dropTarget = null;
+ } else {
+ dropTarget = null;
+ reorderTo = null;
+ }
const ck = c ? `${c.row},${c.col}` : '';
if (ck !== hoverKey) {
hoverKey = ck;
@@ -287,8 +332,12 @@
drag = null;
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
const cell = cellUnder(e.clientX, e.clientY);
+ const to = reorderTo;
if (di.src.from === 'rack' && cell) {
attemptPlace(di.src.index, cell.row, cell.col);
+ } else if (di.src.from === 'rack' && onRack && to != null) {
+ // Dropped a rack tile back onto the rack → reorder it to the drop slot.
+ reorderRack(di.src.index, to);
} else if (di.src.from === 'board' && onRack) {
// Dropped a pending tile back onto the rack → recall it to its original slot.
placement = recallAt(placement, di.src.row, di.src.col);
@@ -302,12 +351,14 @@
} else {
drag = null;
}
+ clearReorder();
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
+ clearReorder();
telegramClosingConfirmation(false);
});
@@ -667,7 +718,15 @@
a finished game shows the final rack greyed out and the controls disabled. -->
-
+
{#if !gameOver && placement.pending.length > 0}
diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte
index 0a50e1b..6a11f3b 100644
--- a/ui/src/game/Rack.svelte
+++ b/ui/src/game/Rack.svelte
@@ -9,6 +9,8 @@
variant,
selected,
shuffling = false,
+ draggingId = null,
+ dropIndex = null,
ondown,
}: {
// Each slot carries a stable id that travels with its tile through a shuffle, so the
@@ -17,12 +19,18 @@
variant: Variant;
selected: number | null;
shuffling?: boolean;
+ // While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
+ // the drag ghost stands in) and dropIndex is the slot where a gap opens (Stage 17).
+ draggingId?: number | null;
+ dropIndex?: number | null;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
// Used slots are hidden (the rack shifts left, freeing room on the right for the
- // MakeMove control); the slot still exists in the model for per-tile recall.
+ // MakeMove control); the slot still exists in the model for per-tile recall. While
+ // reordering, the dragged tile is lifted out (the ghost shows it).
const visible = $derived(slots.filter((s) => !s.used));
+ const shown = $derived(draggingId == null ? visible : visible.filter((s) => s.id !== draggingId));
// hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile
// height). The duration scales with the horizontal distance — i.e. the arc length — so
@@ -44,11 +52,12 @@
}
-
{:else}
diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts
index b7cba9a..7c3f820 100644
--- a/ui/src/gen/fbs/scrabblefb.ts
+++ b/ui/src/gen/fbs/scrabblefb.ts
@@ -10,6 +10,8 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
+export { DraftRequest } from './scrabblefb/draft-request.js';
+export { DraftView } from './scrabblefb/draft-view.js';
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
diff --git a/ui/src/gen/fbs/scrabblefb/draft-request.ts b/ui/src/gen/fbs/scrabblefb/draft-request.ts
new file mode 100644
index 0000000..c9f2069
--- /dev/null
+++ b/ui/src/gen/fbs/scrabblefb/draft-request.ts
@@ -0,0 +1,60 @@
+// automatically generated by the FlatBuffers compiler, do not modify
+
+import * as flatbuffers from 'flatbuffers';
+
+export class DraftRequest {
+ bb: flatbuffers.ByteBuffer|null = null;
+ bb_pos = 0;
+ __init(i:number, bb:flatbuffers.ByteBuffer):DraftRequest {
+ this.bb_pos = i;
+ this.bb = bb;
+ return this;
+}
+
+static getRootAsDraftRequest(bb:flatbuffers.ByteBuffer, obj?:DraftRequest):DraftRequest {
+ return (obj || new DraftRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+static getSizePrefixedRootAsDraftRequest(bb:flatbuffers.ByteBuffer, obj?:DraftRequest):DraftRequest {
+ bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
+ return (obj || new DraftRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+gameId():string|null
+gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+gameId(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 4);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
+json():string|null
+json(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+json(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 6);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
+static startDraftRequest(builder:flatbuffers.Builder) {
+ builder.startObject(2);
+}
+
+static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(0, gameIdOffset, 0);
+}
+
+static addJson(builder:flatbuffers.Builder, jsonOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(1, jsonOffset, 0);
+}
+
+static endDraftRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
+ const offset = builder.endObject();
+ return offset;
+}
+
+static createDraftRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, jsonOffset:flatbuffers.Offset):flatbuffers.Offset {
+ DraftRequest.startDraftRequest(builder);
+ DraftRequest.addGameId(builder, gameIdOffset);
+ DraftRequest.addJson(builder, jsonOffset);
+ return DraftRequest.endDraftRequest(builder);
+}
+}
diff --git a/ui/src/gen/fbs/scrabblefb/draft-view.ts b/ui/src/gen/fbs/scrabblefb/draft-view.ts
new file mode 100644
index 0000000..ca9f039
--- /dev/null
+++ b/ui/src/gen/fbs/scrabblefb/draft-view.ts
@@ -0,0 +1,48 @@
+// automatically generated by the FlatBuffers compiler, do not modify
+
+import * as flatbuffers from 'flatbuffers';
+
+export class DraftView {
+ bb: flatbuffers.ByteBuffer|null = null;
+ bb_pos = 0;
+ __init(i:number, bb:flatbuffers.ByteBuffer):DraftView {
+ this.bb_pos = i;
+ this.bb = bb;
+ return this;
+}
+
+static getRootAsDraftView(bb:flatbuffers.ByteBuffer, obj?:DraftView):DraftView {
+ return (obj || new DraftView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+static getSizePrefixedRootAsDraftView(bb:flatbuffers.ByteBuffer, obj?:DraftView):DraftView {
+ bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
+ return (obj || new DraftView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+json():string|null
+json(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
+json(optionalEncoding?:any):string|Uint8Array|null {
+ const offset = this.bb!.__offset(this.bb_pos, 4);
+ return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
+}
+
+static startDraftView(builder:flatbuffers.Builder) {
+ builder.startObject(1);
+}
+
+static addJson(builder:flatbuffers.Builder, jsonOffset:flatbuffers.Offset) {
+ builder.addFieldOffset(0, jsonOffset, 0);
+}
+
+static endDraftView(builder:flatbuffers.Builder):flatbuffers.Offset {
+ const offset = builder.endObject();
+ return offset;
+}
+
+static createDraftView(builder:flatbuffers.Builder, jsonOffset:flatbuffers.Offset):flatbuffers.Offset {
+ DraftView.startDraftView(builder);
+ DraftView.addJson(builder, jsonOffset);
+ return DraftView.endDraftView(builder);
+}
+}
diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts
index 80f3543..cb01b0e 100644
--- a/ui/src/lib/client.ts
+++ b/ui/src/lib/client.ts
@@ -83,6 +83,13 @@ export interface GatewayClient {
checkWord(gameId: string, word: string, variant: Variant): Promise;
complaint(gameId: string, word: string, note: string): Promise;
+ // --- draft (Stage 17) ---
+ /** The player's server-persisted client-side composition (rack order + board tiles), so a
+ * reload or a second device resumes the same arrangement. The JSON is opaque to the
+ * gateway; the client owns the {rack_order, board_tiles} shape. */
+ draftGet(gameId: string): Promise;
+ draftSave(gameId: string, json: string): Promise;
+
// --- chat ---
chatPost(gameId: string, body: string): Promise;
chatList(gameId: string): Promise;
diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts
index 788d04d..437120f 100644
--- a/ui/src/lib/codec.test.ts
+++ b/ui/src/lib/codec.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import * as fb from '../gen/fbs/scrabblefb';
import { BLANK_INDEX, setAlphabet } from './alphabet';
import {
+ decodeDraftView,
decodeFriendList,
decodeGameList,
decodeInvitation,
@@ -11,6 +12,7 @@ import {
decodeStateView,
decodeStats,
encodeCheckWord,
+ encodeDraftSave,
encodeExchange,
encodeStateRequest,
encodeSubmitPlay,
@@ -18,6 +20,20 @@ import {
} from './codec';
describe('codec', () => {
+ it('round-trips a draft save request and view (Stage 17)', () => {
+ const json = '{"rack_order":"1,0","board_tiles":[]}';
+ const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json)));
+ expect(req.gameId()).toBe('g1');
+ expect(req.json()).toBe(json);
+
+ const b = new Builder(64);
+ const j = b.createString('{"x":1}');
+ fb.DraftView.startDraftView(b);
+ fb.DraftView.addJson(b, j);
+ b.finish(fb.DraftView.endDraftView(b));
+ expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
+ });
+
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
setAlphabet('english', [
{ index: 0, letter: 'a', value: 1 },
diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts
index cff9d81..ea1c51f 100644
--- a/ui/src/lib/codec.ts
+++ b/ui/src/lib/codec.ts
@@ -73,6 +73,18 @@ export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Ui
return finish(b, fb.StateRequest.endStateRequest(b));
}
+// encodeDraftSave wraps the player's composition JSON (Stage 17). The string is opaque on the
+// wire — the gateway forwards it verbatim and only the client reads {rack_order, board_tiles}.
+export function encodeDraftSave(gameId: string, json: string): Uint8Array {
+ const b = new Builder(256);
+ const gid = b.createString(gameId);
+ const j = b.createString(json);
+ fb.DraftRequest.startDraftRequest(b);
+ fb.DraftRequest.addGameId(b, gid);
+ fb.DraftRequest.addJson(b, j);
+ return finish(b, fb.DraftRequest.endDraftRequest(b));
+}
+
export function encodeSubmitPlay(
gameId: string,
dir: 'H' | 'V',
@@ -359,6 +371,13 @@ export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
return { word: s(r.word()), legal: r.legal() };
}
+// decodeDraftView returns the player's stored composition JSON (empty when none is stored or
+// for the save acknowledgement); the caller parses {rack_order, board_tiles}.
+export function decodeDraftView(buf: Uint8Array): string {
+ const v = fb.DraftView.getRootAsDraftView(new ByteBuffer(buf));
+ return v.json() ?? '';
+}
+
export function decodeHistory(buf: Uint8Array): History {
const h = fb.History.getRootAsHistory(new ByteBuffer(buf));
const moves: MoveRecord[] = [];
diff --git a/ui/src/lib/draft.test.ts b/ui/src/lib/draft.test.ts
new file mode 100644
index 0000000..aab8f16
--- /dev/null
+++ b/ui/src/lib/draft.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from './draft';
+import type { PendingTile } from './placement';
+
+describe('draft', () => {
+ it('round-trips the rack order and board tiles', () => {
+ const pending: PendingTile[] = [
+ { rackIndex: 2, row: 7, col: 7, letter: 'Q', blank: false },
+ { rackIndex: 0, row: 7, col: 8, letter: 'I', blank: true },
+ ];
+ const parsed = parseDraft(serializeDraft([3, 0, 1, 2], pending));
+ expect(parsed).not.toBeNull();
+ expect(parsed!.rackOrder).toEqual([3, 0, 1, 2]);
+ expect(parsed!.tiles).toEqual([
+ { row: 7, col: 7, letter: 'Q', blank: false },
+ { row: 7, col: 8, letter: 'I', blank: true },
+ ]);
+ });
+
+ it('parses an empty or malformed draft as null', () => {
+ expect(parseDraft('')).toBeNull();
+ expect(parseDraft('not json')).toBeNull();
+ });
+
+ it('parses a draft with no rack order or tiles', () => {
+ expect(parseDraft(JSON.stringify({ rack_order: '', board_tiles: [] }))).toEqual({ rackOrder: [], tiles: [] });
+ });
+
+ it('accepts a valid rack permutation and rejects a stale one', () => {
+ expect(validRackOrder([2, 0, 1], 3)).toEqual([2, 0, 1]);
+ expect(validRackOrder([0, 1], 3)).toBeNull(); // wrong length (the rack changed)
+ expect(validRackOrder([0, 0, 1], 3)).toBeNull(); // a duplicate, not a permutation
+ expect(validRackOrder([0, 1, 3], 3)).toBeNull(); // an index out of range
+ });
+
+ it('drops draft tiles whose cell is now occupied', () => {
+ const tiles = [
+ { row: 7, col: 7, letter: 'A', blank: false },
+ { row: 7, col: 8, letter: 'B', blank: false },
+ ];
+ expect(liveDraftTiles(tiles, (r, c) => r === 7 && c === 7)).toEqual([
+ { row: 7, col: 8, letter: 'B', blank: false },
+ ]);
+ });
+});
diff --git a/ui/src/lib/draft.ts b/ui/src/lib/draft.ts
new file mode 100644
index 0000000..b2063ad
--- /dev/null
+++ b/ui/src/lib/draft.ts
@@ -0,0 +1,59 @@
+// Draft (client-side composition) serialization, kept pure for unit tests. The server stores
+// the JSON opaquely (Stage 17); only the client interprets {rack_order, board_tiles}. The rack
+// order is a comma-joined permutation of the server rack's indices, in the player's visual
+// order; the board tiles are the tiles laid but not yet submitted.
+
+import type { Tile } from './model';
+import type { PendingTile } from './placement';
+
+interface DraftData {
+ rack_order: string;
+ board_tiles: Tile[];
+}
+
+/** serializeDraft builds the JSON to persist: the rack order and the pending board tiles. */
+export function serializeDraft(rackOrder: number[], pending: PendingTile[]): string {
+ const data: DraftData = {
+ rack_order: rackOrder.join(','),
+ board_tiles: pending.map((p) => ({ row: p.row, col: p.col, letter: p.letter, blank: p.blank })),
+ };
+ return JSON.stringify(data);
+}
+
+/** parseDraft decodes a stored draft, or null when empty or malformed. */
+export function parseDraft(json: string): { rackOrder: number[]; tiles: Tile[] } | null {
+ if (!json) return null;
+ try {
+ const d = JSON.parse(json) as Partial;
+ const rackOrder = String(d.rack_order ?? '')
+ .split(',')
+ .filter((s) => s !== '')
+ .map(Number);
+ const tiles = Array.isArray(d.board_tiles) ? d.board_tiles : [];
+ return { rackOrder, tiles };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * validRackOrder returns order when it is a permutation of [0, len), else null — so a stale
+ * order (the rack changed since the draft was saved) is ignored and the server order is kept.
+ */
+export function validRackOrder(order: number[], len: number): number[] | null {
+ if (order.length !== len) return null;
+ const seen = new Set();
+ for (const i of order) {
+ if (!Number.isInteger(i) || i < 0 || i >= len || seen.has(i)) return null;
+ seen.add(i);
+ }
+ return order;
+}
+
+/**
+ * liveDraftTiles drops saved tiles whose cell is now occupied on the committed board (the
+ * opponent has since played there) — the position-only reconcile after a refresh.
+ */
+export function liveDraftTiles(tiles: Tile[], occupied: (row: number, col: number) => boolean): Tile[] {
+ return tiles.filter((t) => !occupied(t.row, t.col));
+}
diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts
index 9de0d4d..80c8187 100644
--- a/ui/src/lib/mock/client.ts
+++ b/ui/src/lib/mock/client.ts
@@ -93,6 +93,7 @@ export class MockGateway implements GatewayClient {
private blocks: AccountRef[] = [];
private invitations: Invitation[] = mockInvitations();
private readonly stats: Stats = { ...MOCK_STATS };
+ private readonly drafts = new Map();
constructor() {
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
@@ -230,6 +231,7 @@ export class MockGateway implements GatewayClient {
g.rack.push(...draw(variant, drawn));
g.bagLen -= drawn;
g.view.toMove = (seat + 1) % g.view.players;
+ this.drafts.delete(gameId);
this.scheduleOpponentReply(gameId);
return { move: structuredClone(move), game: structuredClone(g.view) };
}
@@ -263,6 +265,7 @@ export class MockGateway implements GatewayClient {
};
g.moves.push(move);
g.view.moveCount += 1;
+ this.drafts.delete(gameId);
if (action === 'resign') {
g.view.status = 'finished';
g.view.endReason = 'resignation';
@@ -319,6 +322,15 @@ export class MockGateway implements GatewayClient {
}
async complaint(): Promise {}
+ // --- draft (Stage 17): an in-memory composition store, so the reload/off-turn flow is
+ // exercised without a backend. A committed move clears the actor's own draft, as on the server.
+ async draftGet(gameId: string): Promise {
+ return this.drafts.get(gameId) ?? '';
+ }
+ async draftSave(gameId: string, json: string): Promise {
+ this.drafts.set(gameId, json);
+ }
+
// --- chat ---
async chatPost(gameId: string, body: string): Promise {
const g = this.game(gameId);
diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts
index 23cec7a..607db57 100644
--- a/ui/src/lib/transport.ts
+++ b/ui/src/lib/transport.ts
@@ -114,6 +114,12 @@ export function createTransport(baseUrl: string): GatewayClient {
async complaint(id, word, note) {
await exec('game.complaint', codec.encodeComplaint(id, word, note));
},
+ async draftGet(id) {
+ return codec.decodeDraftView(await exec('draft.get', codec.encodeGameAction(id)));
+ },
+ async draftSave(id, json) {
+ await exec('draft.save', codec.encodeDraftSave(id, json));
+ },
async chatPost(id, body) {
return codec.decodeChatMessage(await exec('chat.post', codec.encodeChatPost(id, body)));
--
2.52.0
From e16076c89e52f5b8f5a8b8d275d64e7212c97685 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Mon, 8 Jun 2026 13:33:05 +0200
Subject: [PATCH 036/223] Stage 17 round 6 (#16-20): landing page, /app/ move,
cache + stream fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Close out Stage 17 round 6:
- Landing page at / — one Vite build with two entries (index.html = game
SPA, landing.html = a lightweight landing reusing the theme/i18n/
aboutContent leaf modules, not the app store).
- Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/
(gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/
+ /telegram/). Per-language "Play in Telegram" link via new
VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset).
- Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the
go:embed zero modtime emitted no validators, so the client re-downloaded
the whole bundle every launch).
- Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default
interval (the first tick at 15s raced the edge idle timeout -> reconnect
storm).
PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated;
round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e,
full e2e (60) green.
---
.gitea/workflows/ci.yaml | 2 +
PLAN.md | 46 +++--
deploy/.env.example | 2 +
deploy/README.md | 6 +-
deploy/docker-compose.yml | 2 +
docs/ARCHITECTURE.md | 11 +-
docs/FUNCTIONAL.md | 9 +-
docs/FUNCTIONAL_ru.md | 9 +-
gateway/Dockerfile | 4 +
gateway/README.md | 9 +-
gateway/internal/config/config.go | 2 +-
gateway/internal/connectsrv/server.go | 22 ++-
gateway/internal/webui/dist/index.html | 7 +-
gateway/internal/webui/dist/landing.html | 16 ++
gateway/internal/webui/webui.go | 49 +++--
gateway/internal/webui/webui_test.go | 59 +++---
ui/README.md | 6 +-
ui/e2e/landing.spec.ts | 14 ++
ui/landing.html | 13 ++
ui/public/telegram-logo.svg | 16 ++
ui/src/Landing.svelte | 236 +++++++++++++++++++++++
ui/src/landing.ts | 7 +
ui/src/lib/i18n/en.ts | 4 +
ui/src/lib/i18n/ru.ts | 4 +
ui/src/lib/landing.test.ts | 20 ++
ui/src/lib/landing.ts | 16 ++
ui/vite.config.ts | 10 +
27 files changed, 519 insertions(+), 82 deletions(-)
create mode 100644 gateway/internal/webui/dist/landing.html
create mode 100644 ui/e2e/landing.spec.ts
create mode 100644 ui/landing.html
create mode 100644 ui/public/telegram-logo.svg
create mode 100644 ui/src/Landing.svelte
create mode 100644 ui/src/landing.ts
create mode 100644 ui/src/lib/landing.test.ts
create mode 100644 ui/src/lib/landing.ts
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index c5e381d..940953a 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -267,6 +267,8 @@ jobs:
TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
+ VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
+ VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
# Unset vars render empty -> the compose ":-" defaults apply.
diff --git a/PLAN.md b/PLAN.md
index 4c0c937..4bfa5ef 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1319,23 +1319,35 @@ provided cert) at the contour caddy; prod VPN; rollback.
`game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every
committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
overlapped — 5 integration tests.
- - **Stage 17 round 6 — REMAINING (next pass), designs ready:**
- 1. **Persistence gateway slice + UI (#4/#5/#6).** *FB (lean):* `DraftRequest{game_id, json}` (save) +
- a game-id request (get) + `DraftView{json}` — one string field; the client serializes/deserializes
- `{rack_order, board_tiles}` itself (no FB tile array). Regen Go (`make -C pkg fbs`) + TS
- (`pnpm codegen`); flatc is pinned **23.5.26** (the local one matches). *Gateway* forwards the JSON as
- `json.RawMessage` (no double-encode). *REST:* `GET`/`PUT /games/:id/draft` (decodes `game.Draft`).
- *UI:* save the rack order (#4) and board draft (#6) on change (debounced) and restore on load
- (next to `gameState`); **#5** — allow placing tiles on the opponent's turn (relax the `isMyTurn`
- gate on placement only; the evaluate-preview and Make-move stay your-turn-only, so an off-turn draft
- is position-only — never scored/submitted).
- 2. **Landing + `/app/` move (#16–20).** An extra Svelte page at `/`, the game SPA under `/app/` (Vite
- `base` conditional: `/app/` for web, `./` for Capacitor); the gateway serves the landing at `/` and
- the SPA at `/app/*`; a bundled Telegram logo (from `.claude/telegram-logo.svg`, **copied into
- `ui/public/`, the reference itself not committed**) linking to the per-language t.me bot (ru
- `Erudit_Game` / en `Scrabble_Game`); theme + language switchers reusing the app stores; reuse the
- `aboutContent` copy. **Note:** moving the game to `/app/` means the Telegram Mini App URL must point
- to `/app/`.
+ - **Stage 17 round 6 — final pass (#4/#5/#6 + #16–20), shipped:**
+ 1. **Draft persistence — gateway slice + UI (#4/#5/#6, PR #20).** FB `DraftRequest{game_id, json}`
+ (save) + `DraftView{json}` (get reuses `GameActionRequest`); the client serializes
+ `{rack_order, board_tiles}` itself (no FB tile array), the gateway forwards it as `json.RawMessage`
+ both ways (no double-encode), and `GET`/`PUT /games/:id/draft` (a server `draftDTO` ↔ `game.Draft`)
+ is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6)
+ and restore on load (`lib/draft.ts`, reconciling against the committed board); **#5** — tiles may be
+ arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only,
+ so an off-turn draft is position-only). Off-turn tiles keep the **existing pending highlight** — no
+ caption, no new style (owner's call). The backend draft endpoint is sub-ms.
+ 2. **Landing + `/app/` move (#16–20, this PR).** One Vite build with **two HTML entries** — the game
+ SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.svelte`, reusing the
+ theme/i18n/`aboutContent` leaf modules, not the app store, so it stays small). The gateway serves the
+ **landing at `/`** and the **game SPA at `/app/` and `/telegram/`** (`webui.Handler(stripPrefix,
+ indexName)`); relative base keeps one build serving every mount with a shared `dist/assets/` (the
+ planned per-target `base` conditional proved unnecessary). **Correction to the original note:** the
+ Telegram **Mini App stays at `/telegram/`** — only the plain web app moved off `/` to `/app/`, so
+ BotFather is untouched. The landing's "Play in Telegram" link is **per-language** via two new build
+ vars `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` (test/prod bots differ → no hardcoding; the
+ button hides when unset). Logo copied `.claude/telegram-logo.svg` → `ui/public/` (source stays
+ untracked).
+ - **Edge robustness (folded into the landing PR).** (a) **Static cache headers** — the embedded
+ `http.FileServer` over `go:embed` has a zero modtime, so it emitted no validators → the client
+ re-downloaded the whole bundle every launch; now hash-named `/assets/*` are `immutable` (a relaunch
+ is a cache hit) and the HTML shells are `no-cache`. (b) **Live-stream 15 s abort** — the `Subscribe`
+ heartbeat only fired after the first 15 s tick, so the stream sat silent and raced a ~15 s edge idle
+ timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
+ default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
+ the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
## Deferred TODOs (cross-stage)
diff --git a/deploy/.env.example b/deploy/.env.example
index 7edeb9f..8b98c68 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -25,6 +25,8 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK=
+VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot
+VITE_TELEGRAM_LINK_RU= # landing "Play in Telegram" link, Russian bot
VITE_GATEWAY_URL=
# --- Gateway ----------------------------------------------------------------
diff --git a/deploy/README.md b/deploy/README.md
index b5778f4..e30f417 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -12,7 +12,7 @@ operational reference for **every environment variable**.
| Service | Image | Role |
| --- | --- | --- |
| `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; everything else → gateway. TLS per `CADDY_SITE_ADDRESS`. |
-| `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded SPA at `/` and `/telegram/`; Connect-RPC edge. |
+| `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded landing at `/` and the game SPA at `/app/` + `/telegram/`; Connect-RPC edge. |
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
| `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. |
@@ -84,9 +84,11 @@ connector **fails at boot** if both are empty.
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me//`). |
+| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
+| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
-The three `VITE_*` are **build-args** baked into the gateway image at build time, so
+The five `VITE_*` are **build-args** baked into the gateway image at build time, so
changing them requires a rebuild (`--build`), not just a restart.
## Fixed internal wiring (not operator-set)
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 7836d94..10585ce 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -78,6 +78,8 @@ services:
args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
+ VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
+ VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 46045bb..404d898 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -573,13 +573,16 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
## 13. Deployment (informational)
Single public origin, path-routed. The gateway **embeds** the static UI build
-(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves the one
-SPA at both `/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that
-path redirects to the root — the client-side guard). An in-compose **caddy** is the
+(`go:embed`, baked in by a node stage in `gateway/Dockerfile`). The Vite build has two
+entries: a lightweight **landing page** served at `/`, and the game **SPA** served at
+`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
+redirects to the root — the client-side guard). Hash-named `/assets/*` are served
+`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
+`no-cache` so a new deploy is picked up. An in-compose **caddy** is the
contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to
**Grafana** (anonymous-admin, so the one shared login gates it with no per-user
Grafana accounts) and the rest of `/_gm/*` to the backend-rendered **admin console**;
-everything else (`/`, `/telegram/`, the Connect edge) goes to the gateway. The
+everything else (`/`, `/app/`, `/telegram/`, the Connect edge) goes to the gateway. The
**Telegram connector** runs as a separate container with **no public ingress** — it
long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC.
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 771ae57..0320e7c 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -21,6 +21,9 @@ email, the statistics screen, and the in-game history viewer with GCG export.
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
+A public **landing page** at the site root introduces the game, switches language and
+theme, and links into the web app or the matching Telegram bot; the game itself runs at
+`/app/` (web) and `/telegram/` (the Telegram Mini App).
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
A player arrives from a platform (Telegram first), via email login, or as an
@@ -83,7 +86,11 @@ the other player and the leaver keeps their score. In a game with three or four
players the leaver's seat is dropped and the others play on, the game ending when a
single active player remains; the disposition of the leaver's tiles (returned to
the bag or removed from play) is chosen when the game is created, and the leaver's
-rack is never shown to the others.
+rack is never shown to the others. A player's **board composition is kept per game**:
+the rack arrangement and the tiles laid but not yet submitted are saved as they compose
+and restored on return (including on another device); a player may **arrange tiles during
+the opponent's turn**, but that draft is position-only — the score preview and submission
+stay available only on the player's own turn.
### Robot opponent *(Stage 5)*
When auto-match finds no human within ten seconds, a robot opponent takes the empty
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index cbbd2aa..7edfee6 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -22,6 +22,9 @@ top-1 подсказку, безлимитную проверку слова с
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
+Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
+тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по
+адресам `/app/` (веб) и `/telegram/` (Telegram Mini App).
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как
@@ -85,7 +88,11 @@ Mini App** авторизует по подписанным `initData` плат
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
убрать из игры) выбирается при создании партии, а его стойка никогда не
-показывается остальным.
+показывается остальным. **Композиция на доске сохраняется по партии**: расположение
+фишек на стойке и выложенные, но не отправленные фишки сохраняются по мере составления
+хода и восстанавливаются при возврате (в том числе на другом устройстве); игрок может
+**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
+предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник *(Stage 5)*
Если авто-подбор не находит человека за десять секунд, свободное место занимает
diff --git a/gateway/Dockerfile b/gateway/Dockerfile
index 4a22bad..ab6cca1 100644
--- a/gateway/Dockerfile
+++ b/gateway/Dockerfile
@@ -20,10 +20,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK=
+ARG VITE_TELEGRAM_LINK_EN=
+ARG VITE_TELEGRAM_LINK_RU=
ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
+ VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
+ VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION
diff --git a/gateway/README.md b/gateway/README.md
index 43d1334..af4201f 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -6,8 +6,9 @@ cleartext (`h2c`), authenticates the originating credential, mints/resolves a
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
-in by the gateway image's node stage) and serves the one SPA at `/` (web) and
-`/telegram/` (the Mini App) — the single-origin model. It can also serve the
+in by the gateway image's node stage) and serves a **landing page** at `/` and the game
+**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
+Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
in the deployed contour the front caddy owns `/_gm` (see
[`../deploy`](../deploy)). See
@@ -28,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
-internal/webui/ # embedded SPA build (go:embed dist) served at / and /telegram/
+internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
```
The FlatBuffers payloads and the backend push proto are the shared wire
@@ -77,7 +78,7 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it |
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
-| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
+| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go
index cd89309..e035687 100644
--- a/gateway/internal/config/config.go
+++ b/gateway/internal/config/config.go
@@ -73,7 +73,7 @@ const (
defaultBackendTimeout = 5 * time.Second
defaultSessionTTL = 10 * time.Minute
defaultSessionCacheMax = 50000
- defaultPushHeartbeatInterval = 15 * time.Second
+ defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17)
defaultServiceName = "scrabble-gateway"
)
diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go
index 57bf31a..455ad3d 100644
--- a/gateway/internal/connectsrv/server.go
+++ b/gateway/internal/connectsrv/server.go
@@ -99,12 +99,14 @@ func (s *Server) HTTPHandler() http.Handler {
// does not serve the app shell at the operator path.
mux.Handle("/_gm/", http.NotFoundHandler())
}
- // The embedded single-page UI is served at the site root and, for the Telegram
- // Mini App, under /telegram/ — the single-origin model (docs/ARCHITECTURE.md
- // §13). Both mounts sit below the h2c wrap so the Connect edge (a more specific
- // prefix) keeps priority; "/" is the catch-all SPA fallback for the hash router.
- mux.Handle("/telegram/", webui.Handler("/telegram/"))
- mux.Handle("/", webui.Handler(""))
+ // The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram Mini
+ // App), with a separate landing page at the catch-all "/" — the single-origin model
+ // (docs/ARCHITECTURE.md §13). All sit below the h2c wrap so the Connect edge (a more
+ // specific prefix) keeps priority. Each SPA mount falls back to the app shell
+ // (index.html) for the hash router; "/" falls back to the landing (landing.html).
+ mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
+ mux.Handle("/app/", webui.Handler("/app/", "index.html"))
+ mux.Handle("/", webui.Handler("", "landing.html"))
return h2c.NewHandler(mux, &http2.Server{})
}
@@ -184,6 +186,14 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
events, cancel := s.hub.Subscribe(uid)
defer cancel()
+ // Send an immediate heartbeat so the stream's first byte flushes through the proxy chain
+ // right away and resets edge/client idle timers, instead of the connection sitting silent
+ // until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect
+ // every interval (Stage 17).
+ if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
+ return err
+ }
+
ticker := time.NewTicker(s.heartbeat)
defer ticker.Stop()
diff --git a/gateway/internal/webui/dist/index.html b/gateway/internal/webui/dist/index.html
index b6598b4..6abfc51 100644
--- a/gateway/internal/webui/dist/index.html
+++ b/gateway/internal/webui/dist/index.html
@@ -6,10 +6,11 @@
Scrabble
+
- UI build placeholder. The production gateway image embeds the real Vite
- build (see gateway/Dockerfile); seeing this page means the binary was
- built without a UI build.
+ App shell build placeholder. The production gateway image embeds the real Vite
+ build (see gateway/Dockerfile); seeing this page means the binary was built
+ without a UI build.