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, ``, w, h) + fmt.Fprintf(&b, ``, padL, padT, padL, float64(h-padB)) + fmt.Fprintf(&b, ``, padL, float64(h-padB), w-padR, float64(h-padB)) + for _, frac := range []float64{0, 0.5, 1} { + v := maxY * frac + y := yOf(v) + fmt.Fprintf(&b, ``, padL, y, w-padR, y) + fmt.Fprintf(&b, `%s`, padL-5, y+3, FormatDuration(v)) + } + for _, ord := range xTicks(maxOrd) { + fmt.Fprintf(&b, `%d`, xOf(ord), h-padB+15, ord) + } + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Max })) + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Avg })) + fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Min })) + b.WriteString(``) + 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{""} { + if !strings.Contains(svg, want) { + t.Errorf("chart missing %q\n%s", want, svg) + } + } + if n := strings.Count(svg, "no statistics

{{end}} +{{if .MoveChart}} +

Move timing

+

Think time per move number across all games — min · mean · max.

+{{.MoveChart}} +
+{{end}}

Identities

diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index 4ed3b6d..bf2d777 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -2,7 +2,7 @@

Users

{{with .Data}}
KindExternal IDConfirmedCreated
- + {{range .Items}} @@ -11,9 +11,10 @@ +{{if .HasMoveStats}}{{else}}{{end}} {{else}} - + {{end}}
AccountDisplay nameKindLangCreated
AccountDisplay nameKindLangCreatedMove minavgmax
{{.Kind}} {{.Language}} {{.CreatedAt}}{{.MoveMin}}{{.MoveAvg}}{{.MoveMax}}
no users
no users
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)}
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
@@ -599,26 +624,39 @@ {#if panel === 'chat'} (panel = 'none')}> - + {/if} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index dd82a80..bc258cc 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -126,7 +126,11 @@ $effect(() => { const e = app.lastEvent; if (!e) return; - if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load(); + if (e.kind === 'opponent_moved' && e.gameId === id) { + // Skip the echo of my own move (the backend now notifies the actor too, for the + // player's other devices): this device already reloaded after the submit. + if (e.seat !== view?.seat) void load(); + } else if (e.kind === 'your_turn' && e.gameId === id) void load(); else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); }); @@ -522,13 +526,7 @@
{#if !gameOver && placement.pending.length > 0} - - {#snippet trigger()}🏁{/snippet} - {#snippet popover(close)} - - - {/snippet} - + {/if}
{: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 @@

Seats

- + {{range .Seats}} - + {{end}}
SeatPlayerScoreHintsWinner
SeatPlayerScoreHints usedWinner
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
diff --git a/ui/src/components/Toast.svelte b/ui/src/components/Toast.svelte index 72e8e32..12ee496 100644 --- a/ui/src/components/Toast.svelte +++ b/ui/src/components/Toast.svelte @@ -1,9 +1,20 @@ {#if app.toast} -
{app.toast.text}
+
+ {app.toast.text} +
{/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 !gameOver && placement.pending.length > 0} - + {/if}
{:else} @@ -883,18 +914,19 @@ flex: 1; min-width: 0; } + /* A borderless icon button (like the tab bar), not a filled accent button — and disabled + while the pending word is known to be illegal (Stage 17). */ .make { min-width: 56px; - background: var(--accent); - color: var(--accent-text); + background: none; + color: var(--text); border: none; - border-radius: var(--radius-sm); display: grid; place-items: center; - font-size: 1.6rem; + font-size: 1.8rem; } .make:disabled { - opacity: 0.55; + opacity: 0.4; } .pop { padding: 9px 14px; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 369e320..0a50e1b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -26,8 +26,9 @@ // hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile // height). The duration scales with the horizontal distance — i.e. the arc length — so - // the longest swap (slot 1 ↔ 7) takes ~0.5s and shorter swaps land sooner. It runs only - // while a shuffle is in progress; ordinary reflow (placing/recalling a tile) is instant. + // the longest swap (slot 1 ↔ 7) takes ~0.3s and shorter swaps land sooner. It runs only + // while a shuffle is in progress (and motion is not reduced); ordinary reflow + // (placing/recalling a tile) is instant. function hop(node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, active: boolean) { const dx = from.left - to.left; const dy = from.top - to.top; @@ -36,7 +37,7 @@ const span = node.parentElement?.getBoundingClientRect().width || dist; const lift = (to.height || from.height) * 0.5; return { - duration: Math.max(160, Math.min(500, (dist / span) * 560)), + duration: Math.max(120, Math.min(300, (dist / span) * 340)), css: (t: number, u: number) => `transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`, }; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 63e67c5..c3f0b55 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -40,9 +40,9 @@ export const en = { 'new.title': 'New game', 'new.subtitle': 'Auto-match with another player', - 'new.english': 'English', - 'new.russian': 'Russian', - 'new.erudit': 'Эрудит', + 'new.english': 'Scrabble', + 'new.russian': 'Scrabble', + 'new.erudit': 'Erudite', 'new.find': 'Find a game', 'new.searching': 'Looking for an opponent…', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 319c606..f7fdeac 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -41,8 +41,8 @@ export const ru: Record = { 'new.title': 'Новая игра', 'new.subtitle': 'Автоподбор соперника', - 'new.english': 'Английский', - 'new.russian': 'Русский', + 'new.english': 'Скрэббл', + 'new.russian': 'Скрэббл', 'new.erudit': 'Эрудит', 'new.find': 'Найти игру', 'new.searching': 'Ищем соперника…', diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index 31ad921..1f21dfd 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -11,13 +11,22 @@ export interface VariantOption { label: MessageKey; } -// ALL_VARIANTS lists every variant in display order. +// ALL_VARIANTS lists every variant in display order. The labels are display names, 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. export const ALL_VARIANTS: VariantOption[] = [ { id: 'english', label: 'new.english' }, { id: 'russian_scrabble', label: 'new.russian' }, { id: 'erudit', label: 'new.erudit' }, ]; +// variantNameKey returns the i18n key for a variant's display name (used by the in-game +// title and the lobby cards). +export function variantNameKey(v: Variant): MessageKey { + return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english'; +} + // VARIANT_LANGUAGE maps each variant to its game language. en -> English; // ru -> Russian + Эрудит. export const VARIANT_LANGUAGE: Record = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' }; -- 2.52.0 From f916d5e0ca744fdff9816af8b2429ab8c25f8662 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 7 Jun 2026 09:42:23 +0200 Subject: [PATCH 024/223] Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin game detail now shows, per robot seat, the game's deterministic play-to-win decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the ~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports, account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants (never early, never in the sleep window), PlayToWin export, and an admin render integration test asserting the intent + ETA + target appear. --- backend/internal/account/userlist.go | 13 ++++++ .../templates/pages/game_detail.gohtml | 5 ++- backend/internal/adminconsole/views.go | 11 ++++- backend/internal/game/service.go | 6 +++ backend/internal/game/store.go | 18 ++++++++ backend/internal/inttest/admin_test.go | 39 ++++++++++++++++ backend/internal/robot/strategy.go | 34 ++++++++++++++ backend/internal/robot/strategy_test.go | 31 +++++++++++++ .../internal/server/handlers_admin_console.go | 45 ++++++++++++++++++- 9 files changed, 198 insertions(+), 4 deletions(-) diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index 2f22777..e2c12fc 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -34,6 +34,19 @@ type UserFilter struct { // robotExists is the correlated subquery testing whether account a is a robot. const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')` +// IsRobot reports whether the account is a robot pool member (it carries a robot +// identity). The admin console uses it to label a game's robot seats. +func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`, + accountID).Scan(&ok) + if err != nil { + return false, fmt.Errorf("account: is-robot %s: %w", accountID, err) + } + return ok, nil +} + // userListWhere builds the shared WHERE clause and its positional args (from $1). func userListWhere(f UserFilter) (string, []any) { args := []any{f.Robots} diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 436fbb2..976d691 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -17,13 +17,14 @@

Seats

- + {{range .Seats}} - + {{end}}
SeatPlayerScoreHints usedWinner
SeatPlayerScoreHints usedWinnerRobot
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}
{{.Seat}}{{.DisplayName}}{{.Score}}{{.HintsUsed}}{{if .Winner}}winner{{end}}{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}
next move {{.NextMove}}{{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}
- e.key === 'Enter' && send()} - /> - - + {#if myTurn} + e.key === 'Enter' && send()} + /> + + {:else} + {#if nudgeOnCooldown}{t('chat.awaitingReply')}{/if} + + {/if}
@@ -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 @@
-

{t('app.title')}

-

{t('about.description')}

+

{c.title}

+

+ {c.rulesPrefix}{c.rulesLink}. +

+ +
+

{c.randomTitle}

+

❗️{c.randomRespect}

+
    + {#each c.random as item (item)}
  • {item}
  • {/each} +
+
+ +
+

{c.friendsTitle}

+
    + {#each c.friends as item (item)}
  • {item}
  • {/each} +
+
+

{t('about.version', { v: version })}

@@ -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 @@ } -
- {#each visible as slot (slot.id)} +
+ {#each shown as slot, i (slot.id)}
{#if !gameOver && placement.pending.length > 0} - + {/if}
{: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.

diff --git a/gateway/internal/webui/dist/landing.html b/gateway/internal/webui/dist/landing.html new file mode 100644 index 0000000..f06ae67 --- /dev/null +++ b/gateway/internal/webui/dist/landing.html @@ -0,0 +1,16 @@ + + + + + + Scrabble + + + +

+ Landing 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 index 8bccfc3..5bde63e 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -1,12 +1,16 @@ -// Package webui serves the embedded single-page UI build over the public edge. +// Package webui serves the embedded static 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 +// The committed dist/ holds only placeholder index.html / landing.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 +// 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: the game SPA is mounted at /app/ +// (web) and /telegram/ (the Telegram Mini App), with a separate landing page at / — the // single-origin model in docs/ARCHITECTURE.md §13. +// +// Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and +// cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry +// no-cache so a new deploy is picked up immediately. package webui import ( @@ -30,25 +34,30 @@ func distFS() fs.FS { 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 { +// Handler serves the embedded UI. An existing file is served directly (hash-named assets get +// an immutable cache); every other path falls back to indexName (the SPA shell or the landing +// page) so a client-side deep link still loads. 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. +// "/app/" or "/telegram/"). +func Handler(stripPrefix, indexName 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) + serveIndex(w, content, indexName) 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) + // Unknown path or a directory: serve the shell, never a listing. + serveIndex(w, content, indexName) return } + // Hash-named build assets are immutable — cache them for a year so reopening the + // app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download. + if strings.HasPrefix(name, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } files.ServeHTTP(w, r) }) if p := strings.TrimSuffix(stripPrefix, "/"); p != "" { @@ -57,14 +66,16 @@ func Handler(stripPrefix string) http.Handler { 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") +// serveIndex writes the named HTML shell with a 200 status, so a client-routed deep link +// still loads the app rather than a 404. The shell is marked no-cache so a new deploy's +// shell (and the asset URLs it references) is fetched fresh. +func serveIndex(w http.ResponseWriter, content fs.FS, indexName string) { + data, err := fs.ReadFile(content, indexName) if err != nil { http.Error(w, "ui not built", http.StatusInternalServerError) return } + w.Header().Set("Cache-Control", "no-cache") 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 index 4528507..c7a4d4a 100644 --- a/gateway/internal/webui/webui_test.go +++ b/gateway/internal/webui/webui_test.go @@ -16,37 +16,50 @@ func get(t *testing.T, h http.Handler, target string) *http.Response { return rec.Result() } -func TestHandlerServesIndexAndFallsBack(t *testing.T) { - h := Handler("") +func body(t *testing.T, resp *http.Response) string { + t.Helper() + b, _ := io.ReadAll(resp.Body) + return string(b) +} - // 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) - } +// TestLandingMountServesLandingAndFallsBack: "/" serves the landing shell (no-cache) and +// any unknown path falls back to it. +func TestLandingMountServesLandingAndFallsBack(t *testing.T) { + h := Handler("", "landing.html") - // 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) + resp := get(t, h, "/") + if resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-landing") { + t.Fatalf("GET / did not serve the landing shell (status %d)", 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) + if cc := get(t, h, "/").Header.Get("Cache-Control"); cc != "no-cache" { + t.Errorf("landing Cache-Control = %q, want no-cache", cc) } - body, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(body), " { + await page.goto('/landing.html'); + + // The primary call to action opens the web app mount. + await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/'); + + // The language switch flips the copy to Russian (reusing the app i18n). + await page.getByRole('button', { name: 'Русский' }).click(); + await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible(); +}); diff --git a/ui/landing.html b/ui/landing.html new file mode 100644 index 0000000..b92f035 --- /dev/null +++ b/ui/landing.html @@ -0,0 +1,13 @@ + + + + + + + Scrabble + + +
+ + + diff --git a/ui/public/telegram-logo.svg b/ui/public/telegram-logo.svg new file mode 100644 index 0000000..c67526f --- /dev/null +++ b/ui/public/telegram-logo.svg @@ -0,0 +1,16 @@ + + + + Artboard + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte new file mode 100644 index 0000000..930fd49 --- /dev/null +++ b/ui/src/Landing.svelte @@ -0,0 +1,236 @@ + + +
+
+
+ {#each locales as lc (lc)} + + {/each} +
+
+ {#each themes as th (th)} + + {/each} +
+
+ +
+

{about.title}

+

{t('landing.tagline')}

+ +
+ +
+

+ {about.rulesPrefix}{about.rulesLink} +

+
+
+

{about.randomTitle}

+

❗️ {about.randomRespect}

+
    + {#each about.random as r (r)}
  • {r}
  • {/each} +
+
+
+

{about.friendsTitle}

+
    + {#each about.friends as f (f)}
  • {f}
  • {/each} +
+
+
+
+ +
{t('about.version', { v: __APP_VERSION__ })}
+
+ + diff --git a/ui/src/landing.ts b/ui/src/landing.ts new file mode 100644 index 0000000..2b0c25e --- /dev/null +++ b/ui/src/landing.ts @@ -0,0 +1,7 @@ +import { mount } from 'svelte'; +import './app.css'; +import Landing from './Landing.svelte'; + +// Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at +// /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts. +export default mount(Landing, { target: document.getElementById('app')! }); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3a17859..362daca 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -152,6 +152,10 @@ export const en = { 'about.description': 'A multiplatform Scrabble game.', 'about.version': 'Version {v}', + 'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', + 'landing.playWeb': 'Play in browser', + 'landing.playTelegram': 'Play in Telegram', + 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b8a2614..592461e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -153,6 +153,10 @@ export const ru: Record = { 'about.description': 'Мультиплатформенная игра в скрабл.', 'about.version': 'Версия {v}', + 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', + 'landing.playWeb': 'Играть в браузере', + 'landing.playTelegram': 'Играть в Telegram', + 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/landing.test.ts b/ui/src/lib/landing.test.ts new file mode 100644 index 0000000..fb050d2 --- /dev/null +++ b/ui/src/lib/landing.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { telegramBotLink } from './landing'; + +describe('telegramBotLink', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('returns the per-language bot link when configured', () => { + vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game'); + vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game'); + expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game'); + expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game'); + }); + + it('returns null when the locale link is unset or blank', () => { + vi.stubEnv('VITE_TELEGRAM_LINK_EN', ''); + vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' '); + expect(telegramBotLink('en')).toBeNull(); + expect(telegramBotLink('ru')).toBeNull(); + }); +}); diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts new file mode 100644 index 0000000..d3915b8 --- /dev/null +++ b/ui/src/lib/landing.ts @@ -0,0 +1,16 @@ +// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so +// the per-language Telegram-bot link selection is unit-testable. + +import type { Locale } from './i18n/index.svelte'; + +/** + * telegramBotLink returns the t.me link for the locale's game bot, or null when it is not + * configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU) + * because the test and prod contours run different bots (different usernames), so the link + * cannot be hardcoded. + */ +export function telegramBotLink(locale: Locale): string | null { + const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN; + const link = (raw as string | undefined)?.trim(); + return link ? link : null; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index b15496d..0b927ed 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; @@ -34,5 +35,14 @@ export default defineConfig(({ mode }) => ({ build: { target: 'es2022', sourcemap: true, + // Two entries (Stage 17): the game SPA (index.html, served at /app/ + /telegram/) and the + // public landing page (landing.html, served at /). Assets are shared in dist/assets/, and + // the relative base lets one build serve under any path. + rollupOptions: { + input: { + main: resolve(import.meta.dirname, 'index.html'), + landing: resolve(import.meta.dirname, 'landing.html'), + }, + }, }, })); -- 2.52.0 From 3fd279cf8c38579ab456e717c4688e55017fb2d2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 16:40:07 +0200 Subject: [PATCH 037/223] Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA Owner review-pass rework of the landing page: - Rename the per-language Telegram link build var VITE_TELEGRAM_LINK_EN/_RU -> VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU (it carries a channel username; the landing builds https://t.me/ -- the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*). - Language switcher -> a globe icon dropdown (flags + names), saved + synced to the app prefs. - Theme switcher -> a sun/moon icon toggle, ephemeral (follows the system scheme, no auto, never persisted) -- galaxy-game style. - Drop the "Play in browser" CTA (no standalone-web onboarding yet). Docs: FUNCTIONAL(+ru), PLAN, deploy + ui READMEs. --- .gitea/workflows/ci.yaml | 4 +- PLAN.md | 6 ++ deploy/.env.example | 4 +- deploy/README.md | 4 +- deploy/docker-compose.yml | 4 +- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 5 +- gateway/Dockerfile | 8 +- ui/README.md | 2 +- ui/e2e/landing.spec.ts | 21 ++-- ui/src/Landing.svelte | 192 ++++++++++++++++++++----------------- ui/src/lib/i18n/en.ts | 1 - ui/src/lib/i18n/ru.ts | 1 - ui/src/lib/landing.test.ts | 24 ++--- ui/src/lib/landing.ts | 22 +++-- 15 files changed, 167 insertions(+), 136 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 940953a..235eb45 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -267,8 +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_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }} + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_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 4bfa5ef..83b0c5e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1348,6 +1348,12 @@ provided cert) at the contour caddy; prod VPN; rollback. 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. + - **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram + link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries + a channel **username**, the landing builds `https://t.me/`; the connector keeps the matching + `..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app) + and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto"). + The "Play in browser" CTA was dropped (no standalone-web onboarding yet). ## Deferred TODOs (cross-stage) diff --git a/deploy/.env.example b/deploy/.env.example index 8b98c68..851e36f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -25,8 +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_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot +VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot VITE_GATEWAY_URL= # --- Gateway ---------------------------------------------------------------- diff --git a/deploy/README.md b/deploy/README.md index e30f417..9435c8a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -84,8 +84,8 @@ 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_TELEGRAM_GAME_CHANNEL_NAME_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_GAME_CHANNEL_NAME_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 five `VITE_*` are **build-args** baked into the gateway image at build time, so diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 10585ce..86ef2e2 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -78,8 +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_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-} + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0320e7c..350f045 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -22,8 +22,9 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A 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). +theme, and links to the matching per-language Telegram channel; the game itself runs at +`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral +(it follows the system scheme, not the saved preference); its language choice is saved. ### Identity & sessions *(Stage 1 / 6 / 9 / 15)* A player arrives from a platform (Telegram first), via email login, or as an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 7edfee6..cf56299 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -23,8 +23,9 @@ top-1 подсказку, безлимитную проверку слова с Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и -тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по -адресам `/app/` (веб) и `/telegram/` (Telegram Mini App). +тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам +`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из +системной настройки, а не из сохранённой), выбор языка сохраняется. ### Личность и сессии *(Stage 1 / 6 / 9 / 15)* Игрок приходит с платформы (сначала Telegram), через email-вход или как diff --git a/gateway/Dockerfile b/gateway/Dockerfile index ab6cca1..c65dcf4 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -20,14 +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_TELEGRAM_GAME_CHANNEL_NAME_EN= +ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_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_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \ + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_APP_VERSION=$VITE_APP_VERSION diff --git a/ui/README.md b/ui/README.md index 84ea239..faa051a 100644 --- a/ui/README.md +++ b/ui/README.md @@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time) gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11) enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the -share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` +share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` are the per-language "Play in Telegram" links shown on the landing page (Stage 17). The build has **two entries**: the game SPA (`index.html`, served at `/app/` and diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index f9d33a9..7e6df0a 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -1,14 +1,21 @@ import { expect, test } from './fixtures'; // The landing page is a separate Vite entry (landing.html), served at "/" in production while -// the game SPA moves to /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html. -test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => { +// the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html. +test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => { await page.goto('/landing.html'); - // The primary call to action opens the web app mount. - await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/'); + // The tagline renders (English in the default test browser). + await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); - // The language switch flips the copy to Russian (reusing the app i18n). - await page.getByRole('button', { name: 'Русский' }).click(); - await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible(); + // The language dropdown switches the copy to Russian. + await page.getByRole('button', { name: 'Language' }).click(); + await page.getByRole('menuitem', { name: /Русский/ }).click(); + await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible(); + + // The theme toggle flips the document theme (ephemeral, light<->dark). + const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + await page.getByRole('button', { name: 'Theme' }).click(); + const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + expect(after).not.toBe(before); }); diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 930fd49..16587da 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -1,89 +1,84 @@
-
- {#each locales as lc (lc)} - - {/each} -
-
- {#each themes as th (th)} - - {/each} +
+ + {#if langOpen} + + + + {/if}
+

{about.title}

{t('landing.tagline')}

- + {#if tgLink} + + + {t('landing.playTelegram')} + + {/if}
@@ -125,32 +120,65 @@ .bar { display: flex; justify-content: space-between; - gap: 10px; - flex-wrap: wrap; + align-items: center; } - .seg { - display: flex; - gap: 6px; + .lang { + position: relative; } - .opt { - padding: 7px 12px; - border: 1px solid var(--border); - background: var(--surface); + .icon { + min-width: 40px; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + background: transparent; color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius-sm); - user-select: none; - font-size: 0.85rem; + cursor: pointer; } - .opt.active { - background: var(--accent); - color: var(--accent-text); - border-color: var(--accent); + .backdrop { + position: fixed; + inset: 0; + z-index: 8; + background: none; + border: none; + } + .menu { + position: absolute; + left: 0; + top: calc(100% + 6px); + z-index: 9; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + min-width: 150px; + overflow: hidden; + } + .menu button { + text-align: left; + padding: 10px 14px; + background: none; + border: none; + color: var(--text); + white-space: nowrap; + } + .menu button:hover { + background: var(--surface-2); + } + .menu button.on { + color: var(--accent); + font-weight: 600; } .hero { text-align: center; display: flex; flex-direction: column; - gap: 14px; + gap: 16px; padding: 24px 0 8px; } .hero h1 { @@ -164,33 +192,20 @@ color: var(--text-muted); font-size: 1.05rem; } - .cta { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; - margin-top: 6px; - } .play { + align-self: center; display: inline-flex; align-items: center; gap: 9px; - padding: 12px 22px; + padding: 12px 24px; border-radius: var(--radius-sm); font-weight: 700; text-decoration: none; - border: 1px solid var(--border); - } - .play.primary { background: var(--accent); color: var(--accent-text); - border-color: var(--accent); + margin-top: 6px; } - .play.tg { - background: var(--surface); - color: var(--text); - } - .play.tg img { + .play img { display: block; } .info { @@ -225,7 +240,6 @@ display: flex; flex-direction: column; gap: 5px; - color: var(--text); } .ft { margin-top: auto; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 362daca..afd94d7 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -153,7 +153,6 @@ export const en = { 'about.version': 'Version {v}', 'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', - 'landing.playWeb': 'Play in browser', 'landing.playTelegram': 'Play in Telegram', 'lang.en': 'English', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 592461e..b318565 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -154,7 +154,6 @@ export const ru: Record = { 'about.version': 'Версия {v}', 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', - 'landing.playWeb': 'Играть в браузере', 'landing.playTelegram': 'Играть в Telegram', 'lang.en': 'English', diff --git a/ui/src/lib/landing.test.ts b/ui/src/lib/landing.test.ts index fb050d2..25bfad8 100644 --- a/ui/src/lib/landing.test.ts +++ b/ui/src/lib/landing.test.ts @@ -1,20 +1,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { telegramBotLink } from './landing'; +import { telegramChannelLink } from './landing'; -describe('telegramBotLink', () => { +describe('telegramChannelLink', () => { afterEach(() => vi.unstubAllEnvs()); - it('returns the per-language bot link when configured', () => { - vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game'); - vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game'); - expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game'); - expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game'); + it('builds the per-language t.me link from the channel name', () => { + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game'); + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated + expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game'); + expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game'); }); - it('returns null when the locale link is unset or blank', () => { - vi.stubEnv('VITE_TELEGRAM_LINK_EN', ''); - vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' '); - expect(telegramBotLink('en')).toBeNull(); - expect(telegramBotLink('ru')).toBeNull(); + it('returns null when the locale channel is unset or blank', () => { + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', ''); + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' '); + expect(telegramChannelLink('en')).toBeNull(); + expect(telegramChannelLink('ru')).toBeNull(); }); }); diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts index d3915b8..4368b2f 100644 --- a/ui/src/lib/landing.ts +++ b/ui/src/lib/landing.ts @@ -1,16 +1,20 @@ // Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so -// the per-language Telegram-bot link selection is unit-testable. +// the per-language Telegram-channel link selection is unit-testable. import type { Locale } from './i18n/index.svelte'; /** - * telegramBotLink returns the t.me link for the locale's game bot, or null when it is not - * configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU) - * because the test and prod contours run different bots (different usernames), so the link - * cannot be hardcoded. + * telegramChannelLink returns the t.me link for the locale's game channel, or null when it is + * not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN + * / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different + * channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_* + * (the id to post, the name to link). A leading "@" is tolerated. */ -export function telegramBotLink(locale: Locale): string | null { - const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN; - const link = (raw as string | undefined)?.trim(); - return link ? link : null; +export function telegramChannelLink(locale: Locale): string | null { + const raw = + locale === 'ru' + ? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU + : import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN; + const name = (raw as string | undefined)?.trim().replace(/^@/, ''); + return name ? `https://t.me/${name}` : null; } -- 2.52.0 From 34385240b98bd2739956574d8bc7ec723cdb41e0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 17:11:10 +0200 Subject: [PATCH 038/223] Game/Telegram review polish: USSR flag, touch drag ghost, TG fullscreen header Backlog item 2 of ~4 (owner review pass): - USSR flag emblem redrawn (canonical hammer & sickle, scaled down 1.5x below the star). - Touch drag-and-drop: enlarge the drag ghost 1.5x on touch only (the finger hides the tile); suppress the iOS tap-highlight that lingered on a rack tile sliding into a dragged tile's slot. - Telegram fullscreen: its native nav no longer hides our header -- the header drops below the content-safe-area top inset and the menu (hamburger) lifts into the nav band, centred (--tg-content-top from the SDK inset + a tg-fullscreen class; new telegram.ts helper + app wiring). Tests: UI check/test:unit/build + full e2e (60) green. The iOS tap-highlight fix and the TG-fullscreen layout want on-device verification on the deploy. --- PLAN.md | 6 ++++++ ui/public/flag-ussr.svg | 22 ++++++++++++---------- ui/src/app.css | 3 +++ ui/src/components/Header.svelte | 16 ++++++++++++++++ ui/src/game/Game.svelte | 10 +++++++--- ui/src/game/Rack.svelte | 4 ++++ ui/src/lib/app.svelte.ts | 17 +++++++++++++++++ ui/src/lib/telegram.ts | 11 +++++++++++ 8 files changed, 76 insertions(+), 13 deletions(-) diff --git a/PLAN.md b/PLAN.md index 83b0c5e..62cd4e2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1354,6 +1354,12 @@ provided cert) at the contour caddy; prod VPN; rollback. `..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app) and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto"). The "Play in browser" CTA was dropped (no standalone-web onboarding yet). + - **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle, + scaled down ×1.5 below the star); touch drag enlarges the drag ghost ×1.5 (touch only — the + finger hides the tile) and suppresses the iOS tap-highlight that lingered on a rack tile sliding + into a dragged tile's slot; and **Telegram fullscreen** no longer hides our header under its + native nav — the header drops below the content-safe-area top inset and the menu (hamburger) + lifts into the nav band, centred (`--tg-content-top` from the SDK + a `tg-fullscreen` class). ## Deferred TODOs (cross-stage) diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg index 0bdc2ea..58e4d8b 100644 --- a/ui/public/flag-ussr.svg +++ b/ui/public/flag-ussr.svg @@ -1,14 +1,16 @@ - - - - - - - - - - + + + + + + + + + + + diff --git a/ui/src/app.css b/ui/src/app.css index ff1ed3e..5f33900 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -41,6 +41,9 @@ --radius-sm: 6px; --gap: 8px; --pad: 12px; + /* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's + content-safe-area inset (Stage 17), 0 elsewhere. */ + --tg-content-top: 0px; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; --shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06); diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index e39b0a6..d1cb200 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -89,4 +89,20 @@ transform: rotate(45deg); margin-left: 3px; } + /* Telegram fullscreen: its native nav overlays the top of the viewport (height + --tg-content-top, set from the content-safe-area inset). Drop the header content below the + nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the + corners, leaving the centre clear (Stage 17). */ + :global(html.tg-fullscreen) .bar { + padding-top: var(--tg-content-top); + } + :global(html.tg-fullscreen) .end { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + height: var(--tg-content-top); + justify-content: center; + z-index: 30; + } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index dd29acc..4af1517 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -60,7 +60,7 @@ let checkResult = $state<{ word: string; legal: boolean } | null>(null); let resignOpen = $state(false); let messages = $state([]); - let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null); + let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); const checkedWords = new Map(); let cooling = $state(false); @@ -315,7 +315,7 @@ const src = downInfo.src; const letter = src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? ''; - drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY }; + drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch' }; // 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 @@ -814,7 +814,7 @@ {#if drag} -
+
{drag.blank ? '' : drag.letter}
{/if} @@ -1092,6 +1092,10 @@ pointer-events: none; z-index: 60; } + /* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x (Stage 17). */ + .ghost.touch { + transform: translate(-50%, -50%) scale(1.5); + } .alpha { display: grid; grid-template-columns: repeat(6, 1fr); diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 6a11f3b..e85295b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -91,6 +91,10 @@ font-size: 1.4rem; touch-action: none; user-select: none; + -webkit-user-select: none; + /* iOS shows a tap/active highlight that can linger on the neighbour sliding into a + dragged tile's slot (Stage 17); suppress it so only our own styles mark a tile. */ + -webkit-tap-highlight-color: transparent; } .tile.selected { outline: 3px solid var(--accent); diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 6957f46..c47efb1 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -13,6 +13,7 @@ import { insideTelegram, onTelegramPath, telegramColorScheme, + telegramContentSafeAreaTop, telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, @@ -227,6 +228,19 @@ function syncTelegramChrome(): void { ); } +/** + * syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native + * nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a + * `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its + * band (Stage 17). Called on launch and on Telegram's safe-area / fullscreen change events. + */ +function syncTelegramSafeArea(): void { + if (typeof document === 'undefined') return; + const top = telegramContentSafeAreaTop(); + document.documentElement.style.setProperty('--tg-content-top', `${top}px`); + document.documentElement.classList.toggle('tg-fullscreen', top > 0); +} + export async function bootstrap(): Promise { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; @@ -263,6 +277,9 @@ export async function bootstrap(): Promise { // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from // fighting tile drag / board scroll. syncTelegramChrome(); + syncTelegramSafeArea(); + telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); + telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramDisableVerticalSwipes(); try { await adoptSession(await gateway.authTelegram(launch.initData)); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 1ed3a63..b7460d7 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -10,6 +10,8 @@ interface TelegramWebApp { initDataUnsafe?: { start_param?: string }; themeParams?: TelegramThemeParams; colorScheme?: 'light' | 'dark'; + isFullscreen?: boolean; + contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; onEvent?: (event: string, handler: () => void) => void; @@ -99,6 +101,15 @@ export function telegramSetChrome(header: string, background: string, bottom: st if (bottom) w?.setBottomBarColor?.(bottom); } +/** + * telegramContentSafeAreaTop returns the height (px) Telegram's own UI overlays at the top of + * the viewport in fullscreen (its nav band; the content-safe area, Bot API 8.0). It is 0 + * outside Telegram or on clients predating it, so callers can pad/position defensively. + */ +export function telegramContentSafeAreaTop(): number { + return webApp()?.contentSafeAreaInset?.top ?? 0; +} + /** * 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. -- 2.52.0 From b720907db2f65367dc10d423c51e9ab30b758bdc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 18:23:10 +0200 Subject: [PATCH 039/223] Review fixes #2: bigger flag star, TG header below nav, board-tile relocation Addressing the review on #23: - Flag star scaled up ~25% (the hammer&sickle emblem unchanged, kept clear of it). - TG fullscreen header: drop the WHOLE header below the content-safe-area top inset (the hamburger stays to the right of the title), instead of pinning the hamburger to the physical top edge. - DnD: a placed (pending) tile can now be relocated by dragging it to another board cell (board->board); it lifts off its source cell while dragged; and it can be grabbed even on the zoomed board (touch-action:none on the pending cell, so the drag wins over the board pan). The manual-selection blue frame now clears on recall. --- ui/public/flag-ussr.svg | 4 ++-- ui/src/components/Header.svelte | 9 ------- ui/src/game/Board.svelte | 3 +++ ui/src/game/Game.svelte | 42 ++++++++++++++++++++++++++++----- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/ui/public/flag-ussr.svg b/ui/public/flag-ussr.svg index 58e4d8b..eb4d881 100644 --- a/ui/public/flag-ussr.svg +++ b/ui/public/flag-ussr.svg @@ -1,7 +1,7 @@ - - + + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index d1cb200..e2b5a2c 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -96,13 +96,4 @@ :global(html.tg-fullscreen) .bar { padding-top: var(--tg-content-top); } - :global(html.tg-fullscreen) .end { - position: fixed; - top: 0; - left: 50%; - transform: translateX(-50%); - height: var(--tg-content-top); - justify-content: center; - z-index: 30; - } diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index a434519..fd68828 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -277,6 +277,9 @@ } .cell.pending { background: var(--tile-pending); + /* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed + board) instead of the touch starting a board pan (Stage 17). */ + touch-action: none; } /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 4af1517..058f299 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -70,7 +70,11 @@ const premium = $derived(premiumGrid(variant)); const ctr = $derived(centre(variant)); const pendingMap = $derived( - new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])), + new Map( + placement.pending + .filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col)) + .map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]), + ), ); const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null); // Highlight the last word with a dark tile bg; while placing, only the pending tiles @@ -228,6 +232,9 @@ // (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); + // While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell — + // hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17). + let draggingPend = $state<{ row: number; col: number } | null>(null); let dragPointerId = -1; function beginDrag(src: DragSrc, e: PointerEvent) { @@ -261,10 +268,10 @@ if (busy || gameOver) return; beginDrag({ from: 'rack', index }, e); } - // A pending tile can be dragged back to the rack, but only on the unzoomed board: when - // zoomed the one-finger gesture scrolls the board, so recall there is via double-tap. + // A placed (pending) tile can be dragged to relocate it on the board or back to the rack — + // works zoomed too (the tile has touch-action:none, so its drag wins over the board pan). function onBoardDown(e: PointerEvent, row: number, col: number) { - if (busy || zoomed || gameOver) return; + if (busy || gameOver) return; beginDrag({ from: 'board', row, col }, e); } function cellUnder(x: number, y: number): { row: number; col: number } | null { @@ -283,6 +290,7 @@ function clearReorder() { reorderDragId = null; reorderTo = null; + draggingPend = 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. @@ -316,8 +324,10 @@ 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, touch: e.pointerType === 'touch' }; - // A rack tile is lifted out of the rack while dragged (the ghost stands in for it). + // A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a + // placed board tile is likewise lifted off its cell while relocated. reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null; + draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : 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. } @@ -371,9 +381,13 @@ } 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' && cell) { + // Dropped a placed tile on another board cell → relocate it there. + relocatePending(di.src.row, di.src.col, cell.row, cell.col); } else if (di.src.from === 'board' && onRack) { - // Dropped a pending tile back onto the rack → recall it to its original slot. + // Dropped a placed tile back onto the rack → recall it to its original slot. placement = recallAt(placement, di.src.row, di.src.col); + selected = null; recompute(); scheduleDraftSave(); } @@ -416,6 +430,22 @@ } function onRecall(row: number, col: number) { placement = recallAt(placement, row, col); + selected = null; + recompute(); + scheduleDraftSave(); + } + // relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one + // (a board→board drag), keeping its rack slot and any blank letter (Stage 17). + function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) { + const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol); + if (!pt) return; + if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) { + return; + } + let p = recallAt(placement, fromRow, fromCol); + p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined); + placement = p; + focus = { row: toRow, col: toCol }; recompute(); scheduleDraftSave(); } -- 2.52.0 From 6b6baf5710383eac1390d72905a7eb0992b8e842 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 19:23:48 +0200 Subject: [PATCH 040/223] Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lobby: group the my-games list into your-turn / opponent-turn / finished (empty sections hidden), ordered by last activity (your-turn oldest-first, the other two newest-first), as a compact line-separated list. gameDTO and FB GameView gain last_activity_unix (turn start while active, finish time once finished); a pure lib/lobbysort.ts holds the grouping/ordering. Friends: the in-game 'add to friends' item is now server-derived via a new GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with a pending OR declined request (both read as 'request sent'), so it is correct across reloads; it shows a disabled '✓ in friends' once accepted. It live-updates when the opponent answers: RespondFriendRequest now publishes friend_added (accept) / friend_declined (new notify sub-kind, decline) to the original requester, whose open game re-derives its friend state. Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests; backend integration ListOutgoingRequests + respond-publishes-to-requester; e2e updated for the new lobby section labels + a non-friend active opponent. Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN. --- PLAN.md | 28 ++++- backend/internal/inttest/social_test.go | 118 ++++++++++++++++++ backend/internal/notify/events.go | 4 +- backend/internal/notify/notify.go | 7 +- backend/internal/server/dto.go | 48 ++++--- backend/internal/server/handlers.go | 1 + backend/internal/server/handlers_friends.go | 22 ++++ backend/internal/social/friends.go | 39 ++++++ docs/ARCHITECTURE.md | 6 +- docs/FUNCTIONAL.md | 11 +- docs/FUNCTIONAL_ru.md | 11 +- gateway/internal/backendclient/api.go | 21 ++-- gateway/internal/backendclient/api_social.go | 14 +++ gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/encode_social.go | 10 ++ .../internal/transcode/transcode_social.go | 12 ++ .../transcode/transcode_social_test.go | 29 +++++ gateway/internal/transcode/transcode_test.go | 5 +- pkg/fbs/scrabble.fbs | 15 ++- pkg/fbs/scrabblefb/GameView.go | 17 ++- pkg/fbs/scrabblefb/OutgoingRequestList.go | 75 +++++++++++ ui/e2e/smoke.spec.ts | 2 +- ui/e2e/social.spec.ts | 4 +- ui/e2e/telegram.spec.ts | 2 +- ui/src/game/Game.svelte | 32 ++++- ui/src/gen/fbs/scrabblefb.ts | 1 + ui/src/gen/fbs/scrabblefb/game-view.ts | 14 ++- .../fbs/scrabblefb/outgoing-request-list.ts | 66 ++++++++++ ui/src/lib/client.ts | 2 + ui/src/lib/codec.ts | 12 ++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/lobbysort.test.ts | 68 ++++++++++ ui/src/lib/lobbysort.ts | 39 ++++++ ui/src/lib/mock/client.ts | 13 +- ui/src/lib/mock/data.ts | 10 +- ui/src/lib/model.ts | 2 + ui/src/lib/result.test.ts | 1 + ui/src/lib/transport.ts | 3 + ui/src/screens/Lobby.svelte | 57 ++++++--- 40 files changed, 743 insertions(+), 81 deletions(-) create mode 100644 pkg/fbs/scrabblefb/OutgoingRequestList.go create mode 100644 ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts create mode 100644 ui/src/lib/lobbysort.test.ts create mode 100644 ui/src/lib/lobbysort.ts diff --git a/PLAN.md b/PLAN.md index 62cd4e2..549c9aa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1355,11 +1355,29 @@ provided cert) at the contour caddy; prod VPN; rollback. and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto"). The "Play in browser" CTA was dropped (no standalone-web onboarding yet). - **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle, - scaled down ×1.5 below the star); touch drag enlarges the drag ghost ×1.5 (touch only — the - finger hides the tile) and suppresses the iOS tap-highlight that lingered on a rack tile sliding - into a dragged tile's slot; and **Telegram fullscreen** no longer hides our header under its - native nav — the header drops below the content-safe-area top inset and the menu (hamburger) - lifts into the nav band, centred (`--tg-content-top` from the SDK + a `tg-fullscreen` class). + scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5 + (touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a + rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board + cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the + board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram + fullscreen** no longer hides our header under its native nav — the whole header drops below the + content-safe-area top inset (title and the right-aligned menu both clear the nav), via + `--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way + to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our + own header and simply push it clear.) + - **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games + into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last + activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a + compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB + `GameView` gained `last_activity_unix` (the turn start while active, the finish time once + finished). The in-game **"add to friends"** item is now **server-derived** (new `GET + /user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested — + pending **or** declined, which both read as "request sent") so it is correct across reloads, shows + a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers: + `RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify + sub-kind, decline) to the **original requester**, whose open game re-derives its friend state. + Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent + reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 33908fa..3ecb7f0 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "strings" + "sync" "testing" "time" @@ -14,9 +15,36 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" "scrabble/backend/internal/social" + fb "scrabble/pkg/fbs/scrabblefb" ) +// capturePublisher records every published intent for assertions on live events. +type capturePublisher struct { + mu sync.Mutex + intents []notify.Intent +} + +func (c *capturePublisher) Publish(in ...notify.Intent) { + c.mu.Lock() + defer c.mu.Unlock() + c.intents = append(c.intents, in...) +} + +// notified reports whether a Notification with the given sub-kind was published to user. +func (c *capturePublisher) notified(user uuid.UUID, sub string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for _, in := range c.intents { + if in.UserID == user && in.Kind == notify.KindNotification && + string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub { + return true + } + } + return false +} + // newSocialService builds a social service over the shared pool, reading game // state through a real game service. func newSocialService() *social.Service { @@ -383,3 +411,93 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) { t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err) } } + +// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to +// friends" item (Stage 17): a pending request shows for the requester only; an accepted one +// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as +// still "sent"); a lazily expired pending one drops (it may be re-sent). +func TestListOutgoingRequests(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + + // Pending: outgoing for the requester, not the addressee. + _, s1 := newGameWithSeats(t, 2) + a, b := s1[0], s1[1] + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("send: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b { + t.Fatalf("outgoing pending = %v, want [b]", got) + } + if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 { + t.Fatalf("addressee outgoing = %v, want none", got) + } + // Accepted: a friendship, no longer an outgoing request. + if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { + t.Fatalf("accept: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 { + t.Fatalf("outgoing after accept = %v, want none", got) + } + + // Declined: stays outgoing (reads as sent; cannot re-send). + _, s2 := newGameWithSeats(t, 2) + c, d := s2[0], s2[1] + if err := svc.SendFriendRequest(ctx, c, d); err != nil { + t.Fatalf("send2: %v", err) + } + if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil { + t.Fatalf("decline: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d { + t.Fatalf("outgoing after decline = %v, want [d]", got) + } + + // Lazily expired pending: omitted (may be re-sent). + _, s3 := newGameWithSeats(t, 2) + e, f := s3[0], s3[1] + if err := svc.SendFriendRequest(ctx, e, f); err != nil { + t.Fatalf("send3: %v", err) + } + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil { + t.Fatalf("backdate: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 { + t.Fatalf("expired outgoing = %v, want none", got) + } +} + +// TestRespondPublishesToRequester checks that answering a request notifies the original +// requester over the live channel (Stage 17): accept -> friend_added, decline -> +// friend_declined, so a game screen watching that opponent re-derives its friend state. +func TestRespondPublishesToRequester(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + + _, s1 := newGameWithSeats(t, 2) + a, b := s1[0], s1[1] + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("send: %v", err) + } + if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { + t.Fatalf("accept: %v", err) + } + if !pub.notified(a, notify.NotifyFriendAdded) { + t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded) + } + + _, s2 := newGameWithSeats(t, 2) + c, d := s2[0], s2[1] + if err := svc.SendFriendRequest(ctx, c, d); err != nil { + t.Fatalf("send2: %v", err) + } + if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil { + t.Fatalf("decline: %v", err) + } + if !pub.notified(c, notify.NotifyFriendDeclined) { + t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined) + } +} diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 27de53e..0d443c6 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -85,8 +85,8 @@ func MatchFound(userID, gameID uuid.UUID) Intent { // Notification is a lightweight "re-poll" signal to userID that a friend request or // invitation changed. kind is a sub-discriminator (NotifyFriendRequest, -// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to -// scope its refresh. +// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the +// client may use to scope its refresh. func Notification(userID uuid.UUID, kind string) Intent { b := flatbuffers.NewBuilder(32) k := b.CreateString(kind) diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index fd1fb91..89decb9 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -34,8 +34,11 @@ const ( const ( NotifyFriendRequest = "friend_request" NotifyFriendAdded = "friend_added" - NotifyInvitation = "invitation" - NotifyGameStarted = "game_started" + // NotifyFriendDeclined tells the original requester their request was declined, so a + // game screen watching that opponent re-derives its "add to friends" state. + NotifyFriendDeclined = "friend_declined" + NotifyInvitation = "invitation" + NotifyGameStarted = "game_started" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 8fb648c..83eb07a 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -83,16 +83,19 @@ type seatDTO struct { // gameDTO is the shared game summary. type gameDTO struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` - Seats []seatDTO `json:"seats"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` + // LastActivityUnix is the lobby sort key: the current turn's start for an active + // game, the finish time once finished (Stage 17). + LastActivityUnix int64 `json:"last_activity_unix"` + Seats []seatDTO `json:"seats"` } // moveResultDTO is the outcome of a committed move. @@ -189,17 +192,22 @@ func gameDTOFromGame(g game.Game) gameDTO { IsWinner: s.IsWinner, }) } + last := g.TurnStartedAt + if g.FinishedAt != nil { + last = *g.FinishedAt + } return gameDTO{ - ID: g.ID.String(), - Variant: g.Variant.String(), - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MoveCount: g.MoveCount, + EndReason: g.EndReason, + LastActivityUnix: last.Unix(), + Seats: seats, } } diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 45c5262..e1e1965 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -87,6 +87,7 @@ func (s *Server) registerRoutes() { u.POST("/games/:id/nudge", s.handleNudge) u.GET("/friends", s.handleListFriends) u.GET("/friends/incoming", s.handleIncomingRequests) + u.GET("/friends/outgoing", s.handleOutgoingRequests) u.POST("/friends/request", s.handleFriendRequest) u.POST("/friends/respond", s.handleFriendRespond) u.POST("/friends/cancel", s.handleFriendCancel) diff --git a/backend/internal/server/handlers_friends.go b/backend/internal/server/handlers_friends.go index 83e0f3f..6e650fb 100644 --- a/backend/internal/server/handlers_friends.go +++ b/backend/internal/server/handlers_friends.go @@ -31,6 +31,12 @@ type incomingListDTO struct { Requests []accountRefDTO `json:"requests"` } +// outgoingListDTO is the addressees the caller has already requested (a live pending +// request or one the addressee declined) and therefore cannot re-request. +type outgoingListDTO struct { + Requests []accountRefDTO `json:"requests"` +} + // friendCodeDTO is a freshly issued one-time friend code (returned once). type friendCodeDTO struct { Code string `json:"code"` @@ -218,6 +224,22 @@ func (s *Server) handleIncomingRequests(c *gin.Context) { c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)}) } +// handleOutgoingRequests returns the addressees the caller has already requested +// (pending or declined) and cannot re-request. +func (s *Server) handleOutgoingRequests(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, outgoingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)}) +} + // handleIssueFriendCode issues a one-time add-a-friend code for the caller. func (s *Server) handleIssueFriendCode(c *gin.Context) { uid, ok := userID(c) diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index d879535..7c7406a 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque if !ok { return ErrRequestNotFound } + // Tell the original requester their request was answered, so a game screen watching + // this opponent re-derives its "add to friends" state (accepted -> friends, declined + // -> stays "request sent"). + if accept { + svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded)) + } else { + svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined)) + } return nil } @@ -156,6 +164,14 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) } +// ListOutgoingRequests returns the account IDs the caller has already requested and +// cannot (re-)request: a live (not yet expired) pending request, or one the addressee +// permanently declined. The game's "add to friends" item reads it to stay disabled +// across reloads (a declined request reads identically to a still-pending one). +func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { + return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) +} + // loadEdges returns every friendship row between a and b in either direction (at // most one per direction). It feeds SendFriendRequest's re-send classification. func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) { @@ -294,6 +310,29 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c return out, nil } +// listOutgoingRequests returns the addressees of the caller's requests that block a +// re-send: a live (created after cutoff) pending request, or a permanently declined +// one. An ignored pending request that has lazily expired is omitted (it may be re-sent). +func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) { + stmt := postgres.SELECT(table.Friendships.AddresseeID). + FROM(table.Friendships). + WHERE( + table.Friendships.RequesterID.EQ(postgres.UUID(accountID)). + AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)). + OR(table.Friendships.Status.EQ(postgres.String(friendPending)). + AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))), + ) + var rows []model.Friendships + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("social: list outgoing requests: %w", err) + } + out := make([]uuid.UUID, 0, len(rows)) + for _, r := range rows { + out = append(out, r.AddresseeID) + } + return out, nil +} + // edgeEither matches a friendship row between a and b in either direction. func edgeEither(a, b uuid.UUID) postgres.BoolExpression { return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 404d898..ea18f3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -473,8 +473,10 @@ including the mover**, so the mover's own other devices and their lobby refresh in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge** (from the social service), **match-found** (from the matchmaker, §8), and **notify** (Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request, -friend-added, invitation or game-started; emitted on a friend-request and invitation -create and on an invitation's game start). Event payloads are FlatBuffers-encoded by +friend-added, friend-declined, invitation or game-started; emitted on a friend-request, +on answering one (accept → friend-added, decline → friend-declined — to the original +requester, so a game screen watching that opponent re-derives its "add to friends" state, +Stage 17), and on an invitation create or its game start). Event payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for match-found and, for the lobby **notification badge** (incoming friend requests + open invitations), the client polls on lobby diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 350f045..39b93c5 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -58,7 +58,11 @@ account is kept and the guest's games move into it. A merge is blocked only whil 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 +Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three +sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and +orders them so the games awaiting your move come first, the longest-waiting on top, while +opponent-turn and finished games are most-recent first; it renders as a compact, +line-separated list (Stage 17). The game types offered on **New Game** are 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 @@ -111,7 +115,10 @@ digits, valid for twelve hours), or send a **request to someone you have played with** — they accept, ignore it (a request lapses after thirty days and can then be re-sent), or decline (a decline blocks further requests from you until they hand you a code). Cancelling your own pending request withdraws it; unfriending removes the -friendship. Block globally — switch off incoming chat +friendship. In a game, an **add to friends** item for each opponent mirrors the live +relationship: it reads *request sent* (disabled) while a request is pending or was +declined, and *in friends* once accepted — updating in place the moment the opponent +answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat and/or friend requests — and block individual players (a per-user block hides that person's chat and stops requests and game invitations both ways; it also ends any existing friendship). Per-game chat is for quick reactions: messages are short diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index cf56299..1add5a3 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -59,7 +59,11 @@ Mini App** авторизует по подписанным `initData` плат запрещено, только пока у аккаунтов есть общая незавершённая игра. ### Лобби и подбор *(Stage 4 / 15)* -Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра** +Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции — +*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, +что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу +соперника и завершённые — самые свежие сверху; отображается компактным списком с +линиями-разделителями (Stage 17). Типы партий на экране **Новая игра** ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble @@ -113,7 +117,10 @@ Mini App** авторизует по подписанным `initData` плат тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки -снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие +снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого +соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный), +пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте +в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие чат и/или заявки — и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 42299eb..94ce576 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -93,16 +93,17 @@ type SeatResp struct { // GameResp is the shared game summary. type GameResp struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` - Seats []SeatResp `json:"seats"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` + LastActivityUnix int64 `json:"last_activity_unix"` + Seats []SeatResp `json:"seats"` } // MoveResultResp is the outcome of a committed move. diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 28399eb..34609dc 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -25,6 +25,12 @@ type IncomingListResp struct { Requests []AccountRefResp `json:"requests"` } +// OutgoingListResp is the addressees the caller has already requested (a live pending +// request or one the addressee declined) and cannot re-request. +type OutgoingListResp struct { + Requests []AccountRefResp `json:"requests"` +} + // FriendCodeResp is a freshly issued one-time friend code. type FriendCodeResp struct { Code string `json:"code"` @@ -134,6 +140,14 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR return out, err } +// ListOutgoing returns the addressees the caller has already requested (pending or +// declined) and cannot re-request. +func (c *Client) ListOutgoing(ctx context.Context, userID string) (OutgoingListResp, error) { + var out OutgoingListResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/outgoing", userID, "", nil, &out) + return out, err +} + // IssueFriendCode issues a one-time friend code for the caller. func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) { var out FriendCodeResp diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 06dd632..8c61144 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -357,6 +357,7 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers fb.GameViewAddMoveCount(b, int32(g.MoveCount)) fb.GameViewAddEndReason(b, endReason) fb.GameViewAddSeats(b, seats) + fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) return fb.GameViewEnd(b) } diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index f6b6dca..618e378 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -54,6 +54,16 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte { return b.FinishedBytes() } +// encodeOutgoingList builds an OutgoingRequestList payload. +func encodeOutgoingList(r backendclient.OutgoingListResp) []byte { + b := flatbuffers.NewBuilder(256) + v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector) + fb.OutgoingRequestListStart(b) + fb.OutgoingRequestListAddRequests(b, v) + b.Finish(fb.OutgoingRequestListEnd(b)) + return b.FinishedBytes() +} + // encodeBlockList builds a BlockList payload. func encodeBlockList(r backendclient.BlockListResp) []byte { b := flatbuffers.NewBuilder(256) diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index fdfa6f6..b89c65c 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -13,6 +13,7 @@ import ( const ( MsgFriendsList = "friends.list" MsgFriendsIncoming = "friends.incoming" + MsgFriendsOutgoing = "friends.outgoing" MsgFriendRequest = "friends.request" MsgFriendRespond = "friends.respond" MsgFriendCancel = "friends.cancel" @@ -37,6 +38,7 @@ const ( func registerStage8(r *Registry, backend *backendclient.Client) { r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true} r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true} + r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true} r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true} r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true} r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true} @@ -78,6 +80,16 @@ func friendsIncomingHandler(backend *backendclient.Client) Handler { } } +func friendsOutgoingHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.ListOutgoing(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeOutgoingList(res), nil + } +} + func friendRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTargetRequest(req.Payload, 0) diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index e510df0..c5e01fb 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -54,6 +54,35 @@ func TestFriendsListRoundTripDecodesNames(t *testing.T) { } } +func TestFriendsOutgoingRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/friends/outgoing" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}]}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgFriendsOutgoing) + if !ok { + t.Fatal("friends.outgoing not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + ol := fb.GetRootAsOutgoingRequestList(payload, 0) + if ol.RequestsLength() != 1 { + t.Fatalf("outgoing length = %d, want 1", ol.RequestsLength()) + } + var ref fb.AccountRef + ol.Requests(&ref, 0) + if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" { + t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName()) + } +} + func TestFriendRequestForwardsTarget(t *testing.T) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-User-ID"); got != "u-1" { diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index f9cb2b7..8e1420f 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -158,7 +158,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if r.URL.Path != "/api/v1/user/games" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) + _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) }) defer cleanup() @@ -177,6 +177,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if string(g.Id()) != "g-1" { t.Errorf("game id = %q, want g-1", g.Id()) } + if g.LastActivityUnix() != 1717000000 { + t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix()) + } var seat fb.SeatView g.Seats(&seat, 1) if string(seat.DisplayName()) != "Ann" { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b1d8400..b1d5651 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -66,6 +66,9 @@ table GameView { move_count:int; end_reason:string; seats:[SeatView]; + // last_activity_unix is the lobby sort key: the current turn's start for an active + // game, the finish time for a finished one (Stage 17). + last_activity_unix:long; } // MoveRecord is one decoded move (a committed play, or a hint preview). @@ -389,6 +392,13 @@ table IncomingRequestList { requests:[AccountRef]; } +// OutgoingRequestList is the accounts the caller has already requested and cannot +// (re-)request: a live pending request or one the addressee declined. The game's +// "add to friends" item reads it to stay disabled across reloads (Stage 17). +table OutgoingRequestList { + requests:[AccountRef]; +} + // FriendCode is a freshly issued one-time add-a-friend code (returned once). table FriendCode { code:string; @@ -492,8 +502,9 @@ table MatchFoundEvent { // NotificationEvent is a lightweight "something changed, re-poll" signal that // drives the lobby badge (incoming friend requests, invitations). kind is a sub- -// discriminator ("friend_request", "friend_added", "invitation", "game_started"); -// the client re-fetches its lobby counters on any of them. +// discriminator ("friend_request", "friend_added", "friend_declined", "invitation", +// "game_started"); the client re-fetches its lobby counters (and, for a requester +// watching a game, its friend state) on any of them. table NotificationEvent { kind:string; } diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 1dcd1a4..01c977a 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -149,8 +149,20 @@ func (rcv *GameView) SeatsLength() int { return 0 } +func (rcv *GameView) LastActivityUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameView) MutateLastActivityUnix(n int64) bool { + return rcv._tab.MutateInt64Slot(24, n) +} + func GameViewStart(builder *flatbuffers.Builder) { - builder.StartObject(10) + builder.StartObject(11) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -185,6 +197,9 @@ func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT) func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) { + builder.PrependInt64Slot(10, lastActivityUnix, 0) +} func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/OutgoingRequestList.go b/pkg/fbs/scrabblefb/OutgoingRequestList.go new file mode 100644 index 0000000..5ad4d29 --- /dev/null +++ b/pkg/fbs/scrabblefb/OutgoingRequestList.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type OutgoingRequestList struct { + _tab flatbuffers.Table +} + +func GetRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &OutgoingRequestList{} + x.Init(buf, n+offset) + return x +} + +func FinishOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &OutgoingRequestList{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *OutgoingRequestList) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *OutgoingRequestList) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *OutgoingRequestList) Requests(obj *AccountRef, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *OutgoingRequestList) RequestsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func OutgoingRequestListStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0) +} +func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func OutgoingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index ace5d86..041b507 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -8,7 +8,7 @@ test('guest reaches a board and previews a placement', async ({ page }) => { await page.getByRole('button', { name: /guest/i }).click(); - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); const activeRow = page.getByRole('button', { name: /Ann/ }); await expect(activeRow).toBeVisible(); await activeRow.click(); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 33cdd70..a0ce08b 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures'; async function loginLobby(page: Page): Promise { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); } async function openFriends(page: Page): Promise { @@ -107,7 +107,7 @@ test('play with friends: a game type is required to send an invitation', async ( await expect(send).toBeEnabled(); await send.click(); // the mock creates it and returns to the lobby - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); }); test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => { diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index a710e5e..0700bd2 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -27,7 +27,7 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme', await page.goto('/'); // No guest-login click: the Mini App authenticates from initData and lands on the lobby. - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); // The Telegram themeParams override the background token at runtime. await expect diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 058f299..1b152b0 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -189,6 +189,7 @@ rackIds = cached.view.rack.map((_, i) => i); } void load(); + void loadFriends(); }); $effect(() => { @@ -201,6 +202,9 @@ } else if (e.kind === 'your_turn' && e.gameId === id) void load(); else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); + // A request the player sent was answered (accepted -> now friends; declined -> stays + // "request sent"): re-derive the in-game friend state. + else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends(); }); // Tick the nudge cooldown while the chat is open so the control re-enables on time. @@ -681,13 +685,31 @@ } } + // Friend state for the in-game "add to friends" item, derived from the server so it is + // correct across reloads and live-updates when a request is answered (Stage 17): + // `friends` are the caller's accepted friends; `requested` are the addressees already + // requested (pending or declined — both block a re-send and read as "request sent"). + let friends = $state(new Set()); let requested = $state(new Set()); const noop = () => {}; + // loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social + // surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets. + async function loadFriends() { + if (app.profile?.isGuest) return; + try { + const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]); + friends = new Set(fl.map((f) => f.accountId)); + requested = new Set(out.map((f) => f.accountId)); + } catch { + /* best-effort */ + } + } + async function addFriend(accountId: string) { try { await gateway.friendRequest(accountId); - requested = new Set([...requested, accountId]); + requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends showToast(t('friends.requestSent')); } catch (e) { handleError(e); @@ -707,9 +729,11 @@ ...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []), ...(!app.profile?.isGuest ? opponents.map((s) => - requested.has(s.accountId) - ? { label: t('game.requestSent'), onclick: noop, disabled: true } - : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }, + friends.has(s.accountId) + ? { label: t('game.alreadyFriends'), onclick: noop, disabled: true } + : requested.has(s.accountId) + ? { label: t('game.requestSent'), onclick: noop, disabled: true } + : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }, ) : []), ...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]), diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 7c3f820..5ea566f 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -44,6 +44,7 @@ export { MoveResult } from './scrabblefb/move-result.js'; export { NotificationEvent } from './scrabblefb/notification-event.js'; export { NudgeEvent } from './scrabblefb/nudge-event.js'; export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js'; +export { OutgoingRequestList } from './scrabblefb/outgoing-request-list.js'; export { PlayTile } from './scrabblefb/play-tile.js'; export { Profile } from './scrabblefb/profile.js'; export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index 7e0f8e9..61a516b 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -88,8 +88,13 @@ seatsLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +lastActivityUnix():bigint { + const offset = this.bb!.__offset(this.bb_pos, 24); + return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); +} + static startGameView(builder:flatbuffers.Builder) { - builder.startObject(10); + builder.startObject(11); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -144,12 +149,16 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint) { + builder.addFieldInt64(10, lastActivityUnix, BigInt('0')); +} + static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset):flatbuffers.Offset { +static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset { GameView.startGameView(builder); GameView.addId(builder, idOffset); GameView.addVariant(builder, variantOffset); @@ -161,6 +170,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, GameView.addMoveCount(builder, moveCount); GameView.addEndReason(builder, endReasonOffset); GameView.addSeats(builder, seatsOffset); + GameView.addLastActivityUnix(builder, lastActivityUnix); return GameView.endGameView(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts b/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts new file mode 100644 index 0000000..ded6fa1 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts @@ -0,0 +1,66 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { AccountRef } from '../scrabblefb/account-ref.js'; + + +export class OutgoingRequestList { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):OutgoingRequestList { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList { + return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +requests(index: number, obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +requestsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startOutgoingRequestList(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, requestsOffset, 0); +} + +static createRequestsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startRequestsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endOutgoingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset { + OutgoingRequestList.startOutgoingRequestList(builder); + OutgoingRequestList.addRequests(builder, requestsOffset); + return OutgoingRequestList.endOutgoingRequestList(builder); +} +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index cb01b0e..878a743 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -98,6 +98,8 @@ export interface GatewayClient { // --- friends (Stage 8) --- friendsList(): Promise; friendsIncoming(): Promise; + /** Addressees the caller has already requested (pending or declined); cannot re-request. */ + friendsOutgoing(): Promise; friendRequest(accountId: string): Promise; friendRespond(requesterId: string, accept: boolean): Promise; friendCancel(accountId: string): Promise; diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index ea1c51f..b5e69ca 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -249,6 +249,7 @@ function decodeGameView(g: fb.GameView): GameView { turnTimeoutSecs: g.turnTimeoutSecs(), moveCount: g.moveCount(), endReason: s(g.endReason()), + lastActivityUnix: Number(g.lastActivityUnix()), seats, }; } @@ -587,6 +588,16 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] { return out; } +export function decodeOutgoingList(buf: Uint8Array): AccountRef[] { + const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf)); + const out: AccountRef[] = []; + for (let i = 0; i < l.requestsLength(); i++) { + const r = l.requests(i); + if (r) out.push(decodeAccountRef(r)); + } + return out; +} + export function decodeBlockList(buf: Uint8Array): AccountRef[] { const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf)); const out: AccountRef[] = []; @@ -678,6 +689,7 @@ function emptyGame(): GameView { turnTimeoutSecs: 0, moveCount: 0, endReason: '', + lastActivityUnix: 0, seats: [], }; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index afd94d7..d8a353a 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -241,6 +241,7 @@ export const en = { 'game.exportGcg': 'Export GCG', 'game.gcgActiveOnly': 'Available once the game is finished.', 'game.requestSent': 'Request sent', + 'game.alreadyFriends': '✓ In friends', 'time.minutes': '{n} min', 'time.hours': '{n} h', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b318565..aa35294 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -242,6 +242,7 @@ export const ru: Record = { 'game.exportGcg': 'Экспорт GCG', 'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.requestSent': 'Запрос отправлен', + 'game.alreadyFriends': '✓ В друзьях', 'time.minutes': '{n} мин', 'time.hours': '{n} ч', diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts new file mode 100644 index 0000000..3f5b9f9 --- /dev/null +++ b/ui/src/lib/lobbysort.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { groupGames, isMyTurn } from './lobbysort'; +import type { GameView, Seat } from './model'; + +const ME = 'me'; +const seat = (s: number, accountId: string): Seat => ({ + seat: s, + accountId, + displayName: accountId, + score: 0, + hintsUsed: 0, + isWinner: false, +}); + +function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView { + return { + id, + variant: 'english', + dictVersion: 'v1', + status, + players: 2, + toMove, + turnTimeoutSecs: 0, + moveCount: 0, + endReason: '', + lastActivityUnix, + seats: [seat(0, ME), seat(1, 'opp')], + }; +} + +describe('groupGames', () => { + it('partitions into your-turn, their-turn and finished', () => { + const g = groupGames( + [ + game('a', 'active', 0, 100), // toMove 0 == my seat -> my turn + game('b', 'active', 1, 100), // their turn + game('c', 'finished', 0, 100), + ], + ME, + ); + expect(g.yourTurn.map((x) => x.id)).toEqual(['a']); + expect(g.theirTurn.map((x) => x.id)).toEqual(['b']); + expect(g.finished.map((x) => x.id)).toEqual(['c']); + }); + + it('orders your-turn oldest-first, the other two newest-first', () => { + const g = groupGames( + [ + game('y_new', 'active', 0, 200), + game('y_old', 'active', 0, 100), + game('t_new', 'active', 1, 200), + game('t_old', 'active', 1, 100), + game('f_new', 'finished', 0, 200), + game('f_old', 'finished', 0, 100), + ], + ME, + ); + expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']); + expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']); + expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']); + }); + + it('isMyTurn is false for a finished game even at my seat', () => { + expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false); + expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); + expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false); + }); +}); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts new file mode 100644 index 0000000..1735d95 --- /dev/null +++ b/ui/src/lib/lobbysort.ts @@ -0,0 +1,39 @@ +// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three +// sections — games awaiting the caller's move, games awaiting the opponent, and finished +// games — each ordered by last activity: your-turn oldest-first (the longest-neglected on +// top), the other two newest-first. + +import type { GameView } from './model'; + +/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */ +export function isMyTurn(game: GameView, myId: string): boolean { + const me = game.seats.find((s) => s.accountId === myId); + return game.status === 'active' && !!me && game.toMove === me.seat; +} + +/** LobbyGroups holds the three ordered lobby sections. */ +export interface LobbyGroups { + yourTurn: GameView[]; + theirTurn: GameView[]; + finished: GameView[]; +} + +/** + * groupGames partitions games for myId into the three lobby sections and orders each: the + * your-turn games by ascending last activity (the longest-waiting first), the opponent-turn + * and finished games by descending last activity (the most recent first). + */ +export function groupGames(games: GameView[], myId: string): LobbyGroups { + const yourTurn: GameView[] = []; + const theirTurn: GameView[] = []; + const finished: GameView[] = []; + for (const g of games) { + if (g.status !== 'active') finished.push(g); + else if (isMyTurn(g, myId)) yourTurn.push(g); + else theirTurn.push(g); + } + yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix); + theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); + finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); + return { yourTurn, theirTurn, finished }; +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 80c8187..e555407 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -90,6 +90,7 @@ export class MockGateway implements GatewayClient { private pendingMatch: string | null = null; private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f })); private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f })); + private outgoing: AccountRef[] = []; private blocks: AccountRef[] = []; private invitations: Invitation[] = mockInvitations(); private readonly stats: Stats = { ...MOCK_STATS }; @@ -155,6 +156,7 @@ export class MockGateway implements GatewayClient { turnTimeoutSecs: 86400, moveCount: 0, endReason: '', + lastActivityUnix: Math.floor(Date.now() / 1000), seats: [ { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }, @@ -372,8 +374,15 @@ export class MockGateway implements GatewayClient { async friendsIncoming(): Promise { return this.incoming.map((f) => ({ ...f })); } - async friendRequest(_accountId: string): Promise { - // The real backend requires a shared game; the mock simply acknowledges. + async friendsOutgoing(): Promise { + return this.outgoing.map((f) => ({ ...f })); + } + async friendRequest(accountId: string): Promise { + // The real backend requires a shared game; the mock records the outgoing request so + // the game's "add to friends" item reads as sent across reloads. + if (!this.outgoing.some((o) => o.accountId === accountId)) { + this.outgoing.push({ accountId, displayName: this.nameFor(accountId) }); + } } async friendRespond(requesterId: string, accept: boolean): Promise { const i = this.incoming.findIndex((r) => r.accountId === requesterId); diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index d9ab895..402f243 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -43,10 +43,9 @@ export const PROFILE: Profile = { // Seed social/account data for the mock (pnpm start + Playwright). The mock profile // is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable. -export const MOCK_FRIENDS: AccountRef[] = [ - { accountId: 'ann', displayName: 'Ann' }, - { accountId: 'kaya', displayName: 'Kaya' }, -]; +// Ann is the active game's opponent but deliberately not a friend, so the in-game +// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend. +export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }]; export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }]; @@ -144,6 +143,7 @@ function activeGame(): MockGame { turnTimeoutSecs: 86400, moveCount: G1_MOVES.length, endReason: '', + lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)], }, moves: G1_MOVES, @@ -177,6 +177,7 @@ function finishedG2(): MockGame { turnTimeoutSecs: 86400, moveCount: 2, endReason: 'normal', + lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)], }, moves: [ @@ -211,6 +212,7 @@ function finishedG3(): MockGame { turnTimeoutSecs: 86400, moveCount: 1, endReason: 'resignation', + lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)], }, moves: [ diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 318a2d2..9ce89d3 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -40,6 +40,8 @@ export interface GameView { turnTimeoutSecs: number; moveCount: number; endReason: string; + /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ + lastActivityUnix: number; seats: Seat[]; } diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 3a92bb0..d12bb59 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -22,6 +22,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView { turnTimeoutSecs: 0, moveCount: 0, endReason: '', + lastActivityUnix: 0, seats, }; } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 607db57..324a3f3 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -137,6 +137,9 @@ export function createTransport(baseUrl: string): GatewayClient { async friendsIncoming() { return codec.decodeIncomingList(await exec('friends.incoming', codec.empty())); }, + async friendsOutgoing() { + return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty())); + }, async friendRequest(accountId) { await exec('friends.request', codec.encodeTarget(accountId)); }, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index daf61a4..e4480a1 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -9,6 +9,7 @@ import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { resultBadge } from '../lib/result'; import { getLobby, setLobby } from '../lib/lobbycache'; + import { groupGames } from '../lib/lobbysort'; import type { AccountRef, GameView, Invitation } from '../lib/model'; let games = $state([]); @@ -46,8 +47,7 @@ }); const myId = $derived(app.session?.userId ?? ''); - const active = $derived(games.filter((g) => g.status === 'active')); - const finished = $derived(games.filter((g) => g.status !== 'active')); + const groups = $derived(groupGames(games, myId)); function opponents(g: GameView): string { return g.seats @@ -129,25 +129,26 @@
{/if} - {#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)} + {#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)} {#if group.list.length}
-

{t(group.h as 'lobby.activeGames')}

- {#each group.list as g (g.id)} - {@const b = resultBadge(g, myId)} - - {/each} +

{t(group.h as 'lobby.yourTurn')}

+
+ {#each group.list as g (g.id)} + + {/each} +
{/if} {/each} - {#if !active.length && !finished.length && !invitations.length} + {#if !games.length && !invitations.length}

{t('lobby.noActive')}

{/if}
@@ -186,7 +187,6 @@ font-size: 0.9rem; margin: 0; } - .row, .invite { display: flex; align-items: center; @@ -202,6 +202,31 @@ border-radius: var(--radius); user-select: none; } + /* Game rows are a compact, flat list: no per-card frame, a hairline divider between + consecutive rows (Stage 17). */ + .list { + display: flex; + flex-direction: column; + } + .row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + text-align: left; + padding: 10px 6px; + border: none; + background: none; + color: var(--text); + user-select: none; + } + .row + .row { + border-top: 1px solid var(--border); + } + .row:active { + background: var(--surface-2); + } .info { display: flex; flex-direction: column; -- 2.52.0 From 356f49054623bf5138c36f993439557ab74fc4be Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 19:58:55 +0200 Subject: [PATCH 041/223] Stage 17 round 6 (#18, PR D): admin Messages moderation section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new /_gm/messages console page lists posted chat messages (nudges excluded) newest-first — time, source (guest/robot/oldest identity kind), sender (linked to the user card), IP, body, game (linked to the game card) — searchable by sender name / external-id glob masks and pinnable to one game (?game=) or sender (?user=), linked from the game and user cards. The list query lives in social (raw SQL, kind='message', source via a SQL CASE), reusing the now-exported account.LikePattern. Server-rendered adminconsole MessagesView + messages.gohtml, 50/page via the shared pager. Tests: adminconsole render case; backend integration AdminListMessages (real Postgres) — nudge exclusion, game/sender pins, glob masks, source. Docs: ARCHITECTURE section 8 chat moderation, PLAN round-6. --- PLAN.md | 9 ++ backend/internal/account/userlist.go | 8 +- backend/internal/adminconsole/render_test.go | 1 + .../adminconsole/templates/layout.gohtml | 1 + .../templates/pages/game_detail.gohtml | 2 +- .../templates/pages/messages.gohtml | 37 ++++++ .../templates/pages/user_detail.gohtml | 2 +- backend/internal/adminconsole/views.go | 26 ++++ backend/internal/inttest/social_test.go | 51 ++++++++ .../internal/server/handlers_admin_console.go | 56 +++++++++ backend/internal/social/adminchat.go | 113 ++++++++++++++++++ docs/ARCHITECTURE.md | 6 +- 12 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 backend/internal/adminconsole/templates/pages/messages.gohtml create mode 100644 backend/internal/social/adminchat.go diff --git a/PLAN.md b/PLAN.md index 549c9aa..1c05946 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1378,6 +1378,15 @@ provided cert) at the contour caddy; prod VPN; rollback. sub-kind, decline) to the **original requester**, whose open game re-derives its friend state. Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design. + - **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists + posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot / + oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by + sender name / external-id glob masks and pinnable to one game (`?game=`) or sender (`?user=`), + linked from the game and user cards. Server-rendered (`adminconsole` `MessagesView` + + `messages.gohtml`, 50/page via the shared pager); the list query lives in `social` (raw SQL, + `kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern` + glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the + Users section), a top-level nav entry plus the card deep-links. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index e2c12fc..b2bd372 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -51,11 +51,11 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) func userListWhere(f UserFilter) (string, []any) { args := []any{f.Robots} where := robotExists + ` = $1` - if name := likePattern(f.NameMask); name != "" { + if name := LikePattern(f.NameMask); name != "" { args = append(args, name) where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) } - if ext := likePattern(f.ExternalIDMask); ext != "" { + if ext := LikePattern(f.ExternalIDMask); ext != "" { args = append(args, ext) where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) } @@ -95,9 +95,9 @@ func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) { return n, nil } -// likePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern, +// LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern, // escaping the SQL wildcards already in the input first. An empty/blank mask returns "". -func likePattern(mask string) string { +func LikePattern(mask string) string { mask = strings.TrimSpace(mask) if mask == "" { return "" diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 39778d2..ab98a2c 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -26,6 +26,7 @@ func TestRendererRendersEveryPage(t *testing.T) { {"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"}, {"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, + {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"}, {"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"}, {"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"}, diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index c7e4946..970d876 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -16,6 +16,7 @@ Users Games Complaints + Messages Dictionary Broadcast Grafana ↗ diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 976d691..068b1a6 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -1,7 +1,7 @@ {{define "content" -}} {{with .Data}}

Game {{.ID}}

- +

Summary

  • Variant {{.Variant}}
  • diff --git a/backend/internal/adminconsole/templates/pages/messages.gohtml b/backend/internal/adminconsole/templates/pages/messages.gohtml new file mode 100644 index 0000000..7c985f5 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/messages.gohtml @@ -0,0 +1,37 @@ +{{define "content" -}} +

    Messages

    +{{with .Data}} +
    +{{if .GameID}}{{end}} +{{if .UserID}}{{end}} + + + +
    +{{if or .GameID .UserID}} +

    Filtered{{if .GameID}} to game {{.GameID}}{{end}}{{if .UserID}} from sender{{end}} · clear

    +{{end}} + + + +{{range .Items}} + + + + + + + + +{{else}} + +{{end}} + +
    TimeSourceSenderIPMessageGame
    {{.CreatedAt}}{{.Source}}{{.SenderName}}{{.IP}}{{.Body}}game
    no messages
    + +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index eb0f664..08b75b2 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -1,7 +1,7 @@ {{define "content" -}} {{with .Data}}

    {{.DisplayName}}

    - +

    Account

      diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index cb92d26..1ef4aed 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -73,6 +73,32 @@ type UserRow struct { MoveMax string } +// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the +// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a +// game or user card); FilterQuery is the active filters encoded for the pager links. +type MessagesView struct { + Items []MessageRow + Pager Pager + NameMask string + ExtMask string + GameID string + UserID string + FilterQuery string +} + +// MessageRow is one chat message in the moderation list: its sender (linked to the user +// card), source, IP, body, game (linked to the game card) and time. +type MessageRow struct { + ID string + SenderID string + SenderName string + Source string + IP string + Body string + GameID string + CreatedAt string +} + // UserDetailView is one account with its stats, identities and recent games. type UserDetailView struct { ID string diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 3ecb7f0..b171ce6 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -501,3 +501,54 @@ func TestRespondPublishesToRequester(t *testing.T) { t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined) } } + +// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only +// (nudges excluded), the game / sender pins, the sender glob masks, and the source label. +func TestAdminListMessages(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move + if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.9"); err != nil { + t.Fatalf("post: %v", err) + } + if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges + t.Fatalf("nudge: %v", err) + } + + // Pinned to the game: the message is listed; the nudge (kind=nudge) is excluded. + msgs, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID}, 50, 0) + if err != nil { + t.Fatalf("admin list: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("game messages = %d, want 1 (nudge excluded)", len(msgs)) + } + if m := msgs[0]; m.Body != "good luck" || m.SenderID != seats[0] || m.SenderIP != "203.0.113.9" { + t.Fatalf("message = %+v, want body=good luck sender=seat0 ip=203.0.113.9", m) + } + if msgs[0].Source != "telegram" { // provisionAccount provisions a telegram identity + t.Errorf("source = %q, want telegram", msgs[0].Source) + } + if n, _ := svc.AdminCountMessages(ctx, social.AdminMessageFilter{GameID: gameID}); n != 1 { + t.Errorf("count = %d, want 1", n) + } + + // Sender pin: seat 0 has the message; seat 1 has only a nudge. + if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{SenderID: seats[0]}, 50, 0); len(got) == 0 { + t.Error("sender=seat0 returned nothing") + } + if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, SenderID: seats[1]}, 50, 0); len(got) != 0 { + t.Errorf("sender=seat1 has only a nudge, got %d messages", len(got)) + } + + // Sender glob masks: the telegram external id matches "tg-*"; bogus masks exclude. + if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "tg-*"}, 50, 0); len(got) != 1 { + t.Errorf("ext mask tg-* = %d, want 1", len(got)) + } + if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "zzz-*"}, 50, 0); len(got) != 0 { + t.Errorf("ext mask zzz-* = %d, want 0", len(got)) + } + if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, NameMask: "zzz-no-such-*"}, 50, 0); len(got) != 0 { + t.Errorf("name mask miss = %d, want 0", len(got)) + } +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 5631c8b..c1a0b3f 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -19,6 +19,7 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/robot" + "scrabble/backend/internal/social" ) // adminPageSize is the page size of the admin console's paginated lists. @@ -51,6 +52,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/complaints", s.consoleComplaints) gm.GET("/complaints/:id", s.consoleComplaintDetail) gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint) + gm.GET("/messages", s.consoleMessages) gm.GET("/dictionary", s.consoleDictionary) gm.POST("/dictionary/reload", s.consoleReloadDictionary) gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) @@ -130,6 +132,60 @@ func (s *Server) consoleUsers(c *gin.Context) { s.renderConsole(c, "users", "users", "Users", view) } +// consoleMessages renders the paginated chat-message moderation list, optionally pinned to +// one game (?game=) or sender (?user=) and filtered by sender glob masks (?name / ?ext). +func (s *Server) consoleMessages(c *gin.Context) { + ctx := c.Request.Context() + page := consolePage(c) + gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game"))) + userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user"))) + filter := social.AdminMessageFilter{ + GameID: gameID, + SenderID: userID, + NameMask: c.Query("name"), + ExtMask: c.Query("ext"), + } + total, _ := s.social.AdminCountMessages(ctx, filter) + items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize) + if err != nil { + s.consoleError(c, err) + return + } + q := url.Values{} + if filter.GameID != uuid.Nil { + q.Set("game", filter.GameID.String()) + } + if filter.SenderID != uuid.Nil { + q.Set("user", filter.SenderID.String()) + } + if strings.TrimSpace(filter.NameMask) != "" { + q.Set("name", filter.NameMask) + } + if strings.TrimSpace(filter.ExtMask) != "" { + q.Set("ext", filter.ExtMask) + } + view := adminconsole.MessagesView{ + Pager: adminconsole.NewPager(page, adminPageSize, total), + NameMask: filter.NameMask, + ExtMask: filter.ExtMask, + FilterQuery: q.Encode(), + } + if filter.GameID != uuid.Nil { + view.GameID = filter.GameID.String() + } + if filter.SenderID != uuid.Nil { + view.UserID = filter.SenderID.String() + } + for _, m := range items { + view.Items = append(view.Items, adminconsole.MessageRow{ + ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName, + Source: m.Source, IP: m.SenderIP, Body: m.Body, + GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt), + }) + } + s.renderConsole(c, "messages", "messages", "Messages", view) +} + // consoleUserDetail renders one account with its stats, identities and games. func (s *Server) consoleUserDetail(c *gin.Context) { ctx := c.Request.Context() diff --git a/backend/internal/social/adminchat.go b/backend/internal/social/adminchat.go new file mode 100644 index 0000000..cfeb9f2 --- /dev/null +++ b/backend/internal/social/adminchat.go @@ -0,0 +1,113 @@ +package social + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// AdminMessage is one chat message in the admin moderation list (Stage 17): the message +// plus its sender's resolved display name and source, for the operator console. +type AdminMessage struct { + ID uuid.UUID + GameID uuid.UUID + SenderID uuid.UUID + SenderName string + // Source is the sender's account kind: "guest", "robot", or its oldest identity kind + // (e.g. "email", "telegram"); "—" when it has none. + Source string + Body string + SenderIP string + CreatedAt time.Time +} + +// AdminMessageFilter narrows the admin message list. A nil GameID/SenderID leaves that +// field unfiltered; NameMask/ExtMask are glob masks (account.LikePattern) matched +// case-insensitively against the sender's display name / any identity's external id. +type AdminMessageFilter struct { + GameID uuid.UUID + SenderID uuid.UUID + NameMask string + ExtMask string +} + +// AdminListMessages returns the filtered chat messages — real messages only, nudges +// excluded — newest first, paginated, for the admin moderation console. +func (svc *Service) AdminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) { + return svc.store.adminListMessages(ctx, f, limit, offset) +} + +// AdminCountMessages counts the filtered chat messages, for the admin list pager. +func (svc *Service) AdminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) { + return svc.store.adminCountMessages(ctx, f) +} + +// adminMessageSource is the SQL CASE projecting a sender's source: guest, robot, or its +// oldest identity kind ("—" when it has none). +const adminMessageSource = `CASE + WHEN a.is_guest THEN 'guest' + WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot' + ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—') +END` + +// adminMessageWhere builds the shared WHERE clause and its positional args (from $1). +// Only real messages are listed; nudges are excluded. +func adminMessageWhere(f AdminMessageFilter) (string, []any) { + where := `m.kind = 'message'` + var args []any + if f.GameID != uuid.Nil { + args = append(args, f.GameID) + where += fmt.Sprintf(` AND m.game_id = $%d`, len(args)) + } + if f.SenderID != uuid.Nil { + args = append(args, f.SenderID) + where += fmt.Sprintf(` AND m.sender_id = $%d`, len(args)) + } + if name := account.LikePattern(f.NameMask); name != "" { + args = append(args, name) + where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) + } + if ext := account.LikePattern(f.ExtMask); ext != "" { + args = append(args, ext) + where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args)) + } + return where, args +} + +func (s *Store) adminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) { + where, args := adminMessageWhere(f) + q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at +FROM backend.chat_messages m +JOIN backend.accounts a ON a.account_id = m.sender_id +WHERE ` + where + + fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("social: admin list messages: %w", err) + } + defer rows.Close() + var out []AdminMessage + for rows.Next() { + var m AdminMessage + if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt); err != nil { + return nil, fmt.Errorf("social: scan admin message: %w", err) + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *Store) adminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) { + where, args := adminMessageWhere(f) + var n int + q := `SELECT COUNT(*) FROM backend.chat_messages m JOIN backend.accounts a ON a.account_id = m.sender_id WHERE ` + where + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("social: admin count messages: %w", err) + } + return n, nil +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea18f3c..fd8b691 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,7 +375,11 @@ English game the Latin pool. lightly obfuscated forms) are rejected, since the chat is for quick reactions, not contact exchange. Each message stores the sender's IP (forwarded by the gateway in Stage 6) for moderation. A sender who has disabled chat cannot post, - and messages from a blocked sender are hidden from the viewer. + and messages from a blocked sender are hidden from the viewer. The operator console + has a **Messages** section (Stage 17) that lists posted messages (nudges excluded) + newest-first with the sender's resolved name, **source** (guest / robot / oldest + identity kind), IP and game, searchable by sender name / external-id glob masks and + pinnable to one game or sender (linked from the game and user cards). - **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting the opponent may nudge **once per hour per game**; it is not allowed on one's own turn. The platform-native delivery is wired with the gateway / platform -- 2.52.0 From 645df52c0b0f58c0d0a45e1adbba6b0992901675 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 21:31:44 +0200 Subject: [PATCH 042/223] Round-6 follow-up: UX polish + client-IP fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Client IP: the compose caddy trusts X-Forwarded-For from private-range upstreams (trusted_proxies private_ranges), so the real client IP survives the host-caddy hop (it was logging the docker caddy hop 172.18.0.x for chat moderation and bucketing the gateway per-IP rate limiter on it). Correct and spoof-safe in both contours (prod has no host caddy); peerIP unit-tested. - Ad banner gated off behind a compile-time SHOW_AD_BANNER=false (the if-branch, the AdBanner import and banner.ts are tree-shaken out of the prod bundle). - Landing: the Telegram entry is just the 64px logo (clickable, no button/text). - TG-fullscreen header: title + menu centred as a pair (hamburger right of the title), pinned to the bottom of the TG nav band. - Edge-swipe back (Screen): a left-edge rightward drag navigates to back (touch/pen only, armed from <=24px; skipped inside Telegram). - Chat soft-keyboard: a bottom-sheet Modal lifted above the keyboard by a visualViewport-driven transform (compositor-only, no page/sheet relayout). iOS-specific, needs on-device tuning; native resize=none awaits Capacitor. - Tests: e2e for the in-game '✓ in friends' item and a board→board tile relocation; codec units for last_activity_unix + OutgoingRequestList. Deferred to the next PR (agreed): #4 enrich the your-turn/game-end push; #5 hide finished games from the lobby. --- PLAN.md | 25 ++++++++++++ deploy/caddy/Caddyfile | 8 ++++ docs/ARCHITECTURE.md | 11 ++++-- gateway/internal/connectsrv/peerip_test.go | 35 +++++++++++++++++ ui/e2e/game.spec.ts | 23 +++++++++++ ui/e2e/social.spec.ts | 12 ++++++ ui/src/Landing.svelte | 26 ++++++------- ui/src/components/Header.svelte | 24 +++++++++--- ui/src/components/Modal.svelte | 44 ++++++++++++++++++++-- ui/src/components/Screen.svelte | 30 ++++++++++++++- ui/src/game/Game.svelte | 2 +- ui/src/lib/codec.test.ts | 18 +++++++++ 12 files changed, 229 insertions(+), 29 deletions(-) create mode 100644 gateway/internal/connectsrv/peerip_test.go diff --git a/PLAN.md b/PLAN.md index 1c05946..3cb1e03 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1387,6 +1387,31 @@ provided cert) at the contour caddy; prod VPN; rollback. `kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern` glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the Users section), a top-level nav entry plus the card deep-links. + - **Round-6 follow-up — UX polish + client-IP fix (this PR):** + - **Client IP through the edge.** The compose caddy now sets `trusted_proxies static + private_ranges`, so the real client IP survives the host-caddy hop (it was logging the + docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's + per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host + caddy → public clients untrusted → real peer used). `peerIP` unit-tested. + - **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte` + — the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod + bundle (code kept for post-release polish). + - **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption). + - **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger + right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's + own controls. + - **Edge-swipe back** (`Screen.svelte`): a left-edge rightward drag navigates to `back` + (touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped + inside Telegram, which has its own back). + - **Chat soft-keyboard** is a **bottom-sheet** `Modal` lifted above the keyboard by a + `transform` driven by `visualViewport` (compositor-only — the board behind and the sheet + no longer relayout as the keyboard animates). iOS-specific; needs on-device fine-tuning. + The native `Keyboard.setResizeMode('none')` path waits for Capacitor (not yet wired). + - **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item + and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`. + - **Deferred to the next PR (agreed):** #4 enrich the out-of-app "your turn" / game-end push + with the opponent's name, last word and score; #5 let a player hide finished games from + their lobby (swipe + a desktop affordance). ## Deferred TODOs (cross-stage) diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 25a4e20..ba26fbe 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -8,6 +8,14 @@ # ACME and the contour is self-contained. { admin off + # Trust X-Forwarded-For from private-range upstreams so the real client IP survives + # (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy + # (a private IP) is trusted, so its forwarded client IP is preserved. Prod (no host caddy): + # clients connect from public IPs, which are NOT trusted, so Caddy uses the real peer — + # the same config is correct (and spoof-safe) in both contours (Stage 17). + servers { + trusted_proxies static private_ranges + } } {$CADDY_SITE_ADDRESS::80} { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd8b691..96154dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,8 +40,9 @@ Three executables plus per-platform side-services: in-memory mock transport (`pnpm start`) runs the whole slice with no backend. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), - a one-line **announcement banner** under the nav (a client-side mock rotation today — - a server-driven channel later, §10), and a client **board-style** setting (bonus-label + a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off + in the build until polished after release**, Stage 17 — a server-driven channel later, §10), + and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in [`UI_DESIGN.md`](UI_DESIGN.md). - **`platform/telegram`** — the Telegram side-service (the "connector", module @@ -608,7 +609,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): (`.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`). + (`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from + private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP — + used for chat-moderation logging and the gateway's per-IP rate limiting — survives the + host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the + real peer, so the single config is correct and spoof-safe in both contours (Stage 17). - **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. diff --git a/gateway/internal/connectsrv/peerip_test.go b/gateway/internal/connectsrv/peerip_test.go new file mode 100644 index 0000000..f54b1cb --- /dev/null +++ b/gateway/internal/connectsrv/peerip_test.go @@ -0,0 +1,35 @@ +package connectsrv + +import ( + "net/http" + "testing" +) + +// TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate +// limiter both rely on: the first X-Forwarded-For hop (the real client, once Caddy is +// configured to trust its upstream), falling back to the connection peer (Stage 17). +func TestPeerIP(t *testing.T) { + tests := []struct { + name string + addr string + xff string + want string + }{ + {"xff single", "10.0.0.1:5000", "203.0.113.7", "203.0.113.7"}, + {"xff client then proxies", "10.0.0.1:5000", "203.0.113.7, 172.18.0.3", "203.0.113.7"}, + {"xff trims spaces", "10.0.0.1:5000", " 203.0.113.9 , 10.0.0.2", "203.0.113.9"}, + {"no xff uses peer host", "203.0.113.5:42000", "", "203.0.113.5"}, + {"no xff no port", "203.0.113.6", "", "203.0.113.6"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := http.Header{} + if tc.xff != "" { + h.Set("X-Forwarded-For", tc.xff) + } + if got := peerIP(tc.addr, h); got != tc.want { + t.Errorf("peerIP(%q, xff=%q) = %q, want %q", tc.addr, tc.xff, got, tc.want) + } + }) + } +} diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 511d7c2..7abf0a2 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -139,6 +139,29 @@ test('dropping the game ends it and shows the result', async ({ page }) => { await expect(page.locator('.status .over')).toBeVisible(); }); +test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + const from = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; + + const target = page.locator('[data-cell]:not(.filled):not(.pending)').nth(45); + const fb = await pending.first().boundingBox(); + const tb = await target.boundingBox(); + // Pointer-drag the placed tile to a new cell (mouse events synthesise pointer events). + await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2); + await page.mouse.down(); + await page.mouse.move(tb!.x + tb!.width / 2, tb!.y + tb!.height / 2, { steps: 10 }); + await page.mouse.up(); + + // Still exactly one pending tile (relocated, not duplicated), now at a different cell. + await expect(pending).toHaveCount(1); + const to = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; + expect(to).not.toBe(from); +}); + test('the board-label mode in Settings changes the on-board labels', async ({ page }) => { await openGame(page); // beginner (default) renders split "3× / word" labels. diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index a0ce08b..228eeda 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -122,6 +122,18 @@ test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) await expect(sent).toBeDisabled(); }); +test('game: an opponent who is already a friend shows a disabled "in friends"', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend + await page.locator('.burger').first().click(); + // The in-game friend item is derived from the server's friend list (Stage 17): a friend reads + // a disabled "✓ in friends", not the addable "Add to friends". + const inFriends = page.getByRole('button', { name: /in friends/i }); + await expect(inFriends).toBeVisible(); + await expect(inFriends).toBeDisabled(); + await expect(page.getByRole('button', { name: /Add to friends: Kaya/ })).toHaveCount(0); +}); + test('profile edit disables Save and flags an invalid display name', async ({ page }) => { await loginLobby(page); await page.locator('.burger').first().click(); diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 16587da..64fd2bd 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -74,9 +74,8 @@

      {about.title}

      {t('landing.tagline')}

      {#if tgLink} - - - {t('landing.playTelegram')} + + {/if}
    @@ -192,21 +191,20 @@ color: var(--text-muted); font-size: 1.05rem; } - .play { + /* The Telegram entry is just the bigger logo (no button chrome, no caption); the link + keeps an aria-label for assistive tech (Stage 17). */ + .tg { align-self: center; display: inline-flex; - align-items: center; - gap: 9px; - padding: 12px 24px; - border-radius: var(--radius-sm); - font-weight: 700; - text-decoration: none; - background: var(--accent); - color: var(--accent-text); - margin-top: 6px; + margin-top: 8px; + border-radius: 50%; } - .play img { + .tg img { display: block; + transition: transform 0.12s ease; + } + .tg:hover img { + transform: scale(1.06); } .info { background: var(--surface-2); diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index e2b5a2c..34b6529 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -89,11 +89,25 @@ transform: rotate(45deg); margin-left: 3px; } - /* Telegram fullscreen: its native nav overlays the top of the viewport (height - --tg-content-top, set from the content-safe-area inset). Drop the header content below the - nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the - corners, leaving the centre clear (Stage 17). */ + /* Telegram fullscreen: TG's native nav overlays a band of height --tg-content-top at the top + of the viewport. Pull our title + menu up into the BOTTOM of that band and centre them as a + pair (hamburger right of the title) so they line up with Telegram's own nav controls rather + than floating above them (Stage 17). */ :global(html.tg-fullscreen) .bar { - padding-top: var(--tg-content-top); + min-height: var(--tg-content-top); + box-sizing: border-box; + align-items: flex-end; + justify-content: center; + padding-top: 0; + padding-bottom: 6px; + } + :global(html.tg-fullscreen) .spacer { + display: none; + } + :global(html.tg-fullscreen) h1 { + flex: 0 1 auto; + } + :global(html.tg-fullscreen) .end { + min-width: 0; } diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte index 7fa7379..5317616 100644 --- a/ui/src/components/Modal.svelte +++ b/ui/src/components/Modal.svelte @@ -5,8 +5,15 @@ title = '', onclose, overlayKeyboard = false, + bottomSheet = false, children, - }: { title?: string; onclose?: () => void; overlayKeyboard?: boolean; children?: Snippet } = $props(); + }: { + title?: string; + onclose?: () => void; + overlayKeyboard?: boolean; + bottomSheet?: 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 @@ -14,14 +21,21 @@ // 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). + // bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the + // keyboard with a transform (kb), driven by the visual viewport — a compositor-only + // move, so neither the page behind nor the sheet relayouts as the keyboard animates + // (Stage 17). The backdrop is not resized in this mode (no per-event reflow). let vh = $state(0); let top = $state(0); + let kb = $state(0); $effect(() => { const vv = typeof window !== 'undefined' ? window.visualViewport : null; if (!vv || overlayKeyboard) return; const update = () => { vh = vv.height; top = vv.offsetTop; + // Soft-keyboard height: the layout viewport minus the visible viewport. + kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); }; update(); vv.addEventListener('resize', update); @@ -38,11 +52,19 @@
    onclose?.()} > -
+{{if .FlaggedHighRateAt}} +
+ +
+{{end}}

Statistics

{{if .HasStats}} diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index ef8cf21..9a116a0 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -17,7 +17,7 @@ {{range .Items}} {{.ID}} -{{.DisplayName}}{{if .Guest}} guest{{end}} +{{.DisplayName}}{{if .Guest}} guest{{end}}{{if .FlaggedHighRate}} high-rate{{end}} {{.Kind}} {{.Language}} {{.CreatedAt}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 1ef4aed..8aef4c0 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -59,18 +59,20 @@ type UsersView struct { } // 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). +// pre-formatted move-duration summary (empty when it has no timed move); +// FlaggedHighRate marks the soft high-rate badge (R3). type UserRow struct { - ID string - DisplayName string - Kind string - Language string - Guest bool - CreatedAt string - HasMoveStats bool - MoveMin string - MoveAvg string - MoveMax string + ID string + DisplayName string + Kind string + Language string + Guest bool + FlaggedHighRate bool + CreatedAt string + HasMoveStats bool + MoveMin string + MoveAvg string + MoveMax string } // MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the @@ -110,15 +112,18 @@ type UserDetailView struct { PaidAccount bool // MergedInto is the primary account id when this account has been retired by a // merge (Stage 11), or empty for a live account. - MergedInto string - HintBalance int - CreatedAt string - HasStats bool - Stats StatsRow - Identities []IdentityRow - Games []GameRow - TelegramID string - ConnectorEnabled bool + MergedInto string + // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, + // empty for an unflagged account; the card shows it with the Clear action (R3). + FlaggedHighRateAt string + HintBalance int + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow + 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 @@ -247,6 +252,35 @@ type BroadcastView struct { ConnectorEnabled bool } +// ThrottledView is the rate-limit observability page: the recent gateway-reported +// throttle episodes (in-memory, reset on restart) and the accounts currently +// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active +// auto-flag tuning. +type ThrottledView struct { + Episodes []ThrottleEpisodeRow + Flagged []FlaggedAccountRow + FlagThreshold int + FlagWindow string +} + +// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the +// user card and is set only for the user class (the other classes key by IP). +type ThrottleEpisodeRow struct { + Class string + Key string + UserID string + Rejected int + FirstSeen string + LastSeen string +} + +// FlaggedAccountRow is one account carrying the high-rate flag. +type FlaggedAccountRow struct { + ID string + DisplayName string + FlaggedAt string +} + // MessageView is the result page shown after a POST action. type MessageView struct { Heading string diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9cc69e0..063911f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -12,6 +12,7 @@ import ( "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/postgres" + "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/robot" "scrabble/backend/internal/telemetry" ) @@ -35,6 +36,9 @@ type Config struct { Lobby lobby.Config // Robot configures the robot opponent driver (scan cadence). Robot robot.Config + // RateWatch tunes the conservative high-rate auto-flag applied to the + // gateway's rate-limiter rejection reports (R3). + RateWatch ratewatch.Config // SMTP configures the email relay used for confirm-codes. An empty Host // selects the development log mailer (the code is logged, not sent). SMTP account.SMTPConfig @@ -105,6 +109,14 @@ func Load() (Config, error) { return Config{}, err } + rw := ratewatch.DefaultConfig() + if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil { + return Config{}, err + } + if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil { + return Config{}, err + } + guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval) if err != nil { return Config{}, err @@ -131,6 +143,7 @@ func Load() (Config, error) { Game: gm, Lobby: lb, Robot: rb, + RateWatch: rw, SMTP: smtp, ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"), GuestReapInterval: guestReapInterval, @@ -170,6 +183,9 @@ func (c Config) validate() error { if err := c.Robot.Validate(); err != nil { return fmt.Errorf("config: %w", err) } + if err := c.RateWatch.Validate(); err != nil { + return fmt.Errorf("config: %w", err) + } if c.GuestReapInterval <= 0 { return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive") } diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 1be5922..286b171 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "testing" + "time" "github.com/google/uuid" @@ -195,6 +196,62 @@ func TestServiceLanguageRoundTrip(t *testing.T) { } } +// TestHighRateFlagRoundTrip covers the R3 soft high-rate marker: a fresh account +// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode +// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after +// the operator clear takes a fresh timestamp. +func TestHighRateFlagRoundTrip(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + if !acc.FlaggedHighRateAt.IsZero() { + t.Fatalf("fresh FlaggedHighRateAt = %v, want zero", acc.FlaggedHighRateAt) + } + + first := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + set, err := store.FlagHighRate(ctx, acc.ID, first) + if err != nil { + t.Fatalf("flag: %v", err) + } + if !set { + t.Fatal("first FlagHighRate reported not set") + } + if set, err = store.FlagHighRate(ctx, acc.ID, first.Add(time.Hour)); err != nil { + t.Fatalf("re-flag: %v", err) + } else if set { + t.Fatal("second FlagHighRate must not overwrite the marker") + } + got, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if !got.FlaggedHighRateAt.Equal(first) { + t.Errorf("FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, first) + } + + if err := store.ClearHighRateFlag(ctx, acc.ID); err != nil { + t.Fatalf("clear: %v", err) + } + if got, err = store.GetByID(ctx, acc.ID); err != nil { + t.Fatalf("get by id: %v", err) + } else if !got.FlaggedHighRateAt.IsZero() { + t.Errorf("cleared FlaggedHighRateAt = %v, want zero", got.FlaggedHighRateAt) + } + + second := first.Add(24 * time.Hour) + if set, err = store.FlagHighRate(ctx, acc.ID, second); err != nil || !set { + t.Fatalf("re-flag after clear = (%v, %v), want (true, nil)", set, err) + } + if got, err = store.GetByID(ctx, acc.ID); err != nil { + t.Fatalf("get by id: %v", err) + } else if !got.FlaggedHighRateAt.Equal(second) { + t.Errorf("re-flagged FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, second) + } +} + // TestIdentityExternalID covers the reverse identity lookup the push-target route // uses: it returns the external_id for the matching kind and ErrNotFound otherwise, // including for a guest that carries no identity. diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 6b77c97..0171c54 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -16,6 +16,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/server" ) @@ -206,6 +207,72 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) { } } +// TestConsoleThrottledViewAndFlagClear drives the R3 rate-limit surface end to +// end against real stores: a gateway report past the threshold auto-flags the +// account, the throttled view shows the episode and the flagged account, the +// user card carries the marker, and the operator clear (a same-origin POST) +// reverses it. +func TestConsoleThrottledViewAndFlagClear(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player") + if err != nil { + t.Fatalf("provision: %v", err) + } + + watch := ratewatch.New(ratewatch.Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, accounts, zap.NewNop()) + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), + Accounts: accounts, + Games: newGameService(), + Registry: testRegistry, + DictDir: dictDir(), + RateWatch: watch, + }) + h := srv.Handler() + + report := `{"window_seconds":30,"entries":[` + + `{"class":"user","key":"` + acc.ID.String() + `","rejected":150},` + + `{"class":"public","key":"10.1.2.3","rejected":7}]}` + req := httptest.NewRequest(http.MethodPost, "http://admin.test/api/v1/internal/ratelimit/report", strings.NewReader(report)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("report = %d, want 204", rec.Code) + } + + got, err := accounts.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if got.FlaggedHighRateAt.IsZero() { + t.Fatal("account not auto-flagged past the threshold") + } + + base := "http://admin.test/_gm" + code, body := consoleDo(h, http.MethodGet, base+"/throttled", "", "") + if code != http.StatusOK || !strings.Contains(body, acc.ID.String()) || + !strings.Contains(body, "10.1.2.3") || !strings.Contains(body, "Throttled Player") { + t.Fatalf("throttled view = %d, episode/flag shown = %v/%v", + code, strings.Contains(body, "10.1.2.3"), strings.Contains(body, "Throttled Player")) + } + if code, body = consoleDo(h, http.MethodGet, base+"/users/"+acc.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "Clear high-rate flag") { + t.Fatalf("user card = %d, has clear action = %v", code, strings.Contains(body, "Clear high-rate flag")) + } + + // The clear POST is CSRF-guarded like every console action. + if code, _ = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "", ""); code != http.StatusForbidden { + t.Fatalf("clear without origin = %d, want 403", code) + } + if code, body = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "x=1", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Cleared") { + t.Fatalf("clear with origin = %d, has Cleared = %v", code, strings.Contains(body, "Cleared")) + } + if got, err = accounts.GetByID(ctx, acc.ID); err != nil || !got.FlaggedHighRateAt.IsZero() { + t.Fatalf("flag survived the clear: %v (err %v)", got.FlaggedHighRateAt, err) + } +} + // 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/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index 2501d3a..ae26769 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -30,4 +30,5 @@ type Accounts struct { MergedInto *uuid.UUID MergedAt *time.Time ServiceLanguage *string + FlaggedHighRateAt *time.Time } diff --git a/backend/internal/postgres/jet/backend/model/game_drafts.go b/backend/internal/postgres/jet/backend/model/game_drafts.go new file mode 100644 index 0000000..c9c32c2 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/game_drafts.go @@ -0,0 +1,21 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type GameDrafts struct { + GameID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID `sql:"primary_key"` + RackOrder string + BoardTiles string + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/game_hidden.go b/backend/internal/postgres/jet/backend/model/game_hidden.go new file mode 100644 index 0000000..d64f4a1 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/game_hidden.go @@ -0,0 +1,19 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type GameHidden struct { + AccountID uuid.UUID `sql:"primary_key"` + GameID uuid.UUID `sql:"primary_key"` + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index 906a396..021e7a4 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -34,6 +34,7 @@ type accountsTable struct { MergedInto postgres.ColumnString MergedAt postgres.ColumnTimestampz ServiceLanguage postgres.ColumnString + FlaggedHighRateAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -92,8 +93,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedIntoColumn = postgres.StringColumn("merged_into") MergedAtColumn = postgres.TimestampzColumn("merged_at") ServiceLanguageColumn = postgres.StringColumn("service_language") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn} + FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn} defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn} ) @@ -118,6 +120,7 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { MergedInto: MergedIntoColumn, MergedAt: MergedAtColumn, ServiceLanguage: ServiceLanguageColumn, + FlaggedHighRateAt: FlaggedHighRateAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/jet/backend/table/game_drafts.go b/backend/internal/postgres/jet/backend/table/game_drafts.go new file mode 100644 index 0000000..18f099e --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/game_drafts.go @@ -0,0 +1,90 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var GameDrafts = newGameDraftsTable("backend", "game_drafts", "") + +type gameDraftsTable struct { + postgres.Table + + // Columns + GameID postgres.ColumnString + AccountID postgres.ColumnString + RackOrder postgres.ColumnString + BoardTiles postgres.ColumnString + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type GameDraftsTable struct { + gameDraftsTable + + EXCLUDED gameDraftsTable +} + +// AS creates new GameDraftsTable with assigned alias +func (a GameDraftsTable) AS(alias string) *GameDraftsTable { + return newGameDraftsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new GameDraftsTable with assigned schema name +func (a GameDraftsTable) FromSchema(schemaName string) *GameDraftsTable { + return newGameDraftsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new GameDraftsTable with assigned table prefix +func (a GameDraftsTable) WithPrefix(prefix string) *GameDraftsTable { + return newGameDraftsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new GameDraftsTable with assigned table suffix +func (a GameDraftsTable) WithSuffix(suffix string) *GameDraftsTable { + return newGameDraftsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newGameDraftsTable(schemaName, tableName, alias string) *GameDraftsTable { + return &GameDraftsTable{ + gameDraftsTable: newGameDraftsTableImpl(schemaName, tableName, alias), + EXCLUDED: newGameDraftsTableImpl("", "excluded", ""), + } +} + +func newGameDraftsTableImpl(schemaName, tableName, alias string) gameDraftsTable { + var ( + GameIDColumn = postgres.StringColumn("game_id") + AccountIDColumn = postgres.StringColumn("account_id") + RackOrderColumn = postgres.StringColumn("rack_order") + BoardTilesColumn = postgres.StringColumn("board_tiles") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{GameIDColumn, AccountIDColumn, RackOrderColumn, BoardTilesColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{RackOrderColumn, BoardTilesColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{RackOrderColumn, BoardTilesColumn, UpdatedAtColumn} + ) + + return gameDraftsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + GameID: GameIDColumn, + AccountID: AccountIDColumn, + RackOrder: RackOrderColumn, + BoardTiles: BoardTilesColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/game_hidden.go b/backend/internal/postgres/jet/backend/table/game_hidden.go new file mode 100644 index 0000000..d55a2a9 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/game_hidden.go @@ -0,0 +1,84 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var GameHidden = newGameHiddenTable("backend", "game_hidden", "") + +type gameHiddenTable struct { + postgres.Table + + // Columns + AccountID postgres.ColumnString + GameID postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type GameHiddenTable struct { + gameHiddenTable + + EXCLUDED gameHiddenTable +} + +// AS creates new GameHiddenTable with assigned alias +func (a GameHiddenTable) AS(alias string) *GameHiddenTable { + return newGameHiddenTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new GameHiddenTable with assigned schema name +func (a GameHiddenTable) FromSchema(schemaName string) *GameHiddenTable { + return newGameHiddenTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new GameHiddenTable with assigned table prefix +func (a GameHiddenTable) WithPrefix(prefix string) *GameHiddenTable { + return newGameHiddenTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new GameHiddenTable with assigned table suffix +func (a GameHiddenTable) WithSuffix(suffix string) *GameHiddenTable { + return newGameHiddenTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newGameHiddenTable(schemaName, tableName, alias string) *GameHiddenTable { + return &GameHiddenTable{ + gameHiddenTable: newGameHiddenTableImpl(schemaName, tableName, alias), + EXCLUDED: newGameHiddenTableImpl("", "excluded", ""), + } +} + +func newGameHiddenTableImpl(schemaName, tableName, alias string) gameHiddenTable { + var ( + AccountIDColumn = postgres.StringColumn("account_id") + GameIDColumn = postgres.StringColumn("game_id") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{AccountIDColumn, GameIDColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{CreatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn} + ) + + return gameHiddenTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + AccountID: AccountIDColumn, + GameID: GameIDColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index edc7307..70d757d 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -18,6 +18,8 @@ func UseSchema(schema string) { EmailConfirmations = EmailConfirmations.FromSchema(schema) FriendCodes = FriendCodes.FromSchema(schema) Friendships = Friendships.FromSchema(schema) + GameDrafts = GameDrafts.FromSchema(schema) + GameHidden = GameHidden.FromSchema(schema) GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema) GameInvitations = GameInvitations.FromSchema(schema) GameMoves = GameMoves.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index 7a63bef..f33592e 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -35,6 +35,10 @@ CREATE TABLE accounts ( merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL, merged_at timestamptz, service_language text CHECK (service_language IN ('en', 'ru')), + -- Soft, reversible "suspected high-rate" marker (R3): set once when the gateway + -- reports sustained rate-limiter rejections past the threshold; an operator + -- clears it in the admin console. Never an automatic ban. + flagged_high_rate_at timestamptz, CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')), CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0) ); diff --git a/backend/internal/ratewatch/ratewatch.go b/backend/internal/ratewatch/ratewatch.go new file mode 100644 index 0000000..f236f78 --- /dev/null +++ b/backend/internal/ratewatch/ratewatch.go @@ -0,0 +1,235 @@ +// Package ratewatch ingests the gateway's periodic rate-limiter rejection +// reports (R3). It keeps an in-memory window of recent throttle episodes for +// the admin console's view and applies the conservative high-rate auto-flag: +// when one account's rejections within the rolling window cross the threshold, +// the account store stamps the soft, reversible flagged_high_rate_at marker +// (set-once; an operator clears it; never an automatic ban). Like the gateway's +// active_users gauge it is single-instance and resets on restart by design — +// the durable part is the account flag, not the episode window. +package ratewatch + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// ClassUser is the limiter class whose keys are account ids — the only class +// the auto-flag applies to (the others are keyed by client IP). +const ClassUser = "user" + +const ( + // maxSeries bounds the distinct (class, key) series kept for the console + // view, so a key-spraying client cannot grow the map: past the bound the + // least-recently-throttled series is evicted. + maxSeries = 200 + // minRetention keeps an episode visible in the console for at least an hour + // after its last rejection (longer when the flag window is longer). + minRetention = time.Hour +) + +// Config tunes the conservative high-rate auto-flag. +type Config struct { + // FlagThreshold is the rejected-call count within FlagWindow past which a + // user account is flagged. + FlagThreshold int + // FlagWindow is the rolling window the rejections accumulate over. + FlagWindow time.Duration +} + +// DefaultConfig returns the agreed conservative defaults — 1000 rejected calls +// within a rolling 10 minutes (~1.7/s sustained, far above the client's +// capped-backoff retry noise yet a fraction of an abusive loop). +func DefaultConfig() Config { + return Config{FlagThreshold: 1000, FlagWindow: 10 * time.Minute} +} + +// Validate reports whether the configuration values are acceptable. +func (c Config) Validate() error { + if c.FlagThreshold <= 0 { + return fmt.Errorf("ratewatch: flag threshold must be positive") + } + if c.FlagWindow <= 0 { + return fmt.Errorf("ratewatch: flag window must be positive") + } + return nil +} + +// Flagger stamps the account-level high-rate marker; account.Store satisfies it. +type Flagger interface { + FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) +} + +// Entry is one reported aggregate: the rejections of one limiter key within one +// gateway report window (the wire mirror of the gateway's rejection summary). +type Entry struct { + Class string + Key string + Rejected int +} + +// Episode is one key's recent-throttle aggregate for the admin view. +type Episode struct { + Class string + Key string + Rejected int + FirstSeen time.Time + LastSeen time.Time +} + +// Watch accumulates reports and applies the auto-flag rule. +type Watch struct { + cfg Config + flagger Flagger + log *zap.Logger + now func() time.Time + + mu sync.Mutex + series map[seriesKey]*series +} + +type seriesKey struct{ class, key string } + +type series struct { + points []point // ascending by time +} + +type point struct { + at time.Time + n int +} + +// New constructs a Watch over flagger with cfg. A nil logger is replaced by a +// no-op one; a nil flagger disables the auto-flag (the view still works). +func New(cfg Config, flagger Flagger, log *zap.Logger) *Watch { + if log == nil { + log = zap.NewNop() + } + return &Watch{ + cfg: cfg, + flagger: flagger, + log: log, + now: time.Now, + series: make(map[seriesKey]*series), + } +} + +// Ingest records one gateway report. Entries with an empty class or key or a +// non-positive count are skipped. When a user-class series crosses the flag +// threshold within the flag window, the account is flagged (the store keeps it +// set-once, so a sustained episode costs one no-op UPDATE per report). +func (w *Watch) Ingest(ctx context.Context, entries []Entry) { + if len(entries) == 0 { + return + } + now := w.now() + var flag []uuid.UUID + w.mu.Lock() + for _, e := range entries { + if e.Class == "" || e.Key == "" || e.Rejected <= 0 { + continue + } + k := seriesKey{class: e.Class, key: e.Key} + s := w.series[k] + if s == nil { + s = &series{} + w.series[k] = s + } + s.points = append(s.points, point{at: now, n: e.Rejected}) + if e.Class == ClassUser && s.sumSince(now.Add(-w.cfg.FlagWindow)) >= w.cfg.FlagThreshold { + if id, err := uuid.Parse(e.Key); err == nil { + flag = append(flag, id) + } + } + } + w.pruneLocked(now) + w.mu.Unlock() + + if w.flagger == nil { + return + } + for _, id := range flag { + set, err := w.flagger.FlagHighRate(ctx, id, now) + switch { + case err != nil: + w.log.Warn("high-rate flag failed", zap.String("account_id", id.String()), zap.Error(err)) + case set: + w.log.Info("account flagged high-rate", + zap.String("account_id", id.String()), + zap.Int("threshold", w.cfg.FlagThreshold), + zap.Duration("window", w.cfg.FlagWindow)) + } + } +} + +// Config returns the active auto-flag tuning (the admin console captions it). +func (w *Watch) Config() Config { return w.cfg } + +// Recent returns the retained throttle episodes, most recently throttled first. +func (w *Watch) Recent() []Episode { + w.mu.Lock() + defer w.mu.Unlock() + out := make([]Episode, 0, len(w.series)) + for k, s := range w.series { + if len(s.points) == 0 { + continue + } + ep := Episode{ + Class: k.class, + Key: k.key, + FirstSeen: s.points[0].at, + LastSeen: s.points[len(s.points)-1].at, + } + for _, p := range s.points { + ep.Rejected += p.n + } + out = append(out, ep) + } + sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) }) + return out +} + +// sumSince totals the points at or after cutoff. +func (s *series) sumSince(cutoff time.Time) int { + sum := 0 + for i := len(s.points) - 1; i >= 0; i-- { + if s.points[i].at.Before(cutoff) { + break + } + sum += s.points[i].n + } + return sum +} + +// pruneLocked drops points past retention, empty series, and — past maxSeries — +// the least-recently-throttled series. The caller holds w.mu. +func (w *Watch) pruneLocked(now time.Time) { + cutoff := now.Add(-max(minRetention, w.cfg.FlagWindow)) + for k, s := range w.series { + i := 0 + for i < len(s.points) && s.points[i].at.Before(cutoff) { + i++ + } + s.points = s.points[i:] + if len(s.points) == 0 { + delete(w.series, k) + } + } + for len(w.series) > maxSeries { + var oldest seriesKey + var oldestAt time.Time + first := true + for k, s := range w.series { + last := s.points[len(s.points)-1].at + if first || last.Before(oldestAt) { + oldest, oldestAt, first = k, last, false + } + } + delete(w.series, oldest) + } +} diff --git a/backend/internal/ratewatch/ratewatch_test.go b/backend/internal/ratewatch/ratewatch_test.go new file mode 100644 index 0000000..0bde791 --- /dev/null +++ b/backend/internal/ratewatch/ratewatch_test.go @@ -0,0 +1,140 @@ +package ratewatch + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" +) + +// fakeFlagger records flag calls and reports them as newly set. +type fakeFlagger struct { + calls []uuid.UUID +} + +func (f *fakeFlagger) FlagHighRate(_ context.Context, id uuid.UUID, _ time.Time) (bool, error) { + f.calls = append(f.calls, id) + return true, nil +} + +// watchAt returns a Watch with a controllable clock. +func watchAt(cfg Config, flagger Flagger, at *time.Time) *Watch { + w := New(cfg, flagger, nil) + w.now = func() time.Time { return *at } + return w +} + +// TestIngestAggregatesAndRecent verifies episodes accumulate per (class, key), +// invalid entries are skipped, and Recent orders by last rejection. +func TestIngestAggregatesAndRecent(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + w := watchAt(DefaultConfig(), nil, &now) + ctx := context.Background() + + w.Ingest(ctx, []Entry{ + {Class: "public", Key: "10.0.0.1", Rejected: 3}, + {Class: "user", Key: "u-1", Rejected: 5}, + {Class: "", Key: "x", Rejected: 1}, + {Class: "user", Key: "", Rejected: 1}, + {Class: "user", Key: "u-1", Rejected: 0}, + }) + now = now.Add(30 * time.Second) + w.Ingest(ctx, []Entry{{Class: "public", Key: "10.0.0.1", Rejected: 4}}) + + got := w.Recent() + if len(got) != 2 { + t.Fatalf("Recent returned %d episodes, want 2", len(got)) + } + if got[0].Class != "public" || got[0].Key != "10.0.0.1" || got[0].Rejected != 7 { + t.Errorf("first episode = %+v, want public/10.0.0.1 rejected=7", got[0]) + } + if !got[0].LastSeen.After(got[0].FirstSeen) { + t.Errorf("episode span = [%v, %v], want a positive span", got[0].FirstSeen, got[0].LastSeen) + } + if got[1].Class != "user" || got[1].Rejected != 5 { + t.Errorf("second episode = %+v, want user rejected=5", got[1]) + } +} + +// TestAutoFlagThreshold verifies the flag fires only for a user-class series +// crossing the threshold within the window, with a parseable account id. +func TestAutoFlagThreshold(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + flagged := &fakeFlagger{} + id := uuid.New() + w := watchAt(Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, flagged, &now) + ctx := context.Background() + + w.Ingest(ctx, []Entry{ + {Class: "user", Key: id.String(), Rejected: 99}, + {Class: "public", Key: "10.0.0.1", Rejected: 1000}, + {Class: "user", Key: "not-a-uuid", Rejected: 1000}, + }) + if len(flagged.calls) != 0 { + t.Fatalf("flagged %v below the threshold", flagged.calls) + } + + now = now.Add(30 * time.Second) + w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 1}}) + if len(flagged.calls) != 1 || flagged.calls[0] != id { + t.Fatalf("flag calls = %v, want exactly [%s]", flagged.calls, id) + } +} + +// TestAutoFlagWindowExpiry verifies rejections age out of the rolling window. +func TestAutoFlagWindowExpiry(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + flagged := &fakeFlagger{} + id := uuid.New() + w := watchAt(Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, flagged, &now) + ctx := context.Background() + + w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 60}}) + now = now.Add(11 * time.Minute) + w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 60}}) + if len(flagged.calls) != 0 { + t.Fatalf("flagged %v across an expired window", flagged.calls) + } + now = now.Add(time.Minute) + w.Ingest(ctx, []Entry{{Class: "user", Key: id.String(), Rejected: 50}}) + if len(flagged.calls) != 1 { + t.Fatalf("flag calls = %v, want one in-window crossing", flagged.calls) + } +} + +// TestSeriesBound verifies the episode map stays bounded by evicting the +// least-recently-throttled series. +func TestSeriesBound(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + w := watchAt(DefaultConfig(), nil, &now) + ctx := context.Background() + + for i := range maxSeries + 10 { + now = now.Add(time.Second) + w.Ingest(ctx, []Entry{{Class: "public", Key: fmt.Sprintf("10.0.%d.%d", i/256, i%256), Rejected: 1}}) + } + got := w.Recent() + if len(got) != maxSeries { + t.Fatalf("retained %d series, want %d", len(got), maxSeries) + } + for _, ep := range got { + if ep.Key == "10.0.0.0" { + t.Fatal("the least-recently-throttled series survived the bound") + } + } +} + +// TestConfigValidate covers the tuning guards. +func TestConfigValidate(t *testing.T) { + if err := DefaultConfig().Validate(); err != nil { + t.Errorf("default config invalid: %v", err) + } + if err := (Config{FlagThreshold: 0, FlagWindow: time.Minute}).Validate(); err == nil { + t.Error("zero threshold passed validation") + } + if err := (Config{FlagThreshold: 1, FlagWindow: 0}).Validate(); err == nil { + t.Error("zero window passed validation") + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index f760409..374df86 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -37,6 +37,11 @@ func (s *Server) registerRoutes() { // before delivering an out-of-app notification. in.POST("/push-target", s.handlePushTarget) } + if s.ratewatch != nil { + // The gateway's periodic rate-limiter rejection summary (R3): feeds the + // admin console's throttled view and the high-rate auto-flag. + s.internal.POST("/ratelimit/report", s.handleRateLimitReport) + } u := s.user if s.accounts != nil { u.GET("/profile", s.handleProfile) @@ -120,10 +125,8 @@ func gameIDParam(c *gin.Context) (uuid.UUID, bool) { // X-Forwarded-For (the first hop), falling back to the direct peer. func clientIP(c *gin.Context) string { if xff := c.GetHeader("X-Forwarded-For"); xff != "" { - if i := strings.IndexByte(xff, ','); i >= 0 { - return strings.TrimSpace(xff[:i]) - } - return strings.TrimSpace(xff) + first, _, _ := strings.Cut(xff, ",") + return strings.TrimSpace(first) } return c.ClientIP() } diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index bfa3758..e86302f 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -19,6 +19,7 @@ import ( "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/robot" "scrabble/backend/internal/social" ) @@ -48,6 +49,8 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/users", s.consoleUsers) gm.GET("/users/:id", s.consoleUserDetail) gm.POST("/users/:id/message", s.consoleUserMessage) + gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) + gm.GET("/throttled", s.consoleThrottled) gm.GET("/games", s.consoleGames) gm.GET("/games/:id", s.consoleGameDetail) gm.GET("/complaints", s.consoleComplaints) @@ -117,7 +120,8 @@ func (s *Server) consoleUsers(c *gin.Context) { } view.Items = append(view.Items, adminconsole.UserRow{ ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind, - Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt), + Language: it.PreferredLanguage, Guest: it.IsGuest, + FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt), }) ids = append(ids, it.ID) } @@ -257,6 +261,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) { if acc.MergedInto != uuid.Nil { view.MergedInto = acc.MergedInto.String() } + if !acc.FlaggedHighRateAt.IsZero() { + view.FlaggedHighRateAt = fmtTime(acc.FlaggedHighRateAt) + } if view.HasStats { if st, err := s.accounts.GetStats(ctx, id); err == nil { view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints} @@ -551,6 +558,56 @@ func (s *Server) consolePostBroadcast(c *gin.Context) { } } +// consoleThrottled renders the rate-limit observability page: the recent +// gateway-reported throttle episodes (in-memory, reset on a backend restart) +// and the accounts currently carrying the soft high-rate flag (R3). +func (s *Server) consoleThrottled(c *gin.Context) { + ctx := c.Request.Context() + var view adminconsole.ThrottledView + if s.ratewatch != nil { + cfg := s.ratewatch.Config() + view.FlagThreshold = cfg.FlagThreshold + view.FlagWindow = cfg.FlagWindow.String() + for _, ep := range s.ratewatch.Recent() { + row := adminconsole.ThrottleEpisodeRow{ + Class: ep.Class, Key: ep.Key, Rejected: ep.Rejected, + FirstSeen: fmtTime(ep.FirstSeen), LastSeen: fmtTime(ep.LastSeen), + } + if ep.Class == ratewatch.ClassUser { + if id, err := uuid.Parse(ep.Key); err == nil { + row.UserID = id.String() + } + } + view.Episodes = append(view.Episodes, row) + } + } + flagged, err := s.accounts.ListFlaggedHighRate(ctx) + if err != nil { + s.consoleError(c, err) + return + } + for _, fa := range flagged { + view.Flagged = append(view.Flagged, adminconsole.FlaggedAccountRow{ + ID: fa.ID.String(), DisplayName: fa.DisplayName, FlaggedAt: fmtTime(fa.FlaggedHighRateAt), + }) + } + s.renderConsole(c, "throttled", "throttled", "Throttled", view) +} + +// consoleClearHighRateFlag clears the soft high-rate marker — the operator's +// reversible review action (R3). +func (s *Server) consoleClearHighRateFlag(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + if err := s.accounts.ClearHighRateFlag(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) +} + // variantVersions builds the per-variant resident-version summary from the registry. func (s *Server) variantVersions() []adminconsole.VariantVersions { out := make([]adminconsole.VariantVersions, 0, len(engine.Variants())) diff --git a/backend/internal/server/handlers_ratelimit.go b/backend/internal/server/handlers_ratelimit.go new file mode 100644 index 0000000..512a9ae --- /dev/null +++ b/backend/internal/server/handlers_ratelimit.go @@ -0,0 +1,41 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "scrabble/backend/internal/ratewatch" +) + +// rateLimitReportRequest mirrors the gateway's periodic rejection summary: every +// entry aggregates one limiter key (class + key) over the report window. +type rateLimitReportRequest struct { + WindowSeconds int `json:"window_seconds"` + Entries []rateLimitReportEntry `json:"entries"` +} + +// rateLimitReportEntry is one (class, key) aggregate of the report. +type rateLimitReportEntry struct { + Class string `json:"class"` + Key string `json:"key"` + Rejected int `json:"rejected"` +} + +// handleRateLimitReport ingests one gateway rejection report into the rate +// watch — the admin console's throttled view and the high-rate auto-flag (R3). +// Internal, gateway-only: like sessions/resolve it trusts the network segment. +// Malformed individual entries are skipped by the watch itself. +func (s *Server) handleRateLimitReport(c *gin.Context) { + var req rateLimitReportRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid rate-limit report") + return + } + entries := make([]ratewatch.Entry, 0, len(req.Entries)) + for _, e := range req.Entries { + entries = append(entries, ratewatch.Entry{Class: e.Class, Key: e.Key, Rejected: e.Rejected}) + } + s.ratewatch.Ingest(c.Request.Context(), entries) + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/server/handlers_test.go b/backend/internal/server/handlers_test.go index d3257dd..c8e086a 100644 --- a/backend/internal/server/handlers_test.go +++ b/backend/internal/server/handlers_test.go @@ -10,6 +10,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/game" + "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/session" ) @@ -57,6 +58,23 @@ func TestResolveSessionRejectsEmptyToken(t *testing.T) { } } +// TestRateLimitReportEndpoint covers the internal R3 report route: a malformed +// body is a 400, a valid report lands in the rate watch with 204. +func TestRateLimitReportEndpoint(t *testing.T) { + watch := ratewatch.New(ratewatch.DefaultConfig(), nil, nil) + s := New(":0", Deps{RateWatch: watch}) + if rec := do(t, s, http.MethodPost, "/api/v1/internal/ratelimit/report", `{bad`, nil); rec.Code != http.StatusBadRequest { + t.Fatalf("malformed report = %d, want 400", rec.Code) + } + body := `{"window_seconds":30,"entries":[{"class":"user","key":"` + uuid.NewString() + `","rejected":7}]}` + if rec := do(t, s, http.MethodPost, "/api/v1/internal/ratelimit/report", body, nil); rec.Code != http.StatusNoContent { + t.Fatalf("report = %d, want 204", rec.Code) + } + if eps := watch.Recent(); len(eps) != 1 || eps[0].Rejected != 7 { + t.Fatalf("watch episodes = %+v, want one entry with rejected=7", eps) + } +} + func TestSubmitPlayRejectsBadDirection(t *testing.T) { headers := map[string]string{"X-User-ID": uuid.New().String()} path := "/api/v1/user/games/" + uuid.New().String() + "/play" diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 9c914f7..618efdc 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -24,6 +24,7 @@ import ( "scrabble/backend/internal/game" "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" + "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/session" "scrabble/backend/internal/social" "scrabble/backend/internal/telemetry" @@ -71,6 +72,10 @@ type Deps struct { // nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured" // notice). Connector *connector.Client + // RateWatch ingests the gateway's rate-limiter rejection reports (R3): the + // admin console's throttled view + the high-rate auto-flag. A nil RateWatch + // disables the internal report endpoint and the console view. + RateWatch *ratewatch.Watch } // Server owns the gin engine, the underlying HTTP server and the readiness @@ -93,6 +98,7 @@ type Server struct { registry *engine.Registry dictDir string connector *connector.Client + ratewatch *ratewatch.Watch console *adminconsole.Renderer public *gin.RouterGroup @@ -133,6 +139,7 @@ func New(addr string, deps Deps) *Server { registry: deps.Registry, dictDir: deps.DictDir, connector: deps.Connector, + ratewatch: deps.RateWatch, http: &http.Server{Addr: addr, Handler: engine}, } s.registerProbes(engine) -- 2.52.0 From f20a4b49ff19b551e8ceb8579591215111a4fb70 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 10 Jun 2026 02:20:10 +0200 Subject: [PATCH 066/223] R3: split the landing into its own static container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gateway/Dockerfile gains a `landing` target: caddy:2-alpine + the shared Vite build (identical build args keep the ui stage a single cached build); the gateway target drops landing.html from the embed. - The contour caddy routes /app/, /telegram/ and the Connect path to the gateway; the catch-all — the landing at / and any stray path — goes to the new landing service, so junk traffic is absorbed by static file serving. - deploy/landing/Caddyfile mirrors the webui caching (immutable assets, no-cache shells) and falls back unknown paths to the landing shell. - The gateway's / now 308-redirects to /app/ (keeps a local no-caddy run usable); webui placeholder landing.html removed. - CI deploy probe checks both / (landing) and /app/ (gateway). Verified: both images build; the landing container serves landing.html at / (no-cache) with junk-path fallback; the gateway image redirects / to /app/ and carries no landing content. --- .gitea/workflows/ci.yaml | 12 ++++++--- deploy/caddy/Caddyfile | 16 ++++++++--- deploy/docker-compose.yml | 28 +++++++++++++++++-- deploy/landing/Caddyfile | 27 +++++++++++++++++++ gateway/Dockerfile | 31 +++++++++++++++------- gateway/internal/connectsrv/server.go | 14 +++++----- gateway/internal/connectsrv/server_test.go | 20 ++++++++++++++ gateway/internal/webui/dist/landing.html | 16 ----------- gateway/internal/webui/webui.go | 23 ++++++++-------- gateway/internal/webui/webui_test.go | 20 +++++--------- 10 files changed, 141 insertions(+), 66 deletions(-) create mode 100644 deploy/landing/Caddyfile delete mode 100644 gateway/internal/webui/dist/landing.html diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 3283130..00df876 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -298,17 +298,21 @@ jobs: # pick up the fresh config. docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana - - name: Probe the gateway through caddy + - name: Probe the landing and the gateway through caddy run: | set -u + # Two probes through the contour caddy (R3 split): "/" is the static + # landing container, "/app/" is the gateway-served SPA shell. 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/" + if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ && + docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then + echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)" exit 0 fi sleep 3 done - echo "probe failed; recent gateway logs:" + echo "probe failed; recent landing + gateway logs:" + docker logs --tail 50 scrabble-landing || true docker logs --tail 50 scrabble-gateway || true exit 1 diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index ba26fbe..8860263 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,7 +1,9 @@ # 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. +# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to +# the gateway; the catch-all — notably the public landing at / — goes to the +# static landing container (R3), so stray traffic never reaches the Go edge. +# 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 18) so this caddy does its own @@ -36,8 +38,14 @@ } } - # The SPA (/, /telegram/) and the Connect edge are served by the gateway. - handle { + # The game SPA and the Connect edge are served by the gateway. + @gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/* + handle @gateway { reverse_proxy gateway:8081 } + + # Everything else — the public landing at / and any stray path — is static. + handle { + reverse_proxy landing:80 + } } diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cb071f6..953825a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -75,6 +75,7 @@ services: build: context: .. dockerfile: gateway/Dockerfile + target: gateway args: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} @@ -100,6 +101,28 @@ services: # caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly. networks: [internal] + # --- Landing (static) ------------------------------------------------------- + # The public landing page in its own caddy container (R3): the contour caddy + # routes the catch-all (notably /) here, the gateway keeps only /app/, + # /telegram/ and the Connect edge. Shares the gateway Dockerfile's UI build + # stage — identical build args keep that stage a single cached build. + landing: + container_name: scrabble-landing + image: scrabble-landing:latest + build: + context: .. + dockerfile: gateway/Dockerfile + target: landing + args: + VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} + VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} + VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-} + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} + VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} + VITE_APP_VERSION: ${APP_VERSION:-dev} + restart: unless-stopped + networks: [internal] + # --- Telegram connector (egress via the VPN sidecar) ----------------------- vpn: container_name: scrabble-telegram-vpn @@ -145,12 +168,13 @@ services: OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317 OTEL_EXPORTER_OTLP_INSECURE: "true" - # --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway) -- + # --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway; + # the catch-all incl. the landing -> the static landing container) ------- caddy: container_name: scrabble-caddy image: caddy:2-alpine restart: unless-stopped - depends_on: [gateway, backend, grafana] + depends_on: [gateway, backend, grafana, landing] environment: # Test: ":80" (host caddy terminates TLS). Prod: a domain for own ACME. CADDY_SITE_ADDRESS: ${CADDY_SITE_ADDRESS:-:80} diff --git a/deploy/landing/Caddyfile b/deploy/landing/Caddyfile new file mode 100644 index 0000000..f14b522 --- /dev/null +++ b/deploy/landing/Caddyfile @@ -0,0 +1,27 @@ +# Static landing container (R3). Serves the public landing page and the built +# assets it references at /; the game SPA (/app/, /telegram/) and the Connect +# edge stay on the gateway. The contour caddy routes the catch-all here, so +# stray public paths are absorbed by static file serving and never reach the Go +# edge. This file is baked into the image at build time (gateway/Dockerfile, +# target `landing`), not bind-mounted. +{ + admin off +} + +:80 { + root * /srv + encode zstd gzip + + # Mirror the gateway webui caching: hash-named build assets are immutable, + # every HTML shell is no-cache so a new deploy is picked up immediately. + header /assets/* Cache-Control "public, max-age=31536000, immutable" + @shell not path /assets/* + header @shell Cache-Control "no-cache" + + # An unknown path falls back to the landing shell (the gateway's old "/" + # behaviour); "/" itself resolves through the index below. + try_files {path} /landing.html + file_server { + index landing.html + } +} diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 8a45a94..092fd6c 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,15 +1,18 @@ -# 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. +# Multi-stage build for the gateway service and the landing image. A node stage +# builds the static UI (Vite) once; the `landing` target serves that build from a +# static caddy container (the public landing at /, R3), while the final gateway +# target embeds it — minus landing.html — into the Go binary +# (gateway/internal/webui/dist), which serves the game SPA at /app/ and +# /telegram/ (docs/ARCHITECTURE.md §13). The Go stage mirrors +# platform/telegram/Dockerfile and ships on distroless nonroot. # # 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: +# Build from the repository root so go.work, pkg/, gateway/, ui/ and +# deploy/landing/ are all in the Docker context: # docker build -f gateway/Dockerfile \ # --build-arg VITE_GATEWAY_URL=https://example \ # -t scrabble-gateway . +# docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # --- UI build ---------------------------------------------------------------- FROM node:22-alpine AS ui @@ -38,6 +41,14 @@ RUN pnpm install --frozen-lockfile COPY ui ./ RUN pnpm build +# --- landing ------------------------------------------------------------------- +# The public landing page as its own static container (R3): the same Vite build +# served by caddy at /, so stray public traffic is absorbed by static file +# serving and never reaches the Go edge. +FROM caddy:2-alpine AS landing +COPY deploy/landing/Caddyfile /etc/caddy/Caddyfile +COPY --from=ui /ui/dist /srv + # --- Go build ---------------------------------------------------------------- FROM golang:1.26.3-alpine AS build WORKDIR /src @@ -46,9 +57,11 @@ 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. +# go:embed bakes the real bundle into the binary. The landing shell ships in the +# landing image, not in the gateway (R3). RUN rm -rf gateway/internal/webui/dist COPY --from=ui /ui/dist gateway/internal/webui/dist +RUN rm gateway/internal/webui/dist/landing.html # Reduce the workspace to what the gateway needs: gateway + pkg (loadtest is not in # this context; its scrabble/gateway replace targets ./gateway, which is present here). @@ -56,6 +69,6 @@ RUN go work edit -dropuse=./backend -dropuse=./platform/telegram -dropuse=./load RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/gateway ./gateway/cmd/gateway # --- runtime ----------------------------------------------------------------- -FROM gcr.io/distroless/static-debian12:nonroot +FROM gcr.io/distroless/static-debian12:nonroot AS gateway COPY --from=build /out/gateway /usr/local/bin/gateway ENTRYPOINT ["/usr/local/bin/gateway"] diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 5c6fef0..f1880d3 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -158,14 +158,16 @@ func (s *Server) HTTPHandler() http.Handler { // does not serve the app shell at the operator path. mux.Handle("/_gm/", http.NotFoundHandler()) } - // 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). + // The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram + // Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below + // the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and + // each mount falls back to the app shell (index.html) for the hash router. The + // public landing moved to its own static container behind the contour caddy + // (R3), so the catch-all redirects a stray root hit to the app shell — which + // keeps a local no-caddy run usable. mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html")) mux.Handle("/app/", webui.Handler("/app/", "index.html")) - mux.Handle("/", webui.Handler("", "landing.html")) + mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect)) // Every request body on the public listener is capped (the admin proxy POSTs // included); the h2c server carries explicit stream/idle sizing (R3). return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{ diff --git a/gateway/internal/connectsrv/server_test.go b/gateway/internal/connectsrv/server_test.go index 226f43b..d5c8f52 100644 --- a/gateway/internal/connectsrv/server_test.go +++ b/gateway/internal/connectsrv/server_test.go @@ -197,6 +197,26 @@ func TestExecuteOversizedPayloadRejected(t *testing.T) { } } +// TestRootRedirectsToApp verifies the gateway no longer serves a landing at "/" +// (it lives in the landing container since R3): a stray root hit is redirected +// to the app shell. +func TestRootRedirectsToApp(t *testing.T) { + front := httptest.NewServer(connectsrv.NewServer(connectsrv.Deps{}).HTTPHandler()) + defer front.Close() + + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := client.Get(front.URL + "/") + if err != nil { + t.Fatalf("get /: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusPermanentRedirect || resp.Header.Get("Location") != "/app/" { + t.Fatalf("GET / = %d -> %q, want 308 -> /app/", resp.StatusCode, resp.Header.Get("Location")) + } +} + func TestExecuteUnknownMessageType(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() diff --git a/gateway/internal/webui/dist/landing.html b/gateway/internal/webui/dist/landing.html deleted file mode 100644 index f06ae67..0000000 --- a/gateway/internal/webui/dist/landing.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - Scrabble - - - -

- Landing 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 index 5bde63e..2336265 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -1,12 +1,13 @@ // Package webui serves the embedded static UI build over the public edge. // -// The committed dist/ holds only placeholder index.html / landing.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: the game SPA is mounted at /app/ -// (web) and /telegram/ (the Telegram Mini App), with a separate landing page at / — the -// single-origin model in docs/ARCHITECTURE.md §13. +// 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 — minus landing.html, which +// ships in the separate landing container since R3 — 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: the game SPA is +// mounted at /app/ (web) and /telegram/ (the Telegram Mini App) — the single-origin +// model in docs/ARCHITECTURE.md §13. // // Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and // cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry @@ -35,10 +36,10 @@ func distFS() fs.FS { } // Handler serves the embedded UI. An existing file is served directly (hash-named assets get -// an immutable cache); every other path falls back to indexName (the SPA shell or the landing -// page) so a client-side deep link still loads. 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. -// "/app/" or "/telegram/"). +// an immutable cache); every other path falls back to indexName (the SPA shell) so a +// client-side deep link still loads. 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. "/app/" or +// "/telegram/"). func Handler(stripPrefix, indexName string) http.Handler { content := distFS() files := http.FileServer(http.FS(content)) diff --git a/gateway/internal/webui/webui_test.go b/gateway/internal/webui/webui_test.go index c7a4d4a..c40d30f 100644 --- a/gateway/internal/webui/webui_test.go +++ b/gateway/internal/webui/webui_test.go @@ -22,20 +22,12 @@ func body(t *testing.T, resp *http.Response) string { return string(b) } -// TestLandingMountServesLandingAndFallsBack: "/" serves the landing shell (no-cache) and -// any unknown path falls back to it. -func TestLandingMountServesLandingAndFallsBack(t *testing.T) { - h := Handler("", "landing.html") - - resp := get(t, h, "/") - if resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-landing") { - t.Fatalf("GET / did not serve the landing shell (status %d)", resp.StatusCode) - } - if cc := get(t, h, "/").Header.Get("Cache-Control"); cc != "no-cache" { - t.Errorf("landing Cache-Control = %q, want no-cache", cc) - } - if resp := get(t, h, "/whatever"); resp.StatusCode != http.StatusOK { - t.Fatalf("GET /whatever status = %d, want 200 (fallback)", resp.StatusCode) +// TestShellNoCache: the served HTML shell carries no-cache so a new deploy's +// shell (and the asset URLs it references) is fetched fresh. +func TestShellNoCache(t *testing.T) { + h := Handler("/app/", "index.html") + if cc := get(t, h, "/app/").Header.Get("Cache-Control"); cc != "no-cache" { + t.Errorf("shell Cache-Control = %q, want no-cache", cc) } } -- 2.52.0 From 7e75c32d07250979e0b34c3e4fb7e124afec67bc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 10 Jun 2026 05:12:17 +0200 Subject: [PATCH 067/223] R3: dashboards, docs and tracker bake-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edge/UX dashboard: aggregate request-rate vs rejection-rate panel (gateway_rate_limited_total by class; no per-user labels). - ARCHITECTURE §2/§11/§12/§13: body cap + explicit h2c sizing, the rate-limit observability pipeline and auto-flag policy, the admin-limiter note (and the caddy-path gap), the landing container topology; fixed the stale 120/min per-user figure. - FUNCTIONAL (+_ru): the Throttled view and the reversible high-rate flag. - gateway/backend/deploy READMEs, TESTING.md, root CLAUDE.md updated. - PRERELEASE.md: R3 interview decisions + implementation refinements logged; tracker R3 -> done (this PR implements it; CI gates the merge). --- .gitignore | 3 ++ CLAUDE.md | 9 ++-- PRERELEASE.md | 30 ++++++++++++- backend/README.md | 11 +++++ deploy/README.md | 18 ++++---- deploy/grafana/dashboards/edge-ux.json | 12 ++++++ docs/ARCHITECTURE.md | 58 +++++++++++++++++++------- docs/FUNCTIONAL.md | 8 ++++ docs/FUNCTIONAL_ru.md | 8 ++++ docs/TESTING.md | 16 ++++++- 10 files changed, 144 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 259564b..912d4c2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ # Local, unstaged env overrides **/.env.local **/.env.*.local + +# Claude Code harness runtime artifacts +.claude/scheduled_tasks.lock diff --git a/CLAUDE.md b/CLAUDE.md index ba32697..28a1502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,8 +127,8 @@ 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 loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2) -backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2) -deploy/ # docker-compose + caddy + otelcol/prometheus/tempo/grafana (+ cAdvisor/postgres_exporter, R2) +backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile also has the `landing` target (R3) +deploy/ # docker-compose + caddy + landing + otelcol/prometheus/tempo/grafana (+ cAdvisor/postgres_exporter, R2) ``` ## Build & test @@ -144,8 +144,9 @@ 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 build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA +docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . +docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing (R3) docker compose -f deploy/docker-compose.yml config # validate the full contour ``` diff --git a/PRERELEASE.md b/PRERELEASE.md index 46cf842..51a2c75 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -19,7 +19,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l |---|-------|-----------|--------| | R1 | Schema & naming reset | 1 + 10 | **done** | | R2 | Stress harness + contour observability + early run | 9a | **done** | -| R3 | Edge hardening | 2 + 8 + 3 | todo | +| R3 | Edge hardening | 2 + 8 + 3 | **done** | | R4 | Push enrichment + kill the last poll | 4 + 5 | todo | | R5 | Bundle slimming | 6 | todo | | R6 | Refactor + docs reconciliation + de-staging | 7 | todo | @@ -253,3 +253,31 @@ Then Stage 18. it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports, separate hardware, pool/limit sizing). - **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps. + +- **R3** (interview + implementation): + - **Locked decisions:** the flag column lands by **editing the R1 baseline** (+ a contour schema + wipe after merge — no migration chain accrues before prod); auto-flag defaults **1000 rejected / + 10 min** (`BACKEND_HIGHRATE_FLAG_THRESHOLD`/`_WINDOW`, rolling window, set-once, operator clears, + no auto-ban); landing image = **caddy:2-alpine**; throttle data flows **gateway → backend** (a + 30 s per-key summary POST to the new `/api/v1/internal/ratelimit/report`, the existing trusted + direction) with the episode window + flag rule in the backend (`internal/ratewatch`); rejection + logging = **Warn summary per key per window + Debug per rejection** — a deliberate deviation from + the phase's "structured log per rejection" (the R2 hammer would have logged ~522k lines in + minutes); all three R2-report tails included (explicit h2c sizing, the session-resolve failure + cause at Warn, reviving the admin limiter). + - **Body cap:** `GATEWAY_MAX_BODY_BYTES` (default 1 MiB) as both the Connect per-message read limit + and an `http.MaxBytesReader` wrap of the public mux; an oversized Execute is `resource_exhausted`. + - **Dead config found:** `AdminPerMinute`/`AdminBurst` were never wired — the gateway `/_gm` mount is + now 429-guarded per IP ahead of its Basic-Auth. The caddy-fronted contour path stays unlimited + (stock caddy has no limiter) — an accepted gap, recorded in `docs/ARCHITECTURE.md` §12. + - **Landing split:** a `landing` target in `gateway/Dockerfile` (the UI build stage is shared; + identical compose build args keep it one cached build); the gateway drops `landing.html` from the + embed and 308-redirects `/` → `/app/`; the contour caddy routes `/app/`, `/telegram/` and the + Connect path to the gateway and the catch-all to the landing container; the CI deploy probe now + checks both `/` (landing) and `/app/` (gateway). + - **Observability:** `gateway_rate_limited_total{class}` (user/public/email/admin, aggregate-only) + + a rate-vs-rejections panel on the Edge/UX dashboard; the admin console gains the **Throttled** + page (the in-memory episode window, reset-on-restart like `active_users`, plus the flagged-account + queue) and the flag badge / clear action on the user list / card. + - The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models + (their tables were added after the last jetgen run; no behaviour change). diff --git a/backend/README.md b/backend/README.md index 03453ce..724642f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -99,6 +99,14 @@ durable owner — then the durable account wins and a fresh session is minted fo The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). +**R3** adds rate-limit observability: the gateway posts its periodic rejection +summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a +bounded in-memory episode window for the console's **Throttled** page and applies the +conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD` +rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible +`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a +**Clear** action on the user card; never an automatic ban). + ## Package layout ``` @@ -121,6 +129,7 @@ internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + frien internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) +internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag (R3) ``` ## Configuration (environment) @@ -153,6 +162,8 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b | `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | +| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged (R3). | +| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. | ## Run diff --git a/deploy/README.md b/deploy/README.md index 9435c8a..5caa0e5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,9 +1,9 @@ # 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 +The full Scrabble contour: `backend` + `gateway` + the static `landing` + 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**. @@ -11,8 +11,9 @@ 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 landing at `/` and the game SPA at `/app/` + `/telegram/`; Connect-RPC edge. | +| `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; `/app/`, `/telegram/` + the Connect path → gateway; the catch-all (incl. `/`) → landing. TLS per `CADDY_SITE_ADDRESS`. | +| `gateway` | built (`gateway/Dockerfile`, target `gateway`) | Public edge; serves the embedded game SPA at `/app/` + `/telegram/`; Connect-RPC edge. `/` redirects to `/app/`. | +| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths (R3). | | `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`. | @@ -88,8 +89,9 @@ connector **fails at boot** if both are empty. | `VITE_TELEGRAM_GAME_CHANNEL_NAME_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 five `VITE_*` are **build-args** baked into the gateway image at build time, so -changing them requires a rebuild (`--build`), not just a restart. +The five `VITE_*` are **build-args** baked into the gateway and landing images at +build time (both targets share one UI build stage — keep the args identical so it is +built once), so changing them requires a rebuild (`--build`), not just a restart. ## Fixed internal wiring (not operator-set) diff --git a/deploy/grafana/dashboards/edge-ux.json b/deploy/grafana/dashboards/edge-ux.json index 44b49d6..413a268 100644 --- a/deploy/grafana/dashboards/edge-ux.json +++ b/deploy/grafana/dashboards/edge-ux.json @@ -34,6 +34,18 @@ "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}}" }] + }, + { + "type": "timeseries", + "title": "Rate limiting — request rate vs rejections (R3)", + "description": "Aggregate only (no per-user labels, the Stage 12/17 discipline): total edge request rate against the limiter rejection rate by class. Per-key detail lives in the admin console's Throttled view.", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "sum(rate(edge_request_duration_count[5m]))", "legendFormat": "requests" }, + { "refId": "B", "expr": "sum(rate(gateway_rate_limited_total[5m])) by (class)", "legendFormat": "rejected · {{class}}" } + ] } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6b6e0f2..d1546a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,6 +98,15 @@ dropped). Horizontal scaling is explicit future work. response was lost — its button is disabled while offline and the player re-issues it on reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. + **Edge hardening (R3):** every request body on the public listener is capped at + `GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP + layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized + `Execute` is refused (`resource_exhausted`) without buffering. The h2c server carries explicit + sizing: `MaxConcurrentStreams` 250 (the x/net default made visible — a real client holds one + `Subscribe` stream plus a few unary calls) and a 3-minute connection `IdleTimeout` (a live + `Subscribe` stream keeps its connection active, so only abandoned connections are reaped); the + `http.Server` sets only `ReadHeaderTimeout` (10 s) — Read/WriteTimeout would kill the stream. + R7 revisits the exact values under load. - **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the `Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet @@ -572,6 +581,21 @@ promotions) is future work and would deliver short markdown messages (text + lin 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. +- **Rate-limit observability (R3):** every limiter rejection increments the gateway + counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate + only, honouring the no-per-user-label discipline above) and logs one **Debug** line; + a gateway reporter drains the per-key rejection tracker every 30 s, emits one **Warn** + summary per throttled key and posts the report to the backend + (`POST /api/v1/internal/ratelimit/report`, network-trusted like `sessions/resolve`). + The backend's `ratewatch` keeps a bounded in-memory episode window (single-instance, + resets on restart, like `active_users`) surfaced on the admin console's **Throttled** + page next to the flagged-account review queue, and applies the **conservative + auto-flag**: an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD` rejected calls + (default 1000) within `BACKEND_HIGHRATE_FLAG_WINDOW` (default 10 min) gets the soft, + reversible `accounts.flagged_high_rate_at` marker — set once, shown in the user + list/detail, cleared by the operator, **never an automatic ban** and never a request + gate. The Edge/UX dashboard graphs the aggregate request rate against the rejection + rate by class. - 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 @@ -582,12 +606,12 @@ promotions) is future work and would deliver short markdown messages (text + lin | Concern | Enforced by | | --- | --- | -| Public rate limiting / anti-abuse | gateway | +| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — R3, §11) | | Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector | | 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 | 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 | +| 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, which the per-IP admin limiter class guards ahead of its Basic-Auth (R3) — the caddy-fronted path has no limiter (stock caddy), an accepted gap. 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 @@ -597,7 +621,7 @@ mutual auth is a future hardening step. **Short numeric codes** (email confirm-codes and Stage 8 friend codes) are stored only as SHA-256 hashes and are short-lived and single-use. The unauthenticated email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem** -is authenticated, so it rides the per-user limit (120 / min) and is further bounded +is authenticated, so it rides the per-user limit (300 / min) and is further bounded by the code's 12 h TTL, single use, and **one live code per issuer** (which caps the valid-code population). Brute-forcing a 6-digit friend code within these limits is an accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable); @@ -605,22 +629,27 @@ 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`). The Vite build has two -entries: a lightweight **landing page** served at `/`, and the game **SPA** served at +Single public origin, path-routed. The Vite build has two entries: a lightweight +**landing page** and the game **SPA**. The gateway **embeds** the SPA build +(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it 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 +redirects to the root — the client-side guard); a stray hit on the gateway's `/` +308-redirects to `/app/`. The **landing** ships in its own static container (R3): the +`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, +`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by +static file serving and never reaches the Go edge. 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 (`/`, `/app/`, `/telegram/`, the Connect edge) goes to the gateway. The +`no-cache` so a new deploy is picked up — both containers apply the same caching. 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**; `/app/`, `/telegram/` and the Connect path go to the gateway; the +catch-all — notably the landing at `/` — goes to the landing container. 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** — +one Postgres, the static `landing`, 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. All three services export OTLP to the collector; the connector shares the VPN sidecar's netns, so its `AWG_CONF` must not @@ -633,7 +662,8 @@ network (project-scoped DNS); only caddy joins the shared external `edge` networ 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 + host, then `GET /` + `GET /app/` probes through caddy — the landing container and + the gateway, R3). The host caddy terminates TLS and forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP (`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP — diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c425027..3399b89 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -171,3 +171,11 @@ applied after a reload). When a Telegram connector is configured an operator can **message a user** (by their Telegram identity) or **post to the game channel**. State-changing actions are protected by a same-origin check; the console tracks no operator identity. + +The console also surfaces **rate-limit abuse** (R3): a **Throttled** page lists the +recently throttled users/IPs the gateway reported (an in-memory window — it resets on +a backend restart) and the accounts currently carrying the soft **high-rate flag**. An +account sustaining rejections past a tunable threshold is flagged automatically — +the marker is reversible, shown as a badge in the user list and on the user card, and +**never blocks play**; the operator reviews and clears it from the user card. There is +no automatic ban. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f82266b..ee0e7b3 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -175,3 +175,11 @@ identity, их игры) и **игры** (сводка + места), разби подключён Telegram-коннектор, оператор также может **написать пользователю** (по его Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия защищены проверкой same-origin; личность оператора не отслеживается. + +Консоль также показывает **злоупотребление лимитами** (R3): страница **Throttled** +перечисляет недавно затроттленных пользователей/IP по отчётам gateway (окно в памяти — +сбрасывается при рестарте backend) и аккаунты с действующим мягким **high-rate +флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается +автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке +аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с +карточки пользователя. Автоматического бана нет. diff --git a/docs/TESTING.md b/docs/TESTING.md index 2319a8e..02dc510 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -76,7 +76,14 @@ tests or touching CI. unsubscribe), the transcode round-trips (FlatBuffers↔JSON, X-User-ID forwarding, nested GameView, domain-code surfacing), the admin Basic-Auth reverse proxy (401 / forward), and a full Connect `Execute` path end to end - (guest auth, unauthenticated rejection, unknown message type). The backend gains + (guest auth, unauthenticated rejection, unknown message type). **R3** adds the + edge-hardening cases: an oversized `Execute` payload is refused + (`resource_exhausted`, the `GATEWAY_MAX_BODY_BYTES` cap), a limiter rejection + lands in `gateway_rate_limited_total{class}` and the rejection tracker + (drain/aggregate unit tests), the report POST reaches + `/api/v1/internal/ratelimit/report` with the agreed JSON shape, the `/_gm` + mount is 429-guarded by the per-IP admin class, and the gateway's `/` + 308-redirects to `/app/` (the landing left the embed). The backend gains the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues no statistics) and the **email-as-login** flow (request/verify, returning user) in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account @@ -92,7 +99,12 @@ tests or touching CI. 404 when not). Postgres-backed `inttest` drives the **complaint resolution → dictionary-change pipeline** (file → resolve with a disposition → pending change → mark applied), the admin **list/count** read queries, and the **/_gm console over HTTP** - (pages render; a resolve POST needs a same-origin header). + (pages render; a resolve POST needs a same-origin header). **R3** adds `ratewatch` + unit tests (window accumulation, the auto-flag threshold + expiry, the bounded + episode map), the account-store **high-rate flag round-trip** (set-once / clear / + re-flag) and a console flow in `inttest`: a gateway report auto-flags the account, + the **Throttled** page shows the episode and the flagged queue, the user card + carries the marker and the CSRF-guarded **Clear** reverses it. - **Observability & performance** *(Stage 12)* — `pkg/telemetry` unit-tests the exporter selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector; the nil-runtime fallback). The domain metrics are exercised through a manual -- 2.52.0 From 41a642ef974dd6ca6c78c20ce38c3c05ecb9e7fe Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 10 Jun 2026 08:01:50 +0200 Subject: [PATCH 068/223] =?UTF-8?q?R4:=20push=20enrichment=20=E2=80=94=20e?= =?UTF-8?q?vents=20carry=20a=20state=20delta,=20kill=20the=20last=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich the in-app live stream into a delta channel so the UI renders a move from the event without a follow-up game.state, and make the matchmaking poll a stream-down fallback. - pkg/fbs: trailing fields on opponent_moved (move+game+bag_len), your_turn (move_count), match_found (state), game_over (game), notify (account/invitation/state), MoveResult (rack+bag_len); regenerate Go + TS. - backend: notify owns the FB encoding (encode.go + payload.go input structs); game/lobby/social map their domain types in. emitMove builds the move delta; game.Service.InitialState feeds match_found/game_started the recipient's initial StateView; friends/invitations notify carry their account/invitation. The move-commit response (submit_play/pass/exchange/resign) returns the actor's refilled rack + bag size. - gateway: MoveResult transcode carries rack+bag_len. - ui: pure lib/gamedelta.ts reducer advances the per-game cache keyed on move_count (idempotent + gap-safe); app.svelte seeds the cache on match_found/game_started; Game.svelte applies the delta (commit/pass/exchange/resign drop their load()); NewGame polls only while app.streamAlive is false. - docs: ARCHITECTURE §10, FUNCTIONAL(+ru), backend/gateway/ui READMEs; PRERELEASE R4 marked done + Refinements. --- PRERELEASE.md | 38 +++- backend/README.md | 5 +- backend/internal/game/emit_test.go | 4 +- backend/internal/game/eventwire.go | 79 +++++++ backend/internal/game/service.go | 32 ++- backend/internal/game/types.go | 10 +- backend/internal/lobby/invitations.go | 70 ++++++- backend/internal/lobby/lobby.go | 7 +- backend/internal/lobby/matchmaker.go | 16 +- backend/internal/lobby/matchmaker_test.go | 7 + backend/internal/notify/encode.go | 197 ++++++++++++++++++ backend/internal/notify/events.go | 101 +++++++-- backend/internal/notify/notify_test.go | 100 ++++++++- backend/internal/notify/payload.go | 89 ++++++++ backend/internal/server/dto.go | 26 ++- backend/internal/server/handlers_game.go | 6 +- backend/internal/social/friendcodes.go | 2 +- backend/internal/social/friends.go | 19 +- docs/ARCHITECTURE.md | 21 +- docs/FUNCTIONAL.md | 3 +- docs/FUNCTIONAL_ru.md | 3 +- gateway/README.md | 4 +- gateway/internal/backendclient/api.go | 9 +- gateway/internal/transcode/encode.go | 7 + pkg/fbs/scrabble.fbs | 42 +++- pkg/fbs/scrabblefb/GameOverEvent.go | 18 +- pkg/fbs/scrabblefb/MatchFoundEvent.go | 18 +- pkg/fbs/scrabblefb/MoveResult.go | 57 ++++- pkg/fbs/scrabblefb/NotificationEvent.go | 50 ++++- pkg/fbs/scrabblefb/OpponentMovedEvent.go | 49 ++++- pkg/fbs/scrabblefb/YourTurnEvent.go | 17 +- ui/README.md | 4 +- ui/src/game/Game.svelte | 69 ++++-- ui/src/gen/fbs/scrabblefb/game-over-event.ts | 21 +- .../gen/fbs/scrabblefb/match-found-event.ts | 19 +- ui/src/gen/fbs/scrabblefb/move-result.ts | 42 +++- .../gen/fbs/scrabblefb/notification-event.ts | 39 +++- .../fbs/scrabblefb/opponent-moved-event.ts | 42 +++- ui/src/gen/fbs/scrabblefb/your-turn-event.ts | 14 +- ui/src/lib/app.svelte.ts | 22 +- ui/src/lib/codec.ts | 60 +++++- ui/src/lib/gamecache.ts | 2 +- ui/src/lib/gamedelta.test.ts | 99 +++++++++ ui/src/lib/gamedelta.ts | 69 ++++++ ui/src/lib/mock/client.ts | 8 +- ui/src/lib/model.ts | 20 +- ui/src/screens/NewGame.svelte | 58 ++++-- 47 files changed, 1514 insertions(+), 180 deletions(-) create mode 100644 backend/internal/game/eventwire.go create mode 100644 backend/internal/notify/encode.go create mode 100644 backend/internal/notify/payload.go create mode 100644 ui/src/lib/gamedelta.test.ts create mode 100644 ui/src/lib/gamedelta.ts diff --git a/PRERELEASE.md b/PRERELEASE.md index 51a2c75..4940155 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -20,7 +20,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | R1 | Schema & naming reset | 1 + 10 | **done** | | R2 | Stress harness + contour observability + early run | 9a | **done** | | R3 | Edge hardening | 2 + 8 + 3 | **done** | -| R4 | Push enrichment + kill the last poll | 4 + 5 | todo | +| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** | | R5 | Bundle slimming | 6 | todo | | R6 | Refactor + docs reconciliation + de-staging | 7 | todo | | R7 | Final stress run + tuning | 9b | todo | @@ -281,3 +281,39 @@ Then Stage 18. queue) and the flag badge / clear action on the user list / card. - The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models (their tables were added after the last jetgen run; no behaviour change). + +- **R4** (interview + implementation): + - **Locked decisions:** **delta-first**, not full snapshots — an event carries only the new move and + the UI applies it to its per-game cache, keyed on `move_count` (idempotent + gap-safe: a gap or the + actor's own move falls back to a `game.state` + `game.history` refetch). `match_found` / + `game_started` carry the recipient's **initial `StateView`** (instant lobby→game); the fallback + refetch stays the existing two calls (no merged endpoint); the matchmaking poll runs **only while + the stream is down** (2.5 s); **all** UI-state-changing events carry their payload (incl. lobby `notify`). + - **Enriched events** (`pkg/fbs` trailing fields — backward-compatible, no FB regen of *values*, only + the schema): `opponent_moved` (+`move`/`game`/`bag_len`), `your_turn` (+`move_count`), `match_found` + (+`state`), `game_over` (+`game`), `notify` (+`account`/`invitation`/`state`). The pre-R4 + `opponent_moved` scalars (`seat`/`action`/`score`/`total`) stay for wire back-compat, now redundant + with `move`/`game` — slated for the R6 de-stage. + - **Encoding placement:** the `notify` package keeps ownership of the FlatBuffers encoding (a new + `encode.go` mirrors the gateway transcode but reads wire-agnostic `notify.*` input structs + + `engine.MoveRecord`); the game/lobby/social services map their domain types to those structs, so the + wire schema stays out of the domain. **Flagged for R6:** this partly duplicates the gateway encoders + (different source types) — a candidate consolidation. + - **Actor self-fetch killed too** (beyond literal "push"): the `submit_play`/`pass`/`exchange`/`resign` + **response** (`MoveResult`) now returns the actor's refilled rack + bag size, so the mover renders the + next turn from the response — `Game.svelte`'s `commit`/`pass`/`exchange`/`resign` drop their `await load()`. + - **`match_found` enrichment** needs a per-seat initial state: `lobby.GameCreator` gained `InitialState`, + and `game.Service.InitialState` builds the `notify.PlayerState` (rack re-encoded to wire indices, the + variant alphabet embedded for a first-seen variant). + - **UI:** a pure `lib/gamedelta.ts` reducer (`applyMoveDelta` / `applyGameOver` / `seedInitialState`, + unit-tested) advances the cache; `app.svelte` seeds it on `match_found` / `game_started`; `Game.svelte` + applies the delta (falling back to `load()` while composing, on a gap, or on its own move's new rack); + `NewGame.svelte` polls only when `app.streamAlive` is false and guards its teardown so a push-delivered + match is not cancelled. + - **notify (friends/invitations) scope:** the backend carries the full account / invitation payload on the + wire (per "all events → push"); the UI seeds the game cache from `game_started` but keeps its lightweight + **authoritative** badge refresh (`refreshNotifications`, on the rare `notify` event + on foreground) rather + than adding client-side friend/invitation caches — the per-move hot path is fully de-fetched, which was the + goal. Deeper lobby-cache consumption is an easy follow-up. + - **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips + + `emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta. diff --git a/backend/README.md b/backend/README.md index 724642f..e968a21 100644 --- a/backend/README.md +++ b/backend/README.md @@ -53,8 +53,9 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and -exposes `Poll` so a waiting player can collect the started game (the live -match-found notification arrives with the `gateway`). +exposes `Poll` so a waiting player can collect the started game — R4 made it the **stream-down +fallback**: while the client is streaming, the live match-found push (enriched with the recipient's +initial game state) drives it instead. Stage 6 opens the backend to the edge. The route groups gain their first handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go index c9f7acf..db953de 100644 --- a/backend/internal/game/emit_test.go +++ b/backend/internal/game/emit_test.go @@ -33,7 +33,7 @@ func TestEmitMoveNotifiesActor(t *testing.T) { TurnTimeout: time.Hour, Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}}, } - svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}) + svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80) kinds := map[uuid.UUID][]string{} var yourTurn notify.Intent @@ -87,7 +87,7 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) { EndReason: "out_of_tiles", Seats: []Seat{{Seat: 0, AccountID: winner, Score: 120, IsWinner: true}, {Seat: 1, AccountID: loser, Score: 95}}, } - svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}) + svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}, 0) over := map[uuid.UUID]notify.Intent{} for _, in := range pub.intents { diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go new file mode 100644 index 0000000..5ee04ca --- /dev/null +++ b/backend/internal/game/eventwire.go @@ -0,0 +1,79 @@ +package game + +import ( + "scrabble/backend/internal/engine" + "scrabble/backend/internal/notify" +) + +// The mappers below project the game domain into the wire-agnostic notify.* input +// structs the enriched live events carry (R4). They keep the wire schema out of the +// game package: notify owns the FlatBuffers encoding, this file only resolves the +// values (seat display names, last-activity sort key) into its input shapes. + +// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched +// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix +// mirrors the gateway view (the current turn's start while active, the finish time once +// finished). +func gameSummary(g Game, names []string) notify.GameSummary { + seats := make([]notify.SeatStanding, 0, len(g.Seats)) + for _, s := range g.Seats { + name := "" + if s.Seat >= 0 && s.Seat < len(names) { + name = names[s.Seat] + } + seats = append(seats, notify.SeatStanding{ + Seat: s.Seat, + AccountID: s.AccountID.String(), + DisplayName: name, + Score: s.Score, + HintsUsed: s.HintsUsed, + IsWinner: s.IsWinner, + }) + } + last := g.TurnStartedAt + if g.FinishedAt != nil { + last = *g.FinishedAt + } + return notify.GameSummary{ + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: last.Unix(), + } +} + +// playerState projects a StateView into the notify.PlayerState carried by the +// match_found / game_started events. The rack is re-encoded to wire alphabet indices; +// the variant alphabet display table is embedded when includeAlphabet is set (an +// initial view whose recipient may not have cached the variant yet). +func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) { + rack, err := engine.EncodeRack(v.Game.Variant, v.Rack) + if err != nil { + return notify.PlayerState{}, err + } + ps := notify.PlayerState{ + Game: gameSummary(v.Game, names), + Seat: v.Seat, + Rack: rack, + BagLen: v.BagLen, + HintsRemaining: v.HintsRemaining, + } + if includeAlphabet { + tab, err := engine.AlphabetTable(v.Game.Variant) + if err != nil { + return notify.PlayerState{}, err + } + ps.Alphabet = make([]notify.AlphabetLetter, len(tab)) + for i, e := range tab { + ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value} + } + } + return ps, nil +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index bf1e068..90859dc 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -291,7 +291,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, // 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 + return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil } // afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own @@ -362,7 +362,7 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game if err != nil { return Game{}, err } - svc.emitMove(ctx, post, rec) + svc.emitMove(ctx, post, rec, g.BagLen()) return post, nil } @@ -373,10 +373,13 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game // 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(ctx context.Context, post Game, rec engine.MoveRecord) { +func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) { + // Resolve the seat names once and reuse them for every recipient's enriched summary. + names := svc.seatNames(ctx, post) + summary := gameSummary(post, names) intents := make([]notify.Intent, 0, 2*len(post.Seats)) for _, s := range post.Seats { - intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) + intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen)) } // Game pushes are routed out-of-app by the game's own language, not the recipient's // last-login bot (Stage 17). @@ -391,7 +394,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco word = rec.Words[0] } opponent := svc.displayName(ctx, post.Seats, rec.Player) - yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove)) + yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount) yourTurn.Language = lang intents = append(intents, yourTurn) } @@ -400,7 +403,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco // seat, each with their own perspective + recipient-first score, so an offline player gets // an out-of-app "game over" push (online players take it from the in-app refresh). for _, s := range post.Seats { - over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat)) + over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary) over.Language = lang intents = append(intents, over) } @@ -785,6 +788,20 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) }, nil } +// InitialState returns accountID's full initial view of game gameID as the notify +// PlayerState carried by the match_found / game_started events (R4), so a client can +// render a freshly started game from the event without a follow-up fetch. The variant +// alphabet table is always embedded (the recipient may be seeing the variant for the +// first time). It satisfies lobby.GameCreator. +func (svc *Service) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) { + v, err := svc.GameState(ctx, gameID, accountID) + if err != nil { + return notify.PlayerState{}, err + } + names := svc.seatNames(ctx, v.Game) + return playerState(v, names, true) +} + // Participants returns the seated account IDs in seat order, the seat index whose // turn it is, and the game status. It is a snapshot read (no engine, no lock) that // lets the social package gate per-game chat and nudges without importing the @@ -1009,6 +1026,9 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er // seatNames resolves each seat's display name for GCG export. func (svc *Service) seatNames(ctx context.Context, g Game) []string { names := make([]string, g.Players) + if svc.accounts == nil { + return names + } for _, s := range g.Seats { if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil { names[s.Seat] = acc.DisplayName diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index fc2e031..e523899 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -124,10 +124,14 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) { } // MoveResult is the outcome of a committed transition: the decoded move and the -// post-move game. +// post-move game, plus the actor's own refilled rack and the bag size after the draw +// (Rack/BagLen, R4), so the mover renders the next state from the response without a +// follow-up game.state. type MoveResult struct { - Move engine.MoveRecord - Game Game + Move engine.MoveRecord + Game Game + Rack []string + BagLen int } // HintResult is a revealed hint and the requesting player's remaining hint diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 92043dc..f9b762a 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -105,18 +105,72 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) { } } -// notify publishes a re-poll Notification of the given sub-kind to each user. -func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) { - if len(userIDs) == 0 { +// emitInvitation publishes the invitation notification to each invitee, carrying the invitation +// itself so the client adds it to its lobby list without a refetch (R4). +func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) { + if len(inviteeIDs) == 0 { return } - intents := make([]notify.Intent, 0, len(userIDs)) - for _, id := range userIDs { - intents = append(intents, notify.Notification(id, kind)) + summary := svc.invitationSummary(ctx, inv) + intents := make([]notify.Intent, 0, len(inviteeIDs)) + for _, id := range inviteeIDs { + intents = append(intents, notify.NotificationInvitation(id, summary)) } svc.pub.Publish(intents...) } +// emitGameStarted publishes the game_started notification to each seated player, carrying their +// initial view of the started game so the client seeds its game cache without a refetch (R4). A +// seat whose state cannot be read is skipped (it still sees the game on the next lobby load). +func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) { + intents := make([]notify.Intent, 0, len(seats)) + for _, id := range seats { + state, err := svc.games.InitialState(ctx, g.ID, id) + if err != nil { + continue + } + intents = append(intents, notify.NotificationGameStarted(id, state)) + } + svc.pub.Publish(intents...) +} + +// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries, +// resolving the inviter's and invitees' display names from the account store. +func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary { + name := func(id uuid.UUID) string { + if acc, err := svc.accounts.GetByID(ctx, id); err == nil { + return acc.DisplayName + } + return "" + } + invitees := make([]notify.InvitationInvitee, 0, len(inv.Invitees)) + for _, iv := range inv.Invitees { + invitees = append(invitees, notify.InvitationInvitee{ + AccountID: iv.AccountID.String(), + DisplayName: name(iv.AccountID), + Seat: iv.Seat, + Response: iv.Response, + }) + } + gameID := "" + if inv.GameID != nil { + gameID = inv.GameID.String() + } + return notify.InvitationSummary{ + ID: inv.ID.String(), + Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)}, + Invitees: invitees, + Variant: inv.Settings.Variant.String(), + TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second), + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + DropoutTiles: inv.Settings.DropoutTiles.String(), + Status: inv.Status, + GameID: gameID, + ExpiresAtUnix: inv.ExpiresAt.Unix(), + } +} + // CreateInvitation records a pending invitation from inviterID to inviteeIDs (in // seat order, 1..N) with the given settings. The total seat count must be 2-4, // invitees distinct and not the inviter, every invitee an existing account with no @@ -176,7 +230,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu if err != nil { return Invitation{}, err } - svc.notify(notify.NotifyInvitation, inviteeIDs...) + svc.emitInvitation(ctx, inv, inviteeIDs) return inv, nil } @@ -224,7 +278,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil { return err } - svc.notify(notify.NotifyGameStarted, seats...) + svc.emitGameStarted(ctx, g, seats) return nil } diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 8908a38..10065ea 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -14,12 +14,17 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" ) // GameCreator is the slice of the game domain the lobby needs: starting a seated -// game. game.Service satisfies it. +// game and reading a player's initial view of it. game.Service satisfies it. type GameCreator interface { Create(ctx context.Context, params game.CreateParams) (game.Game, error) + // InitialState returns a seated player's full initial view of a started game, used + // to enrich the match_found / game_started events so the client renders the new game + // without a follow-up fetch (R4). + InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) } // RobotProvider supplies a robot account to substitute for a missing human in diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 56bcd11..dc334c2 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -75,11 +75,19 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) { // emitMatchFound pushes match_found to every seat of a freshly started game. // Emitting to a robot seat is harmless (no client subscription exists for it). -func (m *Matchmaker) emitMatchFound(g game.Game) { +func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) { lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17) intents := make([]notify.Intent, 0, len(g.Seats)) for _, s := range g.Seats { - mf := notify.MatchFound(s.AccountID, g.ID) + state, err := m.games.InitialState(ctx, g.ID, s.AccountID) + if err != nil { + // A waiter still discovers the game through Poll (the ws-down fallback), so skip the + // enriched push for this seat rather than failing the match. + m.log.Warn("match_found initial state", + zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err)) + continue + } + mf := notify.MatchFound(s.AccountID, g.ID, state) mf.Language = lang intents = append(intents, mf) } @@ -128,7 +136,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e m.mu.Lock() m.results[opponent] = g m.mu.Unlock() - m.emitMatchFound(g) + m.emitMatchFound(ctx, g) return EnqueueResult{Matched: true, Game: g}, nil } @@ -227,7 +235,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { m.mu.Lock() m.results[s.human] = g m.mu.Unlock() - m.emitMatchFound(g) + m.emitMatchFound(ctx, g) } } diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 4092e57..e6403ef 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -11,6 +11,7 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" ) // fakeCreator records the games a matchmaker asks it to start. @@ -27,6 +28,12 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil } +// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing +// tests assert on matching behaviour, not the payload, so an empty state is enough. +func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) { + return notify.PlayerState{}, nil +} + // fakeRobots is a RobotProvider returning a fixed robot id, or an error to model // an empty pool. It records the variant of the last substitution request. type fakeRobots struct { diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go new file mode 100644 index 0000000..076d2ef --- /dev/null +++ b/backend/internal/notify/encode.go @@ -0,0 +1,197 @@ +package notify + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/backend/internal/engine" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// The builders below encode the nested wire tables embedded in enriched event +// payloads (R4). They mirror the gateway's transcode encoders, but read the domain's +// already-resolved values (notify.* input structs and the decoded engine.MoveRecord) +// rather than the gateway's REST DTOs. Each returns the offset of the table it built; +// callers must build every nested table before opening the parent event table. + +// buildGameView builds a GameView table from a GameSummary and returns its offset. +func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT { + seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) + for i, s := range g.Seats { + aid := b.CreateString(s.AccountID) + dname := b.CreateString(s.DisplayName) + fb.SeatViewStart(b) + fb.SeatViewAddSeat(b, int32(s.Seat)) + fb.SeatViewAddAccountId(b, aid) + fb.SeatViewAddScore(b, int32(s.Score)) + fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) + fb.SeatViewAddIsWinner(b, s.IsWinner) + fb.SeatViewAddDisplayName(b, dname) + seatOffs[i] = fb.SeatViewEnd(b) + } + fb.GameViewStartSeatsVector(b, len(seatOffs)) + for i := len(seatOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(seatOffs[i]) + } + seats := b.EndVector(len(seatOffs)) + + id := b.CreateString(g.ID) + variant := b.CreateString(g.Variant) + dictVer := b.CreateString(g.DictVersion) + status := b.CreateString(g.Status) + endReason := b.CreateString(g.EndReason) + + fb.GameViewStart(b) + fb.GameViewAddId(b, id) + fb.GameViewAddVariant(b, variant) + fb.GameViewAddDictVersion(b, dictVer) + fb.GameViewAddStatus(b, status) + fb.GameViewAddPlayers(b, int32(g.Players)) + fb.GameViewAddToMove(b, int32(g.ToMove)) + fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) + fb.GameViewAddMoveCount(b, int32(g.MoveCount)) + fb.GameViewAddEndReason(b, endReason) + fb.GameViewAddSeats(b, seats) + fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) + return fb.GameViewEnd(b) +} + +// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns +// its offset. The values match the move-result DTO (Count is the engine count: the +// number of tiles swapped on an exchange, zero otherwise). +func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT { + tileOffs := make([]flatbuffers.UOffsetT, len(m.Tiles)) + for i, t := range m.Tiles { + letter := b.CreateString(t.Letter) + fb.TileRecordStart(b) + fb.TileRecordAddRow(b, int32(t.Row)) + fb.TileRecordAddCol(b, int32(t.Col)) + fb.TileRecordAddLetter(b, letter) + fb.TileRecordAddBlank(b, t.Blank) + tileOffs[i] = fb.TileRecordEnd(b) + } + fb.MoveRecordStartTilesVector(b, len(tileOffs)) + for i := len(tileOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(tileOffs[i]) + } + tiles := b.EndVector(len(tileOffs)) + + wordOffs := make([]flatbuffers.UOffsetT, len(m.Words)) + for i, w := range m.Words { + wordOffs[i] = b.CreateString(w) + } + fb.MoveRecordStartWordsVector(b, len(wordOffs)) + for i := len(wordOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(wordOffs[i]) + } + words := b.EndVector(len(wordOffs)) + + action := b.CreateString(m.Action.String()) + dir := b.CreateString(m.Dir.String()) + fb.MoveRecordStart(b) + fb.MoveRecordAddPlayer(b, int32(m.Player)) + fb.MoveRecordAddAction(b, action) + fb.MoveRecordAddDir(b, dir) + fb.MoveRecordAddMainRow(b, int32(m.MainRow)) + fb.MoveRecordAddMainCol(b, int32(m.MainCol)) + fb.MoveRecordAddTiles(b, tiles) + fb.MoveRecordAddWords(b, words) + fb.MoveRecordAddCount(b, int32(m.Count)) + fb.MoveRecordAddScore(b, int32(m.Score)) + fb.MoveRecordAddTotal(b, int32(m.Total)) + return fb.MoveRecordEnd(b) +} + +// buildAlphabet builds the AlphabetEntry vector embedded in a StateView and returns +// its offset. +func buildAlphabet(b *flatbuffers.Builder, entries []AlphabetLetter) flatbuffers.UOffsetT { + offs := make([]flatbuffers.UOffsetT, len(entries)) + for i, e := range entries { + letter := b.CreateString(e.Letter) + fb.AlphabetEntryStart(b) + fb.AlphabetEntryAddIndex(b, byte(e.Index)) + fb.AlphabetEntryAddLetter(b, letter) + fb.AlphabetEntryAddValue(b, int32(e.Value)) + offs[i] = fb.AlphabetEntryEnd(b) + } + fb.StateViewStartAlphabetVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + return b.EndVector(len(offs)) +} + +// buildStateView builds a StateView table from a PlayerState and returns its offset. +func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT { + game := buildGameView(b, s.Game) + rackBytes := make([]byte, len(s.Rack)) + for i, v := range s.Rack { + rackBytes[i] = byte(v) + } + rack := b.CreateByteVector(rackBytes) + hasAlphabet := len(s.Alphabet) > 0 + var alphabet flatbuffers.UOffsetT + if hasAlphabet { + alphabet = buildAlphabet(b, s.Alphabet) + } + fb.StateViewStart(b) + fb.StateViewAddGame(b, game) + fb.StateViewAddSeat(b, int32(s.Seat)) + fb.StateViewAddRack(b, rack) + fb.StateViewAddBagLen(b, int32(s.BagLen)) + fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) + if hasAlphabet { + fb.StateViewAddAlphabet(b, alphabet) + } + return fb.StateViewEnd(b) +} + +// buildAccountRef builds an AccountRef table and returns its offset. +func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT { + aid := b.CreateString(a.AccountID) + name := b.CreateString(a.DisplayName) + fb.AccountRefStart(b) + fb.AccountRefAddAccountId(b, aid) + fb.AccountRefAddDisplayName(b, name) + return fb.AccountRefEnd(b) +} + +// buildInvitation builds an Invitation table from an InvitationSummary and returns its offset. +func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT { + inviter := buildAccountRef(b, inv.Inviter) + inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees)) + for i, iv := range inv.Invitees { + aid := b.CreateString(iv.AccountID) + name := b.CreateString(iv.DisplayName) + resp := b.CreateString(iv.Response) + fb.InvitationInviteeStart(b) + fb.InvitationInviteeAddAccountId(b, aid) + fb.InvitationInviteeAddDisplayName(b, name) + fb.InvitationInviteeAddSeat(b, int32(iv.Seat)) + fb.InvitationInviteeAddResponse(b, resp) + inviteeOffs[i] = fb.InvitationInviteeEnd(b) + } + fb.InvitationStartInviteesVector(b, len(inviteeOffs)) + for i := len(inviteeOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(inviteeOffs[i]) + } + invitees := b.EndVector(len(inviteeOffs)) + + id := b.CreateString(inv.ID) + variant := b.CreateString(inv.Variant) + dropout := b.CreateString(inv.DropoutTiles) + status := b.CreateString(inv.Status) + gameID := b.CreateString(inv.GameID) + fb.InvitationStart(b) + fb.InvitationAddId(b, id) + fb.InvitationAddInviter(b, inviter) + fb.InvitationAddInvitees(b, invitees) + fb.InvitationAddVariant(b, variant) + fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) + fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) + fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) + fb.InvitationAddDropoutTiles(b, dropout) + fb.InvitationAddStatus(b, status) + fb.InvitationAddGameId(b, gameID) + fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix) + return fb.InvitationEnd(b) +} diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 7813cce..b88f4ab 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -6,6 +6,7 @@ import ( flatbuffers "github.com/google/flatbuffers/go" "github.com/google/uuid" + "scrabble/backend/internal/engine" fb "scrabble/pkg/fbs/scrabblefb" ) @@ -17,8 +18,9 @@ import ( // deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push (Stage // 17): the player who just moved, their move kind, the main word of a scoring play (empty // otherwise) and the recipient-first running score line. Empty strings render the plain "your -// turn" text. -func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string) Intent { +// turn" text. moveCount is the post-move count, which the client compares against its cached +// game to detect a missed in-app move and fall back to a refetch (R4). +func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent { b := flatbuffers.NewBuilder(128) gid := b.CreateString(gameID.String()) name := b.CreateString(opponentName) @@ -32,38 +34,51 @@ func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAc fb.YourTurnEventAddLastAction(b, action) fb.YourTurnEventAddLastWord(b, word) fb.YourTurnEventAddScoreLine(b, score) + fb.YourTurnEventAddMoveCount(b, int32(moveCount)) b.Finish(fb.YourTurnEventEnd(b)) return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()} } // GameOver announces to userID that game gameID finished. result is the outcome from userID's // own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both -// feed the out-of-app "game over" push (Stage 17). -func GameOver(userID, gameID uuid.UUID, result, scoreLine string) Intent { - b := flatbuffers.NewBuilder(64) +// feed the out-of-app "game over" push (Stage 17). game is the final post-game summary (the +// adjusted scores after rack penalties and the winner flag), so an in-app client settles the +// finished game from the event without a refetch (R4). +func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent { + b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) res := b.CreateString(result) score := b.CreateString(scoreLine) + gameOff := buildGameView(b, game) fb.GameOverEventStart(b) fb.GameOverEventAddGameId(b, gid) fb.GameOverEventAddResult(b, res) fb.GameOverEventAddScoreLine(b, score) + fb.GameOverEventAddGame(b, gameOff) b.Finish(fb.GameOverEventEnd(b)) return Intent{UserID: userID, Kind: KindGameOver, Payload: b.FinishedBytes(), EventID: eventID()} } -// OpponentMoved tells userID that seat just committed a move in game gameID, -// summarising it (the client refetches the full state). -func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent { - b := flatbuffers.NewBuilder(64) +// OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta +// the client applies to its cached game without a refetch (R4): move is the decoded play/pass/ +// exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and +// bagLen is the bag size after the draw. The seat/action/score/total scalars repeat the move's +// summary for pre-R4 wire back-compat. +func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent { + b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) - act := b.CreateString(action) + act := b.CreateString(move.Action.String()) + moveOff := buildMoveRecord(b, move) + gameOff := buildGameView(b, game) fb.OpponentMovedEventStart(b) fb.OpponentMovedEventAddGameId(b, gid) - fb.OpponentMovedEventAddSeat(b, int32(seat)) + fb.OpponentMovedEventAddSeat(b, int32(move.Player)) fb.OpponentMovedEventAddAction(b, act) - fb.OpponentMovedEventAddScore(b, int32(score)) - fb.OpponentMovedEventAddTotal(b, int32(total)) + fb.OpponentMovedEventAddScore(b, int32(move.Score)) + fb.OpponentMovedEventAddTotal(b, int32(move.Total)) + fb.OpponentMovedEventAddMove(b, moveOff) + fb.OpponentMovedEventAddGame(b, gameOff) + fb.OpponentMovedEventAddBagLen(b, int32(bagLen)) b.Finish(fb.OpponentMovedEventEnd(b)) return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()} } @@ -99,21 +114,24 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent { return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()} } -// MatchFound tells userID that game gameID, which they are seated in, has -// started (an auto-match pairing or a robot substitution). -func MatchFound(userID, gameID uuid.UUID) Intent { - b := flatbuffers.NewBuilder(64) +// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match +// pairing or a robot substitution). state is the recipient's full initial view of the new game, +// so the client navigates straight in from the event with no follow-up fetch (R4). +func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent { + b := flatbuffers.NewBuilder(512) gid := b.CreateString(gameID.String()) + stateOff := buildStateView(b, state) fb.MatchFoundEventStart(b) fb.MatchFoundEventAddGameId(b, gid) + fb.MatchFoundEventAddState(b, stateOff) b.Finish(fb.MatchFoundEventEnd(b)) return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()} } -// Notification is a lightweight "re-poll" signal to userID that a friend request or -// invitation changed. kind is a sub-discriminator (NotifyFriendRequest, -// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the -// client may use to scope its refresh. +// Notification is a lightweight "re-poll" signal to userID that something in their lobby +// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded, +// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the +// enriched constructors below, which let the client update its lobby without a refetch (R4). func Notification(userID uuid.UUID, kind string) Intent { b := flatbuffers.NewBuilder(32) k := b.CreateString(kind) @@ -123,6 +141,47 @@ func Notification(userID uuid.UUID, kind string) Intent { return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} } +// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the +// account it concerns (the requester, the new friend or the decliner), so the client updates its +// requests/friends lists and the in-game "add friend" state without a refetch (R4). +func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent { + b := flatbuffers.NewBuilder(128) + k := b.CreateString(kind) + accOff := buildAccountRef(b, acc) + fb.NotificationEventStart(b) + fb.NotificationEventAddKind(b, k) + fb.NotificationEventAddAccount(b, accOff) + b.Finish(fb.NotificationEventEnd(b)) + return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's +// initial view of the just-started invited game, so the client seeds its game cache and the +// lobby list without a refetch (R4). +func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent { + b := flatbuffers.NewBuilder(512) + k := b.CreateString(NotifyGameStarted) + stateOff := buildStateView(b, state) + fb.NotificationEventStart(b) + fb.NotificationEventAddKind(b, k) + fb.NotificationEventAddState(b, stateOff) + b.Finish(fb.NotificationEventEnd(b)) + return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} +} + +// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation, +// so the client adds it to its lobby invitations list without a refetch (R4). +func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent { + b := flatbuffers.NewBuilder(512) + k := b.CreateString(NotifyInvitation) + invOff := buildInvitation(b, inv) + fb.NotificationEventStart(b) + fb.NotificationEventAddKind(b, k) + fb.NotificationEventAddInvitation(b, invOff) + b.Finish(fb.NotificationEventEnd(b)) + return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} +} + // eventID returns a best-effort correlation id for one emitted event. func eventID() string { if id, err := uuid.NewV7(); err == nil { diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 70a7427..e17b1b2 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" + "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" fb "scrabble/pkg/fbs/scrabblefb" ) @@ -61,7 +62,7 @@ func TestNopPublisherDiscards(t *testing.T) { func TestYourTurnPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() - in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95") + in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95", 7) if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" { t.Fatalf("intent metadata wrong: %+v", in) } @@ -72,6 +73,9 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) { if got := ev.DeadlineUnix(); got != 1717000000 { t.Fatalf("deadline = %d, want 1717000000", got) } + if got := ev.MoveCount(); got != 7 { + t.Fatalf("move_count = %d, want 7", got) + } if string(ev.OpponentName()) != "Ann" || string(ev.LastAction()) != "play" || string(ev.LastWord()) != "STOOL" || string(ev.ScoreLine()) != "120:95" { t.Fatalf("enriched fields wrong: name=%q action=%q word=%q score=%q", @@ -81,7 +85,8 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) { func TestGameOverPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() - in := notify.GameOver(uid, gid, "won", "120:95:80") + summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}} + in := notify.GameOver(uid, gid, "won", "120:95:80", summary) if in.UserID != uid || in.Kind != notify.KindGameOver || in.EventID == "" { t.Fatalf("intent metadata wrong: %+v", in) } @@ -89,19 +94,106 @@ func TestGameOverPayloadRoundTrips(t *testing.T) { if string(ev.GameId()) != gid.String() || string(ev.Result()) != "won" || string(ev.ScoreLine()) != "120:95:80" { t.Fatalf("game_over fields wrong: game=%q result=%q score=%q", ev.GameId(), ev.Result(), ev.ScoreLine()) } + g := ev.Game(nil) + if g == nil || string(g.Id()) != gid.String() || g.MoveCount() != 18 || g.SeatsLength() != 1 { + t.Fatalf("final game summary wrong: %+v", g) + } } func TestOpponentMovedPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() - in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130) + move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130} + summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}} + in := notify.OpponentMoved(uid, gid, move, summary, 42) if in.Kind != notify.KindOpponentMoved { t.Fatalf("kind = %q", in.Kind) } ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0) + // The pre-R4 summary scalars repeat the move. if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 { - t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d", + t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d", ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total()) } + // The R4 delta: the move, the post-move summary and the bag size. + if ev.BagLen() != 42 { + t.Fatalf("bag_len = %d, want 42", ev.BagLen()) + } + m := ev.Move(nil) + if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 { + t.Fatalf("move wrong: %+v", m) + } + if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 { + t.Fatalf("game summary wrong: %+v", ev.Game(nil)) + } +} + +func TestMatchFoundCarriesInitialState(t *testing.T) { + uid, gid := uuid.New(), uuid.New() + state := notify.PlayerState{ + Game: notify.GameSummary{ID: gid.String(), Variant: "scrabble_en", Seats: []notify.SeatStanding{{Seat: 0, DisplayName: "Ann"}}}, + Seat: 0, + Rack: []int{0, 1, 2, 255}, + BagLen: 86, + } + in := notify.MatchFound(uid, gid, state) + if in.UserID != uid || in.Kind != notify.KindMatchFound { + t.Fatalf("intent metadata wrong: %+v", in) + } + ev := fb.GetRootAsMatchFoundEvent(in.Payload, 0) + if string(ev.GameId()) != gid.String() { + t.Fatalf("game id = %q", ev.GameId()) + } + st := ev.State(nil) + if st == nil || st.Seat() != 0 || st.BagLen() != 86 || st.RackLength() != 4 || st.Rack(3) != 255 { + t.Fatalf("initial state wrong: %+v", st) + } + if g := st.Game(nil); g == nil || string(g.Variant()) != "scrabble_en" { + t.Fatalf("state game wrong: %+v", st.Game(nil)) + } +} + +func TestNotificationInvitationCarriesInvitation(t *testing.T) { + uid := uuid.New() + inv := notify.InvitationSummary{ + ID: "inv-1", + Inviter: notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"}, + Invitees: []notify.InvitationInvitee{{AccountID: "b-1", DisplayName: "Bob", Seat: 1, Response: "pending"}}, + Variant: "erudit_ru", + TurnTimeoutSecs: 86400, + Status: "pending", + } + in := notify.NotificationInvitation(uid, inv) + if in.Kind != notify.KindNotification { + t.Fatalf("kind = %q", in.Kind) + } + ev := fb.GetRootAsNotificationEvent(in.Payload, 0) + if string(ev.Kind()) != notify.NotifyInvitation { + t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitation) + } + got := ev.Invitation(nil) + if got == nil || string(got.Id()) != "inv-1" || string(got.Variant()) != "erudit_ru" || got.InviteesLength() != 1 { + t.Fatalf("invitation wrong: %+v", got) + } + var iv fb.InvitationInvitee + if !got.Invitees(&iv, 0) || string(iv.DisplayName()) != "Bob" || iv.Seat() != 1 { + t.Fatalf("invitee wrong") + } + if inviter := got.Inviter(nil); inviter == nil || string(inviter.DisplayName()) != "Ann" { + t.Fatalf("inviter wrong") + } +} + +func TestNotificationAccountCarriesAccount(t *testing.T) { + uid := uuid.New() + in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"}) + ev := fb.GetRootAsNotificationEvent(in.Payload, 0) + if string(ev.Kind()) != notify.NotifyFriendRequest { + t.Fatalf("sub-kind = %q", ev.Kind()) + } + acc := ev.Account(nil) + if acc == nil || string(acc.AccountId()) != "a-1" || string(acc.DisplayName()) != "Ann" { + t.Fatalf("account wrong: %+v", acc) + } } func TestChatMessagePayloadRoundTrips(t *testing.T) { diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go new file mode 100644 index 0000000..3177b5a --- /dev/null +++ b/backend/internal/notify/payload.go @@ -0,0 +1,89 @@ +package notify + +// The structs below are the wire-agnostic inputs the domain services hand to the +// enriched event constructors. Keeping them here — rather than importing the wire +// schema into game/lobby/social — preserves the package boundary: notify owns the +// FlatBuffers encoding, while the domain only fills in already-resolved values (seat +// display names, alphabet-index racks). Each mirrors the matching scrabblefb table. + +// SeatStanding is one seat's public standing inside a GameSummary (mirrors +// scrabblefb.SeatView). +type SeatStanding struct { + Seat int + AccountID string + DisplayName string + Score int + HintsUsed int + IsWinner bool +} + +// GameSummary is the shared, non-private game state embedded in enriched events +// (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current +// turn's start for an active game, the finish time once finished. +type GameSummary struct { + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + TurnTimeoutSecs int + MoveCount int + EndReason string + Seats []SeatStanding + LastActivityUnix int64 +} + +// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an +// initial PlayerState so a client seeing a variant for the first time can render its +// rack (mirrors scrabblefb.AlphabetEntry). +type AlphabetLetter struct { + Index int + Letter string + Value int +} + +// PlayerState is a player's full initial view of a game — the shared summary plus +// their private rack and budgets (mirrors scrabblefb.StateView). Rack carries wire +// alphabet indices (a blank is the sentinel index 255). Alphabet is set only when the +// recipient may not have cached the variant yet (match_found / game_started). +type PlayerState struct { + Game GameSummary + Seat int + Rack []int + BagLen int + HintsRemaining int + Alphabet []AlphabetLetter +} + +// AccountRef is a referenced account with its display name resolved (mirrors +// scrabblefb.AccountRef). +type AccountRef struct { + AccountID string + DisplayName string +} + +// InvitationInvitee is one invited player's seat and response inside an +// InvitationSummary (mirrors scrabblefb.InvitationInvitee). +type InvitationInvitee struct { + AccountID string + DisplayName string + Seat int + Response string +} + +// InvitationSummary is a friend-game invitation carried by the NotifyInvitation event so +// the client adds it to its lobby list without a refetch (mirrors scrabblefb.Invitation). +type InvitationSummary struct { + ID string + Inviter AccountRef + Invitees []InvitationInvitee + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + DropoutTiles string + Status string + GameID string + ExpiresAtUnix int64 +} diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 83eb07a..f362cc2 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -98,10 +98,14 @@ type gameDTO struct { Seats []seatDTO `json:"seats"` } -// moveResultDTO is the outcome of a committed move. +// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as +// wire alphabet indices and BagLen the bag size after the draw (R4), so the mover renders the +// next state from the response without a follow-up state fetch. type moveResultDTO struct { - Move moveRecordDTO `json:"move"` - Game gameDTO `json:"game"` + Move moveRecordDTO `json:"move"` + Game gameDTO `json:"game"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` } // alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and @@ -231,9 +235,19 @@ func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO { } } -// moveResultDTOFrom projects a committed move result into its DTO. -func moveResultDTOFrom(r game.MoveResult) moveResultDTO { - return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)} +// moveResultDTOFrom projects a committed move result into its DTO, encoding the actor's rack as +// wire alphabet indices (Stage 13; R4). +func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) { + rack, err := engine.EncodeRack(r.Game.Variant, r.Rack) + if err != nil { + return moveResultDTO{}, err + } + return moveResultDTO{ + Move: moveRecordDTOFrom(r.Move), + Game: gameDTOFromGame(r.Game), + Rack: rack, + BagLen: r.BagLen, + }, nil } // stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index 5617b0b..0235d01 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -458,7 +458,11 @@ func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) { // writeMoveResult emits a committed move with seat display names filled in. func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) { - dto := moveResultDTOFrom(res) + dto, err := moveResultDTOFrom(res) + if err != nil { + s.abortErr(c, err) + return + } s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{}) c.JSON(http.StatusOK, dto) } diff --git a/backend/internal/social/friendcodes.go b/backend/internal/social/friendcodes.go index 56fd4b4..2d9f4ca 100644 --- a/backend/internal/social/friendcodes.go +++ b/backend/internal/social/friendcodes.go @@ -82,7 +82,7 @@ func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, if err := svc.store.redeemFriendCode(ctx, codeID, issuerID, redeemerID, svc.now()); err != nil { return uuid.UUID{}, err } - svc.pub.Publish(notify.Notification(issuerID, notify.NotifyFriendAdded)) + svc.pub.Publish(notify.NotificationAccount(issuerID, notify.NotifyFriendAdded, svc.accountRef(ctx, redeemerID))) return issuerID, nil } diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index 7c7406a..d15257e 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -29,6 +29,17 @@ const ( // window; a one-time friend code from the addressee bypasses a decline. const friendRequestTTL = 30 * 24 * time.Hour +// accountRef resolves accountID into a notify.AccountRef (the display name from the account +// store, empty on a lookup failure), for enriching the friend_* live events so the client +// updates its requests/friends state without a refetch (R4). +func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef { + ref := notify.AccountRef{AccountID: accountID.String()} + if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil { + ref.DisplayName = acc.DisplayName + } + return ref +} + // SendFriendRequest records a pending friend request from requesterID to // addresseeID — the "befriend an opponent" path. It requires the two to share a // game (active or finished) and refuses a self-request, a request across a block or @@ -91,7 +102,7 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil { return err } - svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest)) + svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) return nil } } @@ -101,7 +112,7 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse } return err } - svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest)) + svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) return nil } @@ -128,9 +139,9 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque // this opponent re-derives its "add to friends" state (accepted -> friends, declined // -> stays "request sent"). if accept { - svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded)) + svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendAdded, svc.accountRef(ctx, addresseeID))) } else { - svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined)) + svc.pub.Publish(notify.NotificationAccount(requesterID, notify.NotifyFriendDeclined, svc.accountRef(ctx, addresseeID))) } return nil } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d1546a5..fcd7cf8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -510,11 +510,22 @@ seat from the same game commit when a game finishes — any path: a closing play resign or timeout) and **enriched your-turn** so the out-of-app push reads in full: it now also carries the mover's display name, their last action and the main word of a scoring play, and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first). -Event payloads are FlatBuffers-encoded by -the backend and forwarded verbatim. A client that is not currently streaming falls -back to the matchmaker's `Poll` for match-found and, for the lobby **notification -badge** (incoming friend requests + open invitations), the client polls on lobby -open and on focus as well as re-polling on the `notify` event — covering a push +**R4 enriched the in-app stream into a delta channel** so the client renders from the event +without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move +summary (per-seat scores, whose turn, move count, status) and the bag size, which the client +applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move +echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history` +refetch); **your-turn** carries that move count as a consistency check; **match-found** and the +**game-started** notify carry the recipient's full **initial `StateView`**, so opening a freshly +started game is instant; **game-over** carries the final summary; the lobby **notify** sub-kinds +carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` / +`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover +renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding +(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload +verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for +match-found — the client polls **only while the stream is down**, since a live stream delivers +match-found itself; for the lobby **notification badge** (incoming friend requests + open +invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback the **gateway** routes from the same firehose: for an event whose recipient has **no live in-app stream** it resolves the backend `/internal/push-target` (their Telegram diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 3399b89..bdbf102 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -42,7 +42,8 @@ language, not whichever bot the player signed in through last. Guests are sessio (auto-match only; no friends, stats or history); an abandoned guest that never joined a game and has been idle past the retention window is garbage-collected. While the app is open the client keeps a live stream and receives in-app updates in real time — the opponent's move, -your turn, chat, nudges and a found match. When the app is **closed**, the chosen +your turn, chat, nudges and a found match. Each update lands as the event itself, applied in place +with no reload, so the board refreshes seamlessly and a found or invited game opens instantly. When the app is **closed**, the chosen out-of-app events (your turn, game over, nudge, a found match, an invitation or friend request) arrive as a **Telegram notification** instead — unless the player keeps notifications in the app only (a profile setting, **on by default**). The "your turn" diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ee0e7b3..57f1e19 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -43,7 +43,8 @@ nudge) приходят от бота **этой партии** — по язы авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, -чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие +чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без +перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья) приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления только в приложении (настройка профиля, **включена по умолчанию**). diff --git a/gateway/README.md b/gateway/README.md index d162bc0..656102c 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -53,7 +53,9 @@ out-of-app push to that connector for recipients with no live in-app stream The Stage 6 message-type slice: `auth.telegram`, `auth.guest`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events -`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found`. Stage 7 +`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (R4 enriched the game events — +and `game_over`/`notify` — to carry the state delta the client applies without a `game.state` +refetch). Stage 7 added the play-loop ops; **Stage 8** added the social/account/history ops — `friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem), `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index e31bd91..fde7358 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -106,10 +106,13 @@ type GameResp struct { Seats []SeatResp `json:"seats"` } -// MoveResultResp is the outcome of a committed move. +// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as +// wire alphabet indices and BagLen the bag size after the draw (R4). type MoveResultResp struct { - Move MoveRecordResp `json:"move"` - Game GameResp `json:"game"` + Move MoveRecordResp `json:"move"` + Game GameResp `json:"game"` + Rack []int `json:"rack"` + BagLen int `json:"bag_len"` } // AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 8c61144..cd2b249 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -123,9 +123,16 @@ func encodeMoveResult(r backendclient.MoveResultResp) []byte { b := flatbuffers.NewBuilder(512) move := buildMoveRecord(b, r.Move) game := buildGameView(b, r.Game) + rackBytes := make([]byte, len(r.Rack)) + for i, v := range r.Rack { + rackBytes[i] = byte(v) + } + rack := b.CreateByteVector(rackBytes) fb.MoveResultStart(b) fb.MoveResultAddMove(b, move) fb.MoveResultAddGame(b, game) + fb.MoveResultAddRack(b, rack) + fb.MoveResultAddBagLen(b, int32(r.BagLen)) b.Finish(fb.MoveResultEnd(b)) return b.FinishedBytes() } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 1814920..462bcd0 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -159,10 +159,16 @@ table SubmitPlayRequest { tiles:[PlayTile]; } -// MoveResult is the outcome of a committed move: the move and the post-move game. +// MoveResult is the outcome of a committed move: the move and the post-move game. rack and +// bag_len carry the actor's own post-move private state — their refilled rack (alphabet indices, +// Stage 13; a blank is the sentinel index 255) and the bag size after drawing — so the mover +// renders the next state straight from this response without a follow-up game.state (R4; added +// trailing — backward-compatible). table MoveResult { move:MoveRecord; game:GameView; + rack:[ubyte]; + bag_len:int; } // StateRequest asks for the requesting player's view of a game. include_alphabet asks the @@ -477,6 +483,8 @@ table GcgExport { // move kind ("play"/"pass"/"exchange"/...), last_word is the main word of a scoring play (empty // otherwise), and score_line is the recipient-first running score (e.g. "120:95:80"). They are // appended (FlatBuffers-optional), so an older reader that only needs game_id/deadline is unaffected. +// move_count is the post-move count (matching the opponent_moved GameView): the client uses it to +// tell whether its cached game already reflects the move, falling back to a refetch on a gap (R4). table YourTurnEvent { game_id:string; deadline_unix:long; @@ -484,25 +492,35 @@ table YourTurnEvent { last_action:string; last_word:string; score_line:string; + move_count:int; } // GameOverEvent signals that a game the recipient is seated in has finished, driving the // out-of-app "game over" push (Stage 17). result is the outcome from the recipient's own -// perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score. +// perspective ("won"/"lost"/"draw"); score_line is the recipient-first final score. game is the +// final post-game summary (adjusted scores after rack penalties + the winner flag), so the client +// settles the finished game from the event without a refetch (R4; added trailing). table GameOverEvent { game_id:string; result:string; score_line:string; + game:GameView; } -// OpponentMovedEvent summarises a move another seat just committed; the client -// refetches the full state. +// OpponentMovedEvent carries a move another seat just committed as a delta the client applies to +// its cached game without a refetch (R4): move is the decoded play/pass/exchange (the same record +// game.history returns), game is the post-move summary (per-seat scores, to_move, move_count, +// status) and bag_len is the bag size after the draw. The leading seat/action/score/total scalars +// are the pre-R4 summary, now redundant with move/game and kept only for wire back-compat. table OpponentMovedEvent { game_id:string; seat:int; action:string; score:int; total:int; + move:MoveRecord; + game:GameView; + bag_len:int; } // NudgeEvent signals that a player nudged the recipient. @@ -511,17 +529,27 @@ table NudgeEvent { from_user_id:string; } -// MatchFoundEvent signals that an auto-match pairing (or robot substitution) -// started a game the recipient is seated in. +// MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the +// recipient is seated in. state is the recipient's full initial view of the new game (empty board, +// dealt rack), so the client navigates straight in from the event with no follow-up fetch (R4; +// added trailing — an older reader still reads just game_id). table MatchFoundEvent { game_id:string; + state:StateView; } // NotificationEvent is a lightweight "something changed, re-poll" signal that // drives the lobby badge (incoming friend requests, invitations). kind is a sub- // discriminator ("friend_request", "friend_added", "friend_declined", "invitation", // "game_started"); the client re-fetches its lobby counters (and, for a requester -// watching a game, its friend state) on any of them. +// watching a game, its friend state) on any of them. To let the client update its lobby without a +// follow-up fetch (R4), each event also carries the payload its kind changed: account for the +// friend_* kinds (the requester/friend), invitation for "invitation" (the new invitation) and +// state for "game_started" (the started game's initial view, like match_found). Only the field +// matching kind is set (all added trailing — backward-compatible). table NotificationEvent { kind:string; + account:AccountRef; + invitation:Invitation; + state:StateView; } diff --git a/pkg/fbs/scrabblefb/GameOverEvent.go b/pkg/fbs/scrabblefb/GameOverEvent.go index e77f00f..ad80145 100644 --- a/pkg/fbs/scrabblefb/GameOverEvent.go +++ b/pkg/fbs/scrabblefb/GameOverEvent.go @@ -65,8 +65,21 @@ func (rcv *GameOverEvent) ScoreLine() []byte { return nil } +func (rcv *GameOverEvent) Game(obj *GameView) *GameView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func GameOverEventStart(builder *flatbuffers.Builder) { - builder.StartObject(3) + builder.StartObject(4) } func GameOverEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -77,6 +90,9 @@ func GameOverEventAddResult(builder *flatbuffers.Builder, result flatbuffers.UOf func GameOverEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(scoreLine), 0) } +func GameOverEventAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(game), 0) +} func GameOverEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/MatchFoundEvent.go b/pkg/fbs/scrabblefb/MatchFoundEvent.go index fc7c0f8..a93b0d7 100644 --- a/pkg/fbs/scrabblefb/MatchFoundEvent.go +++ b/pkg/fbs/scrabblefb/MatchFoundEvent.go @@ -49,12 +49,28 @@ func (rcv *MatchFoundEvent) GameId() []byte { return nil } +func (rcv *MatchFoundEvent) State(obj *StateView) *StateView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(StateView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func MatchFoundEventStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func MatchFoundEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) } +func MatchFoundEventAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(state), 0) +} func MatchFoundEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/MoveResult.go b/pkg/fbs/scrabblefb/MoveResult.go index a8e37f4..98dbd5f 100644 --- a/pkg/fbs/scrabblefb/MoveResult.go +++ b/pkg/fbs/scrabblefb/MoveResult.go @@ -67,8 +67,54 @@ func (rcv *MoveResult) Game(obj *GameView) *GameView { return nil } +func (rcv *MoveResult) Rack(j int) byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1)) + } + return 0 +} + +func (rcv *MoveResult) RackLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *MoveResult) RackBytes() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MoveResult) MutateRack(j int, n byte) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n) + } + return false +} + +func (rcv *MoveResult) BagLen() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MoveResult) MutateBagLen(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + func MoveResultStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(4) } func MoveResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0) @@ -76,6 +122,15 @@ func MoveResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) func MoveResultAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(game), 0) } +func MoveResultAddRack(builder *flatbuffers.Builder, rack flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(rack), 0) +} +func MoveResultStartRackVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(1, numElems, 1) +} +func MoveResultAddBagLen(builder *flatbuffers.Builder, bagLen int32) { + builder.PrependInt32Slot(3, bagLen, 0) +} func MoveResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/NotificationEvent.go b/pkg/fbs/scrabblefb/NotificationEvent.go index 89314bb..74f4eaf 100644 --- a/pkg/fbs/scrabblefb/NotificationEvent.go +++ b/pkg/fbs/scrabblefb/NotificationEvent.go @@ -49,12 +49,60 @@ func (rcv *NotificationEvent) Kind() []byte { return nil } +func (rcv *NotificationEvent) Account(obj *AccountRef) *AccountRef { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(AccountRef) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *NotificationEvent) Invitation(obj *Invitation) *Invitation { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(Invitation) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *NotificationEvent) State(obj *StateView) *StateView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(StateView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func NotificationEventStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(4) } func NotificationEventAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0) } +func NotificationEventAddAccount(builder *flatbuffers.Builder, account flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(account), 0) +} +func NotificationEventAddInvitation(builder *flatbuffers.Builder, invitation flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(invitation), 0) +} +func NotificationEventAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(state), 0) +} func NotificationEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/OpponentMovedEvent.go b/pkg/fbs/scrabblefb/OpponentMovedEvent.go index 6c9fa81..8b0e5c0 100644 --- a/pkg/fbs/scrabblefb/OpponentMovedEvent.go +++ b/pkg/fbs/scrabblefb/OpponentMovedEvent.go @@ -93,8 +93,46 @@ func (rcv *OpponentMovedEvent) MutateTotal(n int32) bool { return rcv._tab.MutateInt32Slot(12, n) } +func (rcv *OpponentMovedEvent) Move(obj *MoveRecord) *MoveRecord { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MoveRecord) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *OpponentMovedEvent) Game(obj *GameView) *GameView { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(GameView) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *OpponentMovedEvent) BagLen() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *OpponentMovedEvent) MutateBagLen(n int32) bool { + return rcv._tab.MutateInt32Slot(18, n) +} + func OpponentMovedEventStart(builder *flatbuffers.Builder) { - builder.StartObject(5) + builder.StartObject(8) } func OpponentMovedEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -111,6 +149,15 @@ func OpponentMovedEventAddScore(builder *flatbuffers.Builder, score int32) { func OpponentMovedEventAddTotal(builder *flatbuffers.Builder, total int32) { builder.PrependInt32Slot(4, total, 0) } +func OpponentMovedEventAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(move), 0) +} +func OpponentMovedEventAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(game), 0) +} +func OpponentMovedEventAddBagLen(builder *flatbuffers.Builder, bagLen int32) { + builder.PrependInt32Slot(7, bagLen, 0) +} func OpponentMovedEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/YourTurnEvent.go b/pkg/fbs/scrabblefb/YourTurnEvent.go index 9534d59..5998f85 100644 --- a/pkg/fbs/scrabblefb/YourTurnEvent.go +++ b/pkg/fbs/scrabblefb/YourTurnEvent.go @@ -93,8 +93,20 @@ func (rcv *YourTurnEvent) ScoreLine() []byte { return nil } +func (rcv *YourTurnEvent) MoveCount() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *YourTurnEvent) MutateMoveCount(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + func YourTurnEventStart(builder *flatbuffers.Builder) { - builder.StartObject(6) + builder.StartObject(7) } func YourTurnEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) @@ -114,6 +126,9 @@ func YourTurnEventAddLastWord(builder *flatbuffers.Builder, lastWord flatbuffers func YourTurnEventAddScoreLine(builder *flatbuffers.Builder, scoreLine flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(scoreLine), 0) } +func YourTurnEventAddMoveCount(builder *flatbuffers.Builder, moveCount int32) { + builder.PrependInt32Slot(6, moveCount, 0) +} func YourTurnEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/README.md b/ui/README.md index faa051a..494edc2 100644 --- a/ui/README.md +++ b/ui/README.md @@ -40,7 +40,9 @@ The build has **two entries**: the game SPA (`index.html`, served at `/app/` and A single Connect `Execute(message_type, payload)` carries every unary op; the request and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`. The session token rides in `Authorization: Bearer`; a domain failure comes back in -`result_code`. `Subscribe` is the live event stream. `lib/transport.ts` is the real +`result_code`. `Subscribe` is the live event stream; R4 made its game events carry a state **delta** +that `lib/gamedelta.ts` applies to the per-game cache (`lib/gamecache.ts`), so a move renders without +a follow-up `game.state` (a gap falls back to a refetch). `lib/transport.ts` is the real client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index b31b234..1c45789 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -13,13 +13,14 @@ import { connection } from '../lib/connection.svelte'; import { GatewayError } from '../lib/client'; import { t } from '../lib/i18n/index.svelte'; - import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model'; + import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import { replay } from '../lib/board'; import { centre, premiumGrid } from '../lib/premiums'; import { variantNameKey } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { shareOrDownloadGcg } from '../lib/share'; - import { getCachedGame, setCachedGame } from '../lib/gamecache'; + import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache'; + import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta'; import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { BLANK, @@ -154,17 +155,42 @@ void loadFriends(); }); + // cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers. + function cacheSnapshot(): CachedGame | undefined { + return view ? { view, moves } : undefined; + } + // applyDelta adopts a reducer result: an advanced cache renders the move with no fetch; a + // flagged refetch falls back to a full load() (a gap, our own move's new rack, or a missing + // payload — see lib/gamedelta). + function applyDelta(res: DeltaResult): void { + if (res.cache) { + view = res.cache.view; + moves = res.cache.moves; + setCachedGame(id, view, moves); + recompute(); + } else if (res.refetch) { + void load(); + } + } + $effect(() => { const e = app.lastEvent; if (!e) return; if (e.kind === 'opponent_moved' && e.gameId === id) { - // Skip the echo of my own move (the backend now notifies the actor too, for the - // player's other devices): this device already reloaded after the submit. - if (e.seat !== view?.seat) void load(); - } else if (e.kind === 'your_turn' && e.gameId === id) void load(); - // A request the player sent was answered (accepted -> now friends; declined -> stays - // "request sent"): re-derive the in-game friend state. - else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends(); + // While composing, reload so a draft overlapping the new move is reconciled; otherwise apply + // the move as a delta with no fetch (R4). + if (placement.pending.length > 0) void load(); + else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen })); + } else if (e.kind === 'your_turn' && e.gameId === id) { + // The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch + // only if we missed the move (our cached count trails the event's). + if (view && e.moveCount > view.game.moveCount) void load(); + } else if (e.kind === 'game_over' && e.gameId === id) { + applyDelta(applyGameOver(cacheSnapshot(), e.game)); + } else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) { + // A request the player sent was answered: re-derive the in-game "add friend" state. + void loadFriends(); + } }); function isCoarse(): boolean { @@ -446,15 +472,27 @@ }, 250); } + // applyMoveResult renders the actor's own just-committed move from the response — the move, the + // post-move game and the refilled rack — without a follow-up game.state + game.history (R4). + function applyMoveResult(r: MoveResult) { + view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 }; + moves = [...moves, r.move]; + setCachedGame(id, view, moves); + rackIds = r.rack.map((_, i) => i); + placement = newPlacement(r.rack); + selected = null; + dirOverride = undefined; + recompute(); + } + async function commit() { const sub = toSubmit(placement, dirOverride); if (!sub) return; busy = true; try { - await gateway.submitPlay(id, sub.dir, sub.tiles, variant); + applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant)); telegramHaptic('success'); zoomed = false; - await load(); } catch (e) { handleError(e); } finally { @@ -472,8 +510,7 @@ async function doPass() { busy = true; try { - await gateway.pass(id); - await load(); + applyMoveResult(await gateway.pass(id)); } catch (e) { handleError(e); } finally { @@ -484,8 +521,7 @@ resignOpen = false; busy = true; try { - await gateway.resign(id); - await load(); + applyMoveResult(await gateway.resign(id)); } catch (e) { handleError(e); } finally { @@ -553,8 +589,7 @@ exchangeOpen = false; busy = true; try { - await gateway.exchange(id, tiles, variant); - await load(); + applyMoveResult(await gateway.exchange(id, tiles, variant)); } catch (e) { handleError(e); } finally { diff --git a/ui/src/gen/fbs/scrabblefb/game-over-event.ts b/ui/src/gen/fbs/scrabblefb/game-over-event.ts index 60c6bb7..08e1831 100644 --- a/ui/src/gen/fbs/scrabblefb/game-over-event.ts +++ b/ui/src/gen/fbs/scrabblefb/game-over-event.ts @@ -2,6 +2,9 @@ import * as flatbuffers from 'flatbuffers'; +import { GameView } from '../scrabblefb/game-view.js'; + + export class GameOverEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -41,8 +44,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +game(obj?:GameView):GameView|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startGameOverEvent(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(4); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { @@ -57,16 +65,13 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off builder.addFieldOffset(2, scoreLineOffset, 0); } +static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, gameOffset, 0); +} + static endGameOverEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createGameOverEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, resultOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset { - GameOverEvent.startGameOverEvent(builder); - GameOverEvent.addGameId(builder, gameIdOffset); - GameOverEvent.addResult(builder, resultOffset); - GameOverEvent.addScoreLine(builder, scoreLineOffset); - return GameOverEvent.endGameOverEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/match-found-event.ts b/ui/src/gen/fbs/scrabblefb/match-found-event.ts index 8a0f9f7..54c931f 100644 --- a/ui/src/gen/fbs/scrabblefb/match-found-event.ts +++ b/ui/src/gen/fbs/scrabblefb/match-found-event.ts @@ -2,6 +2,9 @@ import * as flatbuffers from 'flatbuffers'; +import { StateView } from '../scrabblefb/state-view.js'; + + export class MatchFoundEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -27,22 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +state(obj?:StateView):StateView|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startMatchFoundEvent(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { builder.addFieldOffset(0, gameIdOffset, 0); } +static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, stateOffset, 0); +} + static endMatchFoundEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createMatchFoundEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset { - MatchFoundEvent.startMatchFoundEvent(builder); - MatchFoundEvent.addGameId(builder, gameIdOffset); - return MatchFoundEvent.endMatchFoundEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/move-result.ts b/ui/src/gen/fbs/scrabblefb/move-result.ts index 623950e..cdca742 100644 --- a/ui/src/gen/fbs/scrabblefb/move-result.ts +++ b/ui/src/gen/fbs/scrabblefb/move-result.ts @@ -34,8 +34,28 @@ game(obj?:GameView):GameView|null { return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; } +rack(index: number):number|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0; +} + +rackLength():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +rackArray():Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null; +} + +bagLen():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startMoveResult(builder:flatbuffers.Builder) { - builder.startObject(2); + builder.startObject(4); } static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { @@ -46,6 +66,26 @@ static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { builder.addFieldOffset(1, gameOffset, 0); } +static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, rackOffset, 0); +} + +static createRackVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset { + builder.startVector(1, data.length, 1); + for (let i = data.length - 1; i >= 0; i--) { + builder.addInt8(data[i]!); + } + return builder.endVector(); +} + +static startRackVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(1, numElems, 1); +} + +static addBagLen(builder:flatbuffers.Builder, bagLen:number) { + builder.addFieldInt32(3, bagLen, 0); +} + static endMoveResult(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; diff --git a/ui/src/gen/fbs/scrabblefb/notification-event.ts b/ui/src/gen/fbs/scrabblefb/notification-event.ts index d3532a6..532ee12 100644 --- a/ui/src/gen/fbs/scrabblefb/notification-event.ts +++ b/ui/src/gen/fbs/scrabblefb/notification-event.ts @@ -2,6 +2,11 @@ import * as flatbuffers from 'flatbuffers'; +import { AccountRef } from '../scrabblefb/account-ref.js'; +import { Invitation } from '../scrabblefb/invitation.js'; +import { StateView } from '../scrabblefb/state-view.js'; + + export class NotificationEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -27,22 +32,44 @@ kind(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +account(obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +invitation(obj?:Invitation):Invitation|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? (obj || new Invitation()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +state(obj?:StateView):StateView|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? (obj || new StateView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startNotificationEvent(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(4); } static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) { builder.addFieldOffset(0, kindOffset, 0); } +static addAccount(builder:flatbuffers.Builder, accountOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, accountOffset, 0); +} + +static addInvitation(builder:flatbuffers.Builder, invitationOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, invitationOffset, 0); +} + +static addState(builder:flatbuffers.Builder, stateOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, stateOffset, 0); +} + static endNotificationEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createNotificationEvent(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset { - NotificationEvent.startNotificationEvent(builder); - NotificationEvent.addKind(builder, kindOffset); - return NotificationEvent.endNotificationEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts index 654a3fd..810700d 100644 --- a/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts +++ b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts @@ -2,6 +2,10 @@ import * as flatbuffers from 'flatbuffers'; +import { GameView } from '../scrabblefb/game-view.js'; +import { MoveRecord } from '../scrabblefb/move-record.js'; + + export class OpponentMovedEvent { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -49,8 +53,23 @@ total():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +move(obj?:MoveRecord):MoveRecord|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +game(obj?:GameView):GameView|null { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +bagLen():number { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startOpponentMovedEvent(builder:flatbuffers.Builder) { - builder.startObject(5); + builder.startObject(8); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { @@ -73,18 +92,21 @@ static addTotal(builder:flatbuffers.Builder, total:number) { builder.addFieldInt32(4, total, 0); } +static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, moveOffset, 0); +} + +static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { + builder.addFieldOffset(6, gameOffset, 0); +} + +static addBagLen(builder:flatbuffers.Builder, bagLen:number) { + builder.addFieldInt32(7, bagLen, 0); +} + static endOpponentMovedEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createOpponentMovedEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, seat:number, actionOffset:flatbuffers.Offset, score:number, total:number):flatbuffers.Offset { - OpponentMovedEvent.startOpponentMovedEvent(builder); - OpponentMovedEvent.addGameId(builder, gameIdOffset); - OpponentMovedEvent.addSeat(builder, seat); - OpponentMovedEvent.addAction(builder, actionOffset); - OpponentMovedEvent.addScore(builder, score); - OpponentMovedEvent.addTotal(builder, total); - return OpponentMovedEvent.endOpponentMovedEvent(builder); -} } diff --git a/ui/src/gen/fbs/scrabblefb/your-turn-event.ts b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts index cb14d04..d7934b5 100644 --- a/ui/src/gen/fbs/scrabblefb/your-turn-event.ts +++ b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts @@ -60,8 +60,13 @@ scoreLine(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +moveCount():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startYourTurnEvent(builder:flatbuffers.Builder) { - builder.startObject(6); + builder.startObject(7); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { @@ -88,12 +93,16 @@ static addScoreLine(builder:flatbuffers.Builder, scoreLineOffset:flatbuffers.Off builder.addFieldOffset(5, scoreLineOffset, 0); } +static addMoveCount(builder:flatbuffers.Builder, moveCount:number) { + builder.addFieldInt32(6, moveCount, 0); +} + static endYourTurnEvent(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset):flatbuffers.Offset { +static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint, opponentNameOffset:flatbuffers.Offset, lastActionOffset:flatbuffers.Offset, lastWordOffset:flatbuffers.Offset, scoreLineOffset:flatbuffers.Offset, moveCount:number):flatbuffers.Offset { YourTurnEvent.startYourTurnEvent(builder); YourTurnEvent.addGameId(builder, gameIdOffset); YourTurnEvent.addDeadlineUnix(builder, deadlineUnix); @@ -101,6 +110,7 @@ static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers YourTurnEvent.addLastAction(builder, lastActionOffset); YourTurnEvent.addLastWord(builder, lastWordOffset); YourTurnEvent.addScoreLine(builder, scoreLineOffset); + YourTurnEvent.addMoveCount(builder, moveCount); return YourTurnEvent.endYourTurnEvent(builder); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 793dcc9..6fd32f7 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -25,7 +25,7 @@ import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { isConnectionCode } from './retry'; -import { clearGameCache } from './gamecache'; +import { clearGameCache, setCachedGame } from './gamecache'; import { clearLobby } from './lobbycache'; import type { BoardLabelMode } from './boardlabels'; @@ -36,6 +36,8 @@ export interface Toast { export const app = $state<{ ready: boolean; + /** Whether the live-event stream is connected; drives the matchmaking poll fallback (R4). */ + streamAlive: boolean; session: Session | null; profile: Profile | null; toast: Toast | null; @@ -53,6 +55,7 @@ export const app = $state<{ chatUnread: Record; }>({ ready: false, + streamAlive: false, session: null, profile: null, toast: null, @@ -68,7 +71,6 @@ export const app = $state<{ }); let unsubscribeStream: (() => void) | null = null; -let streamAlive = false; let reconnectTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; @@ -100,7 +102,7 @@ function goForeground(): void { backgrounded = false; foregroundedAt = Date.now(); if (!app.session) return; - if (!streamAlive) openStream(); // silently re-establish a stream dropped while away + if (!app.streamAlive) openStream(); // silently re-establish a stream dropped while away void refreshNotifications(); } @@ -131,7 +133,7 @@ export function handleError(err: unknown): void { function openStream(): void { closeStream(); - streamAlive = true; + app.streamAlive = true; unsubscribeStream = gateway.subscribe( (e) => { reportOnline(); // a delivered event proves the gateway is reachable @@ -151,13 +153,19 @@ function openStream(): void { } else if (e.kind === 'your_turn') { showToast(t('game.yourTurn'), 'info'); } else if (e.kind === 'match_found') { + // Seed the cache from the event's initial state so the game renders instantly on arrival, + // then navigate (R4). + if (e.state) setCachedGame(e.state.game.id, e.state, []); navigate(`/game/${e.gameId}`); } else if (e.kind === 'notify') { + // A started invited game seeds its cache so opening it is instant; the lobby badge stays + // on the authoritative refresh (R4). + if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []); void refreshNotifications(); } }, () => { - streamAlive = false; + app.streamAlive = false; // A background suspend drops the single-shot stream. Keep the indicator hidden while // backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the // reachability watcher and this scheduled retry recover it). Always schedule a retry. @@ -173,7 +181,7 @@ function scheduleReconnect(): void { if (reconnectTimer || !app.session) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; - if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream(); + if (app.session && !app.streamAlive && !backgrounded && !documentHidden()) openStream(); }, 4000); } @@ -205,7 +213,7 @@ function closeStream(): void { } unsubscribeStream?.(); unsubscribeStream = null; - streamAlive = false; + app.streamAlive = false; } async function adoptSession(s: Session): Promise { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index a65c151..41ce3b4 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -322,12 +322,12 @@ export function decodeProfile(buf: Uint8Array): Profile { }; } -export function decodeStateView(buf: Uint8Array): StateView { - const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf)); +// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the +// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index +// rack to display letters with it (Stage 13). +function decodeStateViewTable(v: fb.StateView): StateView { const g = v.game(); const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant; - // Cache the alphabet table when the server included it (a per-variant cache miss), then - // decode the index rack to display letters with it (Stage 13). if (v.alphabetLength() > 0) { const entries: AlphabetEntryWire[] = []; for (let i = 0; i < v.alphabetLength(); i++) { @@ -347,11 +347,24 @@ export function decodeStateView(buf: Uint8Array): StateView { }; } +export function decodeStateView(buf: Uint8Array): StateView { + return decodeStateViewTable(fb.StateView.getRootAsStateView(new ByteBuffer(buf))); +} + export function decodeMoveResult(buf: Uint8Array): MoveResult { const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf)); const m = r.move(); const g = r.game(); - return { move: m ? decodeMove(m) : emptyMove(), game: g ? decodeGameView(g) : emptyGame() }; + // The actor's refilled rack rides back as alphabet indices (R4); decode it with the game's variant. + const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant; + const rack: string[] = []; + for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0)); + return { + move: m ? decodeMove(m) : emptyMove(), + game: g ? decodeGameView(g) : emptyGame(), + rack, + bagLen: r.bagLen(), + }; } export function decodeHintResult(buf: Uint8Array): HintResult { @@ -424,11 +437,30 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null switch (kind) { case 'your_turn': { const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb); - return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()) }; + return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), moveCount: e.moveCount() }; } case 'opponent_moved': { const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb); - return { kind: 'opponent_moved', gameId: s(e.gameId()), seat: e.seat(), action: s(e.action()), score: e.score(), total: e.total() }; + const m = e.move(); + const g = e.game(); + return { + kind: 'opponent_moved', + gameId: s(e.gameId()), + move: m ? decodeMove(m) : undefined, + game: g ? decodeGameView(g) : undefined, + bagLen: e.bagLen(), + }; + } + case 'game_over': { + const e = fb.GameOverEvent.getRootAsGameOverEvent(bb); + const g = e.game(); + return { + kind: 'game_over', + gameId: s(e.gameId()), + result: s(e.result()), + scoreLine: s(e.scoreLine()), + game: g ? decodeGameView(g) : undefined, + }; } case 'chat_message': return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) }; @@ -438,11 +470,21 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null } case 'match_found': { const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb); - return { kind: 'match_found', gameId: s(e.gameId()) }; + const st = e.state(); + return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined }; } case 'notify': { const e = fb.NotificationEvent.getRootAsNotificationEvent(bb); - return { kind: 'notify', sub: s(e.kind()) }; + const acc = e.account(); + const inv = e.invitation(); + const st = e.state(); + return { + kind: 'notify', + sub: s(e.kind()), + account: acc ? decodeAccountRef(acc) : undefined, + invitation: inv ? decodeInvitationTable(inv) : undefined, + state: st ? decodeStateViewTable(st) : undefined, + }; } case 'heartbeat': return { kind: 'heartbeat' }; diff --git a/ui/src/lib/gamecache.ts b/ui/src/lib/gamecache.ts index 878d294..03671c8 100644 --- a/ui/src/lib/gamecache.ts +++ b/ui/src/lib/gamecache.ts @@ -7,7 +7,7 @@ import type { MoveRecord, StateView } from './model'; -interface CachedGame { +export interface CachedGame { view: StateView; moves: MoveRecord[]; } diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts new file mode 100644 index 0000000..67de394 --- /dev/null +++ b/ui/src/lib/gamedelta.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta'; +import type { CachedGame } from './gamecache'; +import type { GameView, MoveRecord, StateView } from './model'; + +function gameView(moveCount: number, over = false): GameView { + return { + id: 'g1', + variant: 'scrabble_en', + dictVersion: 'v1', + status: over ? 'finished' : 'active', + players: 2, + toMove: 1, + turnTimeoutSecs: 300, + moveCount, + endReason: over ? 'standard' : '', + lastActivityUnix: 0, + seats: [], + }; +} + +function move(player: number): MoveRecord { + return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 }; +} + +function cache(moveCount: number, seat = 0, over = false): CachedGame { + const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }; + return { view, moves: [] }; +} + +function delta(moveCount: number, player: number, bagLen = 47): MoveDelta { + return { move: move(player), game: gameView(moveCount), bagLen }; +} + +describe('seedInitialState', () => { + it('wraps an initial view with an empty journal', () => { + const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 }; + expect(seedInitialState(view)).toEqual({ view, moves: [] }); + }); +}); + +describe('applyMoveDelta', () => { + it('ignores a delta for a game not in the cache', () => { + expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false }); + }); + + it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => { + expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true }); + }); + + it('is a no-op for an already-applied move count (idempotent / own echo)', () => { + expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false }); + expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false }); + }); + + it('refetches on a gap (an intermediate move was missed)', () => { + expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true }); + }); + + it("refetches the actor's own move (the new rack is not in the delta)", () => { + // seat 0 is this device; the move's player is 0 -> our own move, our rack changed. + expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true }); + }); + + it("applies an opponent's next move, preserving the rack and appending the move", () => { + const before = cache(3, 0); + const res = applyMoveDelta(before, delta(4, 1, 45)); + expect(res.refetch).toBe(false); + expect(res.cache?.view.game.moveCount).toBe(4); + expect(res.cache?.view.bagLen).toBe(45); + expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move + expect(res.cache?.view.seat).toBe(0); + expect(res.cache?.moves).toHaveLength(1); + expect(res.cache?.moves[0].player).toBe(1); + expect(before.moves).toHaveLength(0); // input not mutated + }); +}); + +describe('applyGameOver', () => { + it('ignores a finished game not in the cache', () => { + expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false }); + }); + + it('refetches a missing final summary only when the game is not already finished', () => { + expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true }); + expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false }); + }); + + it('refetches when the cached board is behind the final move count', () => { + expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true }); + }); + + it('settles the final summary when the board is current', () => { + const res = applyGameOver(cache(10), gameView(10, true)); + expect(res.refetch).toBe(false); + expect(res.cache?.view.game.status).toBe('finished'); + expect(res.cache?.view.game.moveCount).toBe(10); + }); +}); diff --git a/ui/src/lib/gamedelta.ts b/ui/src/lib/gamedelta.ts new file mode 100644 index 0000000..208b4a8 --- /dev/null +++ b/ui/src/lib/gamedelta.ts @@ -0,0 +1,69 @@ +// Pure reducers that advance the per-game cache from live events (R4), so the UI renders a move +// from the event without a follow-up game.state + game.history fetch. They never touch the network +// or the cache store — the stream handler applies the returned cache and the game screen acts on +// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation. + +import type { CachedGame } from './gamecache'; +import type { GameView, MoveRecord, StateView } from './model'; + +/** The fields an opponent_moved delta carries that advance a cached game. */ +export interface MoveDelta { + move?: MoveRecord; + game?: GameView; + bagLen: number; +} + +/** + * DeltaResult is the outcome of applying an event: the advanced cache (set only when it changed) + * and whether the caller must fall back to a full game.state + game.history fetch — a gap, a + * missing payload, or the actor's own move on a device that has not drawn its new rack yet. + */ +export interface DeltaResult { + cache?: CachedGame; + refetch: boolean; +} + +/** + * seedInitialState builds a fresh cache entry from a started game's initial view (match_found / + * game_started). A freshly started game has no moves, so the board replays from an empty journal. + */ +export function seedInitialState(state: StateView): CachedGame { + return { view: state, moves: [] }; +} + +/** + * applyMoveDelta advances cached by one move from an opponent_moved delta, keyed on the post-move + * count so it is idempotent (a re-delivered move, or the echo of one's own move, is a no-op) and + * gap-safe (a missed move asks for a refetch). An opponent's move leaves the recipient's rack + * unchanged, so it is preserved; the actor's own move drew a new rack the delta does not carry, so + * a device still behind on its own move refetches to pick it up. + */ +export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): DeltaResult { + // Nothing cached to advance (the game was never opened on this device): ignore it; the next open + // cold-loads the game. + if (!cached) return { refetch: false }; + // A pre-R4 peer, or a dropped payload, carried no delta: the open game must refetch. + if (!d.move || !d.game) return { refetch: true }; + const have = cached.view.game.moveCount; + const next = d.game.moveCount; + if (next <= have) return { refetch: false }; // already applied (idempotent / own echo) + if (next > have + 1) return { refetch: true }; // a gap — an intermediate move was missed + // The actor's own move changed their rack (a draw), which opponent_moved does not carry. + if (d.move.player === cached.view.seat) return { refetch: true }; + const view: StateView = { ...cached.view, game: d.game, bagLen: d.bagLen }; + return { cache: { view, moves: [...cached.moves, d.move] }, refetch: false }; +} + +/** + * applyGameOver settles a finished game from a game_over event's final summary (the adjusted + * scores after rack penalties and the winner). It refetches when the cached board is behind the + * final move count — the closing move was missed — so history is repaired, and is a harmless + * re-settle when the closing opponent_moved already finished the game. + */ +export function applyGameOver(cached: CachedGame | undefined, game: GameView | undefined): DeltaResult { + if (!cached) return { refetch: false }; + if (!game) return { refetch: cached.view.game.status !== 'finished' }; + if (cached.view.game.moveCount < game.moveCount) return { refetch: true }; + const view: StateView = { ...cached.view, game }; + return { cache: { view, moves: cached.moves }, refetch: false }; +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 76cdbd2..7e8a7b7 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -235,7 +235,7 @@ export class MockGateway implements GatewayClient { g.view.toMove = (seat + 1) % g.view.players; this.drafts.delete(gameId); this.scheduleOpponentReply(gameId); - return { move: structuredClone(move), game: structuredClone(g.view) }; + return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen }; } private async simpleAction( @@ -276,7 +276,7 @@ export class MockGateway implements GatewayClient { g.view.toMove = (seat + 1) % g.view.players; this.scheduleOpponentReply(gameId); } - return { move: structuredClone(move), game: structuredClone(g.view) }; + return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen }; } pass(gameId: string): Promise { @@ -546,8 +546,8 @@ export class MockGateway implements GatewayClient { g.view.seats[opp].score = move.total; g.view.moveCount += 1; g.view.toMove = this.mySeat(g); - this.emit({ kind: 'opponent_moved', gameId, seat: opp, action: 'play', score: 6, total: move.total }); - this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 }); + this.emit({ kind: 'opponent_moved', gameId, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen }); + this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, moveCount: g.view.moveCount }); }, 1600); } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index a0d98fa..fd4378a 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -70,6 +70,9 @@ export interface StateView { export interface MoveResult { move: MoveRecord; game: GameView; + /** The actor's refilled rack after the move (R4), so the mover renders the next state without a refetch. */ + rack: string[]; + bagLen: number; } export interface HintResult { @@ -223,12 +226,19 @@ export interface GameList { games: GameView[]; } -/** A live event delivered over the Subscribe stream. */ +/** + * A live event delivered over the Subscribe stream. The game events carry the move as a + * delta — move plus the post-move summary (and the bag size) — the client applies to its + * cached game without a refetch; match_found / game_started carry the recipient's initial + * StateView; notify carries the changed lobby payload (R4). The enriched fields are optional + * so a client falls back to a refetch when a payload is absent (a gap, or a pre-R4 peer). + */ export type PushEvent = - | { kind: 'your_turn'; gameId: string; deadlineUnix: number } - | { kind: 'opponent_moved'; gameId: string; seat: number; action: string; score: number; total: number } + | { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number } + | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } + | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'chat_message'; message: ChatMessage } | { kind: 'nudge'; gameId: string; fromUserId: string } - | { kind: 'match_found'; gameId: string } - | { kind: 'notify'; sub: string } + | { kind: 'match_found'; gameId: string; state?: StateView } + | { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView } | { kind: 'heartbeat' }; diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 3d5f4a6..4fbc0ff 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -27,6 +27,9 @@ // --- auto-match --- let searching = $state(false); + // matched guards the teardown: once a match arrives (immediately, via the match_found push, or + // via the fallback poll) onDestroy must not dequeue the game we just got. + let matched = $state(false); let poll: ReturnType | null = null; function stop() { @@ -35,6 +38,24 @@ poll = null; } } + // startPoll is the matchmaking fallback used only while the live stream is down: with the stream + // up the match_found push drives navigation (R4). It polls lobby.poll every 2.5s. + function startPoll() { + if (poll) return; + poll = setInterval(async () => { + try { + const p = await gateway.lobbyPoll(); + if (p.matched && p.game) { + matched = true; + searching = false; + stop(); + navigate(`/game/${p.game.id}`); + } + } catch (e) { + handleError(e); + } + }, 2500); + } // 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). @@ -47,31 +68,37 @@ async function find(v: Variant) { searching = true; + matched = false; try { const r = await gateway.lobbyEnqueue(v); if (r.matched && r.game) { + matched = true; searching = false; navigate(`/game/${r.game.id}`); - return; } - poll = setInterval(async () => { - try { - const p = await gateway.lobbyPoll(); - if (p.matched && p.game) { - stop(); - searching = false; - navigate(`/game/${p.game.id}`); - } - } catch (e) { - handleError(e); - } - }, 2500); } catch (e) { searching = false; handleError(e); } + // No immediate match: wait for the match_found push; the effect below polls only when the + // stream is down. } + // Poll for the match only while searching and the stream is down (the push cannot reach us); + // stop once the stream is back or the search ends. + $effect(() => { + if (searching && !app.streamAlive) startPoll(); + else stop(); + }); + // match_found is handled globally (app.svelte navigates); end the search here too so onDestroy + // does not cancel the match we just received. + $effect(() => { + if (app.lastEvent?.kind === 'match_found' && searching) { + matched = true; + searching = false; + } + }); + // --- friend game --- let friends = $state([]); let selected = $state([]); @@ -120,8 +147,9 @@ onDestroy(() => { stop(); - // Abandoned mid-search (navigated away without Cancel): dequeue so we don't linger. - if (searching) void gateway.lobbyCancel().catch(() => {}); + // Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't + // linger. A received match (matched) must not be cancelled. + if (searching && !matched) void gateway.lobbyCancel().catch(() => {}); }); -- 2.52.0 From d4ef951db9d455cc12891d0cfc998409fbc0e1c7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 10 Jun 2026 15:11:45 +0200 Subject: [PATCH 069/223] =?UTF-8?q?R5:=20bundle=20slimming=20=E2=80=94=20r?= =?UTF-8?q?etarget=20the=20budget=20to=20the=20app,=20no=20code=20slimming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analysed the real dist (gzip + sourcemap attribution): the bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport runtime + generated bindings + the Svelte runtime (~2/3 of main), so no in-scope code slimming is warranted. Lazy-loading was rejected (bundle-size.mjs sums every chunk -> zero total-size win, plus +N gateway fetches of latency); i18n lazy-load and chunk-collapsing likewise (caching/HTTP2). Instead bundle-size.mjs now measures per HTML entry with three independent gates (app entry <=100 KB, Svelte+i18n shared <=30 KB, landing-own <=5 KB): the app's real payload is its entry chunk + the shared chunk (~97 KB), never landing.js. Same CLI + exit-code contract, CI step unchanged. Fixed the stale ~82 KB figure in the script and ui/README.md. No app code change. --- PRERELEASE.md | 45 +++++++++++++++--- ui/README.md | 2 +- ui/scripts/bundle-size.mjs | 93 ++++++++++++++++++++++++++++++++------ 3 files changed, 117 insertions(+), 23 deletions(-) diff --git a/PRERELEASE.md b/PRERELEASE.md index 4940155..a515a7b 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -21,7 +21,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | R2 | Stress harness + contour observability + early run | 9a | **done** | | R3 | Edge hardening | 2 + 8 + 3 | **done** | | R4 | Push enrichment + kill the last poll | 4 + 5 | **done** | -| R5 | Bundle slimming | 6 | todo | +| R5 | Bundle slimming | 6 | **done** | | R6 | Refactor + docs reconciliation + de-staging | 7 | todo | | R7 | Final stress run + tuning | 9b | todo | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | @@ -140,12 +140,21 @@ spot; regenerate FB. - Open details: which events carry full vs delta payloads; the fallback-poll cadence when the stream is down. -### R5 — Bundle slimming *(TODO 6)* -Lazy-load secondary screens (Friends/Stats/Settings/About/Profile) and i18n catalogs by -language via dynamic imports; re-measure against the existing 100 KB-gzip budget -(`ui/scripts/bundle-size.mjs`, ~82 KB today). If the win is marginal, stop — acceptable per -the owner. -- Critical files: `ui/src/App.svelte`, `ui/vite.config.ts`, `ui/src/lib/i18n/`. +### R5 — Bundle slimming *(TODO 6)* — done +Analysed the bundle against the 100 KB-gzip budget; **no code slimming was warranted**, and the +budget metric was retargeted to measure the app correctly. The build already minifies + +tree-shakes; the dominant cost is the Connect/FlatBuffers transport runtime + generated bindings ++ the Svelte runtime (≈⅔ of `main`'s source is third-party/generated) — irreducible within scope. +**Lazy-loading was rejected**: `bundle-size.mjs` sums every emitted chunk, so code-splitting yields +no total-size win and adds request latency (+N gateway fetches on first navigation to a split +screen). i18n lazy-load was skipped (the catalogs are a sliver of a Svelte-runtime-dominated shared +chunk, and `en` must stay bundled as the `MessageKey` type source + fallback). Instead, +`bundle-size.mjs` now measures **per HTML entry**, with three independent gates on the natural chunk +boundaries — **app entry ≤ 100 KB, the Svelte+i18n shared chunk ≤ 30 KB, the landing's own chunk +≤ 5 KB** — since the app's real payload is its entry chunk plus the shared chunk (≈97 KB), while the +landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code contract, so the CI +step is unchanged. +- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed. ### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — near last Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical @@ -317,3 +326,25 @@ Then Stage 18. goal. Deeper lobby-cache consumption is an easy follow-up. - **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips + `emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta. + +- **R5** (interview + implementation): + - **No code slimming — by analysis.** A gzip measure + sourcemap attribution of the real `dist` showed + the app bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport + runtime + generated FB/PB bindings (≈⅔ of `main`'s source) and the Svelte runtime — all + third-party/generated, irreducible within R5's scope. App-authored code carries no hand-trimmable fat. + - **Lazy-load rejected** (screens *and* i18n): `bundle-size.mjs` sums every emitted chunk, so + code-splitting moves bytes between chunks for **zero total-size win** while adding request latency (+N + gateway fetches on first navigation to a split screen). i18n lazy-load additionally buys ≤3 KB (en-only + users) at the cost of an async `t()`, and `en` must stay bundled (it is the `MessageKey` type source + + fallback). **Chunk-collapsing rejected** too — keeping the near-static Svelte runtime in its own + cacheable chunk is the recommended practice (an app deploy then re-busts only `main`, not the runtime), + and HTTP/2 makes the extra preload request negligible. + - **Metric retargeted to the app.** The two-entry build (`index.html` app + `landing.html`) makes Rollup + hoist the code shared by both (Svelte runtime + i18n + `aboutContent`) into one preloaded chunk, so the + app actually loads its entry chunk **+ the shared chunk** (≈74 + ≈23 = **≈97 KB**), never `landing.js` + (≈1.6 KB). The old script summed all three chunks (98.8 KB), over-counting the app by `landing.js`. + `bundle-size.mjs` now parses each built HTML for the JS it eagerly loads and gates three parts + independently — **app entry ≤ 100 KB, shared (Svelte+i18n) ≤ 30 KB, landing-own ≤ 5 KB** — reporting the + app total (≈97) and landing total (≈24.5). Same CLI + exit-code contract, so the CI step is unchanged. + - **No app/source/build change** (`App.svelte`, `lib/i18n/`, `vite.config.ts` untouched); no schema + change, no contour wipe. The stale "~82 KB" figure was corrected in `bundle-size.mjs` and `ui/README.md`. diff --git a/ui/README.md b/ui/README.md index 494edc2..d24c980 100644 --- a/ui/README.md +++ b/ui/README.md @@ -21,7 +21,7 @@ pnpm dev # against a running gateway (Vite proxies /scrabble.edge.v1.Ga pnpm check # svelte-check / tsc pnpm test:unit # Vitest (pure logic + FlatBuffers codec) pnpm test:e2e # Playwright smoke against the mock -pnpm build # static bundle into dist/ (prod ~67 KB gzip JS) +pnpm build # static bundle into dist/ (prod app ~97 KB gzip JS; per-chunk budget: scripts/bundle-size.mjs) pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time) ``` diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index f8d71dd..3ccb9ef 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -1,22 +1,85 @@ -// Bundle-size budget gate. Sums the gzipped size of the built app JS and fails if it -// exceeds the budget — a guard against an accidental heavy dependency. The real -// transport build is ~82 KB gzip after the Stage 8 social/account/history surfaces; -// the budget leaves headroom. -import { readdirSync, readFileSync } from 'node:fs'; +// Bundle-size budget gate. Measures the gzipped JS each built HTML entry actually loads and +// fails CI if any part overruns its budget — a guard against an accidental heavy dependency. +// The build has two entries (vite rollupOptions.input): the game app (index.html, served at +// /app/ + /telegram/) and the landing (landing.html, served at /). Rollup hoists the code +// shared by both (the Svelte runtime + i18n + aboutContent) into one chunk each entry +// preloads, so a page's real payload is its own entry chunk plus that shared chunk. +// +// Three independent gates on the natural chunk boundaries, each with realistic headroom: +// - app entry (main): the app's own code; grows with features. +// - shared (svelte+i18n): near-static framework runtime; only drifts on a dep/Svelte bump. +// - landing own: the landing's own code; kept minimal. +// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is +// ~2 KB. Lazy-loading was analysed and rejected for R5 (no total-size win — every chunk still +// ships and is summed — plus added request latency); the bulk is the Connect/FlatBuffers +// transport runtime + generated bindings + the Svelte runtime, irreducible within scope. See +// PRERELEASE.md R5 for the full rationale. +import { readFileSync, existsSync } from 'node:fs'; import { gzipSync } from 'node:zlib'; +import { join } from 'node:path'; -const BUDGET = 100 * 1024; // gzip bytes for app JS -const dir = 'dist/assets'; +const DIST = 'dist'; -let total = 0; -for (const f of readdirSync(dir)) { - if (!f.endsWith('.js')) continue; - const gz = gzipSync(readFileSync(`${dir}/${f}`)).length; - total += gz; - console.log(`${f}: ${(gz / 1024).toFixed(1)} KB gzip`); +// Per-chunk gzip budgets in KB. +const BUDGET = { app: 100, shared: 30, landing: 5 }; + +// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a +// local file (e.g. the Telegram SDK loaded from a CDN) or is missing. +function gzipped(file) { + return file && existsSync(file) ? gzipSync(readFileSync(file)).length : 0; } -console.log(`total app JS: ${(total / 1024).toFixed(1)} KB gzip (budget ${BUDGET / 1024} KB)`); -if (total > BUDGET) { + +// attr reads a double-quoted HTML attribute from a single tag string. +function attr(tag, name) { + const m = tag.match(new RegExp(`\\s${name}="([^"]+)"`)); + return m ? m[1] : null; +} + +// localAsset maps an HTML asset URL to its path under dist/, or null for an external URL. +function localAsset(url) { + return !url || /^https?:/.test(url) ? null : join(DIST, url.replace(/^\.?\/+/, '')); +} + +// entryAssets parses a built HTML entry and returns the local JS it eagerly loads: the module +// entry chunk ( - -
- - - {#if open} - - -
-
{@render popover(close)}
- {/if} -
- - diff --git a/ui/src/components/Menu.svelte b/ui/src/components/Menu.svelte deleted file mode 100644 index f4a59f5..0000000 --- a/ui/src/components/Menu.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index d63388b..25af3ce 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -10,7 +10,6 @@ let { title, back, - menu, tabbar, children, scroll = true, @@ -19,7 +18,6 @@ }: { title: string; back?: string; - menu?: Snippet; tabbar?: Snippet; children?: Snippet; scroll?: boolean; @@ -58,7 +56,7 @@
-
+
{#if SHOW_AD_BANNER}{/if}
{@render children?.()}
{#if tabbar} diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte index 9865762..b6ebbb3 100644 --- a/ui/src/components/TabBar.svelte +++ b/ui/src/components/TabBar.svelte @@ -1,7 +1,7 @@ @@ -53,6 +53,29 @@ :global(.tab:active:not(:disabled) .sq) { background: var(--surface-2); } + /* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted + while selected: a filled square with an accent underline, distinct from the + momentary press tint above. */ + :global(.tab.active .sq) { + background: var(--surface-2); + box-shadow: inset 0 -2px 0 var(--accent); + } + /* A small count badge on the icon square's corner (lobby ⚙️, the Friends tab, the + hint count) — one shared style so every tab badge reads the same. */ + :global(.tab .badge) { + position: absolute; + top: -3px; + right: -3px; + font-size: 0.68rem; + font-weight: 700; + background: var(--accent); + color: var(--accent-text); + border-radius: 999px; + min-width: 15px; + padding: 0 3px; + line-height: 1.4; + text-align: center; + } :global(.tab .lbl) { font-size: 0.62rem; color: var(--text-muted); diff --git a/ui/src/components/TapConfirm.svelte b/ui/src/components/TapConfirm.svelte new file mode 100644 index 0000000..ebaf480 --- /dev/null +++ b/ui/src/components/TapConfirm.svelte @@ -0,0 +1,99 @@ + + + + + diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 82cb2be..5f88566 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -1,15 +1,14 @@ - - - + diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index 410093a..b9889dd 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -1,6 +1,5 @@ - -
-
- e.key === 'Enter' && runCheck()} - placeholder={t('game.checkWordPrompt')} - /> - -
- {#if result} -

- {result.legal - ? t('game.wordLegal', { word: result.word }) - : t('game.wordIllegal', { word: result.word })} -

- - {/if} +
+
+ e.key === 'Enter' && runCheck()} + placeholder={t('game.checkWordPrompt')} + /> +
- + {#if result} +

+ {result.legal + ? t('game.wordLegal', { word: result.word }) + : t('game.wordIllegal', { word: result.word })} +

+ + {/if} +
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index ffc16de..4f4b292 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -12,7 +12,7 @@ import { connection } from '../lib/connection.svelte'; import { GatewayError } from '../lib/client'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; - import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; + import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import { lastMoveCells, replay } from '../lib/board'; import { historyGrid } from '../lib/history'; import { centre, premiumGrid } from '../lib/premiums'; @@ -42,7 +42,6 @@ let moves = $state([]); let placement = $state(newPlacement([])); let preview = $state(null); - let dirOverride = $state(undefined); let busy = $state(false); let zoomed = $state(false); let selected = $state(null); @@ -130,7 +129,6 @@ moves = hist.moves; setCachedGame(id, st, hist.moves); selected = null; - dirOverride = undefined; await applyDraft(st); recompute(); refreshRecent(); @@ -491,11 +489,11 @@ if (previewTimer) clearTimeout(previewTimer); // Off-turn the composition is position-only: no score preview or evaluate. if (!isMyTurn) return; - const sub = toSubmit(placement, dirOverride); + const sub = toSubmit(placement); if (!sub) return; previewTimer = setTimeout(async () => { try { - preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant); + preview = await gateway.evaluate(id, sub.tiles, variant); } catch { /* best-effort */ } @@ -511,17 +509,16 @@ rackIds = r.rack.map((_, i) => i); placement = newPlacement(r.rack); selected = null; - dirOverride = undefined; recompute(); refreshRecent(); } async function commit() { - const sub = toSubmit(placement, dirOverride); + const sub = toSubmit(placement); if (!sub) return; busy = true; try { - applyMoveResult(await gateway.submitPlay(id, sub.dir, sub.tiles, variant)); + applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); telegramHaptic('success'); zoomed = false; } catch (e) { @@ -534,7 +531,6 @@ placement = reset(placement); preview = null; selected = null; - dirOverride = undefined; scheduleDraftSave(); } @@ -857,11 +853,11 @@ {view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })} {#if gameOver} {t('game.over')} — {resultText()} - {:else} + {:else if placement.pending.length === 0} {isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''} {/if} - {#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if} + {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
@@ -880,7 +876,7 @@ />
{#if !gameOver && placement.pending.length > 0} - + {/if} {:else} diff --git a/ui/src/gen/fbs/scrabblefb/eval-request.ts b/ui/src/gen/fbs/scrabblefb/eval-request.ts index 64a2cf1..8ce5914 100644 --- a/ui/src/gen/fbs/scrabblefb/eval-request.ts +++ b/ui/src/gen/fbs/scrabblefb/eval-request.ts @@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } -dir():string|null -dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null -dir(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; -} - tiles(index: number, obj?:PlayTile):PlayTile|null { - const offset = this.bb!.__offset(this.bb_pos, 8); + const offset = this.bb!.__offset(this.bb_pos, 6); return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; } tilesLength():number { - const offset = this.bb!.__offset(this.bb_pos, 8); + const offset = this.bb!.__offset(this.bb_pos, 6); return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } static startEvalRequest(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(2); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { builder.addFieldOffset(0, gameIdOffset, 0); } -static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) { - builder.addFieldOffset(1, dirOffset, 0); -} - static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) { - builder.addFieldOffset(2, tilesOffset, 0); + builder.addFieldOffset(1, tilesOffset, 0); } static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { @@ -80,10 +69,9 @@ static endEvalRequest(builder:flatbuffers.Builder):flatbuffers.Offset { return offset; } -static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset { +static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset { EvalRequest.startEvalRequest(builder); EvalRequest.addGameId(builder, gameIdOffset); - EvalRequest.addDir(builder, dirOffset); EvalRequest.addTiles(builder, tilesOffset); return EvalRequest.endEvalRequest(builder); } diff --git a/ui/src/gen/fbs/scrabblefb/eval-result.ts b/ui/src/gen/fbs/scrabblefb/eval-result.ts index f8e9c4e..6f451a7 100644 --- a/ui/src/gen/fbs/scrabblefb/eval-result.ts +++ b/ui/src/gen/fbs/scrabblefb/eval-result.ts @@ -42,8 +42,15 @@ wordsLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +dir():string|null +dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +dir(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startEvalResult(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(4); } static addLegal(builder:flatbuffers.Builder, legal:boolean) { @@ -70,16 +77,21 @@ static startWordsVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, dirOffset, 0); +} + static endEvalResult(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset):flatbuffers.Offset { +static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset):flatbuffers.Offset { EvalResult.startEvalResult(builder); EvalResult.addLegal(builder, legal); EvalResult.addScore(builder, score); EvalResult.addWords(builder, wordsOffset); + EvalResult.addDir(builder, dirOffset); return EvalResult.endEvalResult(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/submit-play-request.ts b/ui/src/gen/fbs/scrabblefb/submit-play-request.ts index 207d7ed..93dd00d 100644 --- a/ui/src/gen/fbs/scrabblefb/submit-play-request.ts +++ b/ui/src/gen/fbs/scrabblefb/submit-play-request.ts @@ -30,37 +30,26 @@ gameId(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } -dir():string|null -dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null -dir(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; -} - tiles(index: number, obj?:PlayTile):PlayTile|null { - const offset = this.bb!.__offset(this.bb_pos, 8); + const offset = this.bb!.__offset(this.bb_pos, 6); return offset ? (obj || new PlayTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; } tilesLength():number { - const offset = this.bb!.__offset(this.bb_pos, 8); + const offset = this.bb!.__offset(this.bb_pos, 6); return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } static startSubmitPlayRequest(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(2); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { builder.addFieldOffset(0, gameIdOffset, 0); } -static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) { - builder.addFieldOffset(1, dirOffset, 0); -} - static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) { - builder.addFieldOffset(2, tilesOffset, 0); + builder.addFieldOffset(1, tilesOffset, 0); } static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { @@ -80,10 +69,9 @@ static endSubmitPlayRequest(builder:flatbuffers.Builder):flatbuffers.Offset { return offset; } -static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset { +static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset { SubmitPlayRequest.startSubmitPlayRequest(builder); SubmitPlayRequest.addGameId(builder, gameIdOffset); - SubmitPlayRequest.addDir(builder, dirOffset); SubmitPlayRequest.addTiles(builder, tilesOffset); return SubmitPlayRequest.endSubmitPlayRequest(builder); } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 8f2f0af..2c957e0 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -74,12 +74,12 @@ export interface GatewayClient { // table), and gameState's includeAlphabet asks the server to embed that table. gameState(gameId: string, includeAlphabet: boolean): Promise; gameHistory(gameId: string): Promise; - submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise; + submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; pass(gameId: string): Promise; exchange(gameId: string, tiles: string[], variant: Variant): Promise; resign(gameId: string): Promise; hint(gameId: string): Promise; - evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise; + evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; complaint(gameId: string, word: string, note: string): Promise; /** Hide a finished game from the caller's own lobby list; per-account, irreversible. */ diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 547de5c..3e17cf9 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -43,7 +43,6 @@ describe('codec', () => { // A placed blank carries its designated letter's index with the blank flag set. const buf = encodeSubmitPlay( 'g1', - 'H', [ { row: 7, col: 7, letter: 'A', blank: false }, { row: 7, col: 8, letter: 'B', blank: true }, @@ -52,7 +51,6 @@ describe('codec', () => { ); const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf)); expect(r.gameId()).toBe('g1'); - expect(r.dir()).toBe('H'); expect(r.tilesLength()).toBe(2); expect(r.tiles(0)?.letter()).toBe(0); expect(r.tiles(1)?.letter()).toBe(1); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 0d14875..ae38b75 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -87,7 +87,6 @@ export function encodeDraftSave(gameId: string, json: string): Uint8Array { export function encodeSubmitPlay( gameId: string, - dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant, ): Uint8Array { @@ -95,28 +94,19 @@ export function encodeSubmitPlay( const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant)); const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs); const gid = b.createString(gameId); - const d = b.createString(dir); fb.SubmitPlayRequest.startSubmitPlayRequest(b); fb.SubmitPlayRequest.addGameId(b, gid); - fb.SubmitPlayRequest.addDir(b, d); fb.SubmitPlayRequest.addTiles(b, vec); return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b)); } -export function encodeEval( - gameId: string, - dir: 'H' | 'V', - tiles: PlacedTile[], - variant: Variant, -): Uint8Array { +export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array { const b = new Builder(256); const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant)); const vec = fb.EvalRequest.createTilesVector(b, tileOffs); const gid = b.createString(gameId); - const d = b.createString(dir); fb.EvalRequest.startEvalRequest(b); fb.EvalRequest.addGameId(b, gid); - fb.EvalRequest.addDir(b, d); fb.EvalRequest.addTiles(b, vec); return finish(b, fb.EvalRequest.endEvalRequest(b)); } @@ -377,7 +367,7 @@ export function decodeEvalResult(buf: Uint8Array): EvalResult { const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf)); const words: string[] = []; for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i))); - return { legal: r.legal(), score: r.score(), words }; + return { legal: r.legal(), score: r.score(), words, dir: s(r.dir()) }; } export function decodeWordCheck(buf: Uint8Array): WordCheckResult { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2472a96..45971c7 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -67,7 +67,7 @@ export const en = { 'game.checkWord': 'Check word', 'game.dictionary': 'Dictionary', 'game.dropGame': 'Drop game', - 'game.preview': 'Scores {n}', + 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Not a legal move', 'game.chooseBlank': 'Choose a letter for the blank', 'game.exchangeTitle': 'Select tiles to exchange', @@ -86,7 +86,6 @@ export const en = { 'game.check': 'Check', 'game.checkWait': 'Please wait a moment.', 'game.noHintOptions': 'No options with your letters.', - 'game.scores': 'Scores: {n}', 'game.thinking': 'thinking…', 'move.pass': 'pass', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 6738b95..77c9d85 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -68,7 +68,7 @@ export const ru: Record = { 'game.checkWord': 'Проверить слово', 'game.dictionary': 'Словарь', 'game.dropGame': 'Покинуть игру', - 'game.preview': 'Очков: {n}', + 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Недопустимый ход', 'game.chooseBlank': 'Выберите букву для бланка', 'game.exchangeTitle': 'Выберите фишки для обмена', @@ -87,7 +87,6 @@ export const ru: Record = { 'game.check': 'Проверить', 'game.checkWait': 'Секунду, пожалуйста.', 'game.noHintOptions': 'Нет вариантов с вашим набором.', - 'game.scores': 'Очков: {n}', 'game.thinking': 'думает…', 'move.pass': 'пас', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 76366d9..73bc7c9 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -205,7 +205,7 @@ export class MockGateway implements GatewayClient { return { gameId, moves: structuredClone(g.moves) }; } - async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise { + async submitPlay(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { const g = this.game(gameId); const seat = this.mySeat(g); if (g.view.toMove !== seat) throw new GatewayError('not_your_turn'); @@ -213,6 +213,7 @@ export class MockGateway implements GatewayClient { let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0); if (tiles.length === 7) score += 50; const total = g.view.seats[seat].score + score; + const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V'; const move = { player: seat, action: 'play' as const, @@ -311,12 +312,13 @@ export class MockGateway implements GatewayClient { }; } - async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[], _variant: Variant): Promise { + async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise { const g = this.game(gameId); - if (tiles.length === 0) return { legal: false, score: 0, words: [] }; + if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' }; let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0); if (tiles.length === 7) score += 50; - return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] }; + const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V'; + return { legal: true, score, words: [tiles.map((t) => t.letter).join('')], dir }; } async checkWord(_gameId: string, word: string, _variant: Variant): Promise { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 394d8bf..2cc1603 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -84,6 +84,8 @@ export interface EvalResult { legal: boolean; score: number; words: string[]; + /** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */ + dir: string; } export interface WordCheckResult { diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index f46cb74..0f388d9 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { BLANK, cellOccupied, - direction, isBlankSlot, newPlacement, place, @@ -47,23 +46,11 @@ describe('placement state machine', () => { expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0); }); - it('infers direction H for a row, V for a column, null for a single tile', () => { - let h = place(newPlacement(rack), 0, 7, 7); - h = place(h, 1, 7, 8); - expect(direction(h)).toBe('H'); - let v = place(newPlacement(rack), 0, 7, 7); - v = place(v, 1, 8, 7); - expect(direction(v)).toBe('V'); - expect(direction(place(newPlacement(rack), 0, 7, 7))).toBeNull(); - }); - - it('builds a sorted submit payload and honours a direction override', () => { + it('builds a sorted submit payload and returns null when empty', () => { let p = place(newPlacement(rack), 1, 7, 9); p = place(p, 0, 7, 7); const sub = toSubmit(p); - expect(sub?.dir).toBe('H'); expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]); - expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V'); expect(toSubmit(newPlacement(rack))).toBeNull(); }); @@ -78,15 +65,8 @@ describe('placement state machine', () => { expect(isBlankSlot(newPlacement(rack), 0)).toBe(false); }); - it('treats a non-linear placement as no inferred direction', () => { - let p = place(newPlacement(rack), 0, 7, 7); - p = place(p, 1, 8, 8); // diagonal - expect(direction(p)).toBeNull(); - }); - - it('defaults a single-tile submit to H without an override', () => { + it('submits a single tile as a one-tile payload', () => { const sub = toSubmit(place(newPlacement(rack), 0, 7, 7)); - expect(sub?.dir).toBe('H'); expect(sub?.tiles).toHaveLength(1); }); }); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts index d38de1c..28727e8 100644 --- a/ui/src/lib/placement.ts +++ b/ui/src/lib/placement.ts @@ -4,7 +4,7 @@ // payload. It is board-agnostic (the gateway/engine does full legality validation at // submit), which keeps it trivially unit-testable. -import type { Direction, Tile } from './model'; +import type { Tile } from './model'; import type { PlacedTile } from './client'; export interface PendingTile { @@ -119,30 +119,13 @@ export function reorderIndices(n: number, from: number, toSlot: number): number[ return order; } -/** - * direction infers the play orientation from the pending tiles: H if they share a row, - * V if they share a column, null if a single tile (ambiguous) or non-linear (invalid). - */ -export function direction(p: Placement): Direction | null { - if (p.pending.length < 2) return null; - const rows = new Set(p.pending.map((t) => t.row)); - const cols = new Set(p.pending.map((t) => t.col)); - if (rows.size === 1 && cols.size === p.pending.length) return 'H'; - if (cols.size === 1 && rows.size === p.pending.length) return 'V'; - return null; -} - -/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where - * the orientation cannot be inferred; otherwise the inferred direction is used. */ -export function toSubmit( - p: Placement, - dirOverride?: Direction, -): { dir: Direction; tiles: PlacedTile[] } | null { +/** toSubmit builds the submit payload: the placed tiles in board order. The backend + * infers the play's orientation from the tiles and the board, so none is sent. */ +export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null { if (p.pending.length === 0) return null; - const dir = dirOverride ?? direction(p) ?? 'H'; const tiles: PlacedTile[] = p.pending .slice() .sort((a, b) => a.row - b.row || a.col - b.col) .map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })); - return { dir, tiles }; + return { tiles }; } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 9e8366c..725e6ab 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -97,8 +97,8 @@ export function createTransport(baseUrl: string): GatewayClient { async gameHistory(id) { return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id))); }, - async submitPlay(id, dir, tiles, variant) { - return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles, variant))); + async submitPlay(id, tiles, variant) { + return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, tiles, variant))); }, async pass(id) { return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id))); @@ -112,8 +112,8 @@ export function createTransport(baseUrl: string): GatewayClient { async hint(id) { return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id))); }, - async evaluate(id, dir, tiles, variant) { - return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles, variant))); + async evaluate(id, tiles, variant) { + return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant))); }, async checkWord(id, word, variant) { return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant))); -- 2.52.0 From 883212f9d13b208b21c513cab2f521c0281208a2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 22:58:37 +0200 Subject: [PATCH 093/223] UI: remove the lobby game-row tap highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tap/click on a lobby game row flashed a highlight on both tappable areas (the open body and the right chevron/kebab), and the .open:active background lingered while the finger was held — pointless feedback that only spoiled the look. Drop the held :active background and set -webkit-tap-highlight-color: transparent on the row's buttons. --- ui/src/screens/Lobby.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index da1056f..112e0ee 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -324,8 +324,13 @@ user-select: none; touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */ } - .open:active { - background: var(--surface-2); + /* A tap/click on a game row leaves no highlight: drop the WebKit tap-flash on + both tappable areas (the open body and the chevron / kebab) and the held + :active background, which added nothing and only spoiled the look. */ + .open, + .chev, + .kebab { + -webkit-tap-highlight-color: transparent; } .kebab { flex: 0 0 auto; -- 2.52.0 From 9277a70565db75e42d2a89bf738926cd88644d65 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 23:19:16 +0200 Subject: [PATCH 094/223] UI: drop tab-bar tap highlight; don't slide the first screen on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab bar: tapping a bottom-tab icon flashed a background — the icon square's :active press tint plus the default WebKit tap flash, the same pair removed from the lobby rows. Drop the press tint and set -webkit-tap-highlight-color: transparent on .tab. The selected-tab highlight (Settings / Comms hubs) stays. Startup slide: the route pane's in:slideX is local to its {#key} block, so it plays on that block's own first mount when app.ready flips — the lobby slid in on launch as if navigated into from another screen. Gate the slide duration to 0 for the first pane shown after boot (a `started` flag set right after it mounts), so launch is static while every later route change animates as before. --- ui/src/App.svelte | 15 ++++++++++++--- ui/src/components/TabBar.svelte | 12 +++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 9e42fb6..3917034 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -32,8 +32,9 @@ // Screen transitions: the lobby is the navigation root. Entering a screen from the // lobby slides it in from the right (forward); returning to the lobby slides the - // screen out to the right and reveals the lobby (back). Transitions are local, so - // they do not play on the initial mount, and collapse to nothing under reduce-motion. + // screen out to the right and reveals the lobby (back). The first pane shown after boot + // does not slide (the `started` gate below), and transitions collapse to nothing under + // reduce-motion. // Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward, // returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check // back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it @@ -52,7 +53,15 @@ const enterSign = $derived(dir === 'forward' ? 1 : -1); const leaveSign = $derived(dir === 'forward' ? -1 : 1); const routeKey = $derived(router.route.name + (router.route.params.id ?? '')); - const animMs = $derived(app.reduceMotion ? 0 : 260); + // The first pane shown once the app is ready must not slide: it would look like the app + // was navigated into rather than launched. The pane is a local transition inside a {#key} + // block, so it plays on that block's own first mount anyway — hold the duration at 0 until + // just after that mount (the effect runs post-render), then animate every later change. + let started = $state(false); + $effect(() => { + if (app.ready) started = true; + }); + const animMs = $derived(!started || app.reduceMotion ? 0 : 260); // slideX slides a pane horizontally by a full width. sign>0 enters from / exits to // the right; sign<0 the left. Percentage keeps it viewport-relative without reading diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte index b6ebbb3..d29a1ad 100644 --- a/ui/src/components/TabBar.svelte +++ b/ui/src/components/TabBar.svelte @@ -34,12 +34,13 @@ width: 100%; user-select: none; -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; /* no WebKit flash on tap */ } :global(.tab:disabled) { opacity: 0.4; } - /* The icon square hugs the emoji (just a little padding) so it is the press-highlight - target and the badge can sit on its corner. */ + /* The icon square hugs the emoji (just a little padding) so the badge can sit on its + corner. */ :global(.tab .sq) { position: relative; display: inline-grid; @@ -50,12 +51,9 @@ line-height: 1; transition: background-color 0.12s; } - :global(.tab:active:not(:disabled) .sq) { - background: var(--surface-2); - } /* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted - while selected: a filled square with an accent underline, distinct from the - momentary press tint above. */ + while selected: a filled square with an accent underline. A tap itself leaves no + highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */ :global(.tab.active .sq) { background: var(--surface-2); box-shadow: inset 0 -2px 0 var(--accent); -- 2.52.0 From c32a15730a13ea88e8e4ba016eaa18908db726ae Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 23:40:40 +0200 Subject: [PATCH 095/223] UI: fix the lobby slide on Telegram cold launch (correct the cause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first attempt (the App.svelte `started` gate) targeted the first pane mount, but the slide is a second render. On a Telegram cold launch the URL fragment is Telegram's #tgWebAppData=... launch params, which the router parsed as notfound; bootstrap's navigate('/') then corrected it to the lobby asynchronously, re-keying the route pane (notfound -> lobby) and sliding the lobby in as if returning from a screen. A reload was static because the hash was already #/. Treat a Telegram launch fragment as the lobby root in the router, so the route is correct from the first pane (no re-key, no slide). Extract the pure hash->Route parsing into routeparse.ts so it unit-tests without a DOM, and revert the gate (the first pane never slid — local transitions skip the initial mount, as clean browser launches showed). Tests: routeparse unit tests (incl. the tgWebApp fragment); an e2e that launches with the fragment in the URL and asserts the lobby plus the normalised #/ hash. --- ui/e2e/telegram.spec.ts | 17 ++++++++++ ui/src/App.svelte | 15 ++------- ui/src/lib/routeparse.test.ts | 34 +++++++++++++++++++ ui/src/lib/routeparse.ts | 61 +++++++++++++++++++++++++++++++++++ ui/src/lib/router.svelte.ts | 51 +++-------------------------- 5 files changed, 119 insertions(+), 59 deletions(-) create mode 100644 ui/src/lib/routeparse.test.ts create mode 100644 ui/src/lib/routeparse.ts diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index 8a1188a..dbc3e00 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -35,6 +35,23 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme', .toBe('#101418'); }); +test('a Telegram launch fragment in the URL still lands on the lobby and normalises the hash', async ({ + page, +}) => { + await page.addInitScript((stub) => { + Object.assign(window, stub); + }, webAppStub()); + // Telegram appends its launch params to the URL fragment on a cold launch; the router must + // not treat that as a route (it parsed as notfound, which re-keyed the pane and slid the + // lobby in as if returning from another screen). + await page.goto( + '/#tgWebAppData=query_id%3Dtest%26user%3D%257B%2522id%2522%253A1%257D&tgWebAppVersion=7.0&tgWebAppPlatform=ios', + ); + await expect(page.getByText('Your turn')).toBeVisible(); + // The lobby is the root: bootstrap normalised the launch-param fragment to '#/'. + await expect.poll(() => new URL(page.url()).hash).toBe('#/'); +}); + test('tg-fullscreen header keeps a constant native-nav gap as the font scales', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 3917034..9e42fb6 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -32,9 +32,8 @@ // 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). The first pane shown after boot - // does not slide (the `started` gate below), and transitions collapse to nothing under - // reduce-motion. + // screen out to the right and reveals the lobby (back). Transitions are local, so + // they do not play on the initial mount, and collapse to nothing under reduce-motion. // Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward, // returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check // back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it @@ -53,15 +52,7 @@ const enterSign = $derived(dir === 'forward' ? 1 : -1); const leaveSign = $derived(dir === 'forward' ? -1 : 1); const routeKey = $derived(router.route.name + (router.route.params.id ?? '')); - // The first pane shown once the app is ready must not slide: it would look like the app - // was navigated into rather than launched. The pane is a local transition inside a {#key} - // block, so it plays on that block's own first mount anyway — hold the duration at 0 until - // just after that mount (the effect runs post-render), then animate every later change. - let started = $state(false); - $effect(() => { - if (app.ready) started = true; - }); - const animMs = $derived(!started || app.reduceMotion ? 0 : 260); + const animMs = $derived(app.reduceMotion ? 0 : 260); // slideX slides a pane horizontally by a full width. sign>0 enters from / exits to // the right; sign<0 the left. Percentage keeps it viewport-relative without reading diff --git a/ui/src/lib/routeparse.test.ts b/ui/src/lib/routeparse.test.ts new file mode 100644 index 0000000..14f5383 --- /dev/null +++ b/ui/src/lib/routeparse.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from './routeparse'; + +describe('parse', () => { + it('maps the empty hash and the root to the lobby', () => { + expect(parse('').name).toBe('lobby'); + expect(parse('#/').name).toBe('lobby'); + }); + + it('maps known paths to their routes', () => { + expect(parse('#/login').name).toBe('login'); + expect(parse('#/new').name).toBe('new'); + expect(parse('#/settings').name).toBe('settings'); + expect(parse('#/stats').name).toBe('stats'); + expect(parse('#/game/abc')).toEqual({ name: 'game', params: { id: 'abc' } }); + expect(parse('#/game/abc/chat')).toEqual({ name: 'gameChat', params: { id: 'abc' } }); + expect(parse('#/game/abc/check')).toEqual({ name: 'gameCheck', params: { id: 'abc' } }); + }); + + it('maps an unknown path and a game without an id to notfound', () => { + expect(parse('#/bogus').name).toBe('notfound'); + expect(parse('#/game').name).toBe('notfound'); + }); + + it('treats a Telegram launch fragment as the lobby root, not notfound', () => { + // Telegram appends its Mini App launch params to the URL fragment on a cold launch; they + // are launch metadata, not a route. Parsing them as notfound made bootstrap's navigate('/') + // re-key the route pane (notfound -> lobby) and slide the lobby in on launch. + expect(parse('#tgWebAppData=query_id%3Dx&tgWebAppVersion=7.0&tgWebAppPlatform=ios').name).toBe( + 'lobby', + ); + expect(parse('#tgWebAppStartParam=foo').name).toBe('lobby'); + }); +}); diff --git a/ui/src/lib/routeparse.ts b/ui/src/lib/routeparse.ts new file mode 100644 index 0000000..8e4146b --- /dev/null +++ b/ui/src/lib/routeparse.ts @@ -0,0 +1,61 @@ +// Pure hash-path -> Route parsing for the router. Kept dependency-free and free of any +// reactive state so it unit-tests in the node environment; router.svelte.ts wraps it with +// the reactive route rune and navigation. + +export type RouteName = + | 'login' + | 'lobby' + | 'new' + | 'game' + | 'gameChat' + | 'gameCheck' + | 'profile' + | 'settings' + | 'about' + | 'friends' + | 'stats' + | 'notfound'; + +export interface Route { + name: RouteName; + params: Record; +} + +/** + * parse maps a location hash to a Route. An empty hash is the lobby root. A Telegram Mini + * App cold launch appends its launch params to the URL fragment (#tgWebAppData=...& + * tgWebAppVersion=...); those are launch metadata, not a route, so the fragment is treated + * as the lobby root. Otherwise it would parse as notfound, and bootstrap's navigate('/') + * would then re-key the route pane (notfound -> lobby), sliding the lobby in on launch as if + * returning from another screen. + */ +export function parse(hash: string): Route { + const raw = hash.replace(/^#/, ''); + if (raw === '' || raw.startsWith('tgWebApp')) return { name: 'lobby', params: {} }; + const path = raw.split('?')[0]; + const seg = path.split('/').filter(Boolean); + if (seg.length === 0) return { name: 'lobby', params: {} }; + switch (seg[0]) { + case 'login': + return { name: 'login', params: {} }; + case 'new': + return { name: 'new', params: {} }; + case 'game': + if (!seg[1]) return { name: 'notfound', params: {} }; + if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } }; + if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } }; + return { name: 'game', params: { id: seg[1] } }; + case 'profile': + return { name: 'profile', params: {} }; + case 'settings': + return { name: 'settings', params: {} }; + case 'about': + return { name: 'about', params: {} }; + case 'friends': + return { name: 'friends', params: {} }; + case 'stats': + return { name: 'stats', params: {} }; + default: + return { name: 'notfound', params: {} }; + } +} diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts index 2d90df4..3e62dde 100644 --- a/ui/src/lib/router.svelte.ts +++ b/ui/src/lib/router.svelte.ts @@ -1,54 +1,11 @@ // Minimal dependency-free hash router. Hash routing survives a reload and works on // a file:// origin (Capacitor native packaging), where there is no server to honour -// deep paths. The route is a reactive rune so screens re-render on navigation. +// deep paths. The route is a reactive rune so screens re-render on navigation. The pure +// hash->Route parsing lives in routeparse.ts (unit-tested without a DOM). -export type RouteName = - | 'login' - | 'lobby' - | 'new' - | 'game' - | 'gameChat' - | 'gameCheck' - | 'profile' - | 'settings' - | 'about' - | 'friends' - | 'stats' - | 'notfound'; +import { parse, type Route, type RouteName } from './routeparse'; -export interface Route { - name: RouteName; - params: Record; -} - -function parse(hash: string): Route { - const path = (hash.replace(/^#/, '') || '/').split('?')[0]; - const seg = path.split('/').filter(Boolean); - if (seg.length === 0) return { name: 'lobby', params: {} }; - switch (seg[0]) { - case 'login': - return { name: 'login', params: {} }; - case 'new': - return { name: 'new', params: {} }; - case 'game': - if (!seg[1]) return { name: 'notfound', params: {} }; - if (seg[2] === 'chat') return { name: 'gameChat', params: { id: seg[1] } }; - if (seg[2] === 'check') return { name: 'gameCheck', params: { id: seg[1] } }; - return { name: 'game', params: { id: seg[1] } }; - case 'profile': - return { name: 'profile', params: {} }; - case 'settings': - return { name: 'settings', params: {} }; - case 'about': - return { name: 'about', params: {} }; - case 'friends': - return { name: 'friends', params: {} }; - case 'stats': - return { name: 'stats', params: {} }; - default: - return { name: 'notfound', params: {} }; - } -} +export type { Route, RouteName }; export const router = $state<{ route: Route }>({ route: parse(typeof location !== 'undefined' ? location.hash : ''), -- 2.52.0 From 390b4c756f78519b2994dd7ddb5af38a06aefeb0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 23:51:39 +0200 Subject: [PATCH 096/223] UI: soften the board checkerboard's dark cells The gapless-board dark cells mixed 12% black into --cell-bg, which read too contrasty and competed visually with the bonus cells. Halve the tint to 6% (both themes, keyed off --cell-bg as before) for a gentler checkerboard. --- ui/src/game/Board.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index cb9289e..ffb2e89 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -297,7 +297,9 @@ border-radius: 0; } .grid.gridless .cell.dark { - background: color-mix(in srgb, var(--cell-bg) 88%, #000); + /* A gentle checkerboard tint: enough to read the alternation without competing with the + bonus cells. Lightened from a stronger mix that looked too contrasty in light theme. */ + background: color-mix(in srgb, var(--cell-bg) 94%, #000); } .grid.gridless .cell.filled, .grid.gridless .cell.pending { -- 2.52.0 From 74455c7b1231939d085afc0d140c6faf8e71dc2b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 02:17:30 +0200 Subject: [PATCH 097/223] feat: "multiple words per turn" rule for Russian games Add a per-game rule chosen on New Game for Russian variants (default off = the single-word rule; on = standard Scrabble). Off, only the main word along the play direction is validated and scored; perpendicular cross-words are ignored, including in robot move generation. The rule rides every create and enqueue request and joins the matchmaking key, so games and auto-match stay one uniform path; "Russian-only" is a UI affordance (English always sends standard and shows no toggle). - Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score and generate. - Backend: thread the flag through game CreateParams/Game + store (games column), lobby InvitationSettings + invitation row, and the matchmaker queue key (variant + rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration gains multiple_words_per_turn (DB not versioned); jet regenerated. - Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest FlatBuffers tables (Go + TS regenerated) and threaded through the gateway. - UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants only (auto-match and friend invite), default off; English silently sends standard. - Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated. --- PRERELEASE.md | 1 + backend/README.md | 3 +- backend/go.mod | 2 +- backend/internal/engine/game.go | 61 ++++++---- backend/internal/engine/singleword_test.go | 48 ++++++++ backend/internal/game/service.go | 41 ++++--- backend/internal/game/store.go | 7 +- backend/internal/game/types.go | 5 + backend/internal/inttest/lobby_test.go | 4 +- backend/internal/inttest/robot_test.go | 2 +- backend/internal/lobby/invitations.go | 49 ++++---- backend/internal/lobby/matchmaker.go | 85 ++++++++------ backend/internal/lobby/matchmaker_test.go | 78 ++++++++++-- .../jet/backend/model/game_invitations.go | 25 ++-- .../postgres/jet/backend/model/games.go | 35 +++--- .../jet/backend/table/game_invitations.go | 81 +++++++------ .../postgres/jet/backend/table/games.go | 111 +++++++++--------- .../postgres/migrations/00001_baseline.sql | 2 + .../internal/server/handlers_invitations.go | 64 +++++----- backend/internal/server/handlers_user.go | 7 +- docs/ARCHITECTURE.md | 11 ++ docs/FUNCTIONAL.md | 7 +- docs/FUNCTIONAL_ru.md | 8 +- docs/UI_DESIGN.md | 4 +- gateway/internal/backendclient/api.go | 7 +- gateway/internal/backendclient/api_social.go | 4 + gateway/internal/transcode/transcode.go | 2 +- .../internal/transcode/transcode_social.go | 2 + go.work.sum | 2 + loadtest/go.mod | 2 +- pkg/fbs/scrabble.fbs | 5 +- pkg/fbs/scrabblefb/CreateInvitationRequest.go | 17 ++- pkg/fbs/scrabblefb/EnqueueRequest.go | 17 ++- ui/e2e/game.spec.ts | 12 ++ .../scrabblefb/create-invitation-request.ts | 14 ++- ui/src/gen/fbs/scrabblefb/enqueue-request.ts | 14 ++- ui/src/lib/client.ts | 2 +- ui/src/lib/codec.ts | 4 +- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/mock/client.ts | 2 +- ui/src/lib/model.ts | 2 + ui/src/lib/transport.ts | 4 +- ui/src/lib/variants.test.ts | 28 ++++- ui/src/lib/variants.ts | 14 +++ ui/src/screens/NewGame.svelte | 42 ++++++- 46 files changed, 643 insertions(+), 296 deletions(-) create mode 100644 backend/internal/engine/singleword_test.go diff --git a/PRERELEASE.md b/PRERELEASE.md index fc83d01..2617e5c 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -25,6 +25,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | R6 | Refactor + docs reconciliation + de-staging | 7 | **done** | | R7 | Final stress run + tuning | 9b | **done** | | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | +| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/README.md b/backend/README.md index f28cbe6..a02b99c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,7 +29,8 @@ that auto-resigns overdue turns (honouring each player's daily away window). Lik the engine it is a service/store layer; the HTTP surface lives in the `gateway`. The lobby and social fabric. `internal/lobby` holds an in-memory -matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and +matchmaking pool (FIFO per variant and per-turn word rule, pairs two humans into an +auto-match) and friend-game invitations (invite → accept, starting a 2–4 player game once every invitee accepts). `internal/social` owns the friend graph (request/accept), per-user blocks, and per-game chat with nudges folded in as a message kind; chat diff --git a/backend/go.mod b/backend/go.mod index c33416d..80fbf67 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,7 @@ module scrabble/backend go 1.26.3 require ( - gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 + gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 github.com/XSAM/otelsql v0.42.0 github.com/gin-gonic/gin v1.12.0 github.com/go-jet/jet/v2 v2.14.1 diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index ecbe5d0..36e4446 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -92,6 +92,12 @@ type Options struct { // DropoutTiles is the disposition of a dropped-out player's tiles in a game // with three or more seats; the zero value removes them from play. DropoutTiles DropoutTiles + // MultipleWordsPerTurn selects standard Scrabble when true: every cross-word a + // play forms must be a valid word and is scored. When false the game uses the + // "single word per turn" rule — only the main word is validated and scored and + // perpendicular cross-words are ignored. Callers always set this explicitly; the + // zero value (false) is the single-word rule. + MultipleWordsPerTurn bool } // Game is the in-memory state of a single match and the pure rules engine over @@ -104,17 +110,18 @@ type Game struct { variant Variant version string - board *board.Board - bag *Bag - hands [][]byte // per player, alphabet-index bytes with blankTile for blanks - scores []int - toMove int - scorelessRun int - over bool - reason EndReason - resigned []bool // per seat; a resigned seat is skipped and cannot win - dropoutTiles DropoutTiles // disposition of a resigned seat's tiles - log []MoveRecord + board *board.Board + bag *Bag + hands [][]byte // per player, alphabet-index bytes with blankTile for blanks + scores []int + toMove int + scorelessRun int + over bool + reason EndReason + resigned []bool // per seat; a resigned seat is skipped and cannot win + dropoutTiles DropoutTiles // disposition of a resigned seat's tiles + multipleWords bool // false = single-word rule (perpendicular cross-words ignored) + log []MoveRecord } // New starts a game described by opts over a dictionary from reg. It resolves @@ -140,16 +147,17 @@ func New(reg *Registry, opts Options) (*Game, error) { rs := solver.Rules() g := &Game{ - solver: solver, - rules: rs, - variant: opts.Variant, - version: version, - board: board.New(rs.Rows, rs.Cols), - bag: NewBag(rs, opts.Seed), - hands: make([][]byte, opts.Players), - scores: make([]int, opts.Players), - resigned: make([]bool, opts.Players), - dropoutTiles: opts.DropoutTiles, + solver: solver, + rules: rs, + variant: opts.Variant, + version: version, + board: board.New(rs.Rows, rs.Cols), + bag: NewBag(rs, opts.Seed), + hands: make([][]byte, opts.Players), + scores: make([]int, opts.Players), + resigned: make([]bool, opts.Players), + dropoutTiles: opts.DropoutTiles, + multipleWords: opts.MultipleWordsPerTurn, } for i := range g.hands { g.hands[i] = g.bag.Draw(rs.RackSize) @@ -157,6 +165,13 @@ func New(reg *Registry, opts Options) (*Game, error) { return g, nil } +// playOpts returns the solver play options for this game's rules. Under the single-word +// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main +// word is validated and scored, and move generation is not constrained by cross-words. +func (g *Game) playOpts() scrabble.PlayOptions { + return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords} +} + // Play validates and applies the current player's placement of tiles forming a // word in direction dir. It scores the play, refills the rack from the bag, // advances the turn and may end the game. It returns ErrTilesNotOnRack when the @@ -170,7 +185,7 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec if err := g.checkHolds(player, placementTiles(tiles)); err != nil { return MoveRecord{}, err } - move, err := g.solver.ValidatePlay(g.board, dir, tiles) + move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts()) if err != nil { return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) } @@ -279,7 +294,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) { // GenerateMoves returns every legal play for the current player's rack, ranked // by descending score. It is empty when the player has no legal play. func (g *Game) GenerateMoves() []scrabble.Move { - return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both) + return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()) } // Hint returns the highest-scoring legal play for the current player and true, diff --git a/backend/internal/engine/singleword_test.go b/backend/internal/engine/singleword_test.go new file mode 100644 index 0000000..0a27555 --- /dev/null +++ b/backend/internal/engine/singleword_test.go @@ -0,0 +1,48 @@ +package engine + +import "testing" + +// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The +// single-word game ignores perpendicular cross-words, so move generation from a shared +// position is a superset of the standard game's; the standard game does not relax them. +func TestSingleWordRuleWiring(t *testing.T) { + const seed = 7 + mk := func(multipleWords bool) *Game { + g, err := New(testReg, Options{ + Variant: VariantEnglish, + Version: testVersion, + Players: 2, + Seed: seed, + MultipleWordsPerTurn: multipleWords, + }) + if err != nil { + t.Fatalf("new game: %v", err) + } + return g + } + std, single := mk(true), mk(false) + if std.playOpts().IgnoreCrossWords { + t.Error("standard game must not ignore cross-words") + } + if !single.playOpts().IgnoreCrossWords { + t.Error("single-word game must ignore cross-words") + } + + // Play the same opening (the standard game's top move) in both games, then compare + // the next player's candidate moves. Both games share the seed, so the next rack is + // identical; relaxed (single-word) generation never drops a legal standard move. + hint, ok := std.HintView() + if !ok { + t.Fatal("opening game has no hint") + } + if _, err := std.SubmitPlay(hint.Tiles); err != nil { + t.Fatalf("standard opening: %v", err) + } + if _, err := single.SubmitPlay(hint.Tiles); err != nil { + t.Fatalf("single-word opening: %v", err) + } + stdMoves, singleMoves := len(std.GenerateMoves()), len(single.GenerateMoves()) + if singleMoves < stdMoves { + t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves) + } +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index caf8aeb..fc726c8 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -107,11 +107,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro seed = svc.rng() } g, err := engine.New(svc.registry, engine.Options{ - Variant: params.Variant, - Version: svc.version, - Players: len(params.Seats), - Seed: seed, - DropoutTiles: params.DropoutTiles, + Variant: params.Variant, + Version: svc.version, + Players: len(params.Seats), + Seed: seed, + DropoutTiles: params.DropoutTiles, + MultipleWordsPerTurn: params.MultipleWordsPerTurn, }) if err != nil { if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) { @@ -125,15 +126,16 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro return Game{}, fmt.Errorf("game: new id: %w", err) } ins := gameInsert{ - id: id, - variant: params.Variant.String(), - dictVersion: svc.version, - seed: seed, - players: len(params.Seats), - turnTimeoutSecs: int(timeout / time.Second), - hintsAllowed: params.HintsAllowed, - hintsPerPlayer: params.HintsPerPlayer, - dropoutTiles: params.DropoutTiles.String(), + id: id, + variant: params.Variant.String(), + dictVersion: svc.version, + seed: seed, + players: len(params.Seats), + turnTimeoutSecs: int(timeout / time.Second), + hintsAllowed: params.HintsAllowed, + hintsPerPlayer: params.HintsPerPlayer, + dropoutTiles: params.DropoutTiles.String(), + multipleWordsPerTurn: params.MultipleWordsPerTurn, } if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil { return Game{}, err @@ -934,11 +936,12 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) return nil, err } g, err := engine.New(svc.registry, engine.Options{ - Variant: pre.Variant, - Version: pre.DictVersion, - Players: pre.Players, - Seed: seed, - DropoutTiles: pre.DropoutTiles, + Variant: pre.Variant, + Version: pre.DictVersion, + Players: pre.Players, + Seed: seed, + DropoutTiles: pre.DropoutTiles, + MultipleWordsPerTurn: pre.MultipleWordsPerTurn, }) if err != nil { return nil, err diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index d6709ed..a1a9d44 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -38,6 +38,8 @@ type gameInsert struct { hintsAllowed bool hintsPerPlayer int dropoutTiles string + // multipleWordsPerTurn false selects the single-word rule for the game. + multipleWordsPerTurn bool } // statDelta is one account's contribution to its statistics on a game finish. @@ -92,8 +94,8 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUI gi := table.Games.INSERT( table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer, - table.Games.DropoutTiles, - ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles) + table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, + ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn) if _, err := gi.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert game: %w", err) } @@ -761,6 +763,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } + out.MultipleWordsPerTurn = g.MultipleWordsPerTurn if g.EndReason != nil { out.EndReason = *g.EndReason } diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index ded5378..5b7684f 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -80,6 +80,9 @@ type CreateParams struct { HintsPerPlayer int // starting per-seat hint allowance DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove Seed int64 // zero → a random seed is chosen + // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule — + // only the main word is validated and scored. Russian games default to false. + MultipleWordsPerTurn bool } // Game is the persisted state of a match: the games row joined with its seats. @@ -101,6 +104,8 @@ type Game struct { CreatedAt time.Time UpdatedAt time.Time FinishedAt *time.Time + // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. + MultipleWordsPerTurn bool } // Seat is one player's standing in a game. diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index cbef58d..6025cf2 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -35,14 +35,14 @@ func TestMatchmakingPairsAndStartsGame(t *testing.T) { mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second) a, b := provisionAccount(t), provisionAccount(t) - r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish) + r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue a: %v", err) } if r1.Matched { t.Fatal("first enqueue must wait") } - r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish) + r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue b: %v", err) } diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 53998f9..15eda3f 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -150,7 +150,7 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { human := provisionAccount(t) before := time.Now() - r, err := mm.Enqueue(ctx, human, engine.VariantEnglish) + r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue: %v", err) } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 97506e1..c5e866b 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -49,6 +49,8 @@ type InvitationSettings struct { HintsAllowed bool HintsPerPlayer int DropoutTiles engine.DropoutTiles + // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. + MultipleWordsPerTurn bool } // Invitee is one invited player's seat and response. @@ -214,14 +216,15 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err) } ins := invitationInsert{ - id: id, - inviterID: inviterID, - variant: settings.Variant.String(), - turnTimeoutSecs: int(settings.TurnTimeout / time.Second), - hintsAllowed: settings.HintsAllowed, - hintsPerPlayer: settings.HintsPerPlayer, - dropoutTiles: settings.DropoutTiles.String(), - expiresAt: svc.now().Add(invitationTTL), + id: id, + inviterID: inviterID, + variant: settings.Variant.String(), + turnTimeoutSecs: int(settings.TurnTimeout / time.Second), + hintsAllowed: settings.HintsAllowed, + hintsPerPlayer: settings.HintsPerPlayer, + dropoutTiles: settings.DropoutTiles.String(), + multipleWordsPerTurn: settings.MultipleWordsPerTurn, + expiresAt: svc.now().Add(invitationTTL), } if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil { return Invitation{}, err @@ -265,12 +268,13 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U seats[iv.Seat] = iv.AccountID } g, err := svc.games.Create(ctx, game.CreateParams{ - Variant: inv.Settings.Variant, - Seats: seats, - TurnTimeout: inv.Settings.TurnTimeout, - HintsAllowed: inv.Settings.HintsAllowed, - HintsPerPlayer: inv.Settings.HintsPerPlayer, - DropoutTiles: inv.Settings.DropoutTiles, + Variant: inv.Settings.Variant, + Seats: seats, + TurnTimeout: inv.Settings.TurnTimeout, + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + DropoutTiles: inv.Settings.DropoutTiles, + MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn, }) if err != nil { return err @@ -322,6 +326,8 @@ type invitationInsert struct { hintsPerPlayer int dropoutTiles string expiresAt time.Time + // multipleWordsPerTurn false selects the single-word rule. + multipleWordsPerTurn bool } // respondResult reports the state after an invitee response. @@ -335,8 +341,8 @@ func (s *Store) insertInvitation(ctx context.Context, ins invitationInsert, invi ii := table.GameInvitations.INSERT( table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant, table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer, - table.GameInvitations.DropoutTiles, table.GameInvitations.ExpiresAt, - ).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.expiresAt) + table.GameInvitations.DropoutTiles, table.GameInvitations.MultipleWordsPerTurn, table.GameInvitations.ExpiresAt, + ).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.expiresAt) if _, err := ii.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert invitation: %w", err) } @@ -377,11 +383,12 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e ID: row.InvitationID, InviterID: row.InviterID, Settings: InvitationSettings{ - Variant: variant, - TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second, - HintsAllowed: row.HintsAllowed, - HintsPerPlayer: int(row.HintsPerPlayer), - DropoutTiles: dropout, + Variant: variant, + TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second, + HintsAllowed: row.HintsAllowed, + HintsPerPlayer: int(row.HintsPerPlayer), + DropoutTiles: dropout, + MultipleWordsPerTurn: row.MultipleWordsPerTurn, }, Status: row.Status, GameID: row.GameID, diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 4f30942..58de1bc 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -14,6 +14,14 @@ import ( "scrabble/backend/internal/notify" ) +// matchKey buckets the auto-match pool: two players are paired only when they chose +// the same variant and the same per-turn word rule (multipleWords), so a game always +// starts under a rule both players asked for. +type matchKey struct { + variant engine.Variant + multipleWords bool +} + // Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs // the next two humans into a two-player game, or — when no human arrives within // the wait window — substitutes a robot. It holds no database state and is lost on @@ -35,8 +43,8 @@ type Matchmaker struct { log *zap.Logger mu sync.Mutex - queues map[engine.Variant][]uuid.UUID - queued map[uuid.UUID]engine.Variant + queues map[matchKey][]uuid.UUID + queued map[uuid.UUID]matchKey waitingSince map[uuid.UUID]time.Time results map[uuid.UUID]game.Game rng *rand.Rand @@ -55,8 +63,8 @@ func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Durat clock: func() time.Time { return time.Now().UTC() }, pub: notify.Nop{}, log: log, - queues: make(map[engine.Variant][]uuid.UUID), - queued: make(map[uuid.UUID]engine.Variant), + queues: make(map[matchKey][]uuid.UUID), + queued: make(map[uuid.UUID]matchKey), waitingSince: make(map[uuid.UUID]time.Time), results: make(map[uuid.UUID]game.Game), rng: rand.New(rand.NewSource(time.Now().UnixNano())), @@ -101,34 +109,36 @@ type EnqueueResult struct { Game game.Game } -// Enqueue joins accountID to the variant pool. If an opponent already waits, the -// two are paired (seat order randomised for first-move fairness) and a game starts -// immediately; otherwise the account waits, and a later pairing or robot -// substitution is delivered through Poll. An account already waiting in any pool -// gets ErrAlreadyQueued. -func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) { +// Enqueue joins accountID to the auto-match pool for variant under the chosen +// per-turn word rule (multipleWords). If an opponent already waits for the same +// variant and rule, the two are paired (seat order randomised for first-move +// fairness) and a game starts immediately; otherwise the account waits, and a later +// pairing or robot substitution is delivered through Poll. An account already waiting +// in any pool gets ErrAlreadyQueued. +func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) { + key := matchKey{variant: variant, multipleWords: multipleWords} m.mu.Lock() if _, ok := m.queued[accountID]; ok { m.mu.Unlock() return EnqueueResult{}, ErrAlreadyQueued } - q := m.queues[variant] + q := m.queues[key] if len(q) == 0 { - m.queues[variant] = append(q, accountID) - m.queued[accountID] = variant + m.queues[key] = append(q, accountID) + m.queued[accountID] = key m.waitingSince[accountID] = m.clock() m.mu.Unlock() return EnqueueResult{}, nil } opponent := q[0] - m.removeLocked(opponent, variant) + m.removeLocked(opponent, key) seats := []uuid.UUID{opponent, accountID} if m.rng.Intn(2) == 0 { seats[0], seats[1] = seats[1], seats[0] } m.mu.Unlock() - g, err := m.games.Create(ctx, autoMatchParams(variant, seats)) + g, err := m.games.Create(ctx, autoMatchParams(key, seats)) if err != nil { return EnqueueResult{}, err } @@ -161,19 +171,21 @@ 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] + key, ok := m.queued[accountID] if !ok { return false } - m.removeLocked(accountID, variant) + m.removeLocked(accountID, key) return true } -// QueueLen returns the number of accounts waiting in the variant pool. +// QueueLen returns the number of accounts waiting in the variant pool, summed across +// both per-turn word rules. func (m *Matchmaker) QueueLen(variant engine.Variant) int { m.mu.Lock() defer m.mu.Unlock() - return len(m.queues[variant]) + return len(m.queues[matchKey{variant: variant, multipleWords: false}]) + + len(m.queues[matchKey{variant: variant, multipleWords: true}]) } // RunReaper substitutes a robot for any player that has waited past waitDelay, @@ -198,9 +210,9 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { // momentarily empty pool just defers substitution to a later tick. func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { type sub struct { - human uuid.UUID - variant engine.Variant - seats []uuid.UUID + human uuid.UUID + key matchKey + seats []uuid.UUID } m.mu.Lock() var due []uuid.UUID @@ -211,23 +223,23 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { } var subs []sub for _, acc := range due { - variant := m.queued[acc] - robotID, err := m.robots.Pick(variant) + key := m.queued[acc] + robotID, err := m.robots.Pick(key.variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } - m.removeLocked(acc, variant) + m.removeLocked(acc, key) seats := []uuid.UUID{acc, robotID} if m.rng.Intn(2) == 0 { seats[0], seats[1] = seats[1], seats[0] } - subs = append(subs, sub{human: acc, variant: variant, seats: seats}) + subs = append(subs, sub{human: acc, key: key, seats: seats}) } m.mu.Unlock() for _, s := range subs { - g, err := m.games.Create(ctx, autoMatchParams(s.variant, s.seats)) + g, err := m.games.Create(ctx, autoMatchParams(s.key, s.seats)) if err != nil { m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err)) continue @@ -241,13 +253,13 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { // removeLocked drops accountID from the queue, the queued index and the waiting // clock. The caller holds m.mu. -func (m *Matchmaker) removeLocked(accountID uuid.UUID, variant engine.Variant) { +func (m *Matchmaker) removeLocked(accountID uuid.UUID, key matchKey) { delete(m.queued, accountID) delete(m.waitingSince, accountID) - q := m.queues[variant] + q := m.queues[key] for i, id := range q { if id == accountID { - m.queues[variant] = append(q[:i], q[i+1:]...) + m.queues[key] = append(q[:i], q[i+1:]...) break } } @@ -255,12 +267,13 @@ func (m *Matchmaker) removeLocked(accountID uuid.UUID, variant engine.Variant) { // autoMatchParams builds the create parameters for a two-player auto-match with // the casual defaults. -func autoMatchParams(variant engine.Variant, seats []uuid.UUID) game.CreateParams { +func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams { return game.CreateParams{ - Variant: variant, - Seats: seats, - TurnTimeout: game.DefaultTurnTimeout, - HintsAllowed: autoMatchHintsAllowed, - HintsPerPlayer: autoMatchHintsPerPlayer, + Variant: key.variant, + Seats: seats, + TurnTimeout: game.DefaultTurnTimeout, + HintsAllowed: autoMatchHintsAllowed, + HintsPerPlayer: autoMatchHintsPerPlayer, + MultipleWordsPerTurn: key.multipleWords, } } diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 89c41b1..6a7374e 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -80,7 +80,7 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) { ctx := context.Background() a, b := uuid.New(), uuid.New() - r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish) + r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue a: %v", err) } @@ -91,7 +91,7 @@ func TestMatchmakerPairsTwoHumans(t *testing.T) { t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish)) } - r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish) + r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue b: %v", err) } @@ -129,10 +129,10 @@ func TestMatchmakerAlreadyQueued(t *testing.T) { mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) ctx := context.Background() a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); !errors.Is(err, ErrAlreadyQueued) { + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); !errors.Is(err, ErrAlreadyQueued) { t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err) } } @@ -141,7 +141,7 @@ func TestMatchmakerCancel(t *testing.T) { mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) ctx := context.Background() a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } if !mm.Cancel(ctx, a) { @@ -159,10 +159,10 @@ func TestMatchmakerVariantsAreSeparate(t *testing.T) { creator := &fakeCreator{} mm := newTestMatchmaker(creator, uuid.New()) ctx := context.Background() - if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue en: %v", err) } - if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble); err != nil { + if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil { t.Fatalf("enqueue ru: %v", err) } if len(creator.created) != 0 { @@ -179,7 +179,7 @@ func TestMatchmakerFIFO(t *testing.T) { ctx := context.Background() a, b, c := uuid.New(), uuid.New(), uuid.New() for _, id := range []uuid.UUID{a, b, c} { - if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue %s: %v", id, err) } } @@ -204,7 +204,7 @@ func TestMatchmakerReaperSubstitutesRobot(t *testing.T) { ctx := context.Background() a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } @@ -237,7 +237,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) { ctx := context.Background() a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } mm.Cancel(ctx, a) @@ -258,7 +258,7 @@ func TestMatchmakerCancelClearsPendingResult(t *testing.T) { ctx := context.Background() a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result @@ -276,7 +276,7 @@ func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) { ctx := context.Background() a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil { + if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) @@ -287,3 +287,57 @@ func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) { t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish)) } } + +// TestMatchmakerRulesAreSeparate confirms two players who chose the same variant but a +// different per-turn word rule are not paired, and that the rule reaches the started game. +func TestMatchmakerRulesAreSeparate(t *testing.T) { + creator := &fakeCreator{} + mm := newTestMatchmaker(creator, uuid.New()) + ctx := context.Background() + + // Same variant, opposite rules: they must not match. + if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil { + t.Fatalf("enqueue single-word: %v", err) + } + if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil { + t.Fatalf("enqueue standard: %v", err) + } + if len(creator.created) != 0 { + t.Fatalf("different rules must not match; created %d", len(creator.created)) + } + + // A second single-word player pairs with the first; the game carries the rule. + r, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false) + if err != nil { + t.Fatalf("enqueue single-word opponent: %v", err) + } + if !r.Matched { + t.Fatal("same variant and rule must match") + } + if len(creator.created) != 1 { + t.Fatalf("created %d games, want 1", len(creator.created)) + } + if creator.created[0].MultipleWordsPerTurn { + t.Error("single-word match must create a game with MultipleWordsPerTurn=false") + } +} + +// TestMatchmakerReaperKeepsRule confirms a robot substitution carries the waiter's rule. +func TestMatchmakerReaperKeepsRule(t *testing.T) { + creator := &fakeCreator{} + mm := newTestMatchmaker(creator, uuid.New()) + base := time.Now() + mm.clock = func() time.Time { return base } + ctx := context.Background() + + if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil { + t.Fatalf("enqueue: %v", err) + } + mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) + if len(creator.created) != 1 { + t.Fatalf("created %d games, want 1", len(creator.created)) + } + if creator.created[0].MultipleWordsPerTurn { + t.Error("robot substitution must keep the waiter's single-word rule") + } +} diff --git a/backend/internal/postgres/jet/backend/model/game_invitations.go b/backend/internal/postgres/jet/backend/model/game_invitations.go index d031bbe..abe242a 100644 --- a/backend/internal/postgres/jet/backend/model/game_invitations.go +++ b/backend/internal/postgres/jet/backend/model/game_invitations.go @@ -13,16 +13,17 @@ import ( ) type GameInvitations struct { - InvitationID uuid.UUID `sql:"primary_key"` - InviterID uuid.UUID - Variant string - TurnTimeoutSecs int32 - HintsAllowed bool - HintsPerPlayer int16 - DropoutTiles string - Status string - GameID *uuid.UUID - ExpiresAt time.Time - CreatedAt time.Time - UpdatedAt time.Time + InvitationID uuid.UUID `sql:"primary_key"` + InviterID uuid.UUID + Variant string + TurnTimeoutSecs int32 + HintsAllowed bool + HintsPerPlayer int16 + DropoutTiles string + MultipleWordsPerTurn bool + Status string + GameID *uuid.UUID + ExpiresAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go index cd84aed..8b254b4 100644 --- a/backend/internal/postgres/jet/backend/model/games.go +++ b/backend/internal/postgres/jet/backend/model/games.go @@ -13,21 +13,22 @@ import ( ) type Games struct { - GameID uuid.UUID `sql:"primary_key"` - Variant string - DictVersion string - Seed int64 - Status string - Players int16 - ToMove int16 - TurnStartedAt time.Time - TurnTimeoutSecs int32 - HintsAllowed bool - HintsPerPlayer int16 - MoveCount int32 - EndReason *string - CreatedAt time.Time - UpdatedAt time.Time - FinishedAt *time.Time - DropoutTiles string + GameID uuid.UUID `sql:"primary_key"` + Variant string + DictVersion string + Seed int64 + Status string + Players int16 + ToMove int16 + TurnStartedAt time.Time + TurnTimeoutSecs int32 + HintsAllowed bool + HintsPerPlayer int16 + MoveCount int32 + EndReason *string + CreatedAt time.Time + UpdatedAt time.Time + FinishedAt *time.Time + DropoutTiles string + MultipleWordsPerTurn bool } diff --git a/backend/internal/postgres/jet/backend/table/game_invitations.go b/backend/internal/postgres/jet/backend/table/game_invitations.go index 190edbf..8c3a12d 100644 --- a/backend/internal/postgres/jet/backend/table/game_invitations.go +++ b/backend/internal/postgres/jet/backend/table/game_invitations.go @@ -17,18 +17,19 @@ type gameInvitationsTable struct { postgres.Table // Columns - InvitationID postgres.ColumnString - InviterID postgres.ColumnString - Variant postgres.ColumnString - TurnTimeoutSecs postgres.ColumnInteger - HintsAllowed postgres.ColumnBool - HintsPerPlayer postgres.ColumnInteger - DropoutTiles postgres.ColumnString - Status postgres.ColumnString - GameID postgres.ColumnString - ExpiresAt postgres.ColumnTimestampz - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz + InvitationID postgres.ColumnString + InviterID postgres.ColumnString + Variant postgres.ColumnString + TurnTimeoutSecs postgres.ColumnInteger + HintsAllowed postgres.ColumnBool + HintsPerPlayer postgres.ColumnInteger + DropoutTiles postgres.ColumnString + MultipleWordsPerTurn postgres.ColumnBool + Status postgres.ColumnString + GameID postgres.ColumnString + ExpiresAt postgres.ColumnTimestampz + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -70,39 +71,41 @@ func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitatio func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable { var ( - InvitationIDColumn = postgres.StringColumn("invitation_id") - InviterIDColumn = postgres.StringColumn("inviter_id") - VariantColumn = postgres.StringColumn("variant") - TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs") - HintsAllowedColumn = postgres.BoolColumn("hints_allowed") - HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player") - DropoutTilesColumn = postgres.StringColumn("dropout_tiles") - StatusColumn = postgres.StringColumn("status") - GameIDColumn = postgres.StringColumn("game_id") - ExpiresAtColumn = postgres.TimestampzColumn("expires_at") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn} + InvitationIDColumn = postgres.StringColumn("invitation_id") + InviterIDColumn = postgres.StringColumn("inviter_id") + VariantColumn = postgres.StringColumn("variant") + TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs") + HintsAllowedColumn = postgres.BoolColumn("hints_allowed") + HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player") + DropoutTilesColumn = postgres.StringColumn("dropout_tiles") + MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") + StatusColumn = postgres.StringColumn("status") + GameIDColumn = postgres.StringColumn("game_id") + ExpiresAtColumn = postgres.TimestampzColumn("expires_at") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn} ) return gameInvitationsTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - InvitationID: InvitationIDColumn, - InviterID: InviterIDColumn, - Variant: VariantColumn, - TurnTimeoutSecs: TurnTimeoutSecsColumn, - HintsAllowed: HintsAllowedColumn, - HintsPerPlayer: HintsPerPlayerColumn, - DropoutTiles: DropoutTilesColumn, - Status: StatusColumn, - GameID: GameIDColumn, - ExpiresAt: ExpiresAtColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, + InvitationID: InvitationIDColumn, + InviterID: InviterIDColumn, + Variant: VariantColumn, + TurnTimeoutSecs: TurnTimeoutSecsColumn, + HintsAllowed: HintsAllowedColumn, + HintsPerPlayer: HintsPerPlayerColumn, + DropoutTiles: DropoutTilesColumn, + MultipleWordsPerTurn: MultipleWordsPerTurnColumn, + Status: StatusColumn, + GameID: GameIDColumn, + ExpiresAt: ExpiresAtColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go index cbd44ae..8848c44 100644 --- a/backend/internal/postgres/jet/backend/table/games.go +++ b/backend/internal/postgres/jet/backend/table/games.go @@ -17,23 +17,24 @@ type gamesTable struct { postgres.Table // Columns - GameID postgres.ColumnString - Variant postgres.ColumnString - DictVersion postgres.ColumnString - Seed postgres.ColumnInteger - Status postgres.ColumnString - Players postgres.ColumnInteger - ToMove postgres.ColumnInteger - TurnStartedAt postgres.ColumnTimestampz - TurnTimeoutSecs postgres.ColumnInteger - HintsAllowed postgres.ColumnBool - HintsPerPlayer postgres.ColumnInteger - MoveCount postgres.ColumnInteger - EndReason postgres.ColumnString - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz - FinishedAt postgres.ColumnTimestampz - DropoutTiles postgres.ColumnString + GameID postgres.ColumnString + Variant postgres.ColumnString + DictVersion postgres.ColumnString + Seed postgres.ColumnInteger + Status postgres.ColumnString + Players postgres.ColumnInteger + ToMove postgres.ColumnInteger + TurnStartedAt postgres.ColumnTimestampz + TurnTimeoutSecs postgres.ColumnInteger + HintsAllowed postgres.ColumnBool + HintsPerPlayer postgres.ColumnInteger + MoveCount postgres.ColumnInteger + EndReason postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + FinishedAt postgres.ColumnTimestampz + DropoutTiles postgres.ColumnString + MultipleWordsPerTurn postgres.ColumnBool AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -75,49 +76,51 @@ func newGamesTable(schemaName, tableName, alias string) *GamesTable { func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { var ( - GameIDColumn = postgres.StringColumn("game_id") - VariantColumn = postgres.StringColumn("variant") - DictVersionColumn = postgres.StringColumn("dict_version") - SeedColumn = postgres.IntegerColumn("seed") - StatusColumn = postgres.StringColumn("status") - PlayersColumn = postgres.IntegerColumn("players") - ToMoveColumn = postgres.IntegerColumn("to_move") - TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at") - TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs") - HintsAllowedColumn = postgres.BoolColumn("hints_allowed") - HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player") - MoveCountColumn = postgres.IntegerColumn("move_count") - EndReasonColumn = postgres.StringColumn("end_reason") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - FinishedAtColumn = postgres.TimestampzColumn("finished_at") - DropoutTilesColumn = postgres.StringColumn("dropout_tiles") - allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn} - mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn} - defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn} + GameIDColumn = postgres.StringColumn("game_id") + VariantColumn = postgres.StringColumn("variant") + DictVersionColumn = postgres.StringColumn("dict_version") + SeedColumn = postgres.IntegerColumn("seed") + StatusColumn = postgres.StringColumn("status") + PlayersColumn = postgres.IntegerColumn("players") + ToMoveColumn = postgres.IntegerColumn("to_move") + TurnStartedAtColumn = postgres.TimestampzColumn("turn_started_at") + TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs") + HintsAllowedColumn = postgres.BoolColumn("hints_allowed") + HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player") + MoveCountColumn = postgres.IntegerColumn("move_count") + EndReasonColumn = postgres.StringColumn("end_reason") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + FinishedAtColumn = postgres.TimestampzColumn("finished_at") + DropoutTilesColumn = postgres.StringColumn("dropout_tiles") + MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") + allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} + mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} + defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} ) return gamesTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - GameID: GameIDColumn, - Variant: VariantColumn, - DictVersion: DictVersionColumn, - Seed: SeedColumn, - Status: StatusColumn, - Players: PlayersColumn, - ToMove: ToMoveColumn, - TurnStartedAt: TurnStartedAtColumn, - TurnTimeoutSecs: TurnTimeoutSecsColumn, - HintsAllowed: HintsAllowedColumn, - HintsPerPlayer: HintsPerPlayerColumn, - MoveCount: MoveCountColumn, - EndReason: EndReasonColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, - FinishedAt: FinishedAtColumn, - DropoutTiles: DropoutTilesColumn, + GameID: GameIDColumn, + Variant: VariantColumn, + DictVersion: DictVersionColumn, + Seed: SeedColumn, + Status: StatusColumn, + Players: PlayersColumn, + ToMove: ToMoveColumn, + TurnStartedAt: TurnStartedAtColumn, + TurnTimeoutSecs: TurnTimeoutSecsColumn, + HintsAllowed: HintsAllowedColumn, + HintsPerPlayer: HintsPerPlayerColumn, + MoveCount: MoveCountColumn, + EndReason: EndReasonColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + FinishedAt: FinishedAtColumn, + DropoutTiles: DropoutTilesColumn, + MultipleWordsPerTurn: MultipleWordsPerTurnColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index 8e56ab0..4bba8bd 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -97,6 +97,7 @@ CREATE TABLE games ( updated_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, dropout_tiles text NOT NULL DEFAULT 'remove', + multiple_words_per_turn boolean NOT NULL DEFAULT true, CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')), CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')), CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4), @@ -260,6 +261,7 @@ CREATE TABLE game_invitations ( hints_allowed boolean NOT NULL DEFAULT true, hints_per_player smallint NOT NULL DEFAULT 1, dropout_tiles text NOT NULL DEFAULT 'remove', + multiple_words_per_turn boolean NOT NULL DEFAULT true, status text NOT NULL DEFAULT 'pending', game_id uuid REFERENCES games (game_id) ON DELETE SET NULL, expires_at timestamptz NOT NULL, diff --git a/backend/internal/server/handlers_invitations.go b/backend/internal/server/handlers_invitations.go index 7412e8f..f70f10d 100644 --- a/backend/internal/server/handlers_invitations.go +++ b/backend/internal/server/handlers_invitations.go @@ -27,17 +27,18 @@ type invitationInviteeDTO struct { // invitationDTO is a friend-game invitation with its settings and invitees. type invitationDTO struct { - ID string `json:"id"` - Inviter accountRefDTO `json:"inviter"` - Invitees []invitationInviteeDTO `json:"invitees"` - Variant string `json:"variant"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - HintsAllowed bool `json:"hints_allowed"` - HintsPerPlayer int `json:"hints_per_player"` - DropoutTiles string `json:"dropout_tiles"` - Status string `json:"status"` - GameID string `json:"game_id,omitempty"` - ExpiresAtUnix int64 `json:"expires_at_unix"` + ID string `json:"id"` + Inviter accountRefDTO `json:"inviter"` + Invitees []invitationInviteeDTO `json:"invitees"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + DropoutTiles string `json:"dropout_tiles"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + Status string `json:"status"` + GameID string `json:"game_id,omitempty"` + ExpiresAtUnix int64 `json:"expires_at_unix"` } // invitationListDTO is the caller's open invitations. @@ -47,27 +48,29 @@ type invitationListDTO struct { // createInvitationRequest proposes a friend game to the named invitees. type createInvitationRequest struct { - InviteeIDs []string `json:"invitee_ids"` - Variant string `json:"variant"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - HintsAllowed bool `json:"hints_allowed"` - HintsPerPlayer int `json:"hints_per_player"` - DropoutTiles string `json:"dropout_tiles"` + InviteeIDs []string `json:"invitee_ids"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + DropoutTiles string `json:"dropout_tiles"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` } // invitationDTOFrom projects a lobby invitation, resolving names through memo. func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO { dto := invitationDTO{ - ID: inv.ID.String(), - Inviter: s.namedRef(ctx, inv.InviterID, memo), - Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)), - Variant: inv.Settings.Variant.String(), - TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()), - HintsAllowed: inv.Settings.HintsAllowed, - HintsPerPlayer: inv.Settings.HintsPerPlayer, - DropoutTiles: inv.Settings.DropoutTiles.String(), - Status: inv.Status, - ExpiresAtUnix: inv.ExpiresAt.Unix(), + ID: inv.ID.String(), + Inviter: s.namedRef(ctx, inv.InviterID, memo), + Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)), + Variant: inv.Settings.Variant.String(), + TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()), + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + DropoutTiles: inv.Settings.DropoutTiles.String(), + MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn, + Status: inv.Status, + ExpiresAtUnix: inv.ExpiresAt.Unix(), } if inv.GameID != nil { dto.GameID = inv.GameID.String() @@ -102,9 +105,10 @@ func (s *Server) handleCreateInvitation(c *gin.Context) { return } settings := lobby.InvitationSettings{ - Variant: variant, - HintsAllowed: req.HintsAllowed, - HintsPerPlayer: req.HintsPerPlayer, + Variant: variant, + HintsAllowed: req.HintsAllowed, + HintsPerPlayer: req.HintsPerPlayer, + MultipleWordsPerTurn: req.MultipleWordsPerTurn, } if req.TurnTimeoutSecs > 0 { settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 150af11..834aa0a 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -133,9 +133,10 @@ func (s *Server) handleGameState(c *gin.Context) { c.JSON(http.StatusOK, dto) } -// enqueueRequest joins the per-variant auto-match pool. +// enqueueRequest joins the per-variant auto-match pool under a per-turn word rule. type enqueueRequest struct { - Variant string `json:"variant"` + Variant string `json:"variant"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` } // handleEnqueue joins the auto-match pool for a variant. @@ -155,7 +156,7 @@ func (s *Server) handleEnqueue(c *gin.Context) { abortBadRequest(c, "unknown variant") return } - res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant) + res, err := s.matchmaker.Enqueue(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn) if err != nil { s.abortErr(c, err) return diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4f8d1a..9713a31 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -282,6 +282,17 @@ Key points: - **Word legality: validate-at-submit.** An illegal play is rejected by `Solver.ValidatePlay`; there is no challenge phase. +- **Multiple words per turn (Russian games).** Russian variants carry a per-game + **single-word rule**, chosen on New Game (default **off** = single word; on = standard + Scrabble). Off, only the **main word** along the play direction is validated and scored — + perpendicular cross-words are ignored, including in robot move generation; on, every + cross-word must be a real word and is scored. The engine threads it as + `scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the + first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**: + the backend and engine are variant-agnostic about the flag, and English games always send + it on (standard). For auto-match the rule is part of the matchmaking key, so only players + who chose the same rule are paired (the rule field rides every create/enqueue request, so + matchmaking stays one uniform path). - **End of game**: the bag is empty **and** a player empties their rack, **or** **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or a missed turn. The **per-game turn timeout** is chosen at creation diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c98d6b1..839d17e 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -85,7 +85,12 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va 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. Friend games (2–4) are +after 10 s with no human the robot substitutes. For Russian games (auto-match or friend +invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays +the simplified **single-word rule** — only the word laid along the player's line must be a +real word, and any incidental perpendicular words are ignored and not scored — while on is +standard Scrabble. English games are always standard and show no such toggle. In auto-match +the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are formed by inviting players from the friend list (an invitation, like a friend code, is shareable as a Telegram deep link that opens it directly): the inviter chooses the settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 6460d45..f201d97 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -89,7 +89,13 @@ nudge) приходят от бота **этой партии** — по язы приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на любом языке. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с -без человека подставляется робот. Игры с друзьями (2–4) +без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране +новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена — +упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное +вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются; +включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не +показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, +кто выбрал то же правило. Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 059af2e..89cc854 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -182,7 +182,9 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use - **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept / Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the **"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move - time / hints). + time / hints). For a **Russian** variant (auto-match or invite) a **"Multiple words per + turn"** checkbox (`.toggle`, **default off** = the single-word rule) appears; English + variants never show it. - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index cb84729..aefa8ce 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -245,11 +245,12 @@ func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAl return out, err } -// Enqueue joins the auto-match pool for a variant. -func (c *Client) Enqueue(ctx context.Context, userID, variant string) (MatchResp, error) { +// Enqueue joins the auto-match pool for a variant under a per-turn word rule +// (multipleWords true is standard Scrabble, false the single-word rule). +func (c *Client) Enqueue(ctx context.Context, userID, variant string, multipleWords bool) (MatchResp, error) { var out MatchResp err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "", - map[string]string{"variant": variant}, &out) + map[string]any{"variant": variant, "multiple_words_per_turn": multipleWords}, &out) return out, err } diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 36599e7..c58da38 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -99,6 +99,8 @@ type InvitationParams struct { HintsAllowed bool HintsPerPlayer int DropoutTiles string + // MultipleWordsPerTurn true is standard Scrabble; false the single-word rule. + MultipleWordsPerTurn bool } // --- friends --- @@ -195,6 +197,8 @@ func (c *Client) CreateInvitation(ctx context.Context, userID string, p Invitati "hints_allowed": p.HintsAllowed, "hints_per_player": p.HintsPerPlayer, "dropout_tiles": p.DropoutTiles, + + "multiple_words_per_turn": p.MultipleWordsPerTurn, } err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out) return out, err diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 3e645f2..9cfd7bc 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -224,7 +224,7 @@ func gameStateHandler(backend *backendclient.Client) Handler { func enqueueHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEnqueueRequest(req.Payload, 0) - m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant())) + m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant()), in.MultipleWordsPerTurn()) if err != nil { return nil, err } diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index eef958d..576c58d 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -205,6 +205,8 @@ func invitationCreateHandler(backend *backendclient.Client) Handler { HintsAllowed: in.HintsAllowed(), HintsPerPlayer: int(in.HintsPerPlayer()), DropoutTiles: string(in.DropoutTiles()), + + MultipleWordsPerTurn: in.MultipleWordsPerTurn(), } res, err := backend.CreateInvitation(ctx, req.UserID, params) if err != nil { diff --git a/go.work.sum b/go.work.sum index 70cec45..9b21186 100644 --- a/go.work.sum +++ b/go.work.sum @@ -6,6 +6,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 h1:ntN6m4cOB+4FelleO2nkAIZp8WSc+v25neetzfdUuuw= gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= +gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 h1:92jWbAZ5IK3ROrn1g3FjY0wZjeYpVWOJsl/GGT5HN1U= +gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0/go.mod h1:G60OiGZtkrRyYX8P3SSsjVpU707fufmZkvCkNFPFWrY= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= diff --git a/loadtest/go.mod b/loadtest/go.mod index 847ef76..f056023 100644 --- a/loadtest/go.mod +++ b/loadtest/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( connectrpc.com/connect v1.19.2 - gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0 + gitea.iliadenisov.ru/developer/scrabble-solver v1.1.0 github.com/google/flatbuffers v23.5.26+incompatible github.com/google/uuid v1.6.0 github.com/iliadenisov/dafsa v1.1.0 diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b0e74e2..f54fdc3 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -272,9 +272,11 @@ table GameList { // --- lobby (authenticated) --- -// EnqueueRequest joins the per-variant auto-match pool. +// EnqueueRequest joins the auto-match pool for a variant under a per-turn word rule. +// multiple_words_per_turn true is standard Scrabble; false is the single-word rule. table EnqueueRequest { variant:string; + multiple_words_per_turn:bool; } // MatchResult reports whether the caller has been paired into a game yet. @@ -457,6 +459,7 @@ table CreateInvitationRequest { hints_allowed:bool; hints_per_player:int; dropout_tiles:string; + multiple_words_per_turn:bool; } // InvitationActionRequest accepts / declines / cancels an invitation by id. diff --git a/pkg/fbs/scrabblefb/CreateInvitationRequest.go b/pkg/fbs/scrabblefb/CreateInvitationRequest.go index bc4b512..ec9c320 100644 --- a/pkg/fbs/scrabblefb/CreateInvitationRequest.go +++ b/pkg/fbs/scrabblefb/CreateInvitationRequest.go @@ -110,8 +110,20 @@ func (rcv *CreateInvitationRequest) DropoutTiles() []byte { return nil } +func (rcv *CreateInvitationRequest) MultipleWordsPerTurn() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *CreateInvitationRequest) MutateMultipleWordsPerTurn(n bool) bool { + return rcv._tab.MutateBoolSlot(16, n) +} + func CreateInvitationRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(6) + builder.StartObject(7) } func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(inviteeIds), 0) @@ -134,6 +146,9 @@ func CreateInvitationRequestAddHintsPerPlayer(builder *flatbuffers.Builder, hint func CreateInvitationRequestAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(dropoutTiles), 0) } +func CreateInvitationRequestAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) { + builder.PrependBoolSlot(6, multipleWordsPerTurn, false) +} func CreateInvitationRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/EnqueueRequest.go b/pkg/fbs/scrabblefb/EnqueueRequest.go index 645ff5a..080e7f2 100644 --- a/pkg/fbs/scrabblefb/EnqueueRequest.go +++ b/pkg/fbs/scrabblefb/EnqueueRequest.go @@ -49,12 +49,27 @@ func (rcv *EnqueueRequest) Variant() []byte { return nil } +func (rcv *EnqueueRequest) MultipleWordsPerTurn() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *EnqueueRequest) MutateMultipleWordsPerTurn(n bool) bool { + return rcv._tab.MutateBoolSlot(6, n) +} + func EnqueueRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func EnqueueRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0) } +func EnqueueRequestAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) { + builder.PrependBoolSlot(1, multipleWordsPerTurn, false) +} func EnqueueRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index aa0c280..d568e3f 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -70,6 +70,18 @@ test('new game: variant buttons show a rules summary and the move-limit', async await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons }); +test('new game: Russian games offer the "multiple words per turn" toggle, off by default', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /New/ }).click(); // auto-match + // The mock session supports Russian, so the single-word-rule toggle is offered and starts off. + const toggle = page.getByLabel('Multiple words per turn'); + await expect(toggle).toBeVisible(); + await expect(toggle).not.toBeChecked(); + await toggle.check(); + await expect(toggle).toBeChecked(); +}); + 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/src/gen/fbs/scrabblefb/create-invitation-request.ts b/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts index 90efe4d..301edb3 100644 --- a/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts +++ b/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts @@ -61,8 +61,13 @@ dropoutTiles(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +multipleWordsPerTurn():boolean { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + static startCreateInvitationRequest(builder:flatbuffers.Builder) { - builder.startObject(6); + builder.startObject(7); } static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) { @@ -101,12 +106,16 @@ static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffe builder.addFieldOffset(5, dropoutTilesOffset, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(6, +multipleWordsPerTurn, +false); +} + static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, turnTimeoutSecs:number, hintsAllowed:boolean, hintsPerPlayer:number, dropoutTilesOffset:flatbuffers.Offset):flatbuffers.Offset { +static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, turnTimeoutSecs:number, hintsAllowed:boolean, hintsPerPlayer:number, dropoutTilesOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean):flatbuffers.Offset { CreateInvitationRequest.startCreateInvitationRequest(builder); CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset); CreateInvitationRequest.addVariant(builder, variantOffset); @@ -114,6 +123,7 @@ static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffs CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed); CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer); CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset); + CreateInvitationRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); return CreateInvitationRequest.endCreateInvitationRequest(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/enqueue-request.ts b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts index 293bba3..7dd5232 100644 --- a/ui/src/gen/fbs/scrabblefb/enqueue-request.ts +++ b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts @@ -27,22 +27,32 @@ variant(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +multipleWordsPerTurn():boolean { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + static startEnqueueRequest(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { builder.addFieldOffset(0, variantOffset, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(1, +multipleWordsPerTurn, +false); +} + static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset):flatbuffers.Offset { +static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, multipleWordsPerTurn:boolean):flatbuffers.Offset { EnqueueRequest.startEnqueueRequest(builder); EnqueueRequest.addVariant(builder, variantOffset); + EnqueueRequest.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); return EnqueueRequest.endEnqueueRequest(builder); } } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 2c957e0..efe63fb 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -63,7 +63,7 @@ export interface GatewayClient { gamesList(): Promise; // --- lobby --- - lobbyEnqueue(variant: Variant): Promise; + lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise; lobbyPoll(): Promise; /** Leave the auto-match pool (idempotent); a cancelled quick-match must not stay queued. */ lobbyCancel(): Promise; diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index ae38b75..01aa273 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -149,11 +149,12 @@ export function encodeComplaint(gameId: string, word: string, note: string): Uin return finish(b, fb.ComplaintRequest.endComplaintRequest(b)); } -export function encodeEnqueue(variant: Variant): Uint8Array { +export function encodeEnqueue(variant: Variant, multipleWords: boolean): Uint8Array { const b = new Builder(64); const v = b.createString(variant); fb.EnqueueRequest.startEnqueueRequest(b); fb.EnqueueRequest.addVariant(b, v); + fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords); return finish(b, fb.EnqueueRequest.endEnqueueRequest(b)); } @@ -523,6 +524,7 @@ export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSetti fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed); fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer); fb.CreateInvitationRequest.addDropoutTiles(b, dropout); + fb.CreateInvitationRequest.addMultipleWordsPerTurn(b, st.multipleWordsPerTurn); return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b)); } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 45971c7..ee33a01 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -232,6 +232,7 @@ export const en = { 'new.invite': 'Send invitation', 'new.moveTime': 'Move time', 'new.hintsPerPlayer': 'Hints per player', + 'new.multipleWordsPerTurn': 'Multiple words per turn', 'new.invited': 'Invitation sent.', 'new.noFriends': 'Add friends first to invite them.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 77c9d85..9dd8980 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -233,6 +233,7 @@ export const ru: Record = { 'new.invite': 'Отправить приглашение', 'new.moveTime': 'Время на ход', 'new.hintsPerPlayer': 'Подсказок на игрока', + 'new.multipleWordsPerTurn': 'Несколько слов за ход', 'new.invited': 'Приглашение отправлено.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 73bc7c9..3688ee5 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient { } // --- lobby --- - async lobbyEnqueue(variant: Variant): Promise { + async lobbyEnqueue(variant: Variant, _multipleWords: boolean): Promise { // Simulate a 10s-style robot substitution, sped up: match found shortly. const id = crypto.randomUUID(); const g: MockGame = { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 2cc1603..42544ec 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -158,6 +158,8 @@ export interface InvitationSettings { hintsAllowed: boolean; hintsPerPlayer: number; dropoutTiles: 'remove' | 'return'; + /** true = standard Scrabble; false = the single-word rule (Russian games). */ + multipleWordsPerTurn: boolean; } export interface InvitationInvitee { diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 725e6ab..a976a21 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -81,8 +81,8 @@ export function createTransport(baseUrl: string): GatewayClient { return codec.decodeGameList(await exec('games.list', codec.empty())); }, - async lobbyEnqueue(variant) { - return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant))); + async lobbyEnqueue(variant, multipleWords) { + return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant, multipleWords))); }, async lobbyPoll() { return codec.decodeMatchResult(await exec('lobby.poll', codec.empty())); diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts index 91af686..8c2671b 100644 --- a/ui/src/lib/variants.test.ts +++ b/ui/src/lib/variants.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { ALL_VARIANTS, availableVariants } from './variants'; +import { + ALL_VARIANTS, + availableVariants, + supportsMultipleWordsToggle, + multipleWordsForRequest, +} from './variants'; describe('availableVariants', () => { it('is ungated (all variants) for an empty or absent set', () => { @@ -20,3 +25,24 @@ describe('availableVariants', () => { expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']); }); }); + +describe('supportsMultipleWordsToggle', () => { + it('is true for Russian variants only', () => { + expect(supportsMultipleWordsToggle('scrabble_ru')).toBe(true); + expect(supportsMultipleWordsToggle('erudit_ru')).toBe(true); + expect(supportsMultipleWordsToggle('scrabble_en')).toBe(false); + }); +}); + +describe('multipleWordsForRequest', () => { + it('carries the toggle for Russian games', () => { + expect(multipleWordsForRequest('scrabble_ru', false)).toBe(false); + expect(multipleWordsForRequest('scrabble_ru', true)).toBe(true); + expect(multipleWordsForRequest('erudit_ru', false)).toBe(false); + }); + + it('forces standard (true) for English whatever the toggle', () => { + expect(multipleWordsForRequest('scrabble_en', false)).toBe(true); + expect(multipleWordsForRequest('scrabble_en', true)).toBe(true); + }); +}); diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index 747337b..d93addc 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -55,3 +55,17 @@ export function availableVariants(supportedLanguages: string[] | undefined): Var if (langs.length === 0) return ALL_VARIANTS; return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id])); } + +// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle +// applies to a variant. Only Russian games choose the rule; English is always standard, so +// its toggle is not shown. +export function supportsMultipleWordsToggle(v: Variant): boolean { + return VARIANT_LANGUAGE[v] === 'ru'; +} + +// multipleWordsForRequest resolves the per-turn word rule sent when starting a game of the +// variant: Russian games carry the toggle's value, English games are silently standard +// (true), so matchmaking and game creation stay one uniform path. +export function multipleWordsForRequest(v: Variant, toggle: boolean): boolean { + return supportsMultipleWordsToggle(v) ? toggle : true; +} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index e830d50..de103c6 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -7,7 +7,13 @@ import { navigate } from '../lib/router.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { AccountRef, Variant } from '../lib/model'; - import { availableVariants, VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; + import { + availableVariants, + VARIANT_FLAG, + VARIANT_RULES, + supportsMultipleWordsToggle, + multipleWordsForRequest, + } from '../lib/variants'; // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h). const AUTO_MATCH_HOURS = 24; @@ -15,6 +21,10 @@ // The offered variants are gated by the languages the sign-in service supports; // the auto-match list and the friend-invite picker both use this. const variants = $derived(availableVariants(app.session?.supportedLanguages)); + // "Multiple words per turn" off is the single-word rule; it is offered for Russian games + // only (English is always standard and shows no toggle). Shared by both flows. + let multipleWords = $state(false); + const autoHasRussian = $derived(variants.some((v) => supportsMultipleWordsToggle(v.id))); const timeouts = [ { secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, { secs: 1800, key: 'time.minutes' as MessageKey, n: 30 }, @@ -70,7 +80,7 @@ searching = true; matched = false; try { - const r = await gateway.lobbyEnqueue(v); + const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords)); if (r.matched && r.game) { matched = true; searching = false; @@ -137,6 +147,7 @@ hintsAllowed: hints > 0, hintsPerPlayer: hints, dropoutTiles: 'remove', + multipleWordsPerTurn: multipleWordsForRequest(inviteVariant, multipleWords), }); showToast(t('new.invited')); navigate('/'); @@ -171,6 +182,12 @@ {#if mode === 'auto'}

{t('new.subtitle')}

+ {#if autoHasRussian} + + {/if}
{#each variants as v (v.id)}
+ {#if inviteVariant && supportsMultipleWordsToggle(inviteVariant)} + + {/if} {/if} @@ -385,6 +408,21 @@ .field select.placeholder { color: var(--text-muted); } + .toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 11px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: var(--radius-sm); + user-select: none; + } + .toggle span { + font-size: 0.85rem; + color: var(--text); + } .muted { color: var(--text-muted); margin: 0; -- 2.52.0 From 0b57400c6fb8682422e6b654edc8b8e9fa4eb5e0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 10:28:29 +0200 Subject: [PATCH 098/223] feat(ui): single-word rule indicators + auto-match select redesign Surface the per-game "single word" rule to the client and refine the random-opponent New Game screen. - Wire: thread multiple_words_per_turn into the GameView and Invitation FlatBuffers tables (Go + TS regenerated), through pkg/wire builders and both the backend push-event and gateway REST paths. - In-game indicators (single-word games only): a small 1 in the status bar's score-preview slot (yields to the live preview) and a centred "One word per turn" label in the history-drawer header. Standard games show neither. - Invitation card gains a "One word per turn" line for single-word invitations. - Auto-match redesign: variant plaques are mutually-exclusive selects (highlight on tap, no longer enqueue); a lone offered variant is pre-selected; a bottom "Start game" button (disabled until a variant is chosen) confirms. The rule toggle appears once a Russian variant is selected. - Tests: e2e for the new auto flow and the in-game indicator (mock g3 is a single-word game); mock/data + fixtures carry the new field. Docs: UI_DESIGN. --- backend/internal/game/eventwire.go | 23 +++++----- backend/internal/lobby/invitations.go | 23 +++++----- backend/internal/notify/encode.go | 46 ++++++++++--------- backend/internal/notify/payload.go | 46 ++++++++++--------- backend/internal/server/dto.go | 42 +++++++++-------- docs/UI_DESIGN.md | 19 +++++--- gateway/internal/backendclient/api.go | 23 +++++----- gateway/internal/backendclient/api_social.go | 23 +++++----- gateway/internal/transcode/encode.go | 23 +++++----- gateway/internal/transcode/encode_social.go | 23 +++++----- pkg/fbs/scrabble.fbs | 2 + pkg/fbs/scrabblefb/GameView.go | 39 +++++++++++----- pkg/fbs/scrabblefb/Invitation.go | 39 +++++++++++----- pkg/wire/build.go | 48 +++++++++++--------- ui/e2e/game.spec.ts | 27 +++++++++-- ui/src/game/Game.svelte | 15 +++++- ui/src/gen/fbs/scrabblefb/game-view.ts | 32 ++++++++----- ui/src/gen/fbs/scrabblefb/invitation.ts | 27 +++++++---- ui/src/lib/codec.ts | 3 ++ ui/src/lib/gamedelta.test.ts | 1 + ui/src/lib/i18n/en.ts | 2 + ui/src/lib/i18n/ru.ts | 2 + ui/src/lib/lobbysort.test.ts | 1 + ui/src/lib/mock/client.ts | 4 +- ui/src/lib/mock/data.ts | 4 ++ ui/src/lib/model.ts | 4 ++ ui/src/lib/result.test.ts | 1 + ui/src/screens/Lobby.svelte | 1 + ui/src/screens/NewGame.svelte | 37 +++++++++++---- 29 files changed, 364 insertions(+), 216 deletions(-) diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 189aa74..dd266bf 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -35,17 +35,18 @@ func gameSummary(g Game, names []string) notify.GameSummary { last = *g.FinishedAt } return notify.GameSummary{ - ID: g.ID.String(), - Variant: g.Variant.String(), - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, - LastActivityUnix: last.Unix(), + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: last.Unix(), } } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index c5e866b..4e53f4d 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -159,17 +159,18 @@ func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitat gameID = inv.GameID.String() } return notify.InvitationSummary{ - ID: inv.ID.String(), - Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)}, - Invitees: invitees, - Variant: inv.Settings.Variant.String(), - TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second), - HintsAllowed: inv.Settings.HintsAllowed, - HintsPerPlayer: inv.Settings.HintsPerPlayer, - DropoutTiles: inv.Settings.DropoutTiles.String(), - Status: inv.Status, - GameID: gameID, - ExpiresAtUnix: inv.ExpiresAt.Unix(), + ID: inv.ID.String(), + Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)}, + Invitees: invitees, + Variant: inv.Settings.Variant.String(), + TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second), + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn, + DropoutTiles: inv.Settings.DropoutTiles.String(), + Status: inv.Status, + GameID: gameID, + ExpiresAtUnix: inv.ExpiresAt.Unix(), } } diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index 47a4fae..ceb3982 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -28,17 +28,18 @@ func toWireGame(g GameSummary) wire.GameView { } } return wire.GameView{ - ID: g.ID, - Variant: g.Variant, - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: g.TurnTimeoutSecs, - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, - LastActivityUnix: g.LastActivityUnix, + ID: g.ID, + Variant: g.Variant, + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: g.TurnTimeoutSecs, + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: g.LastActivityUnix, } } @@ -102,16 +103,17 @@ func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers. } } return wire.BuildInvitation(b, wire.Invitation{ - ID: inv.ID, - Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, - Invitees: invitees, - Variant: inv.Variant, - TurnTimeoutSecs: inv.TurnTimeoutSecs, - HintsAllowed: inv.HintsAllowed, - HintsPerPlayer: inv.HintsPerPlayer, - DropoutTiles: inv.DropoutTiles, - Status: inv.Status, - GameID: inv.GameID, - ExpiresAtUnix: inv.ExpiresAtUnix, + ID: inv.ID, + Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, + Invitees: invitees, + Variant: inv.Variant, + TurnTimeoutSecs: inv.TurnTimeoutSecs, + HintsAllowed: inv.HintsAllowed, + HintsPerPlayer: inv.HintsPerPlayer, + MultipleWordsPerTurn: inv.MultipleWordsPerTurn, + DropoutTiles: inv.DropoutTiles, + Status: inv.Status, + GameID: inv.GameID, + ExpiresAtUnix: inv.ExpiresAtUnix, }) } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index 3177b5a..ce3c68a 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -21,17 +21,18 @@ type SeatStanding struct { // (mirrors scrabblefb.GameView). LastActivityUnix is the lobby sort key: the current // turn's start for an active game, the finish time once finished. type GameSummary struct { - ID string - Variant string - DictVersion string - Status string - Players int - ToMove int - TurnTimeoutSecs int - MoveCount int - EndReason string - Seats []SeatStanding - LastActivityUnix int64 + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + TurnTimeoutSecs int + MultipleWordsPerTurn bool + MoveCount int + EndReason string + Seats []SeatStanding + LastActivityUnix int64 } // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an @@ -75,15 +76,16 @@ type InvitationInvitee struct { // InvitationSummary is a friend-game invitation carried by the NotifyInvitation event so // the client adds it to its lobby list without a refetch (mirrors scrabblefb.Invitation). type InvitationSummary struct { - ID string - Inviter AccountRef - Invitees []InvitationInvitee - Variant string - TurnTimeoutSecs int - HintsAllowed bool - HintsPerPlayer int - DropoutTiles string - Status string - GameID string - ExpiresAtUnix int64 + ID string + Inviter AccountRef + Invitees []InvitationInvitee + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + MultipleWordsPerTurn bool + DropoutTiles string + Status string + GameID string + ExpiresAtUnix int64 } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 8d225b0..8796720 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -81,15 +81,16 @@ type seatDTO struct { // gameDTO is the shared game summary. type gameDTO struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` // LastActivityUnix is the lobby sort key: the current turn's start for an active // game, the finish time once finished. LastActivityUnix int64 `json:"last_activity_unix"` @@ -198,17 +199,18 @@ func gameDTOFromGame(g game.Game) gameDTO { last = *g.FinishedAt } return gameDTO{ - ID: g.ID.String(), - Variant: g.Variant.String(), - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), - MoveCount: g.MoveCount, - EndReason: g.EndReason, - LastActivityUnix: last.Unix(), - Seats: seats, + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + LastActivityUnix: last.Unix(), + Seats: seats, } } diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 89cc854..435c525 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -108,7 +108,10 @@ Login uses `Screen`. longer merely scrolls the board out of view, which used to leave a stale-open state that made a follow-up plaque tap "jump" the board). The drawer carries its own **header**: a 🏁 **Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬 - (badged with unread chat) at the right, icon-only. Each **opponent**'s card also gains a + (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian + game with "multiple words per turn" off) centres a **"One word per turn"** label between + those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that + yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains a 🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed (see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge. @@ -180,11 +183,15 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a guest sees a sign-in prompt. - **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept / - Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the - **"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move - time / hints). For a **Russian** variant (auto-match or invite) a **"Multiple words per - turn"** checkbox (`.toggle`, **default off** = the single-word rule) appears; English - variants never show it. + Decline for an invitee and a waiting/Cancel state for the inviter; a single-word-rule + invitation adds a **"One word per turn"** line to the card. Creating a game lives in + `NewGame.svelte`: **"Play with friends"** (pick invitees, then variant / move time / + hints) and **auto-match** (random opponent). The auto-match variant plaques are + **mutually-exclusive selects** — a tap **highlights** one (an accent inset border) instead + of starting a game; a lone offered variant is pre-selected, and a bottom **Start game** + button (disabled until a variant is chosen) confirms. For a **Russian** variant (either + flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word + rule) appears once that variant is selected; English variants never show it. - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index aefa8ce..91e421e 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -93,17 +93,18 @@ type SeatResp struct { // GameResp is the shared game summary. type GameResp struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` - LastActivityUnix int64 `json:"last_activity_unix"` - Seats []SeatResp `json:"seats"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` + LastActivityUnix int64 `json:"last_activity_unix"` + Seats []SeatResp `json:"seats"` } // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index c58da38..3d989c4 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -66,17 +66,18 @@ type InvitationInviteeResp struct { // InvitationResp is a friend-game invitation with its settings and invitees. type InvitationResp struct { - ID string `json:"id"` - Inviter AccountRefResp `json:"inviter"` - Invitees []InvitationInviteeResp `json:"invitees"` - Variant string `json:"variant"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - HintsAllowed bool `json:"hints_allowed"` - HintsPerPlayer int `json:"hints_per_player"` - DropoutTiles string `json:"dropout_tiles"` - Status string `json:"status"` - GameID string `json:"game_id"` - ExpiresAtUnix int64 `json:"expires_at_unix"` + ID string `json:"id"` + Inviter AccountRefResp `json:"inviter"` + Invitees []InvitationInviteeResp `json:"invitees"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` + DropoutTiles string `json:"dropout_tiles"` + Status string `json:"status"` + GameID string `json:"game_id"` + ExpiresAtUnix int64 `json:"expires_at_unix"` } // InvitationListResp is the caller's open invitations. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index ae90e6b..eb0e7c4 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -326,17 +326,18 @@ func toWireGame(g backendclient.GameResp) wire.GameView { } } return wire.GameView{ - ID: g.ID, - Variant: g.Variant, - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: g.TurnTimeoutSecs, - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, - LastActivityUnix: g.LastActivityUnix, + ID: g.ID, + Variant: g.Variant, + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: g.TurnTimeoutSecs, + MultipleWordsPerTurn: g.MultipleWordsPerTurn, + MoveCount: g.MoveCount, + EndReason: g.EndReason, + Seats: seats, + LastActivityUnix: g.LastActivityUnix, } } diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index eabcbe8..e9fadcc 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -116,17 +116,18 @@ func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) f } } return wire.BuildInvitation(b, wire.Invitation{ - ID: inv.ID, - Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, - Invitees: invitees, - Variant: inv.Variant, - TurnTimeoutSecs: inv.TurnTimeoutSecs, - HintsAllowed: inv.HintsAllowed, - HintsPerPlayer: inv.HintsPerPlayer, - DropoutTiles: inv.DropoutTiles, - Status: inv.Status, - GameID: inv.GameID, - ExpiresAtUnix: inv.ExpiresAtUnix, + ID: inv.ID, + Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName}, + Invitees: invitees, + Variant: inv.Variant, + TurnTimeoutSecs: inv.TurnTimeoutSecs, + HintsAllowed: inv.HintsAllowed, + HintsPerPlayer: inv.HintsPerPlayer, + MultipleWordsPerTurn: inv.MultipleWordsPerTurn, + DropoutTiles: inv.DropoutTiles, + Status: inv.Status, + GameID: inv.GameID, + ExpiresAtUnix: inv.ExpiresAtUnix, }) } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index f54fdc3..e786445 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -63,6 +63,7 @@ table GameView { players:int; to_move:int; turn_timeout_secs:int; + multiple_words_per_turn:bool; move_count:int; end_reason:string; seats:[SeatView]; @@ -445,6 +446,7 @@ table Invitation { turn_timeout_secs:int; hints_allowed:bool; hints_per_player:int; + multiple_words_per_turn:bool; dropout_tiles:string; status:string; game_id:string; diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 01c977a..89b47c7 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -109,8 +109,20 @@ func (rcv *GameView) MutateTurnTimeoutSecs(n int32) bool { return rcv._tab.MutateInt32Slot(16, n) } -func (rcv *GameView) MoveCount() int32 { +func (rcv *GameView) MultipleWordsPerTurn() bool { o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *GameView) MutateMultipleWordsPerTurn(n bool) bool { + return rcv._tab.MutateBoolSlot(18, n) +} + +func (rcv *GameView) MoveCount() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) if o != 0 { return rcv._tab.GetInt32(o + rcv._tab.Pos) } @@ -118,11 +130,11 @@ func (rcv *GameView) MoveCount() int32 { } func (rcv *GameView) MutateMoveCount(n int32) bool { - return rcv._tab.MutateInt32Slot(18, n) + return rcv._tab.MutateInt32Slot(20, n) } func (rcv *GameView) EndReason() []byte { - o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) } @@ -130,7 +142,7 @@ func (rcv *GameView) EndReason() []byte { } func (rcv *GameView) Seats(obj *SeatView, j int) bool { - o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) if o != 0 { x := rcv._tab.Vector(o) x += flatbuffers.UOffsetT(j) * 4 @@ -142,7 +154,7 @@ func (rcv *GameView) Seats(obj *SeatView, j int) bool { } func (rcv *GameView) SeatsLength() int { - o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) if o != 0 { return rcv._tab.VectorLen(o) } @@ -150,7 +162,7 @@ func (rcv *GameView) SeatsLength() int { } func (rcv *GameView) LastActivityUnix() int64 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) if o != 0 { return rcv._tab.GetInt64(o + rcv._tab.Pos) } @@ -158,11 +170,11 @@ func (rcv *GameView) LastActivityUnix() int64 { } func (rcv *GameView) MutateLastActivityUnix(n int64) bool { - return rcv._tab.MutateInt64Slot(24, n) + return rcv._tab.MutateInt64Slot(26, n) } func GameViewStart(builder *flatbuffers.Builder) { - builder.StartObject(11) + builder.StartObject(12) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -185,20 +197,23 @@ func GameViewAddToMove(builder *flatbuffers.Builder, toMove int32) { func GameViewAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) { builder.PrependInt32Slot(6, turnTimeoutSecs, 0) } +func GameViewAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) { + builder.PrependBoolSlot(7, multipleWordsPerTurn, false) +} func GameViewAddMoveCount(builder *flatbuffers.Builder, moveCount int32) { - builder.PrependInt32Slot(7, moveCount, 0) + builder.PrependInt32Slot(8, moveCount, 0) } func GameViewAddEndReason(builder *flatbuffers.Builder, endReason flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(endReason), 0) + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(endReason), 0) } func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(seats), 0) + builder.PrependUOffsetTSlot(10, flatbuffers.UOffsetT(seats), 0) } func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) { - builder.PrependInt64Slot(10, lastActivityUnix, 0) + builder.PrependInt64Slot(11, lastActivityUnix, 0) } func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() diff --git a/pkg/fbs/scrabblefb/Invitation.go b/pkg/fbs/scrabblefb/Invitation.go index 0f77059..ecee230 100644 --- a/pkg/fbs/scrabblefb/Invitation.go +++ b/pkg/fbs/scrabblefb/Invitation.go @@ -126,15 +126,19 @@ func (rcv *Invitation) MutateHintsPerPlayer(n int32) bool { return rcv._tab.MutateInt32Slot(16, n) } -func (rcv *Invitation) DropoutTiles() []byte { +func (rcv *Invitation) MultipleWordsPerTurn() bool { o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) if o != 0 { - return rcv._tab.ByteVector(o + rcv._tab.Pos) + return rcv._tab.GetBool(o + rcv._tab.Pos) } - return nil + return false } -func (rcv *Invitation) Status() []byte { +func (rcv *Invitation) MutateMultipleWordsPerTurn(n bool) bool { + return rcv._tab.MutateBoolSlot(18, n) +} + +func (rcv *Invitation) DropoutTiles() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) @@ -142,7 +146,7 @@ func (rcv *Invitation) Status() []byte { return nil } -func (rcv *Invitation) GameId() []byte { +func (rcv *Invitation) Status() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) @@ -150,8 +154,16 @@ func (rcv *Invitation) GameId() []byte { return nil } -func (rcv *Invitation) ExpiresAtUnix() int64 { +func (rcv *Invitation) GameId() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Invitation) ExpiresAtUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) if o != 0 { return rcv._tab.GetInt64(o + rcv._tab.Pos) } @@ -159,11 +171,11 @@ func (rcv *Invitation) ExpiresAtUnix() int64 { } func (rcv *Invitation) MutateExpiresAtUnix(n int64) bool { - return rcv._tab.MutateInt64Slot(24, n) + return rcv._tab.MutateInt64Slot(26, n) } func InvitationStart(builder *flatbuffers.Builder) { - builder.StartObject(11) + builder.StartObject(12) } func InvitationAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -189,17 +201,20 @@ func InvitationAddHintsAllowed(builder *flatbuffers.Builder, hintsAllowed bool) func InvitationAddHintsPerPlayer(builder *flatbuffers.Builder, hintsPerPlayer int32) { builder.PrependInt32Slot(6, hintsPerPlayer, 0) } +func InvitationAddMultipleWordsPerTurn(builder *flatbuffers.Builder, multipleWordsPerTurn bool) { + builder.PrependBoolSlot(7, multipleWordsPerTurn, false) +} func InvitationAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(dropoutTiles), 0) + builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(dropoutTiles), 0) } func InvitationAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(status), 0) + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(status), 0) } func InvitationAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(gameId), 0) + builder.PrependUOffsetTSlot(10, flatbuffers.UOffsetT(gameId), 0) } func InvitationAddExpiresAtUnix(builder *flatbuffers.Builder, expiresAtUnix int64) { - builder.PrependInt64Slot(10, expiresAtUnix, 0) + builder.PrependInt64Slot(11, expiresAtUnix, 0) } func InvitationEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 8837a49..4ef6834 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -30,17 +30,18 @@ type SeatView struct { // GameView is the shared, non-private game summary. type GameView struct { - ID string - Variant string - DictVersion string - Status string - Players int - ToMove int - TurnTimeoutSecs int - MoveCount int - EndReason string - Seats []SeatView - LastActivityUnix int64 + ID string + Variant string + DictVersion string + Status string + Players int + ToMove int + TurnTimeoutSecs int + MultipleWordsPerTurn bool + MoveCount int + EndReason string + Seats []SeatView + LastActivityUnix int64 } // TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank @@ -103,17 +104,18 @@ type InvitationInvitee struct { // Invitation is a friend-game invitation with its settings and invitees. type Invitation struct { - ID string - Inviter AccountRef - Invitees []InvitationInvitee - Variant string - TurnTimeoutSecs int - HintsAllowed bool - HintsPerPlayer int - DropoutTiles string - Status string - GameID string - ExpiresAtUnix int64 + ID string + Inviter AccountRef + Invitees []InvitationInvitee + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + MultipleWordsPerTurn bool + DropoutTiles string + Status string + GameID string + ExpiresAtUnix int64 } // BuildGameView builds a GameView table from g and returns its offset. @@ -151,6 +153,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.GameViewAddPlayers(b, int32(g.Players)) fb.GameViewAddToMove(b, int32(g.ToMove)) fb.GameViewAddTurnTimeoutSecs(b, int32(g.TurnTimeoutSecs)) + fb.GameViewAddMultipleWordsPerTurn(b, g.MultipleWordsPerTurn) fb.GameViewAddMoveCount(b, int32(g.MoveCount)) fb.GameViewAddEndReason(b, endReason) fb.GameViewAddSeats(b, seats) @@ -291,6 +294,7 @@ func BuildInvitation(b *flatbuffers.Builder, inv Invitation) flatbuffers.UOffset fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) + fb.InvitationAddMultipleWordsPerTurn(b, inv.MultipleWordsPerTurn) fb.InvitationAddDropoutTiles(b, dropout) fb.InvitationAddStatus(b, status) fb.InvitationAddGameId(b, gameID) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index d568e3f..f4ce823 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -70,16 +70,35 @@ test('new game: variant buttons show a rules summary and the move-limit', async await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons }); -test('new game: Russian games offer the "multiple words per turn" toggle, off by default', async ({ page }) => { +test('new game: auto-match selects a variant, then a Russian pick reveals the off-by-default toggle', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /New/ }).click(); // auto-match - // The mock session supports Russian, so the single-word-rule toggle is offered and starts off. + // Several variants are offered, so nothing is selected: Start is disabled and there is no toggle yet. + const start = page.getByRole('button', { name: /Start game/i }); + await expect(start).toBeDisabled(); + await expect(page.getByLabel('Multiple words per turn')).toHaveCount(0); + // Selecting the Russian Scrabble variant highlights it, enables Start, and reveals the rule toggle (off). + await page.locator('.variant', { hasText: 'Скрэббл' }).click(); + await expect(page.locator('.variant.selected')).toHaveCount(1); + await expect(start).toBeEnabled(); const toggle = page.getByLabel('Multiple words per turn'); await expect(toggle).toBeVisible(); await expect(toggle).not.toBeChecked(); - await toggle.check(); - await expect(toggle).toBeChecked(); +}); + +test('single-word game shows the one-word indicator in the status bar and the history header', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + // g3 is a finished Russian single-word game (vs Rick); open it from the lobby. + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await expect(page.locator('.pane')).toHaveCount(1); + // The status bar carries the small "1️⃣" indicator (single-word, nothing pending). + await expect(page.locator('.oneword')).toBeVisible(); + // Tapping the scoreboard opens the history, whose header shows the spelled-out rule label. + await page.locator('.scoreboard').click(); + await expect(page.locator('.oneword-label')).toBeVisible(); }); test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => { diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 4f4b292..057f5a9 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -790,6 +790,7 @@ {:else} {/if} + {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} @@ -857,7 +858,7 @@ {isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''} {/if} - {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if} + {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} @@ -1103,6 +1104,18 @@ min-width: 64px; text-align: right; } + .oneword { + font-size: 0.95rem; + } + /* The single-word-rule label centred in the history header between its two icons. */ + .oneword-label { + flex: 1; + text-align: center; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + white-space: nowrap; + } .rack-row { display: flex; flex: none; diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index 61a516b..fc03e8e 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -66,35 +66,40 @@ turnTimeoutSecs():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } -moveCount():number { +multipleWordsPerTurn():boolean { const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +moveCount():number { + const offset = this.bb!.__offset(this.bb_pos, 20); return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } endReason():string|null endReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null endReason(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 20); + const offset = this.bb!.__offset(this.bb_pos, 22); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } seats(index: number, obj?:SeatView):SeatView|null { - const offset = this.bb!.__offset(this.bb_pos, 22); + const offset = this.bb!.__offset(this.bb_pos, 24); return offset ? (obj || new SeatView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; } seatsLength():number { - const offset = this.bb!.__offset(this.bb_pos, 22); + const offset = this.bb!.__offset(this.bb_pos, 24); return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } lastActivityUnix():bigint { - const offset = this.bb!.__offset(this.bb_pos, 24); + const offset = this.bb!.__offset(this.bb_pos, 26); return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); } static startGameView(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -125,16 +130,20 @@ static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) { builder.addFieldInt32(6, turnTimeoutSecs, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(7, +multipleWordsPerTurn, +false); +} + static addMoveCount(builder:flatbuffers.Builder, moveCount:number) { - builder.addFieldInt32(7, moveCount, 0); + builder.addFieldInt32(8, moveCount, 0); } static addEndReason(builder:flatbuffers.Builder, endReasonOffset:flatbuffers.Offset) { - builder.addFieldOffset(8, endReasonOffset, 0); + builder.addFieldOffset(9, endReasonOffset, 0); } static addSeats(builder:flatbuffers.Builder, seatsOffset:flatbuffers.Offset) { - builder.addFieldOffset(9, seatsOffset, 0); + builder.addFieldOffset(10, seatsOffset, 0); } static createSeatsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { @@ -150,7 +159,7 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) { } static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint) { - builder.addFieldInt64(10, lastActivityUnix, BigInt('0')); + builder.addFieldInt64(11, lastActivityUnix, BigInt('0')); } static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { @@ -158,7 +167,7 @@ static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { return offset; } -static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset { +static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset { GameView.startGameView(builder); GameView.addId(builder, idOffset); GameView.addVariant(builder, variantOffset); @@ -167,6 +176,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, GameView.addPlayers(builder, players); GameView.addToMove(builder, toMove); GameView.addTurnTimeoutSecs(builder, turnTimeoutSecs); + GameView.addMultipleWordsPerTurn(builder, multipleWordsPerTurn); GameView.addMoveCount(builder, moveCount); GameView.addEndReason(builder, endReasonOffset); GameView.addSeats(builder, seatsOffset); diff --git a/ui/src/gen/fbs/scrabblefb/invitation.ts b/ui/src/gen/fbs/scrabblefb/invitation.ts index 0634f1e..e114cf4 100644 --- a/ui/src/gen/fbs/scrabblefb/invitation.ts +++ b/ui/src/gen/fbs/scrabblefb/invitation.ts @@ -68,34 +68,39 @@ hintsPerPlayer():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +multipleWordsPerTurn():boolean { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + dropoutTiles():string|null dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null dropoutTiles(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 18); + const offset = this.bb!.__offset(this.bb_pos, 20); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } status():string|null status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null status(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 20); + const offset = this.bb!.__offset(this.bb_pos, 22); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } gameId():string|null gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null gameId(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 22); + const offset = this.bb!.__offset(this.bb_pos, 24); return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } expiresAtUnix():bigint { - const offset = this.bb!.__offset(this.bb_pos, 24); + const offset = this.bb!.__offset(this.bb_pos, 26); return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); } static startInvitation(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -138,20 +143,24 @@ static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) { builder.addFieldInt32(6, hintsPerPlayer, 0); } +static addMultipleWordsPerTurn(builder:flatbuffers.Builder, multipleWordsPerTurn:boolean) { + builder.addFieldInt8(7, +multipleWordsPerTurn, +false); +} + static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) { - builder.addFieldOffset(7, dropoutTilesOffset, 0); + builder.addFieldOffset(8, dropoutTilesOffset, 0); } static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) { - builder.addFieldOffset(8, statusOffset, 0); + builder.addFieldOffset(9, statusOffset, 0); } static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { - builder.addFieldOffset(9, gameIdOffset, 0); + builder.addFieldOffset(10, gameIdOffset, 0); } static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) { - builder.addFieldInt64(10, expiresAtUnix, BigInt('0')); + builder.addFieldInt64(11, expiresAtUnix, BigInt('0')); } static endInvitation(builder:flatbuffers.Builder):flatbuffers.Offset { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 01aa273..ecb6454 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -238,6 +238,7 @@ function decodeGameView(g: fb.GameView): GameView { players: g.players(), toMove: g.toMove(), turnTimeoutSecs: g.turnTimeoutSecs(), + multipleWordsPerTurn: g.multipleWordsPerTurn(), moveCount: g.moveCount(), endReason: s(g.endReason()), lastActivityUnix: Number(g.lastActivityUnix()), @@ -686,6 +687,7 @@ function decodeInvitationTable(i: fb.Invitation): Invitation { turnTimeoutSecs: i.turnTimeoutSecs(), hintsAllowed: i.hintsAllowed(), hintsPerPlayer: i.hintsPerPlayer(), + multipleWordsPerTurn: i.multipleWordsPerTurn(), dropoutTiles: s(i.dropoutTiles()), status: s(i.status()), gameId: s(i.gameId()), @@ -721,6 +723,7 @@ function emptyGame(): GameView { players: 0, toMove: 0, turnTimeoutSecs: 0, + multipleWordsPerTurn: true, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 1d3f74a..5e09f7f 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -12,6 +12,7 @@ function gameView(moveCount: number, over = false): GameView { players: 2, toMove: 1, turnTimeoutSecs: 300, + multipleWordsPerTurn: true, moveCount, endReason: over ? 'standard' : '', lastActivityUnix: 0, diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index ee33a01..1199724 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -69,6 +69,7 @@ export const en = { 'game.dropGame': 'Drop game', 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Not a legal move', + 'game.oneWordRule': 'One word per turn', 'game.chooseBlank': 'Choose a letter for the blank', 'game.exchangeTitle': 'Select tiles to exchange', 'game.exchangeConfirm': 'Exchange {n}', @@ -233,6 +234,7 @@ export const en = { 'new.moveTime': 'Move time', 'new.hintsPerPlayer': 'Hints per player', 'new.multipleWordsPerTurn': 'Multiple words per turn', + 'new.start': 'Start game', 'new.invited': 'Invitation sent.', 'new.noFriends': 'Add friends first to invite them.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 9dd8980..d4d2b2e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -70,6 +70,7 @@ export const ru: Record = { 'game.dropGame': 'Покинуть игру', 'game.previewWords': '{words}: {n}', 'game.previewIllegal': 'Недопустимый ход', + 'game.oneWordRule': 'Одно слово за ход', 'game.chooseBlank': 'Выберите букву для бланка', 'game.exchangeTitle': 'Выберите фишки для обмена', 'game.exchangeConfirm': 'Обменять {n}', @@ -234,6 +235,7 @@ export const ru: Record = { 'new.moveTime': 'Время на ход', 'new.hintsPerPlayer': 'Подсказок на игрока', 'new.multipleWordsPerTurn': 'Несколько слов за ход', + 'new.start': 'Начать игру', 'new.invited': 'Приглашение отправлено.', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index 3446d6d..962ecbb 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -21,6 +21,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi players: 2, toMove, turnTimeoutSecs: 0, + multipleWordsPerTurn: true, moveCount: 0, endReason: '', lastActivityUnix, diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 3688ee5..f8d5900 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -142,7 +142,7 @@ export class MockGateway implements GatewayClient { } // --- lobby --- - async lobbyEnqueue(variant: Variant, _multipleWords: boolean): Promise { + async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise { // Simulate a 10s-style robot substitution, sped up: match found shortly. const id = crypto.randomUUID(); const g: MockGame = { @@ -154,6 +154,7 @@ export class MockGateway implements GatewayClient { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: multipleWords, moveCount: 0, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000), @@ -442,6 +443,7 @@ export class MockGateway implements GatewayClient { turnTimeoutSecs: settings.turnTimeoutSecs, hintsAllowed: settings.hintsAllowed, hintsPerPlayer: settings.hintsPerPlayer, + multipleWordsPerTurn: settings.multipleWordsPerTurn, dropoutTiles: settings.dropoutTiles, status: 'pending', gameId: '', diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 1cbbd99..eb235bc 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -61,6 +61,7 @@ export function mockInvitations(): Invitation[] { turnTimeoutSecs: 86400, hintsAllowed: true, hintsPerPlayer: 1, + multipleWordsPerTurn: true, dropoutTiles: 'remove', status: 'pending', gameId: '', @@ -141,6 +142,7 @@ function activeGame(): MockGame { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: true, moveCount: G1_MOVES.length, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, @@ -175,6 +177,7 @@ function finishedG2(): MockGame { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: true, moveCount: 2, endReason: 'normal', lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, @@ -210,6 +213,7 @@ function finishedG3(): MockGame { players: 2, toMove: 0, turnTimeoutSecs: 86400, + multipleWordsPerTurn: false, moveCount: 1, endReason: 'resignation', lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 42544ec..0d7d79f 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -38,6 +38,8 @@ export interface GameView { players: number; toMove: number; turnTimeoutSecs: number; + /** true = standard Scrabble; false = the single-word rule (Russian games). */ + multipleWordsPerTurn: boolean; moveCount: number; endReason: string; /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ @@ -177,6 +179,8 @@ export interface Invitation { turnTimeoutSecs: number; hintsAllowed: boolean; hintsPerPlayer: number; + /** true = standard Scrabble; false = the single-word rule (Russian games). */ + multipleWordsPerTurn: boolean; dropoutTiles: string; status: string; gameId: string; diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index e9b4a43..87d12f2 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -20,6 +20,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView { players: seats.length, toMove, turnTimeoutSecs: 0, + multipleWordsPerTurn: true, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 112e0ee..e8bca00 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -160,6 +160,7 @@ {t('invitations.from', { name: inv.inviter.displayName })} {t(variantKey[inv.variant] ?? 'new.english')} {/if} + {#if !inv.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} {#if inv.inviter.accountId === myId} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index de103c6..25c3737 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -24,7 +24,12 @@ // "Multiple words per turn" off is the single-word rule; it is offered for Russian games // only (English is always standard and shows no toggle). Shared by both flows. let multipleWords = $state(false); - const autoHasRussian = $derived(variants.some((v) => supportsMultipleWordsToggle(v.id))); + // Auto-match: the variant is a select (highlight, no immediate enqueue) confirmed by the + // Start button. A lone offered variant is pre-selected; with several the player must pick. + let selectedAuto = $state(''); + $effect(() => { + if (variants.length === 1 && !selectedAuto) selectedAuto = variants[0].id; + }); const timeouts = [ { secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, { secs: 1800, key: 'time.minutes' as MessageKey, n: 30 }, @@ -182,15 +187,14 @@ {#if mode === 'auto'}

{t('new.subtitle')}

- {#if autoHasRussian} - - {/if}
{#each variants as v (v.id)} - {/each}
+ {#if selectedAuto && supportsMultipleWordsToggle(selectedAuto)} + + {/if}

{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}

+ {:else if friends.length === 0}

{t('new.noFriends')}

{:else} @@ -310,6 +325,12 @@ font-size: 0.8rem; color: var(--text-muted); } + /* Selected auto-match variant: an accent inset border (the button no longer enqueues on + tap; the Start button confirms the choice). */ + .variant.selected { + border-color: var(--accent); + box-shadow: inset 0 0 0 2px var(--accent); + } .movelimit { margin: 0; text-align: center; -- 2.52.0 From f73f76220d48d1e3b2484d83720b0a155e1eca17 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 10:36:13 +0200 Subject: [PATCH 099/223] test(ui): cover the invitation-card single-word indicator Make the mock invitation a Russian single-word game so the card's "One word per turn" line renders, and assert it in the lobby e2e. --- ui/e2e/social.spec.ts | 1 + ui/src/lib/mock/data.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 6f3d1f4..f94b0e6 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -48,6 +48,7 @@ test('invitations: the lobby shows an invitation and accepting clears it', async await loginLobby(page); await expect(page.getByText('Invitations')).toBeVisible(); await expect(page.getByText(/From Kaya/)).toBeVisible(); + await expect(page.getByText('One word per turn')).toBeVisible(); // the single-word-rule line on the card await page.getByRole('button', { name: /^Accept$/ }).click(); await expect(page.getByText(/From Kaya/)).toBeHidden(); }); diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index eb235bc..602ec13 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -57,11 +57,11 @@ export function mockInvitations(): Invitation[] { id: 'inv1', inviter: { accountId: 'kaya', displayName: 'Kaya' }, invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }], - variant: 'scrabble_en', + variant: 'scrabble_ru', turnTimeoutSecs: 86400, hintsAllowed: true, hintsPerPlayer: 1, - multipleWordsPerTurn: true, + multipleWordsPerTurn: false, dropoutTiles: 'remove', status: 'pending', gameId: '', -- 2.52.0 From 5fa51d04d9091fa178811f8bc9fff23746bf7450 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 11:14:20 +0200 Subject: [PATCH 100/223] fix(engine): EvaluatePlay honors the single-word rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The move preview (EvaluatePlay) validated under standard rules — it called ValidatePlay without the game's play options — so under the single-word rule it rejected a play whose only flaw was incidental invalid perpendicular cross-words, even though SubmitPlay accepts it. The UI gates the submit button on the preview, so such a play (e.g. КРАН bridging an existing Р on the test contour) could not be made. Pass g.playOpts() via ValidatePlayOpts, mirroring Play, so the preview's legality and score match submission. Robots are unaffected — they search via GenerateMovesOpts and submit via Play, both already opts-aware — and a regression test asserts that too. --- backend/internal/engine/domain.go | 12 ++- backend/internal/engine/singleword_test.go | 119 ++++++++++++++++++++- docs/ARCHITECTURE.md | 5 +- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/backend/internal/engine/domain.go b/backend/internal/engine/domain.go index 50c58f2..e2e1939 100644 --- a/backend/internal/engine/domain.go +++ b/backend/internal/engine/domain.go @@ -92,10 +92,12 @@ func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) { // EvaluatePlay scores and validates a tentative play without committing it, // backing the unlimited "what would my next move score, and is it legal?" tool. -// It infers the play's orientation from the tiles and the board exactly as -// SubmitPlay does, then returns the decoded move (placed tiles, the words it -// forms, its orientation and its score) or ErrIllegalPlay when the solver -// rejects it. The board, racks, bag and turn are left untouched. +// It infers the play's orientation from the tiles and the board and applies the +// game's play options exactly as SubmitPlay does, so under the single-word rule +// perpendicular cross-words are ignored: the preview's legality and score then +// match what submitting the play would yield. It returns the decoded move (placed +// tiles, the words it forms, its orientation and its score) or ErrIllegalPlay when +// the solver rejects it. The board, racks, bag and turn are left untouched. func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) { if g.over { return MoveRecord{}, ErrGameOver @@ -104,7 +106,7 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) { if err != nil { return MoveRecord{}, err } - move, err := g.solver.ValidatePlay(g.board, resolveDirection(g.board, placements), placements) + move, err := g.solver.ValidatePlayOpts(g.board, resolveDirection(g.board, placements), placements, g.playOpts()) if err != nil { return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) } diff --git a/backend/internal/engine/singleword_test.go b/backend/internal/engine/singleword_test.go index 0a27555..a75216a 100644 --- a/backend/internal/engine/singleword_test.go +++ b/backend/internal/engine/singleword_test.go @@ -1,6 +1,11 @@ package engine -import "testing" +import ( + "errors" + "testing" + + "gitea.iliadenisov.ru/developer/scrabble-solver/scrabble" +) // TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The // single-word game ignores perpendicular cross-words, so move generation from a shared @@ -46,3 +51,115 @@ func TestSingleWordRuleWiring(t *testing.T) { t.Errorf("single-word generation produced %d moves, want >= standard %d", singleMoves, stdMoves) } } + +// setupSingleWordKran builds an Erudit position that reproduces the test-contour +// bug. It replaces the bag-dealt rack with к/а/н and places the existing Р the play +// bridges plus perpendicular neighbours (г, е, н) so that each of the three new +// tiles of the vertical КРАН forms an *invalid* cross-word (гк, еа, нн). The +// multipleWords argument selects the rule. It returns the game and the decoded КРАН +// placement (the three new tiles К, А, Н around the existing Р). +func setupSingleWordKran(t *testing.T, multipleWords bool) (*Game, []TileRecord) { + t.Helper() + g, err := New(testReg, Options{ + Variant: VariantErudit, + Version: testVersion, + Players: 2, + Seed: 1, + MultipleWordsPerTurn: multipleWords, + }) + if err != nil { + t.Fatalf("new erudit game: %v", err) + } + idx := func(s string) byte { + i, err := g.rules.Alphabet.Index(s) + if err != nil { + t.Fatalf("index %q: %v", s, err) + } + return i + } + scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{ + {Row: 5, Col: 8, Letter: idx("р")}, // the existing tile the play bridges + {Row: 4, Col: 7, Letter: idx("г")}, // left of К(4,8): cross-word "гк" + {Row: 6, Col: 7, Letter: idx("е")}, // left of А(6,8): cross-word "еа" + {Row: 7, Col: 7, Letter: idx("н")}, // left of Н(7,8): cross-word "нн" + }}) + g.hands[0] = []byte{idx("к"), idx("а"), idx("н")} + tiles := []TileRecord{ + {Row: 4, Col: 8, Letter: "к"}, + {Row: 6, Col: 8, Letter: "а"}, + {Row: 7, Col: 8, Letter: "н"}, + } + return g, tiles +} + +// TestEvaluatePlayHonorsSingleWordRule is the regression for the contour bug: under +// the single-word rule the EvaluatePlay preview (the "what would this score, and is +// it legal?" tool) must honour the same rule as SubmitPlay and ignore perpendicular +// cross-words, so a play whose only flaw is invalid cross-words is reported legal and +// scored on its main word alone. Before the fix EvaluatePlay validated under standard +// rules and wrongly rejected it. +func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) { + // The main word must be a real Erudit word, so any rejection can only come from + // the (ignored) cross-words rather than the main word itself. + if ok, err := testReg.Lookup(VariantErudit, testVersion, "кран"); err != nil || !ok { + t.Fatalf("precondition: кран must be in the Erudit dictionary (ok=%v, err=%v)", ok, err) + } + + t.Run("single-word rule accepts the cross-invalid play", func(t *testing.T) { + g, tiles := setupSingleWordKran(t, false) + rec, err := g.EvaluatePlay(tiles) + if err != nil { + t.Fatalf("evaluate under single-word rule: %v", err) + } + if len(rec.Words) != 1 || rec.Words[0] != "кран" { + t.Errorf("words = %v, want [кран] only (cross-words ignored)", rec.Words) + } + if rec.Dir != Vertical { + t.Errorf("dir = %v, want Vertical", rec.Dir) + } + if rec.Score <= 0 { + t.Errorf("score = %d, want positive", rec.Score) + } + }) + + t.Run("standard rules reject the same play", func(t *testing.T) { + g, tiles := setupSingleWordKran(t, true) + if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) { + t.Errorf("evaluate under standard rules = %v, want ErrIllegalPlay", err) + } + }) + + t.Run("evaluate agrees with submit under the single-word rule", func(t *testing.T) { + g, tiles := setupSingleWordKran(t, false) + if _, err := g.SubmitPlay(tiles); err != nil { + t.Errorf("submit under single-word rule: %v", err) + } + }) +} + +// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same +// cross-word check while searching for its move: its move source, Candidates -> +// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged +// КРАН (whose cross-words are invalid) appears among the candidates the robot chooses +// from; under standard rules it is correctly absent. The robot submits its pick through +// SubmitPlay (covered above), so this holds both before and after the EvaluatePlay fix — +// the robot never uses EvaluatePlay. +func TestSingleWordRuleRobotCandidates(t *testing.T) { + hasKran := func(cands []MoveRecord) bool { + for _, c := range cands { + if len(c.Words) > 0 && c.Words[0] == "кран" { + return true + } + } + return false + } + + single, _ := setupSingleWordKran(t, false) + if !hasKran(single.Candidates()) { + t.Error("single-word candidates must include the bridged кран play the robot can pick") + } + std, _ := setupSingleWordKran(t, true) + if hasKran(std.Candidates()) { + t.Error("standard candidates must not include кран (its cross-words are invalid)") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9713a31..4fcccb4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -285,8 +285,9 @@ Key points: - **Multiple words per turn (Russian games).** Russian variants carry a per-game **single-word rule**, chosen on New Game (default **off** = single word; on = standard Scrabble). Off, only the **main word** along the play direction is validated and scored — - perpendicular cross-words are ignored, including in robot move generation; on, every - cross-word must be a real word and is scored. The engine threads it as + perpendicular cross-words are ignored, including in robot move generation and the + unlimited move preview; on, every cross-word must be a real word and is scored. The + engine threads it as `scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.0`); connectivity and the first-move centre rule are unaffected. The "Russian-only" limit is a **UI affordance**: the backend and engine are variant-agnostic about the flag, and English games always send -- 2.52.0 From eeb078d52805e7c4110760f6d2b9f5a5d756f101 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 11:38:26 +0200 Subject: [PATCH 101/223] fix(admin): keep the filter query intact in console pager and export links The paginated users and messages lists interpolate the pre-encoded filter query (url.Values.Encode) after the "?" in their pager and CSV-export links. There html/template treats it as a single query value and percent-encodes the structural "=" and "&" again, so "kind=robots" rendered as "kind%3drobots" and the multi-pair message filter collapsed -- every page step dropped the active filter. Type FilterQuery as template.URL so the already-escaped fragment is emitted verbatim (the attribute-level "&" -> "&" stays correct, the browser decodes it back). It is safe because url.Values.Encode output is strictly percent-encoded. games/complaints use status={{.Status}} -- a single value in proper query-value context -- and were never affected. --- backend/internal/adminconsole/render_test.go | 51 +++++++++++++++++++ backend/internal/adminconsole/views.go | 13 +++-- .../internal/server/handlers_admin_console.go | 5 +- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index eb6ab9c..b266e47 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -2,6 +2,7 @@ package adminconsole import ( "bytes" + "html/template" "io/fs" "strings" "testing" @@ -55,6 +56,56 @@ func TestRendererRendersEveryPage(t *testing.T) { } } +// TestPagerLinksPreserveFilterQuery guards the paginated lists whose links carry a +// pre-encoded filter query (url.Values.Encode) past the page number. The query fragment +// must reach the link verbatim: the contextual escaper would otherwise re-encode its +// structural "=" and "&" (turning "kind=robots" into the broken "kind%3drobots"), dropping +// the active filter on every page step. FilterQuery is typed template.URL to prevent that. +func TestPagerLinksPreserveFilterQuery(t *testing.T) { + r := MustNewRenderer() + + t.Run("users", func(t *testing.T) { + var buf bytes.Buffer + view := UsersView{Robots: true, FilterQuery: template.URL("kind=robots"), Pager: NewPager(2, 50, 200)} + if err := r.Render(&buf, "users", PageData{Title: "Users", Data: view}); err != nil { + t.Fatalf("render users: %v", err) + } + out := buf.String() + for _, want := range []string{ + `href="/_gm/users?kind=robots&page=1"`, + `href="/_gm/users?kind=robots&page=3"`, + } { + if !strings.Contains(out, want) { + t.Errorf("users pager: missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "kind%3drobots") { + t.Error("users pager: filter query was double-encoded (kind%3drobots)") + } + }) + + t.Run("messages", func(t *testing.T) { + var buf bytes.Buffer + view := MessagesView{FilterQuery: template.URL("game=abc&user=def"), Pager: NewPager(2, 50, 200)} + if err := r.Render(&buf, "messages", PageData{Title: "Messages", Data: view}); err != nil { + t.Fatalf("render messages: %v", err) + } + out := buf.String() + for _, want := range []string{ + `href="/_gm/messages.csv?game=abc&user=def"`, + `href="/_gm/messages?game=abc&user=def&page=1"`, + `href="/_gm/messages?game=abc&user=def&page=3"`, + } { + if !strings.Contains(out, want) { + t.Errorf("messages pager: missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "%3d") || strings.Contains(out, "%26") { + t.Error("messages pager: filter query was double-encoded (%3d / %26)") + } + }) +} + // TestRendererUnknownPage reports an error for a page that does not exist. func TestRendererUnknownPage(t *testing.T) { r := MustNewRenderer() diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index d63ca1e..1018557 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -51,11 +51,14 @@ type UsersView struct { Items []UserRow Pager Pager // Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current - // glob filters; FilterQuery is those encoded for pager/toggle links. + // glob filters; FilterQuery is those URL-encoded for the pager links. It is an + // already-escaped query fragment (url.Values.Encode), so it is typed template.URL to + // be emitted verbatim — interpolated as a plain string it would have its "=" and "&" + // percent-encoded again by the contextual escaper. Robots bool NameMask string ExternalIDMask string - FilterQuery string + FilterQuery template.URL } // UserRow is one account row in the list. MoveMin/Avg/Max are the account's @@ -77,7 +80,9 @@ type UserRow struct { // MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the // current sender glob filters; GameID/UserID pin the list to one game / sender (set from a -// game or user card); FilterQuery is the active filters encoded for the pager links. +// game or user card); FilterQuery is the active filters URL-encoded for the pager and CSV +// links — an already-escaped query fragment, hence template.URL so it is not re-encoded +// inside the link (see UsersView.FilterQuery). type MessagesView struct { Items []MessageRow Pager Pager @@ -85,7 +90,7 @@ type MessagesView struct { ExtMask string GameID string UserID string - FilterQuery string + FilterQuery template.URL } // MessageRow is one chat message in the moderation list: its sender (linked to the user diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index a75e4c7..e98c3f1 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/csv" "fmt" + "html/template" "net/http" "net/url" "path/filepath" @@ -108,7 +109,7 @@ func (s *Server) consoleUsers(c *gin.Context) { view := adminconsole.UsersView{ Pager: adminconsole.NewPager(page, adminPageSize, total), Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask, - FilterQuery: q.Encode(), + FilterQuery: template.URL(q.Encode()), } ids := make([]uuid.UUID, 0, len(items)) for _, it := range items { @@ -174,7 +175,7 @@ func (s *Server) consoleMessages(c *gin.Context) { Pager: adminconsole.NewPager(page, adminPageSize, total), NameMask: filter.NameMask, ExtMask: filter.ExtMask, - FilterQuery: q.Encode(), + FilterQuery: template.URL(q.Encode()), } if filter.GameID != uuid.Nil { view.GameID = filter.GameID.String() -- 2.52.0 From 359758a01a5ece4baa24204a9966771072097d35 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 12:31:39 +0200 Subject: [PATCH 102/223] feat(ui): tint last-word letters for the recent highlight; lift dark bonus contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dark theme: the 2x/3x bonus-square pairs were too close to tell apart. Soften the 2x squares (sky blue #4a779b, rose #a8636b) and deepen the 3x squares (#2c527a, #9c3f34) so each pair reads as two distinct steps. Light theme is unchanged. Last-word highlight (both themes): stop tinting the tile background — the tile keeps its normal fill, and instead the placed letters (not the point values) are drawn in the recent-move colour. The opponent-just-moved flash now pulses the letter between its normal colour and the recent colour, with no background animation and no white peak. Reconcile the explicit [data-theme=dark] --tile-recent with the OS-dark value so the highlight reads the same however dark is selected, and darken --tile-recent a step in every theme. Update docs/UI_DESIGN.md. --- docs/UI_DESIGN.md | 8 +++++--- ui/src/app.css | 24 +++++++++++++----------- ui/src/game/Board.svelte | 27 ++++++++++++++------------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 435c525..a4ddd69 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -123,9 +123,11 @@ Login uses `Screen`. 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 - the pending tiles are highlighted. + last completed word keeps the normal tile background; instead its letters — not the point + values — are drawn in the recent-move colour, in both themes. It is static while it is the + opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour + and the recent-move colour) when it is our turn (their word). While placing, only the + pending tiles are highlighted. - **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none` nothing. Default **beginner**. diff --git a/ui/src/app.css b/ui/src/app.css index de3930f..f92322f 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -29,7 +29,7 @@ --tile-edge: #d8c190; --tile-text: #2a2113; --tile-pending: #f2cf73; - --tile-recent: #c8a85c; + --tile-recent: #a8884a; --prem-tw: #e06a5b; /* triple word */ --prem-dw: #efa6a0; /* double word + centre */ --prem-tl: #4f8fd6; /* triple letter */ @@ -75,11 +75,11 @@ --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #d8b75e; - --tile-recent: #7a6638; - --prem-tw: #b1493d; - --prem-dw: #8c5450; - --prem-tl: #34608f; - --prem-dl: #3b5a72; + --tile-recent: #6c5a30; + --prem-tw: #9c3f34; /* 3x word: a touch darker red */ + --prem-dw: #a8636b; /* 2x word: softer, pinker */ + --prem-tl: #2c527a; /* 3x letter: a touch darker blue */ + --prem-dl: #4a779b; /* 2x letter: softer, sky blue */ --prem-text: #e7eaf0; } } @@ -106,11 +106,13 @@ --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #f0d98f; - --tile-recent: #4a4636; - --prem-tw: #b1493d; - --prem-dw: #8c5450; - --prem-tl: #34608f; - --prem-dl: #3b5a72; + /* Last-word highlight letter colour; matches the OS-dark value so the highlight reads the + same whether dark is chosen in Settings or via prefers-color-scheme. */ + --tile-recent: #6c5a30; + --prem-tw: #9c3f34; /* 3x word: a touch darker red */ + --prem-dw: #a8636b; /* 2x word: softer, pinker */ + --prem-tl: #2c527a; /* 3x letter: a touch darker blue */ + --prem-dl: #4a779b; /* 2x letter: softer, sky blue */ --prem-text: #e7eaf0; } diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index ffb2e89..9c33563 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -314,25 +314,26 @@ box-shadow: inset 0 0 0 2px var(--accent); background: color-mix(in srgb, var(--accent) 18%, var(--cell-bg)); } - .cell.hl { - background: var(--tile-recent); - /* The bottom edge goes darker than the highlighted fill (not lighter, as the plain - --tile-edge would), so the tile still reads as raised. */ - box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32); + /* Last-word highlight: the tile keeps its normal fill (same as every other placed tile); + instead the letter glyph — not the point value — is drawn in the recent-move colour, in + both themes. */ + .cell.hl .letter, + .cell.flash .letter { + color: var(--tile-recent); } - .cell.flash { - /* Two flashes to draw the eye, then settle back to normal so it does not distract. */ - animation: tileflash 1s ease-in-out 2; + .cell.flash .letter { + /* When the opponent just moved and it is now our turn, the highlighted letter pulses + twice between its normal colour and the recent-move colour to draw the eye, then + settles on the recent colour (matching .hl). The tile background never animates. */ + animation: letterflash 1s ease-in-out 2; } - @keyframes tileflash { + @keyframes letterflash { 0%, 100% { - background: var(--tile-bg); - box-shadow: inset 0 -2px 0 var(--tile-edge); + color: var(--tile-recent); } 50% { - background: var(--tile-recent); - box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32); + color: var(--tile-text); } } /* cqw fonts are sized against the fixed viewport, so labels stay a constant size as -- 2.52.0 From 107add13b61d4c034c900250a90088b54eb21326 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 12:59:39 +0200 Subject: [PATCH 103/223] feat(ui): use a warm burgundy for the last-word highlight in both themes The gold/brown recent colour shared the tile's warm hue, so it could not separate from both the near-black glyph (when dark) and the tan tile (when light) at once. Switch --tile-recent to a burgundy #8c4a3c whose red hue stays distinct from both, in light and dark, and unify the value across all three theme blocks. --- ui/src/app.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/app.css b/ui/src/app.css index f92322f..5e70404 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -29,7 +29,7 @@ --tile-edge: #d8c190; --tile-text: #2a2113; --tile-pending: #f2cf73; - --tile-recent: #a8884a; + --tile-recent: #8c4a3c; --prem-tw: #e06a5b; /* triple word */ --prem-dw: #efa6a0; /* double word + centre */ --prem-tl: #4f8fd6; /* triple letter */ @@ -75,7 +75,7 @@ --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #d8b75e; - --tile-recent: #6c5a30; + --tile-recent: #8c4a3c; --prem-tw: #9c3f34; /* 3x word: a touch darker red */ --prem-dw: #a8636b; /* 2x word: softer, pinker */ --prem-tl: #2c527a; /* 3x letter: a touch darker blue */ @@ -106,9 +106,9 @@ --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #f0d98f; - /* Last-word highlight letter colour; matches the OS-dark value so the highlight reads the - same whether dark is chosen in Settings or via prefers-color-scheme. */ - --tile-recent: #6c5a30; + /* Last-word highlight letter — a warm burgundy accent shared across themes; its red hue + stays distinct from both the near-black glyph and the warm tile, in light and dark. */ + --tile-recent: #8c4a3c; --prem-tw: #9c3f34; /* 3x word: a touch darker red */ --prem-dw: #a8636b; /* 2x word: softer, pinker */ --prem-tl: #2c527a; /* 3x letter: a touch darker blue */ -- 2.52.0 From cb75623677207c4118c1d02e5b04b3717aa4fa66 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 13:07:47 +0200 Subject: [PATCH 104/223] feat(ui): lighten the light-theme last-word highlight to a brighter burgundy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At #8c4a3c the highlight blended into the lighter board in the light theme. Give the light theme a lighter burgundy #9c5849 while the dark theme keeps #8c4a3c — the two are tuned per theme because perceived contrast depends on the surrounding board tone. --- ui/src/app.css | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/src/app.css b/ui/src/app.css index 5e70404..9729f05 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -29,7 +29,9 @@ --tile-edge: #d8c190; --tile-text: #2a2113; --tile-pending: #f2cf73; - --tile-recent: #8c4a3c; + /* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against + the lighter tile the perceived contrast needs it, so the two are tuned per theme. */ + --tile-recent: #9c5849; --prem-tw: #e06a5b; /* triple word */ --prem-dw: #efa6a0; /* double word + centre */ --prem-tl: #4f8fd6; /* triple letter */ @@ -106,8 +108,9 @@ --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #f0d98f; - /* Last-word highlight letter — a warm burgundy accent shared across themes; its red hue - stays distinct from both the near-black glyph and the warm tile, in light and dark. */ + /* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the + near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per + theme; perceived contrast depends on the surrounding board). */ --tile-recent: #8c4a3c; --prem-tw: #9c3f34; /* 3x word: a touch darker red */ --prem-dw: #a8636b; /* 2x word: softer, pinker */ -- 2.52.0 From c305363ccd5607026cdb847a28bbd53ce5d3c781 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 12 Jun 2026 16:00:22 +0200 Subject: [PATCH 105/223] feat(lobby): enter the game immediately and wait for the opponent inside it Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place. Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent". Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated. --- PRERELEASE.md | 9 + backend/README.md | 20 +- backend/cmd/backend/main.go | 6 +- backend/internal/config/config.go | 3 + backend/internal/game/eventwire.go | 10 +- backend/internal/game/service.go | 112 ++++- backend/internal/game/store.go | 211 ++++++++- backend/internal/game/types.go | 19 +- backend/internal/inttest/helpers.go | 18 +- backend/internal/inttest/lobby_test.go | 50 ++- backend/internal/inttest/open_match_test.go | 254 +++++++++++ backend/internal/inttest/robot_test.go | 23 +- backend/internal/lobby/config.go | 29 +- backend/internal/lobby/lobby.go | 19 +- backend/internal/lobby/matchmaker.go | 312 +++++--------- backend/internal/lobby/matchmaker_test.go | 402 ++++++------------ backend/internal/notify/events.go | 16 + backend/internal/notify/notify.go | 5 + .../jet/backend/model/game_players.go | 2 +- .../postgres/jet/backend/model/games.go | 1 + .../postgres/jet/backend/table/games.go | 7 +- .../postgres/migrations/00001_baseline.sql | 20 +- backend/internal/server/dto.go | 20 +- backend/internal/server/handlers.go | 6 +- backend/internal/server/handlers_user.go | 39 +- docs/ARCHITECTURE.md | 63 +-- docs/FUNCTIONAL.md | 17 +- docs/FUNCTIONAL_ru.md | 20 +- docs/UI_DESIGN.md | 7 +- ui/e2e/quickmatch.spec.ts | 31 ++ ui/src/game/Chat.svelte | 8 +- ui/src/game/ChatScreen.svelte | 8 +- ui/src/game/Game.svelte | 40 +- ui/src/lib/codec.test.ts | 14 + ui/src/lib/codec.ts | 6 + ui/src/lib/gateway.ts | 5 + ui/src/lib/i18n/en.ts | 3 + ui/src/lib/i18n/ru.ts | 3 + ui/src/lib/mock/client.ts | 43 +- ui/src/lib/model.ts | 10 +- ui/src/screens/Lobby.svelte | 2 + ui/src/screens/NewGame.svelte | 123 +----- 42 files changed, 1248 insertions(+), 768 deletions(-) create mode 100644 backend/internal/inttest/open_match_test.go create mode 100644 ui/e2e/quickmatch.spec.ts diff --git a/PRERELEASE.md b/PRERELEASE.md index 2617e5c..a18868f 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -26,6 +26,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | R7 | Final stress run + tuning | 9b | **done** | | UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** | +| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) @@ -69,6 +70,14 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l - **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** — a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold, **no auto-ban**. +- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside + it** (status `open`, the opponent seat empty); a second human searching the same variant+rule + joins it, or a robot fills it after a **90 s + random 0–90 s** wait, pushing the in-app + **opponent_joined** event. While open, the starter may move on their turn but resign, chat and + nudge are disabled, and the lobby + opponent card read "searching for opponent". Matchmaking is + now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The + schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the + empty seat. ## Phases diff --git a/backend/README.md b/backend/README.md index a02b99c..ef038c2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -28,9 +28,10 @@ export, per-account statistics on finish, and a background turn-timeout sweeper that auto-resigns overdue turns (honouring each player's daily away window). Like the engine it is a service/store layer; the HTTP surface lives in the `gateway`. -The lobby and social fabric. `internal/lobby` holds an in-memory -matchmaking pool (FIFO per variant and per-turn word rule, pairs two humans into an -auto-match) and +The lobby and social fabric. `internal/lobby` runs **auto-match** — `Enqueue` opens a +real game seating the caller with an **empty opponent seat** (status `open`) or, when +another player already waits for the same variant and per-turn word rule, seats the +caller into that open game and starts it — and friend-game invitations (invite → accept, starting a 2–4 player game once every invitee accepts). `internal/social` owns the friend graph (request/accept), per-user blocks, and per-game chat with nudges folded in as a message kind; chat @@ -52,16 +53,17 @@ robot's moves through the public game API as an ordinary seated player (so only win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra -state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and -exposes `Poll` so a waiting player can collect the started game — it is the **stream-down -fallback**: while the client is streaming, the live match-found push (enriched with the recipient's -initial game state) drives it instead. +state. A background **reaper** seats a pooled robot (matching the game's language) in any open +game whose wait window — a fixed **90 s** plus a random **0–90 s** (so **90–180 s**) — has +elapsed, and the waiting starter is told an opponent took the seat by an in-app +**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and +re-enables resign and chat in place. The backend opens to the edge. The route groups gain their first handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under `/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a slice of authenticated `/api/v1/user` operations (profile, submit play, game -state, lobby enqueue/poll, chat). The social/account/history operations under +state, lobby enqueue, chat). The social/account/history operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` (create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`, @@ -126,7 +128,7 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter -internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations +internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 63b0583..5d559d6 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -170,12 +170,14 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { go robots.Run(ctx, cfg.Robot.DriveInterval) logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval)) - matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger) + matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger) matchmaker.SetNotifier(hub) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) invitations.SetNotifier(hub) - logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) + logger.Info("lobby and social domains ready", + zap.Duration("robot_wait", cfg.Lobby.RobotWait), + zap.Duration("robot_wait_jitter", cfg.Lobby.RobotWaitJitter)) // Rate-limit observability: ingest the gateway's rejection reports for the // admin throttled view and the conservative high-rate auto-flag. diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 10151e0..d7151d6 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -100,6 +100,9 @@ func Load() (Config, error) { if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil { return Config{}, err } + if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil { + return Config{}, err + } if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil { return Config{}, err } diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index dd266bf..9a8106e 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -1,6 +1,8 @@ package game import ( + "github.com/google/uuid" + "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" ) @@ -21,9 +23,15 @@ func gameSummary(g Game, names []string) notify.GameSummary { if s.Seat >= 0 && s.Seat < len(names) { name = names[s.Seat] } + // An open game's still-empty opponent seat carries no account: send an empty id + // (not the nil-UUID string) so the client renders it as "searching for opponent". + accountID := "" + if s.AccountID != uuid.Nil { + accountID = s.AccountID.String() + } seats = append(seats, notify.SeatStanding{ Seat: s.Seat, - AccountID: s.AccountID.String(), + AccountID: accountID, DisplayName: name, Score: s.Score, HintsUsed: s.HintsUsed, diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index fc726c8..971822f 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -145,6 +145,96 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro return svc.store.GetGame(ctx, id) } +// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in +// params and returns the game they land in immediately: another waiting player's open +// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open +// game seating only the caller with an empty opponent seat that a human or the reaper's +// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly +// opened game (ignored when joining one). The bag seed defaults to random; params.Seed +// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1 +// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the +// caller just waits for the opponent. It backs the lobby auto-match enqueue. +func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) { + if _, err := svc.accounts.GetByID(ctx, accountID); err != nil { + if errors.Is(err, account.ErrNotFound) { + return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID) + } + return Game{}, false, err + } + timeout := params.TurnTimeout + if timeout == 0 { + timeout = DefaultTurnTimeout + } + if !allowedTimeout(timeout) { + return Game{}, false, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout) + } + id, err := uuid.NewV7() + if err != nil { + return Game{}, false, fmt.Errorf("game: new id: %w", err) + } + seed := params.Seed + if seed == 0 { + seed = svc.rng() + } + deadline := openDeadline + ins := gameInsert{ + id: id, + variant: params.Variant.String(), + dictVersion: svc.version, + seed: seed, + players: 2, + turnTimeoutSecs: int(timeout / time.Second), + hintsAllowed: params.HintsAllowed, + hintsPerPlayer: params.HintsPerPlayer, + dropoutTiles: params.DropoutTiles.String(), + multipleWordsPerTurn: params.MultipleWordsPerTurn, + status: StatusOpen, + openDeadline: &deadline, + } + // Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat + // is left empty (uuid.Nil) for the opponent. + seats := []uuid.UUID{accountID, uuid.Nil} + if seed&1 == 1 { + seats = []uuid.UUID{uuid.Nil, accountID} + } + gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats) + if err != nil { + return Game{}, false, err + } + if created { + svc.metrics.recordStarted(ctx, params.Variant) + } + g, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return Game{}, false, err + } + return g, joined, nil +} + +// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips +// it to active, returning the now-active game and whether it attached (false, with a +// zero Game, when a human joined first). It backs the matchmaking reaper. +func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) { + attached, err := svc.store.AttachRobot(ctx, gameID, robotID) + if err != nil { + return Game{}, false, err + } + if !attached { + return Game{}, false, nil + } + g, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return Game{}, false, err + } + return g, true, nil +} + +// ExpiredOpen returns the open games due for a robot substitution (deadline at or +// before now) for the matchmaking reaper. +func (svc *Service) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) { + return svc.store.ExpiredOpen(ctx, now) +} + // engineOp applies one transition to the live game, returning the decoded record // and, for an exchange, the swapped tiles. type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error) @@ -189,6 +279,11 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo if !ok { return MoveResult{}, ErrNotAPlayer } + // Resign needs a present opponent to award the win, so it is refused while the game + // is still waiting for one; the UI keeps the button disabled until then. + if pre.Status == StatusOpen { + return MoveResult{}, ErrNoOpponentYet + } if pre.Status != StatusActive { return MoveResult{}, ErrFinished } @@ -260,7 +355,10 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, if !ok { return MoveResult{}, ErrNotAPlayer } - if pre.Status != StatusActive { + // A move is allowed while the game is active or still open (the starter may move on + // their turn before an opponent joins); only a finished game rejects it. The turn + // check below keeps the starter off the still-empty opponent seat. + if pre.Status == StatusFinished { return MoveResult{}, ErrFinished } if pre.ToMove != seat { @@ -382,6 +480,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco summary := gameSummary(post, names) intents := make([]notify.Intent, 0, 2*len(post.Seats)) for _, s := range post.Seats { + if s.AccountID == uuid.Nil { + continue // an open game's opponent seat is not yet filled — nobody to notify + } intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen)) } // Game pushes are routed out-of-app by the game's own language, not the recipient's @@ -406,6 +507,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco // seat, each with their own perspective + recipient-first score, so an offline player gets // an out-of-app "game over" push (online players take it from the in-app refresh). for _, s := range post.Seats { + if s.AccountID == uuid.Nil { + continue + } over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary) over.Language = lang intents = append(intents, over) @@ -540,7 +644,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI if _, ok := pre.seatOf(accountID); !ok { return EvalResult{}, ErrNotAPlayer } - if pre.Status != StatusActive { + if pre.Status == StatusFinished { return EvalResult{}, ErrFinished } @@ -674,7 +778,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint if !ok { return HintResult{}, ErrNotAPlayer } - if pre.Status != StatusActive { + if pre.Status == StatusFinished { return HintResult{}, ErrFinished } if pre.ToMove != seat { @@ -736,7 +840,7 @@ func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID) if !ok { return nil, ErrNotAPlayer } - if pre.Status != StatusActive { + if pre.Status == StatusFinished { return nil, ErrFinished } if pre.ToMove != seat { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index a1a9d44..1ed55ef 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "hash/fnv" "time" "github.com/go-jet/jet/v2/postgres" @@ -40,6 +41,13 @@ type gameInsert struct { dropoutTiles string // multipleWordsPerTurn false selects the single-word rule for the game. multipleWordsPerTurn bool + // status is the lifecycle state to create the game in: StatusActive for a normal + // seated game, StatusOpen for an auto-match game still awaiting an opponent. An + // empty string defaults to StatusActive. + status string + // openDeadline, set only for a StatusOpen game, is when the matchmaking reaper + // substitutes a robot if no human has joined; nil for a normal game. + openDeadline *time.Time } // statDelta is one account's contribution to its statistics on a game finish. @@ -91,24 +99,186 @@ type activeGame struct { // first) inside a single transaction. func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error { return withTx(ctx, s.db, func(tx *sql.Tx) error { - gi := table.Games.INSERT( - table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, - table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer, - table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, - ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn) - if _, err := gi.ExecContext(ctx, tx); err != nil { - return fmt.Errorf("insert game: %w", err) + return insertGameTx(ctx, tx, ins, seats) + }) +} + +// insertGameTx inserts the games row and one game_players row per seat (seat 0 +// first) on tx. A seat whose account id is uuid.Nil is written with a NULL +// account_id — the still-empty opponent seat of a StatusOpen auto-match game. +func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.UUID) error { + status := ins.status + if status == "" { + status = StatusActive + } + var deadline any = postgres.NULL + if ins.openDeadline != nil { + deadline = postgres.TimestampzT(*ins.openDeadline) + } + gi := table.Games.INSERT( + table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, + table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs, + table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, + table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, + ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players, + ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn) + if _, err := gi.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("insert game: %w", err) + } + for seat, accountID := range seats { + var acc any = accountID + if accountID == uuid.Nil { + acc = postgres.NULL } - for seat, accountID := range seats { - pi := table.GamePlayers.INSERT( - table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, - ).VALUES(ins.id, seat, accountID) - if _, err := pi.ExecContext(ctx, tx); err != nil { - return fmt.Errorf("insert seat %d: %w", seat, err) + pi := table.GamePlayers.INSERT( + table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID, + ).VALUES(ins.id, seat, acc) + if _, err := pi.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("insert seat %d: %w", seat, err) + } + } + return nil +} + +// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the +// advisory-lock key that serialises concurrent enqueues for that bucket, so two +// players never both open a game instead of pairing. +func openMatchKey(variant string, multipleWords bool) int64 { + h := fnv.New64a() + _, _ = h.Write([]byte(variant)) + if multipleWords { + _, _ = h.Write([]byte{1}) + } else { + _, _ = h.Write([]byte{0}) + } + return int64(h.Sum64()) +} + +// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it +// lands in: it re-uses the caller's own still-open game (joined=false, created=false, +// a re-enqueue is idempotent), joins another player's waiting open game and flips it +// active (joined=true), or opens a fresh game seating the caller with an empty +// opponent seat (created=true). ins supplies the new game's immutable fields and is +// used only when a game is created. A transaction-scoped advisory lock on the +// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather +// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil +// for the still-empty opponent, in the chosen order) used only when a game is created. +func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameInsert, seats []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) { + err = withTx(ctx, s.db, func(tx *sql.Tx) error { + if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, + openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil { + return fmt.Errorf("open match lock: %w", e) + } + // 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent. + var own uuid.UUID + switch e := tx.QueryRowContext(ctx, + `SELECT g.game_id FROM backend.games g + JOIN backend.game_players p ON p.game_id = g.game_id + WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3 + LIMIT 1`, + ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); { + case e == nil: + gameID = own + return nil + case !errors.Is(e, sql.ErrNoRows): + return fmt.Errorf("find own open game: %w", e) + } + // 2. Another player's open game waiting for an opponent — fill its seat and start it. + var other uuid.UUID + switch e := tx.QueryRowContext(ctx, + `SELECT g.game_id FROM backend.games g + WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 + AND NOT EXISTS (SELECT 1 FROM backend.game_players p + WHERE p.game_id = g.game_id AND p.account_id = $3) + ORDER BY g.created_at + LIMIT 1 FOR UPDATE SKIP LOCKED`, + ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); { + case e == nil: + if er := fillOpenSeat(ctx, tx, other, accountID); er != nil { + return er } + gameID, joined = other, true + return nil + case !errors.Is(e, sql.ErrNoRows): + return fmt.Errorf("find open game: %w", e) } + // 3. None waiting — open a fresh game seating the caller (the other seat empty). + if e := insertGameTx(ctx, tx, ins, seats); e != nil { + return e + } + gameID, created = ins.id, true return nil }) + return gameID, joined, created, err +} + +// AttachRobot fills the empty opponent seat of open game gameID with robotID and +// flips it to active, returning whether it attached. It is a no-op (false) when the +// game is no longer open — a human joined first — so the reaper never double-fills. +func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (bool, error) { + attached := false + err := withTx(ctx, s.db, func(tx *sql.Tx) error { + var status string + switch e := tx.QueryRowContext(ctx, + `SELECT status FROM backend.games WHERE game_id = $1 FOR UPDATE`, gameID).Scan(&status); { + case errors.Is(e, sql.ErrNoRows): + return nil + case e != nil: + return fmt.Errorf("lock game for robot: %w", e) + } + if status != StatusOpen { + return nil + } + if e := fillOpenSeat(ctx, tx, gameID, robotID); e != nil { + return e + } + attached = true + return nil + }) + return attached, err +} + +// fillOpenSeat seats accountID in an open game's empty opponent seat and flips the +// game to active, stamping a fresh turn clock. The caller holds the game row. +func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID) error { + if _, err := tx.ExecContext(ctx, + `UPDATE backend.game_players SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`, + gameID, accountID); err != nil { + return fmt.Errorf("fill opponent seat: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now() + WHERE game_id = $1`, gameID); err != nil { + return fmt.Errorf("activate game: %w", err) + } + return nil +} + +// ExpiredOpen returns the open games whose robot deadline has passed (at or before +// now), oldest deadline first, for the matchmaking reaper to fill with a robot. +func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT game_id, variant FROM backend.games + WHERE status = 'open' AND open_deadline_at IS NOT NULL AND open_deadline_at <= $1 + ORDER BY open_deadline_at`, now) + if err != nil { + return nil, fmt.Errorf("game: expired open: %w", err) + } + defer rows.Close() + var out []OpenGame + for rows.Next() { + var id uuid.UUID + var variantStr string + if err := rows.Scan(&id, &variantStr); err != nil { + return nil, fmt.Errorf("game: scan expired open: %w", err) + } + variant, err := engine.ParseVariant(variantStr) + if err != nil { + return nil, fmt.Errorf("game: expired open %s: %w", id, err) + } + out = append(out, OpenGame{ID: id, Variant: variant}) + } + return out, rows.Err() } // GetGame loads the games row joined with its seats (ordered by seat), or @@ -670,9 +840,14 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e } out := make([]RobotTurn, 0, len(rows)) for _, r := range rows { + // The filter matches only the robot's (non-null) seat, so AccountID is set. + robotID := uuid.Nil + if r.GamePlayers.AccountID != nil { + robotID = *r.GamePlayers.AccountID + } out = append(out, RobotTurn{ GameID: r.Games.GameID, - RobotID: r.GamePlayers.AccountID, + RobotID: robotID, RobotSeat: int(r.GamePlayers.Seat), ToMove: int(r.Games.ToMove), TurnStartedAt: r.Games.TurnStartedAt, @@ -773,9 +948,15 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { } out.Seats = make([]Seat, 0, len(seats)) for _, p := range seats { + // A NULL account_id is the still-empty opponent seat of an open game; surface it + // as uuid.Nil so callers keep the "seats == players" invariant. + accountID := uuid.Nil + if p.AccountID != nil { + accountID = *p.AccountID + } out.Seats = append(out.Seats, Seat{ Seat: int(p.Seat), - AccountID: p.AccountID, + AccountID: accountID, Score: int(p.Score), HintsUsed: int(p.HintsUsed), IsWinner: p.IsWinner, diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 5b7684f..d5fe2f5 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -13,11 +13,16 @@ import ( const ( StatusActive = "active" StatusFinished = "finished" + // StatusOpen is an auto-match game whose starter has already entered it but + // which is still waiting for an opponent: the opponent seat holds no account + // yet. The starter may move on their turn; it becomes StatusActive when a human + // or a robot joins (see OpenGame, Service.OpenOrJoin and Service.AttachRobot). + StatusOpen = "open" ) // Complaint lifecycle values. A complaint is filed StatusComplaintOpen // and closed StatusComplaintResolved by the admin review queue with a -// Disposition. The CHECK constraints live in migration 00008. +// Disposition. The CHECK constraints live in the baseline migration. const ( StatusComplaintOpen = "open" StatusComplaintResolved = "resolved" @@ -43,6 +48,10 @@ var ( ErrNotYourTurn = errors.New("game: not the player's turn") // ErrFinished is returned when a transition is attempted on a finished game. ErrFinished = errors.New("game: game is finished") + // ErrNoOpponentYet is returned when an action that needs a present opponent + // (resign, chat, nudge) is attempted on an auto-match game still waiting for one + // (StatusOpen). + ErrNoOpponentYet = errors.New("game: no opponent has joined yet") // ErrGameActive is returned when an operation allowed only on a finished game // (such as a GCG export) is attempted while the game is still active. ErrGameActive = errors.New("game: game is still active") @@ -117,6 +126,14 @@ type Seat struct { IsWinner bool } +// OpenGame identifies an auto-match game waiting for an opponent whose robot +// deadline has passed: the game the matchmaking reaper fills and the variant that +// selects the substitute robot. +type OpenGame struct { + ID uuid.UUID + Variant engine.Variant +} + // seatOf returns the seat index of accountID and true, or (0, false) when the // account is not seated. func (g Game) seatOf(accountID uuid.UUID) (int, bool) { diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go index fc94a8f..d574dab 100644 --- a/backend/internal/inttest/helpers.go +++ b/backend/internal/inttest/helpers.go @@ -56,11 +56,21 @@ func newRobotService(t *testing.T, games *game.Service) *robot.Service { return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop()) } -// newMatchmaker builds a matchmaker starting real games and substituting from -// robots after wait. -func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker { +// newMatchmaker builds a matchmaker opening real games and substituting from robots +// after minWait plus a random jitter in [0, jitter). +func newMatchmaker(t *testing.T, robots lobby.RobotProvider, minWait, jitter time.Duration) *lobby.Matchmaker { t.Helper() - return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop()) + return lobby.NewMatchmaker(newGameService(), robots, minWait, jitter, zap.NewNop()) +} + +// clearOpenGames deletes every open (awaiting-opponent) game so a matchmaking test +// starts from a clean slate: the shared test database is FIFO-joined across tests, so a +// leftover open game would otherwise be joined (or opened-into) instead of a fresh one. +func clearOpenGames(t *testing.T) { + t.Helper() + if _, err := testDB.ExecContext(context.Background(), `DELETE FROM backend.games WHERE status = 'open'`); err != nil { + t.Fatalf("clear open games: %v", err) + } } // provisionAccount creates a fresh durable account and returns its id. diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index 6025cf2..859be04 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -30,31 +30,67 @@ func englishInvite() lobby.InvitationSettings { } } -func TestMatchmakingPairsAndStartsGame(t *testing.T) { +func TestMatchmakingOpensThenJoins(t *testing.T) { ctx := context.Background() - mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second) + clearOpenGames(t) + mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second) a, b := provisionAccount(t), provisionAccount(t) + // The first player opens a game and enters it immediately, still awaiting an opponent. r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue a: %v", err) } if r1.Matched { - t.Fatal("first enqueue must wait") + t.Fatal("first enqueue must open a game awaiting an opponent, not match") } + if _, _, status, err := newGameService().Participants(ctx, r1.Game.ID); err != nil || status != "open" { + t.Fatalf("opened game status = %q err %v, want open", status, err) + } + + // A second player for the same variant and rule joins that open game, which starts. r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue b: %v", err) } - if !r2.Matched { - t.Fatal("second enqueue must match") + if !r2.Matched || r2.Game.ID != r1.Game.ID { + t.Fatalf("second enqueue = (matched %v, game %s), want it to join the open game %s", r2.Matched, r2.Game.ID, r1.Game.ID) } seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID) if err != nil { t.Fatalf("participants: %v", err) } - if status != "active" || len(seats) != 2 { - t.Fatalf("matched game state: status %q seats %v", status, seats) + has := func(id uuid.UUID) bool { + for _, s := range seats { + if s == id { + return true + } + } + return false + } + if status != "active" || len(seats) != 2 || !has(a) || !has(b) { + t.Fatalf("joined game: status %q seats %v (want active with a=%s and b=%s)", status, seats, a, b) + } +} + +// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the +// caller gets their existing open game rather than a second one. +func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second) + a := provisionAccount(t) + + r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("re-enqueue: %v", err) + } + if r2.Game.ID != r1.Game.ID || r2.Matched { + t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID) } } diff --git a/backend/internal/inttest/open_match_test.go b/backend/internal/inttest/open_match_test.go new file mode 100644 index 0000000..4f7f272 --- /dev/null +++ b/backend/internal/inttest/open_match_test.go @@ -0,0 +1,254 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/social" +) + +// The open-game suite covers an auto-match game that a player enters immediately and +// waits inside (status 'open', the opponent seat empty) until a human or a robot joins. + +// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which +// moves first) whose fresh two-player English opening rack has a legal move. +func evenOpeningSeed(t *testing.T) int64 { + t.Helper() + for seed := int64(2); seed <= 400; seed += 2 { + g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed}) + if err != nil { + t.Fatalf("engine new: %v", err) + } + if _, ok := g.HintView(); ok { + return seed + } + } + t.Fatal("no even opening seed found") + return 0 +} + +// openParams are the casual auto-match settings with a pinned seed. +func openParams(seed int64) game.CreateParams { + return game.CreateParams{ + Variant: engine.VariantEnglish, + TurnTimeout: 24 * time.Hour, + HintsAllowed: true, + HintsPerPlayer: 1, + MultipleWordsPerTurn: true, + Seed: seed, + } +} + +func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game { + t.Helper() + g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute)) + if err != nil { + t.Fatalf("open game: %v", err) + } + if joined || g.Status != game.StatusOpen { + t.Fatalf("opened game = (joined %v, status %q), want (false, open)", joined, g.Status) + } + return g +} + +// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn +// while the game is open, after which it is the empty opponent seat's turn and the +// starter just waits. +func TestOpenGameStarterMovesThenWaits(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + seed := evenOpeningSeed(t) + g := openGame(t, svc, provisionAccount(t), seed) + starter := g.Seats[0].AccountID + + hint, ok := newMirror(t, seed, 2).HintView() + if !ok || len(hint.Tiles) == 0 { + t.Fatal("no opening move for the seed") + } + res, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles) + if err != nil { + t.Fatalf("starter play while open: %v", err) + } + if res.Game.Status != game.StatusOpen { + t.Errorf("after the starter's move the game must stay open, got %q", res.Game.Status) + } + if res.Game.ToMove != 1 { + t.Errorf("after the starter's move it must be the empty seat's turn (to_move 1), got %d", res.Game.ToMove) + } + if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) { + t.Fatalf("starter acting on the opponent's turn = %v, want ErrNotYourTurn", err) + } +} + +// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated +// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act. +func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + starter := provisionAccount(t) + g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute)) + if err != nil { + t.Fatalf("open: %v", err) + } + if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil { + t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID) + } + if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) { + t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err) + } +} + +// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after +// the starter has already made their first move, landing on the board mid-opening and +// able to act on their turn. +func TestOpenGameJoinAfterStarterMoved(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + seed := evenOpeningSeed(t) + g := openGame(t, svc, provisionAccount(t), seed) + starter := g.Seats[0].AccountID + + hint, ok := newMirror(t, seed, 2).HintView() + if !ok { + t.Fatal("no opening move") + } + if _, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles); err != nil { + t.Fatalf("starter play: %v", err) + } + + joiner := provisionAccount(t) + g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute)) + if err != nil { + t.Fatalf("join: %v", err) + } + if !joined || g2.ID != g.ID { + t.Fatalf("join = (joined %v, game %s), want it to join %s", joined, g2.ID, g.ID) + } + if g2.Status != game.StatusActive || g2.MoveCount != 1 || g2.ToMove != 1 { + t.Fatalf("joined game = (status %q, moves %d, to_move %d), want (active, 1, 1)", g2.Status, g2.MoveCount, g2.ToMove) + } + // It is the joiner's turn (seat 1); they can act. + if _, err := svc.Pass(ctx, g.ID, joiner); err != nil { + t.Fatalf("joiner pass on their turn: %v", err) + } +} + +// TestOpenGameResignRejectedUntilOpponent checks resign is refused while the game is +// open and allowed once an opponent (a robot here) has joined. +func TestOpenGameResignRejectedUntilOpponent(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + robots := newRobotService(t, svc) + if err := robots.EnsurePool(ctx); err != nil { + t.Fatalf("ensure pool: %v", err) + } + g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t)) + starter := g.Seats[0].AccountID + + if _, err := svc.Resign(ctx, g.ID, starter); !errors.Is(err, game.ErrNoOpponentYet) { + t.Fatalf("resign while open = %v, want ErrNoOpponentYet", err) + } + + robotID, err := robots.Pick(engine.VariantEnglish) + if err != nil { + t.Fatalf("pick: %v", err) + } + if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID); err != nil || !attached { + t.Fatalf("attach robot = (attached %v, err %v), want attached", attached, err) + } + res, err := svc.Resign(ctx, g.ID, starter) + if err != nil { + t.Fatalf("resign after the opponent joined: %v", err) + } + if res.Game.Status != game.StatusFinished { + t.Errorf("resign must finish the game, got %q", res.Game.Status) + } +} + +// TestOpenGameChatAndNudgeRejected checks chat and nudge are refused while the game is +// open (no opponent to converse with). +func TestOpenGameChatAndNudgeRejected(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + soc := newSocialService() + g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t)) + starter := g.Seats[0].AccountID + + if _, err := soc.PostMessage(ctx, g.ID, starter, "hello", ""); !errors.Is(err, social.ErrGameNotActive) { + t.Errorf("chat while open = %v, want ErrGameNotActive", err) + } + if _, err := soc.Nudge(ctx, g.ID, starter); !errors.Is(err, social.ErrGameNotActive) { + t.Errorf("nudge while open = %v, want ErrGameNotActive", err) + } +} + +// TestOpenGameSweeperSkips checks the turn-timeout sweeper never finishes an open game, +// even with a long-stale turn clock (its seat is the empty opponent's). +func TestOpenGameSweeperSkips(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t)) + + setTurnStarted(t, g.ID, time.Now().Add(-1000*time.Hour)) + if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil { + t.Fatalf("sweep: %v", err) + } + g2, err := svc.GameByID(ctx, g.ID) + if err != nil { + t.Fatalf("get game: %v", err) + } + if g2.Status != game.StatusOpen { + t.Errorf("open game must survive the sweeper, got %q", g2.Status) + } +} + +// TestOpenGameLobbyShowsEmptySeat checks an open game appears in the starter's lobby +// with two seats, one of them the still-empty opponent (uuid.Nil). +func TestOpenGameLobbyShowsEmptySeat(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + starter := provisionAccount(t) + g := openGame(t, svc, starter, evenOpeningSeed(t)) + + games, err := svc.ListForAccount(ctx, starter) + if err != nil { + t.Fatalf("list for account: %v", err) + } + var found *game.Game + for i := range games { + if games[i].ID == g.ID { + found = &games[i] + break + } + } + if found == nil { + t.Fatal("open game must appear in the starter's lobby") + } + var hasStarter, hasEmpty bool + for _, s := range found.Seats { + switch s.AccountID { + case starter: + hasStarter = true + case uuid.Nil: + hasEmpty = true + } + } + if len(found.Seats) != 2 || !hasStarter || !hasEmpty { + t.Errorf("open game seats = %+v, want the starter and one empty (nil) seat", found.Seats) + } +} diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 15eda3f..c653674 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -138,36 +138,29 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) { } } -// TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a -// real robot account after the wait window, discoverable through Poll. +// TestMatchmakerSubstitutesRobotEndToEnd checks the reaper fills an open game's empty +// seat with a real robot account once its wait window has elapsed. func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { ctx := context.Background() + clearOpenGames(t) robots := newRobotService(t, newGameService()) if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } - mm := newMatchmaker(t, robots, 10*time.Second) + // Zero wait and jitter so the opened game is immediately due for a robot. + mm := newMatchmaker(t, robots, 0, 0) human := provisionAccount(t) - before := time.Now() r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue: %v", err) } if r.Matched { - t.Fatal("first enqueue must wait") + t.Fatal("first enqueue must open a game awaiting an opponent") } - mm.Reap(ctx, before.Add(11*time.Second)) - got, err := mm.Poll(ctx, human) - if err != nil { - t.Fatalf("poll: %v", err) - } - if !got.Matched { - t.Fatal("expected a substituted game after the wait window") - } - - seats, _, status, err := newGameService().Participants(ctx, got.Game.ID) + mm.Reap(ctx, time.Now().Add(time.Second)) // past the (zero) wait window + seats, _, status, err := newGameService().Participants(ctx, r.Game.ID) if err != nil { t.Fatalf("participants: %v", err) } diff --git a/backend/internal/lobby/config.go b/backend/internal/lobby/config.go index a016b43..733364c 100644 --- a/backend/internal/lobby/config.go +++ b/backend/internal/lobby/config.go @@ -5,22 +5,30 @@ import ( "time" ) -// Config configures the matchmaking pool's robot substitution. +// Config configures auto-match robot substitution: how long an open game waits for a +// human opponent before a robot is substituted, and how often the reaper scans. type Config struct { - // RobotWait is how long an auto-match player waits for a human before a robot - // is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT. + // RobotWait is the fixed minimum an open auto-match game waits for a human + // opponent before it is eligible for robot substitution. Sourced from + // BACKEND_LOBBY_ROBOT_WAIT. RobotWait time.Duration - // ReaperInterval is how often the substitution reaper scans for over-waited - // players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL. + // RobotWaitJitter is a random extra wait in [0, RobotWaitJitter) added on top of + // RobotWait per game, so the substitution time varies. Sourced from + // BACKEND_LOBBY_ROBOT_WAIT_JITTER. + RobotWaitJitter time.Duration + // ReaperInterval is how often the reaper scans for open games due for a robot. + // Sourced from BACKEND_LOBBY_REAPER_INTERVAL. ReaperInterval time.Duration } -// DefaultConfig returns the matchmaking defaults: a 10-second wait -// (docs/ARCHITECTURE.md §7) scanned every second. +// DefaultConfig returns the matchmaking defaults: a guaranteed 90-second wait for a +// human plus up to 90 random seconds (90–180 s total) before a robot substitutes +// (docs/ARCHITECTURE.md §7), scanned every five seconds. func DefaultConfig() Config { return Config{ - RobotWait: 10 * time.Second, - ReaperInterval: time.Second, + RobotWait: 90 * time.Second, + RobotWaitJitter: 90 * time.Second, + ReaperInterval: 5 * time.Second, } } @@ -29,6 +37,9 @@ func (c Config) Validate() error { if c.RobotWait <= 0 { return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait) } + if c.RobotWaitJitter < 0 { + return fmt.Errorf("lobby: robot wait jitter must not be negative, got %s", c.RobotWaitJitter) + } if c.ReaperInterval <= 0 { return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval) } diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index b627b68..598ddab 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -1,9 +1,10 @@ -// Package lobby forms games: an in-memory matchmaking pool that pairs two humans -// for an auto-match, and friend-game invitations (invite -> accept) that start a -// 2-4 player game once every invitee has accepted. Both produce a game through the -// game domain (a GameCreator); neither imports the engine. The matchmaking pool -// is in-memory and lost on restart (players re-queue); the robot that substitutes -// for a missing human after a short wait is added in a later stage. +// Package lobby forms games: an auto-match maker that drops a player straight into a +// game with an empty opponent seat (or joins them into another player's waiting one), +// and friend-game invitations (invite -> accept) that start a 2-4 player game once +// every invitee has accepted. Both produce games through the game domain; neither +// imports the engine. Auto-match state is the open games in the database, so it +// survives a restart; a background reaper substitutes a pooled robot for any open game +// that waits too long, guaranteeing every game gets an opponent. package lobby import ( @@ -22,8 +23,8 @@ import ( type GameCreator interface { Create(ctx context.Context, params game.CreateParams) (game.Game, error) // InitialState returns a seated player's full initial view of a started game, used - // to enrich the match_found / game_started events so the client renders the new game - // without a follow-up fetch. + // to enrich the game_started event so the client renders the new game without a + // follow-up fetch. InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) } @@ -51,8 +52,6 @@ const ( // Sentinel errors returned by the lobby. var ( - // ErrAlreadyQueued is returned when an account already waits in a pool. - ErrAlreadyQueued = errors.New("lobby: account already in the matchmaking pool") // ErrInvalidInvitation is returned for a malformed invitation (bad player // count, duplicate or self invitee, or unacceptable settings). ErrInvalidInvitation = errors.New("lobby: invalid invitation") diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 58de1bc..6456680 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -2,8 +2,7 @@ package lobby import ( "context" - "math/rand" - "sync" + "math/rand/v2" "time" "github.com/google/uuid" @@ -14,182 +13,91 @@ import ( "scrabble/backend/internal/notify" ) -// matchKey buckets the auto-match pool: two players are paired only when they chose -// the same variant and the same per-turn word rule (multipleWords), so a game always -// starts under a rule both players asked for. -type matchKey struct { - variant engine.Variant - multipleWords bool +// GameMatcher is the slice of the game domain the matchmaker drives: opening or +// joining an auto-match game, substituting a robot into one whose wait elapsed, and +// reading a player's view to enrich the opponent_joined event. game.Service satisfies +// it. +type GameMatcher interface { + OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error) + AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error) + ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error) + InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) } -// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs -// the next two humans into a two-player game, or — when no human arrives within -// the wait window — substitutes a robot. It holds no database state and is lost on -// restart (players simply re-queue). It is safe for concurrent use. +// Matchmaker turns an auto-match enqueue into a real game the player enters at once: +// it opens a game with an empty opponent seat, or joins the caller into another +// player's waiting one. A background reaper substitutes a pooled robot for any open +// game whose wait window has elapsed, guaranteeing every game gets an opponent. All +// matchmaking state is the open games in the database, so it survives a restart; the +// Matchmaker holds only the wait policy and the live-event publisher, and is safe for +// concurrent use. // -// Auto-match is anonymous, so the pool does not consult per-user blocks (those -// govern friends, chat and invitations between known players). -// -// A player who is queued learns of a match — by a waiting human being paired, or -// by robot substitution — through Poll, the interim delivery seam: production -// delivery is a notification (session/in-app push and the platform side-service, -// docs/ARCHITECTURE.md §10), wired with the gateway in a later stage. +// Auto-match is anonymous, so it does not consult per-user blocks (those govern +// friends, chat and invitations between known players). type Matchmaker struct { - games GameCreator - robots RobotProvider - waitDelay time.Duration - clock func() time.Time - pub notify.Publisher - log *zap.Logger - - mu sync.Mutex - queues map[matchKey][]uuid.UUID - queued map[uuid.UUID]matchKey - waitingSince map[uuid.UUID]time.Time - results map[uuid.UUID]game.Game - rng *rand.Rand + games GameMatcher + robots RobotProvider + minWait time.Duration + jitter time.Duration + clock func() time.Time + pub notify.Publisher + log *zap.Logger } -// NewMatchmaker constructs a Matchmaker that starts matched games through games -// and substitutes a robot from robots when a player waits longer than waitDelay. -func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Duration, log *zap.Logger) *Matchmaker { +// NewMatchmaker constructs a Matchmaker that opens auto-match games through games and, +// after a per-game wait of minWait plus a random jitter in [0, jitter), substitutes a +// pooled robot from robots when no human has joined. +func NewMatchmaker(games GameMatcher, robots RobotProvider, minWait, jitter time.Duration, log *zap.Logger) *Matchmaker { if log == nil { log = zap.NewNop() } return &Matchmaker{ - games: games, - robots: robots, - waitDelay: waitDelay, - clock: func() time.Time { return time.Now().UTC() }, - pub: notify.Nop{}, - log: log, - queues: make(map[matchKey][]uuid.UUID), - queued: make(map[uuid.UUID]matchKey), - waitingSince: make(map[uuid.UUID]time.Time), - results: make(map[uuid.UUID]game.Game), - rng: rand.New(rand.NewSource(time.Now().UnixNano())), + games: games, + robots: robots, + minWait: minWait, + jitter: jitter, + clock: func() time.Time { return time.Now().UTC() }, + pub: notify.Nop{}, + log: log, } } -// SetNotifier installs the live-event publisher used to push match_found to the -// seated players when a pairing or robot substitution starts a game. It must be -// called during startup wiring, before the reaper runs; the default is -// notify.Nop (no live events; waiters still discover the game via Poll). +// SetNotifier installs the live-event publisher used to push opponent_joined to a +// waiting starter when a human or a robot takes the empty seat. It must be called +// during startup wiring, before the reaper runs; the default is notify.Nop (no live +// events). func (m *Matchmaker) SetNotifier(p notify.Publisher) { if p != nil { m.pub = p } } -// emitMatchFound pushes match_found to every seat of a freshly started game. -// Emitting to a robot seat is harmless (no client subscription exists for it). -func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) { - lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot - intents := make([]notify.Intent, 0, len(g.Seats)) - for _, s := range g.Seats { - state, err := m.games.InitialState(ctx, g.ID, s.AccountID) - if err != nil { - // A waiter still discovers the game through Poll (the ws-down fallback), so skip the - // enriched push for this seat rather than failing the match. - m.log.Warn("match_found initial state", - zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err)) - continue - } - mf := notify.MatchFound(s.AccountID, g.ID, state) - mf.Language = lang - intents = append(intents, mf) - } - m.pub.Publish(intents...) -} - -// EnqueueResult reports the outcome of joining the pool: either a started game or a -// queued ticket awaiting an opponent. +// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays +// in, and whether it already had an opponent (they joined a waiting game) rather than +// being freshly opened and still awaiting one. type EnqueueResult struct { Matched bool Game game.Game } -// Enqueue joins accountID to the auto-match pool for variant under the chosen -// per-turn word rule (multipleWords). If an opponent already waits for the same -// variant and rule, the two are paired (seat order randomised for first-move -// fairness) and a game starts immediately; otherwise the account waits, and a later -// pairing or robot substitution is delivered through Poll. An account already waiting -// in any pool gets ErrAlreadyQueued. +// Enqueue resolves an auto-match request for accountID under variant and the per-turn +// word rule (multipleWords) into the game they enter immediately — a freshly opened +// game awaiting an opponent, the caller's own still-open game (a re-enqueue is +// idempotent), or another player's open game they just joined. When the caller joins +// an existing game, opponent_joined is pushed to that game's waiting starter. func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) { - key := matchKey{variant: variant, multipleWords: multipleWords} - m.mu.Lock() - if _, ok := m.queued[accountID]; ok { - m.mu.Unlock() - return EnqueueResult{}, ErrAlreadyQueued - } - q := m.queues[key] - if len(q) == 0 { - m.queues[key] = append(q, accountID) - m.queued[accountID] = key - m.waitingSince[accountID] = m.clock() - m.mu.Unlock() - return EnqueueResult{}, nil - } - opponent := q[0] - m.removeLocked(opponent, key) - seats := []uuid.UUID{opponent, accountID} - if m.rng.Intn(2) == 0 { - seats[0], seats[1] = seats[1], seats[0] - } - m.mu.Unlock() - - g, err := m.games.Create(ctx, autoMatchParams(key, seats)) + g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline()) if err != nil { return EnqueueResult{}, err } - // The opponent was waiting; record the game so they can collect it via Poll. - m.mu.Lock() - m.results[opponent] = g - m.mu.Unlock() - m.emitMatchFound(ctx, g) - return EnqueueResult{Matched: true, Game: g}, nil -} - -// Poll reports whether accountID has been matched since it queued, returning the -// started game once (the result is drained on read). It reports Matched=false -// while the account is still waiting or has no pending result. -func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult, error) { - m.mu.Lock() - defer m.mu.Unlock() - if g, ok := m.results[accountID]; ok { - delete(m.results, accountID) - return EnqueueResult{Matched: true, Game: g}, nil + if joined { + m.announceOpponent(ctx, g, accountID) } - return EnqueueResult{}, nil + return EnqueueResult{Matched: joined, Game: g}, nil } -// 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) - key, ok := m.queued[accountID] - if !ok { - return false - } - m.removeLocked(accountID, key) - return true -} - -// QueueLen returns the number of accounts waiting in the variant pool, summed across -// both per-turn word rules. -func (m *Matchmaker) QueueLen(variant engine.Variant) int { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.queues[matchKey{variant: variant, multipleWords: false}]) + - len(m.queues[matchKey{variant: variant, multipleWords: true}]) -} - -// RunReaper substitutes a robot for any player that has waited past waitDelay, -// scanning every interval until ctx is cancelled. It is started once from main. +// RunReaper substitutes a robot for any open game past its wait window, scanning every +// interval until ctx is cancelled. It is started once from main. func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -203,77 +111,83 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { } } -// Reap pairs every player that has waited past waitDelay with a freshly picked -// robot and starts the game, recording it for the player's Poll. RunReaper calls -// it on a timer; it takes now explicitly so tests and ops can drive a single pass -// at a chosen instant. A waiter is only dequeued once a robot is secured, so a -// momentarily empty pool just defers substitution to a later tick. +// Reap substitutes a robot into every open game whose wait window elapsed by now and +// pushes opponent_joined to its starter. RunReaper calls it on a timer; it takes now +// explicitly so tests and ops can drive a single pass at a chosen instant. A game for +// which no robot is available is left for a later tick. func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { - type sub struct { - human uuid.UUID - key matchKey - seats []uuid.UUID + due, err := m.games.ExpiredOpen(ctx, now) + if err != nil { + m.log.Warn("scan open games", zap.Error(err)) + return } - m.mu.Lock() - var due []uuid.UUID - for acc, since := range m.waitingSince { - if now.Sub(since) >= m.waitDelay { - due = append(due, acc) - } - } - var subs []sub - for _, acc := range due { - key := m.queued[acc] - robotID, err := m.robots.Pick(key.variant) + for _, og := range due { + robotID, err := m.robots.Pick(og.Variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } - m.removeLocked(acc, key) - seats := []uuid.UUID{acc, robotID} - if m.rng.Intn(2) == 0 { - seats[0], seats[1] = seats[1], seats[0] - } - subs = append(subs, sub{human: acc, key: key, seats: seats}) - } - m.mu.Unlock() - - for _, s := range subs { - g, err := m.games.Create(ctx, autoMatchParams(s.key, s.seats)) + g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID) if err != nil { - m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err)) + m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err)) continue } - m.mu.Lock() - m.results[s.human] = g - m.mu.Unlock() - m.emitMatchFound(ctx, g) + if !attached { + continue // a human joined first between the scan and the substitution + } + m.announceOpponent(ctx, g, robotID) } } -// removeLocked drops accountID from the queue, the queued index and the waiting -// clock. The caller holds m.mu. -func (m *Matchmaker) removeLocked(accountID uuid.UUID, key matchKey) { - delete(m.queued, accountID) - delete(m.waitingSince, accountID) - q := m.queues[key] - for i, id := range q { - if id == accountID { - m.queues[key] = append(q[:i], q[i+1:]...) - break +// announceOpponent pushes opponent_joined to the game's waiting starter — the seat +// that is not joinerID — so its client fills the opponent card and re-enables resign +// and chat in place. Routed by the game's language, like every game push. +func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID uuid.UUID) { + starter, ok := otherSeat(g, joinerID) + if !ok { + return + } + state, err := m.games.InitialState(ctx, g.ID, starter) + if err != nil { + m.log.Warn("opponent_joined initial state", + zap.String("game", g.ID.String()), zap.String("account", starter.String()), zap.Error(err)) + return + } + intent := notify.OpponentJoined(starter, g.ID, state) + intent.Language = g.Variant.Language() + m.pub.Publish(intent) +} + +// openDeadline is when the reaper substitutes a robot for a game opened now: a fixed +// minimum wait plus a random jitter, so the substitution time varies per game. +func (m *Matchmaker) openDeadline() time.Time { + d := m.minWait + if m.jitter > 0 { + d += rand.N(m.jitter) + } + return m.clock().Add(d) +} + +// otherSeat returns the account at the seat that is not accountID — the open game's +// starter when accountID is the joiner — and false when no seat differs or it is still +// empty. +func otherSeat(g game.Game, accountID uuid.UUID) (uuid.UUID, bool) { + for _, s := range g.Seats { + if s.AccountID != accountID && s.AccountID != uuid.Nil { + return s.AccountID, true } } + return uuid.Nil, false } -// autoMatchParams builds the create parameters for a two-player auto-match with -// the casual defaults. -func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams { +// autoMatchParams builds the create parameters for a two-player auto-match with the +// casual defaults; the game service assembles the seats and pins the bag seed. +func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams { return game.CreateParams{ - Variant: key.variant, - Seats: seats, + Variant: variant, TurnTimeout: game.DefaultTurnTimeout, HintsAllowed: autoMatchHintsAllowed, HintsPerPlayer: autoMatchHintsPerPlayer, - MultipleWordsPerTurn: key.multipleWords, + MultipleWordsPerTurn: multipleWords, } } diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 6a7374e..ad5bb9b 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -14,28 +14,51 @@ import ( "scrabble/backend/internal/notify" ) -// fakeCreator records the games a matchmaker asks it to start. -type fakeCreator struct { - created []game.CreateParams - err error +// stubMatcher is a fake GameMatcher: it returns canned games and records the calls the +// matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and +// the wait-window math without a database. The DB-backed open/join/substitute logic is +// covered by the integration suite. +type stubMatcher struct { + openGame game.Game + openJoined bool + openErr error + openCalls int + lastDeadline time.Time + + expired []game.OpenGame + + attachGame game.Game + attached bool + attachErr error + attachedGames []uuid.UUID } -func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) { - if f.err != nil { - return game.Game{}, f.err +func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) { + s.openCalls++ + s.lastDeadline = deadline + return s.openGame, s.openJoined, s.openErr +} + +func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID) (game.Game, bool, error) { + if s.attachErr != nil { + return game.Game{}, false, s.attachErr } - f.created = append(f.created, p) - return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil + if s.attached { + s.attachedGames = append(s.attachedGames, gameID) + } + return s.attachGame, s.attached, nil } -// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing -// tests assert on matching behaviour, not the payload, so an empty state is enough. -func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) { +func (s *stubMatcher) ExpiredOpen(_ context.Context, _ time.Time) ([]game.OpenGame, error) { + return s.expired, nil +} + +func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) { return notify.PlayerState{}, nil } -// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model -// an empty pool. It records the variant of the last substitution request. +// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an +// empty pool. It records the variant of the last substitution request. type fakeRobots struct { id uuid.UUID err error @@ -50,294 +73,137 @@ func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) { return f.id, nil } -// testWaitDelay is long enough that the reaper never fires in the pairing tests -// (which do not run it); the substitution tests drive reap directly. -const testWaitDelay = 10 * time.Second +// capturePub records every published intent. +type capturePub struct{ intents []notify.Intent } -func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker { - return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop()) -} +func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) } -func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool { - for _, w := range want { - found := false - for _, s := range seats { - if s == w { - found = true - break - } - } - if !found { - return false - } +// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1 +// (uuid.Nil for a still-empty opponent seat). +func twoSeatGame(starter, opponent uuid.UUID) game.Game { + return game.Game{ + ID: uuid.New(), + Variant: engine.VariantEnglish, + Seats: []game.Seat{ + {Seat: 0, AccountID: starter}, + {Seat: 1, AccountID: opponent}, + }, } - return true } -func TestMatchmakerPairsTwoHumans(t *testing.T) { - creator := &fakeCreator{} - mm := newTestMatchmaker(creator, uuid.New()) - ctx := context.Background() - a, b := uuid.New(), uuid.New() +func TestEnqueueOpensGameWithoutOpponent(t *testing.T) { + starter := uuid.New() + m := &stubMatcher{openGame: twoSeatGame(starter, uuid.Nil)} + pub := &capturePub{} + mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop()) + mm.SetNotifier(pub) - r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) + res, err := mm.Enqueue(context.Background(), starter, engine.VariantEnglish, true) if err != nil { - t.Fatalf("enqueue a: %v", err) + t.Fatalf("enqueue: %v", err) } - if r1.Matched { - t.Fatal("first enqueue must wait, not match") + if res.Matched { + t.Error("opening a game must report Matched=false") } - if mm.QueueLen(engine.VariantEnglish) != 1 { - t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish)) + if m.openCalls != 1 { + t.Errorf("OpenOrJoin calls = %d, want 1", m.openCalls) } + if len(pub.intents) != 0 { + t.Errorf("opening a game must not emit opponent_joined; got %d intents", len(pub.intents)) + } +} - r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true) +func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) { + starter, joiner := uuid.New(), uuid.New() + m := &stubMatcher{openGame: twoSeatGame(starter, joiner), openJoined: true} + pub := &capturePub{} + mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop()) + mm.SetNotifier(pub) + + res, err := mm.Enqueue(context.Background(), joiner, engine.VariantEnglish, true) if err != nil { - t.Fatalf("enqueue b: %v", err) - } - if !r2.Matched { - t.Fatal("second enqueue must match") - } - if mm.QueueLen(engine.VariantEnglish) != 0 { - t.Fatalf("queue len = %d, want 0 after match", mm.QueueLen(engine.VariantEnglish)) - } - if len(creator.created) != 1 { - t.Fatalf("created %d games, want 1", len(creator.created)) - } - p := creator.created[0] - if len(p.Seats) != 2 || !seatsContain(p.Seats, a, b) { - t.Errorf("seats = %v, want both %s and %s", p.Seats, a, b) - } - if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer { - t.Errorf("auto-match defaults not applied: %+v", p) - } - - // The waiting opponent learns of the match through Poll, exactly once. - got, err := mm.Poll(ctx, a) - if err != nil { - t.Fatalf("poll a: %v", err) - } - if !got.Matched || got.Game.ID != r2.Game.ID { - t.Errorf("poll a = %+v, want the matched game %s", got, r2.Game.ID) - } - if again, _ := mm.Poll(ctx, a); again.Matched { - t.Error("poll result must drain after the first read") - } -} - -func TestMatchmakerAlreadyQueued(t *testing.T) { - mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) - ctx := context.Background() - a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); !errors.Is(err, ErrAlreadyQueued) { - t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err) + if !res.Matched { + t.Error("joining a waiting game must report Matched=true") + } + if len(pub.intents) != 1 { + t.Fatalf("joining must emit one opponent_joined; got %d", len(pub.intents)) + } + if got := pub.intents[0]; got.Kind != notify.KindOpponentJoined || got.UserID != starter { + t.Errorf("opponent_joined = (kind %q, user %s), want (%q, starter %s)", got.Kind, got.UserID, notify.KindOpponentJoined, starter) } } -func TestMatchmakerCancel(t *testing.T) { - mm := newTestMatchmaker(&fakeCreator{}, uuid.New()) - ctx := context.Background() - a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { - t.Fatalf("enqueue: %v", err) - } - if !mm.Cancel(ctx, a) { - t.Fatal("cancel of a queued account must report true") - } - if mm.QueueLen(engine.VariantEnglish) != 0 { - t.Fatalf("queue len = %d, want 0 after cancel", mm.QueueLen(engine.VariantEnglish)) - } - if mm.Cancel(ctx, a) { - t.Fatal("cancel of an unqueued account must report false") - } -} - -func TestMatchmakerVariantsAreSeparate(t *testing.T) { - creator := &fakeCreator{} - mm := newTestMatchmaker(creator, uuid.New()) - ctx := context.Background() - if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish, true); err != nil { - t.Fatalf("enqueue en: %v", err) - } - if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil { - t.Fatalf("enqueue ru: %v", err) - } - if len(creator.created) != 0 { - t.Fatalf("different variants must not match; created %d", len(creator.created)) - } - if mm.QueueLen(engine.VariantEnglish) != 1 || mm.QueueLen(engine.VariantRussianScrabble) != 1 { - t.Errorf("each variant pool should hold one waiter") - } -} - -func TestMatchmakerFIFO(t *testing.T) { - creator := &fakeCreator{} - mm := newTestMatchmaker(creator, uuid.New()) - ctx := context.Background() - a, b, c := uuid.New(), uuid.New(), uuid.New() - for _, id := range []uuid.UUID{a, b, c} { - if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish, true); err != nil { - t.Fatalf("enqueue %s: %v", id, err) - } - } - // a waited, b matched a (oldest), c waits. - if len(creator.created) != 1 { - t.Fatalf("created %d games, want 1", len(creator.created)) - } - if !seatsContain(creator.created[0].Seats, a, b) { - t.Errorf("FIFO match should pair a and b, got %v", creator.created[0].Seats) - } - if mm.QueueLen(engine.VariantEnglish) != 1 { - t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish)) - } -} - -func TestMatchmakerReaperSubstitutesRobot(t *testing.T) { - creator := &fakeCreator{} - robotID := uuid.New() - mm := newTestMatchmaker(creator, robotID) +func TestEnqueueDeadlineWithinWindow(t *testing.T) { base := time.Now() + m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)} + mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, 90*time.Second, 90*time.Second, zap.NewNop()) mm.clock = func() time.Time { return base } - ctx := context.Background() - a := uuid.New() - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { + if _, err := mm.Enqueue(context.Background(), uuid.New(), engine.VariantEnglish, true); err != nil { t.Fatalf("enqueue: %v", err) } - - mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window - if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 { - t.Fatalf("must not substitute before the wait: created=%d queued=%d", len(creator.created), mm.QueueLen(engine.VariantEnglish)) - } - - mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // past the wait window - if len(creator.created) != 1 { - t.Fatalf("created %d games, want 1 after substitution", len(creator.created)) - } - if !seatsContain(creator.created[0].Seats, a, robotID) { - t.Errorf("substituted game seats = %v, want human %s and robot %s", creator.created[0].Seats, a, robotID) - } - if mm.QueueLen(engine.VariantEnglish) != 0 { - t.Errorf("waiter should be dequeued after substitution") - } - got, err := mm.Poll(ctx, a) - if err != nil || !got.Matched { - t.Errorf("poll after substitution = %+v err=%v, want matched", got, err) + lo, hi := base.Add(90*time.Second), base.Add(180*time.Second) + if m.lastDeadline.Before(lo) || !m.lastDeadline.Before(hi) { + t.Errorf("deadline %s not in [%s, %s)", m.lastDeadline, lo, hi) } } -func TestMatchmakerReaperSkipsCancelled(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, true); err != nil { - t.Fatalf("enqueue: %v", err) +func TestReapSubstitutesRobotAndEmits(t *testing.T) { + human, robotID := uuid.New(), uuid.New() + og := game.OpenGame{ID: uuid.New(), Variant: engine.VariantRussianScrabble} + m := &stubMatcher{ + expired: []game.OpenGame{og}, + attachGame: twoSeatGame(human, robotID), + attached: true, } - mm.Cancel(ctx, a) - mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) - if len(creator.created) != 0 { - t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created)) + robots := &fakeRobots{id: robotID} + pub := &capturePub{} + mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop()) + mm.SetNotifier(pub) + + mm.Reap(context.Background(), time.Now()) + + if robots.lastVariant != engine.VariantRussianScrabble { + t.Errorf("robot picked for %v, want the open game's variant", robots.lastVariant) + } + if len(m.attachedGames) != 1 || m.attachedGames[0] != og.ID { + t.Errorf("attached games = %v, want [%s]", m.attachedGames, og.ID) + } + if len(pub.intents) != 1 || pub.intents[0].Kind != notify.KindOpponentJoined || pub.intents[0].UserID != human { + t.Errorf("reap must emit opponent_joined to the human starter; got %+v", pub.intents) } } -// 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. -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() +func TestReapDefersWithoutRobot(t *testing.T) { + m := &stubMatcher{expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}} + pub := &capturePub{} + mm := NewMatchmaker(m, &fakeRobots{err: errors.New("empty pool")}, time.Minute, time.Minute, zap.NewNop()) + mm.SetNotifier(pub) - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { - t.Fatalf("enqueue: %v", err) + mm.Reap(context.Background(), time.Now()) + + if len(m.attachedGames) != 0 { + t.Errorf("no robot available: must not attach; attached %v", m.attachedGames) } - 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") + if len(pub.intents) != 0 { + t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents)) } } -func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) { - creator := &fakeCreator{} - mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop()) - base := time.Now() - mm.clock = func() time.Time { return base } - ctx := context.Background() - a := uuid.New() +func TestReapSkipsWhenHumanJoinedFirst(t *testing.T) { + m := &stubMatcher{ + expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}, + attached: false, // AttachRobot reports the game already filled by a human + } + pub := &capturePub{} + mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop()) + mm.SetNotifier(pub) - if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil { - t.Fatalf("enqueue: %v", err) - } - mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) - if len(creator.created) != 0 { - t.Errorf("no robot available: must not create a game; created %d", len(creator.created)) - } - if mm.QueueLen(engine.VariantEnglish) != 1 { - t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish)) - } -} - -// TestMatchmakerRulesAreSeparate confirms two players who chose the same variant but a -// different per-turn word rule are not paired, and that the rule reaches the started game. -func TestMatchmakerRulesAreSeparate(t *testing.T) { - creator := &fakeCreator{} - mm := newTestMatchmaker(creator, uuid.New()) - ctx := context.Background() - - // Same variant, opposite rules: they must not match. - if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil { - t.Fatalf("enqueue single-word: %v", err) - } - if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil { - t.Fatalf("enqueue standard: %v", err) - } - if len(creator.created) != 0 { - t.Fatalf("different rules must not match; created %d", len(creator.created)) - } - - // A second single-word player pairs with the first; the game carries the rule. - r, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false) - if err != nil { - t.Fatalf("enqueue single-word opponent: %v", err) - } - if !r.Matched { - t.Fatal("same variant and rule must match") - } - if len(creator.created) != 1 { - t.Fatalf("created %d games, want 1", len(creator.created)) - } - if creator.created[0].MultipleWordsPerTurn { - t.Error("single-word match must create a game with MultipleWordsPerTurn=false") - } -} - -// TestMatchmakerReaperKeepsRule confirms a robot substitution carries the waiter's rule. -func TestMatchmakerReaperKeepsRule(t *testing.T) { - creator := &fakeCreator{} - mm := newTestMatchmaker(creator, uuid.New()) - base := time.Now() - mm.clock = func() time.Time { return base } - ctx := context.Background() - - if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil { - t.Fatalf("enqueue: %v", err) - } - mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) - if len(creator.created) != 1 { - t.Fatalf("created %d games, want 1", len(creator.created)) - } - if creator.created[0].MultipleWordsPerTurn { - t.Error("robot substitution must keep the waiter's single-word rule") + mm.Reap(context.Background(), time.Now()) + + if len(pub.intents) != 0 { + t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents)) } } diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 4e41ced..970fda5 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -122,6 +122,22 @@ func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent { return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()} } +// OpponentJoined tells userID — the starter of an auto-match game still shown as +// "searching for an opponent" — that an opponent (a human or a substituted robot) has +// taken the empty seat. state is the starter's refreshed view (now seating both +// players), so the client fills the opponent card and re-enables resign and chat in +// place without navigating. It reuses the match_found payload layout (game id + state). +func OpponentJoined(userID, gameID uuid.UUID, state PlayerState) Intent { + b := flatbuffers.NewBuilder(512) + gid := b.CreateString(gameID.String()) + stateOff := buildStateView(b, state) + fb.MatchFoundEventStart(b) + fb.MatchFoundEventAddGameId(b, gid) + fb.MatchFoundEventAddState(b, stateOff) + b.Finish(fb.MatchFoundEventEnd(b)) + return Intent{UserID: userID, Kind: KindOpponentJoined, Payload: b.FinishedBytes(), EventID: eventID()} +} + // Notification is a lightweight "re-poll" signal to userID that something in their lobby // changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded, // NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index d96887d..ce34df8 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -24,6 +24,11 @@ const ( KindChatMessage = "chat_message" KindNudge = "nudge" KindMatchFound = "match_found" + // KindOpponentJoined tells the starter of an auto-match game still "searching for an + // opponent" that the empty seat has been taken (by a human or a substituted robot), + // carrying the refreshed StateView so the client fills the opponent card and + // re-enables resign and chat in place. In-app only (never an out-of-app push). + KindOpponentJoined = "opponent_joined" // KindNotification is a lightweight "re-poll your lobby counters" signal // (incoming friend requests, invitations) that drives the lobby badge. KindNotification = "notify" diff --git a/backend/internal/postgres/jet/backend/model/game_players.go b/backend/internal/postgres/jet/backend/model/game_players.go index 0135af9..8018a41 100644 --- a/backend/internal/postgres/jet/backend/model/game_players.go +++ b/backend/internal/postgres/jet/backend/model/game_players.go @@ -14,7 +14,7 @@ import ( type GamePlayers struct { GameID uuid.UUID `sql:"primary_key"` Seat int16 `sql:"primary_key"` - AccountID uuid.UUID + AccountID *uuid.UUID Score int32 HintsUsed int16 IsWinner bool diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go index 8b254b4..b4a7d2f 100644 --- a/backend/internal/postgres/jet/backend/model/games.go +++ b/backend/internal/postgres/jet/backend/model/games.go @@ -29,6 +29,7 @@ type Games struct { CreatedAt time.Time UpdatedAt time.Time FinishedAt *time.Time + OpenDeadlineAt *time.Time DropoutTiles string MultipleWordsPerTurn bool } diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go index 8848c44..bfe8e7c 100644 --- a/backend/internal/postgres/jet/backend/table/games.go +++ b/backend/internal/postgres/jet/backend/table/games.go @@ -33,6 +33,7 @@ type gamesTable struct { CreatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz FinishedAt postgres.ColumnTimestampz + OpenDeadlineAt postgres.ColumnTimestampz DropoutTiles postgres.ColumnString MultipleWordsPerTurn postgres.ColumnBool @@ -92,10 +93,11 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") FinishedAtColumn = postgres.TimestampzColumn("finished_at") + OpenDeadlineAtColumn = postgres.TimestampzColumn("open_deadline_at") DropoutTilesColumn = postgres.StringColumn("dropout_tiles") MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") - allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} - mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} + allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} + mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn} ) @@ -119,6 +121,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { CreatedAt: CreatedAtColumn, UpdatedAt: UpdatedAtColumn, FinishedAt: FinishedAtColumn, + OpenDeadlineAt: OpenDeadlineAtColumn, DropoutTiles: DropoutTilesColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn, diff --git a/backend/internal/postgres/migrations/00001_baseline.sql b/backend/internal/postgres/migrations/00001_baseline.sql index 4bba8bd..2a0edd1 100644 --- a/backend/internal/postgres/migrations/00001_baseline.sql +++ b/backend/internal/postgres/migrations/00001_baseline.sql @@ -96,10 +96,14 @@ CREATE TABLE games ( created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, + -- open_deadline_at is set only while status='open' (an auto-match game awaiting an + -- opponent): the instant the matchmaking reaper substitutes a robot if no human has + -- joined by then. NULL for every active and finished game. + open_deadline_at timestamptz, dropout_tiles text NOT NULL DEFAULT 'remove', multiple_words_per_turn boolean NOT NULL DEFAULT true, CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')), - CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')), + CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished', 'open')), CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4), CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players), CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0), @@ -112,15 +116,19 @@ CREATE TABLE games ( -- The sweeper scans active games oldest-turn-first; a partial index keeps it off the -- finished archive. CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active'; +-- The matchmaking reaper scans open games due for a robot substitution; a partial index +-- keeps it off the active and finished games. +CREATE INDEX games_open_idx ON games (open_deadline_at) WHERE status = 'open'; --- Seats in turn order (seat 0 moves first), one row per player. account_id is a --- durable account. score is the running/final score, is_winner is stamped on finish --- (false for every seat on a draw), hints_used counts the per-game allowance consumed --- before the profile wallet. +-- Seats in turn order (seat 0 moves first), one row per player. account_id is a durable +-- account, or NULL for the still-empty opponent seat of an auto-match game waiting for an +-- opponent (status='open'); it is filled when a human or a robot joins. score is the +-- running/final score, is_winner is stamped on finish (false for every seat on a draw), +-- hints_used counts the per-game allowance consumed before the profile wallet. CREATE TABLE game_players ( game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, seat smallint NOT NULL, - account_id uuid NOT NULL REFERENCES accounts (account_id), + account_id uuid REFERENCES accounts (account_id), score integer NOT NULL DEFAULT 0, hints_used smallint NOT NULL DEFAULT 0, is_winner boolean NOT NULL DEFAULT false, diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 8796720..87eee89 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -1,6 +1,8 @@ package server import ( + "github.com/google/uuid" + "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" @@ -186,9 +188,16 @@ const awayTimeLayout = "15:04" func gameDTOFromGame(g game.Game) gameDTO { seats := make([]seatDTO, 0, len(g.Seats)) for _, s := range g.Seats { + // An open game's still-empty opponent seat has no account: emit an empty id (the + // display name is left empty by fillSeatNames) so the client shows "searching for + // opponent" rather than the nil-UUID. + accountID := "" + if s.AccountID != uuid.Nil { + accountID = s.AccountID.String() + } seats = append(seats, seatDTO{ Seat: s.Seat, - AccountID: s.AccountID.String(), + AccountID: accountID, Score: s.Score, HintsUsed: s.HintsUsed, IsWinner: s.IsWinner, @@ -277,13 +286,12 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { return dto, nil } -// matchDTOFrom projects an enqueue/poll result into its DTO. +// matchDTOFrom projects an enqueue result into its DTO. Enqueue always lands the +// caller in a game (freshly opened or joined), so the game is always present; Matched +// reports whether it already had an opponent. func matchDTOFrom(r lobby.EnqueueResult) matchDTO { - if !r.Matched { - return matchDTO{Matched: false} - } g := gameDTOFromGame(r.Game) - return matchDTO{Matched: true, Game: &g} + return matchDTO{Matched: r.Matched, Game: &g} } // chatDTOFrom projects a chat message into its DTO. diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 7537f6e..b8c83bc 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -76,8 +76,6 @@ 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 { u.GET("/invitations", s.handleListInvitations) @@ -161,14 +159,14 @@ func statusForError(err error) (int, string) { 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.ErrNoOpponentYet): + return http.StatusConflict, "no_opponent_yet" case errors.Is(err, game.ErrGameActive): return http.StatusConflict, "game_active" case errors.Is(err, account.ErrInvalidProfile): return http.StatusBadRequest, "invalid_profile" case errors.Is(err, account.ErrAlreadyConfirmed): return http.StatusConflict, "already_confirmed" - case errors.Is(err, lobby.ErrAlreadyQueued): - return http.StatusConflict, "already_queued" case errors.Is(err, lobby.ErrInvalidInvitation): return http.StatusBadRequest, "invalid_invitation" case errors.Is(err, lobby.ErrInvitationBlocked): diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 834aa0a..798fd14 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -133,13 +133,15 @@ func (s *Server) handleGameState(c *gin.Context) { c.JSON(http.StatusOK, dto) } -// enqueueRequest joins the per-variant auto-match pool under a per-turn word rule. +// enqueueRequest enters per-variant auto-match under a per-turn word rule. type enqueueRequest struct { Variant string `json:"variant"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` } -// handleEnqueue joins the auto-match pool for a variant. +// handleEnqueue enters the caller into auto-match for a variant and returns the game +// they land in immediately: a freshly opened game awaiting an opponent, or another +// player's open game they just joined. The client navigates straight into the game. func (s *Server) handleEnqueue(c *gin.Context) { uid, ok := userID(c) if !ok { @@ -168,39 +170,6 @@ 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) - if !ok { - abortBadRequest(c, "missing identity") - return - } - res, err := s.matchmaker.Poll(c.Request.Context(), uid) - if err != nil { - s.abortErr(c, err) - return - } - dto := matchDTOFrom(res) - if dto.Game != nil { - s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{}) - } - c.JSON(http.StatusOK, dto) -} - // chatPostRequest posts a per-game chat message. type chatPostRequest struct { Body string `json:"body"` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4fcccb4..1d27b84 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -334,10 +334,11 @@ Key points: ## 7. Robot opponent -Substitutes for a human in 2-player auto-match when the pool yields no human -within 10 seconds (§8). It lives in `internal/robot` and plays as an ordinary -seated account through the game service, so only `internal/engine` imports the -solver. It is designed to be indistinguishable from a person. +Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an +open game's empty opponent slot when no human has joined within the wait window (§8). +It lives in `internal/robot` and plays as an ordinary seated account through the game +service, so only `internal/engine` imports the solver. It is designed to be +indistinguishable from a person. The robot keeps **no per-game state**: every choice is derived deterministically from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver @@ -381,17 +382,24 @@ English game the Latin pool. ## 8. Lobby & social -- **Matchmaking**: an **in-memory** FIFO pool keyed by `variant` (the variant - fixes the board language), pairing the next two humans into a two-player - auto-match with the seat order randomised for first-move fairness. The pool is - lost on restart (players re-queue) and is anonymous, so it does not consult - blocks. After **10 s** with no human a background reaper substitutes a pooled - 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. +- **Matchmaking**: auto-match drops the player **straight into a real game and lets + them wait inside it**. `Enqueue` (`POST /lobby/enqueue`) opens a game seating the + caller with an **empty opponent seat** (status `open`, §9), or — when another player + is already waiting for the same `variant` and per-turn rule — seats the caller into + that open game and starts it; which seat the caller takes is randomised for + first-move fairness, and a re-enqueue returns the caller's own still-open game + (idempotent). Matchmaking state is therefore the **open games in the database** (not + an in-memory pool), so it survives a restart and stays anonymous (no block check); + concurrent enqueues for one bucket are serialised by a transaction-scoped advisory + lock so two callers pair rather than each opening a game. A background **reaper** + seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus + a random **0–90 s** (so **90–180 s** total) — has elapsed, guaranteeing every game + gets an opponent. When a human or a robot takes the seat, the waiting starter + receives an **opponent-joined** notification (§10) that fills the opponent card and + re-enables resign and chat **in place** — the starter never leaves the game. While a + game is `open` the starter may move on their turn, but resign, chat and nudge are + refused (no opponent yet) and the lobby and opponent card show a "searching for + opponent" placeholder. - **Friends**: 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 @@ -462,7 +470,10 @@ English game the Latin pool. game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one account's own lobby list, leaving it visible to the other players — finished-only and irreversible by design, so there is no un-hide). - The matchmaking pool is **in-memory** and persists nothing. + Auto-match has no separate store: a game **awaiting an opponent** is an ordinary + `games` row with status `open` and a single seated `game_players` row (the empty + opponent seat is a null `account_id`, filled when a human or robot joins), plus an + `open_deadline_at` stamp the reaper scans for robot substitution. - **Active games are event-sourced.** A game is a `games` row (pinned `variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised turn cursor) plus an append-only, decoded move journal (`game_moves`); the live @@ -470,7 +481,8 @@ English game the Latin pool. rebuilt by replaying the journal on a miss, which the seeded bag makes exact. Each game is serialised by a per-game lock; a persistence failure evicts the live game so the next access rebuilds from the journal. `game_players` records - each seat's account, running score, hints used and winner flag. + each seat's account (**null for an open game's still-empty opponent seat**), + running score, hints used and winner flag. - **Statistics** (`account_stats`, recomputed on each finish for durable non-guest accounts only — the finish-time recompute skips any `is_guest` seat): wins, losses, **draws**, max points in a game, and @@ -520,7 +532,7 @@ catalog is **your-turn** and **opponent-moved** (emitted from the game commit, s robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat, including the mover**, so the mover's own other devices and their lobby refresh — it is in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge** -(from the social service), **match-found** (from the matchmaker, §8), and **notify** +(from the social service), **opponent-joined** (from the matchmaker, §8), and **notify** (a lightweight "re-poll" signal carrying a sub-kind: friend-request, friend-added, friend-declined, invitation or game-started; emitted on a friend-request, on answering one (accept → friend-added, decline → friend-declined — to the original @@ -535,16 +547,17 @@ without a follow-up `game.state`: **opponent-moved** carries the committed move summary (per-seat scores, whose turn, move count, status) and the bag size, which the client applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history` -refetch); **your-turn** carries that move count as a consistency check; **match-found** and the -**game-started** notify carry the recipient's full **initial `StateView`**, so opening a freshly -started game is instant; **game-over** carries the final summary; the lobby **notify** sub-kinds +refetch); **your-turn** carries that move count as a consistency check; the **game-started** +notify carries the recipient's full **initial `StateView`** so opening a freshly started game is +instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the +opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` / `exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding (fed wire-agnostic input structs by the domain services) and the gateway forwards every payload -verbatim. A client that is not currently streaming falls back to the matchmaker's `Poll` for -match-found — the client polls **only while the stream is down**, since a live stream delivers -match-found itself; for the lobby **notification badge** (incoming friend requests + open +verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters +synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined** +event; for the lobby **notification badge** (incoming friend requests + open invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push missed while the app was hidden. **Out-of-app platform push** is a fallback the **gateway** routes from the same firehose: for an event whose recipient has **no @@ -557,7 +570,7 @@ not the recipient's latest-login bot. It then asks the **Telegram connector** to localized message with a Mini App deep-link button — only when the recipient has a Telegram identity and has not confined notifications to the app, so the two channels never duplicate. The connector routes by that language to the matching bot and renders the message in it. The out-of-app set is -your-turn, game-over, nudge, match-found and the invitation / friend-request notify sub-kinds; +your-turn, game-over, nudge and the invitation / friend-request notify sub-kinds; the connector renders the message and skips the rest. Operator broadcasts (`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an **operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 839d17e..6457aa1 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -41,8 +41,9 @@ language, not whichever bot the player signed in through last. Guests are sessio (auto-match only; no friends, stats or history); an abandoned guest that never joined a game and has been idle past the retention window is garbage-collected. While the app is open the client keeps a live stream and receives in-app updates in real time — the opponent's move, -your turn, chat, nudges and a found match. Each update lands as the event itself, applied in place -with no reload, so the board refreshes seamlessly and a found or invited game opens instantly. When the app is **closed**, the chosen +your turn, chat, nudges and an opponent joining a game you are waiting in. Each update lands as the +event itself, applied in place with no reload, so the board refreshes seamlessly and an invited game +opens instantly. When the app is **closed**, the chosen out-of-app events (your turn, game over, nudge, a found match, an invitation or friend request) arrive as a **Telegram notification** instead — unless the player keeps notifications in the app only (a profile setting, **on by default**). The "your turn" @@ -84,8 +85,12 @@ unrestricted). Variants are shown by their **display name** — both Scrabble va "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. For Russian games (auto-match or friend +(always 2 players) drops you **straight into the game and you wait inside it**: if it is your turn you +can already move, otherwise you watch your tiles. While no opponent has joined, the opponent card (and +the game's row in the lobby) reads **"searching for opponent"**, and resign, chat and nudge are +unavailable. Another player searching the same variant and rule joins your game; failing that, a robot +takes the empty seat after **1.5–3 minutes**, so a game always starts — and the New Game screen notes +you can close the app while you wait and come back later. For Russian games (auto-match or friend invitation), New Game also offers **"Multiple words per turn"** (default **off**): off plays the simplified **single-word rule** — only the word laid along the player's line must be a real word, and any incidental perpendicular words are ignored and not scored — while on is @@ -121,8 +126,8 @@ the opponent's turn**, but that draft is position-only — the score preview and stay available only on the player's own turn. ### Robot opponent -When auto-match finds no human within ten seconds, a robot opponent takes the empty -seat so the game starts without waiting. It is meant to feel like a person: it +When auto-match finds no human within the wait window (1.5–3 minutes), a robot opponent +takes the empty seat of the game you are already waiting in. It is meant to feel like a person: it decides once per game whether to play to win (about 40% of the time, so the human wins most games), aims for a close score rather than crushing or throwing the game, and plays at a human pace — short thinking times for most moves, the occasional long diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f201d97..8bda84f 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -42,9 +42,9 @@ nudge) приходят от бота **этой партии** — по язы авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, -чат, nudge и найденный матч. Каждое обновление приходит самим событием и применяется на месте без -перезагрузки — доска обновляется бесшовно, а найденная или приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие -события (ваш ход, конец партии, nudge, найденный матч, приглашение или заявка в друзья) +чат, nudge и подключение соперника к игре, в которой вы ждёте. Каждое обновление приходит самим событием и применяется на месте без +перезагрузки — доска обновляется бесшовно, а приглашённая игра открывается мгновенно. Когда приложение **закрыто**, выбранные внеприложенческие +события (ваш ход, конец партии, nudge, приглашение или заявка в друзья) приходят вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления только в приложении (настройка профиля, **включена по умолчанию**). Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и @@ -87,9 +87,13 @@ nudge) приходят от бота **этой партии** — по язы читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на -любом языке. Авто-подбор (всегда 2 игрока) -встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с -без человека подставляется робот. Для русских игр (авто-подбор или приглашение) на экране +любом языке. Авто-подбор (всегда 2 игрока) сразу **помещает вас в игру, и вы ждёте соперника прямо +в ней**: если ваш ход — вы уже можете ходить, иначе просто рассматриваете свои фишки. Пока соперник не +присоединился, на карточке соперника (и в строке игры в лобби) написано **«Поиск соперника...»**, а +сдача, чат и nudge недоступны. Другой игрок, ищущий тот же вариант и правило, присоединяется к вашей +игре; если такого нет — через **1,5–3 минуты** свободное место занимает робот, так что игра всегда +стартует, и экран новой игры подсказывает, что можно закрыть приложение на время ожидания и вернуться +позже. Для русских игр (авто-подбор или приглашение) на экране новой игры есть опция **«Несколько слов за ход»** (по умолчанию **выключена**): выключена — упрощённое **правило одного слова**: настоящим словом должно быть только слово, выложенное вдоль линии хода, а случайные перпендикулярные слова игнорируются и не засчитываются; @@ -126,8 +130,8 @@ nudge) приходят от бота **этой партии** — по язы предпросмотр счёта и отправка доступны лишь в собственный ход. ### Робот-соперник -Если авто-подбор не находит человека за десять секунд, свободное место занимает -робот-соперник, и партия стартует без ожидания. Он задуман неотличимым от человека: +Если авто-подбор не находит человека за время ожидания (1,5–3 минуты), свободное место в игре, +в которой вы уже ждёте, занимает робот-соперник. Он задуман неотличимым от человека: один раз за партию решает, играть ли на победу (примерно в 40% случаев, так что человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index a4ddd69..fb47aa6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -193,7 +193,12 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use of starting a game; a lone offered variant is pre-selected, and a bottom **Start game** button (disabled until a variant is chosen) confirms. For a **Russian** variant (either flow) a **"Multiple words per turn"** checkbox (`.toggle`, **default off** = the single-word - rule) appears once that variant is selected; English variants never show it. + rule) appears once that variant is selected; English variants never show it. Starting an + auto-match **enters the game immediately** and waits inside it: until an opponent joins, the + opponent's score card (and the game's lobby row) reads the localized **"searching for opponent"** + placeholder, the add-friend 🤝 is hidden, and resign and the chat's send/nudge are disabled; an + **opponent_joined** push restores them in place when a human or robot takes the seat, and a line + under Start game notes the wait can take a while (the app may be closed meanwhile). - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat cards (wins / losses / draws / games / win-rate / best game / best move) — pure numbers, no charts. diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts new file mode 100644 index 0000000..903649f --- /dev/null +++ b/ui/e2e/quickmatch.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from './fixtures'; + +// The quick-match flow drops the player straight into a game that is still waiting for an +// opponent (status 'open'): the opponent card shows "searching for opponent" and resign is +// disabled until the mock attaches a robot shortly after, which restores the game UI. Driven +// entirely by the mock transport (no backend). + +test('quick game: enter immediately, wait for an opponent, then it joins', 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 + + // Pick a variant and start; the player lands in the game at once (no "searching" screen). + await page.locator('.variant').first().click(); + await page.getByRole('button', { name: /Start game/i }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + + // Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the + // history panel) is disabled. + await expect(page.getByText(/Searching for opponent/)).toBeVisible(); + await page.locator('.scoreboard').click(); // open the history panel + await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled(); + + // Attach the opponent deterministically (the mock otherwise joins on a timer). + await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); + + // The opponent card shows its name, the placeholder is gone, and resign is enabled again. + await expect(page.getByText('Robo')).toBeVisible(); + await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled(); +}); diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index f148140..6e689e6 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -8,6 +8,7 @@ myId, busy, myTurn = false, + waiting = false, nudgeOnCooldown = false, onsend, onnudge, @@ -20,6 +21,9 @@ // 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; + // waiting is true while an auto-match game still has no opponent: both send and nudge + // are disabled (there is no one to message or hurry yet). + waiting?: boolean; nudgeOnCooldown?: boolean; onsend: (text: string) => void; onnudge: () => void; @@ -56,11 +60,11 @@ bind:value={text} onkeydown={(e) => e.key === 'Enter' && send()} /> - + {:else} {nudgeOnCooldown ? t('chat.awaitingReply') : ''} - + {/if} diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 5f88566..5f289a4 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -17,7 +17,11 @@ let tick = $state(0); const myId = $derived(app.session?.userId ?? ''); - const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); + const isMyTurn = $derived( + !!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat, + ); + // While the auto-match game still has no opponent, chat and nudge are both disabled. + const waiting = $derived(!!view && view.game.status === 'open'); const nudgeCooldownSecs = 3600; // The nudge is one-per-hour-per-game and clears once the player chats (engagement); the // backend stays authoritative, so a move-based reset is left to it. @@ -87,4 +91,4 @@ } - + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 057f5a9..645572e 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -79,7 +79,7 @@ let recentFlash = $state(false); function refreshRecent() { const v = view; - if (!v || v.game.status !== 'active') { + if (!v || v.game.status === 'finished') { recent = new Set(); recentFlash = false; return; @@ -98,8 +98,12 @@ }); const slots = $derived(rackView(placement)); const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index }))); - const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); - const gameOver = $derived(!!view && view.game.status !== 'active'); + // 'open' is an auto-match game still waiting for an opponent: the starter may move on their + // turn just like an active game, so "playable" covers both; only 'finished' is over. + const waitingForOpponent = $derived(!!view && view.game.status === 'open'); + const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open')); + const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); + const gameOver = $derived(!!view && view.game.status === 'finished'); const bagEmpty = $derived((view?.bagLen ?? 0) === 0); // The seat whose move the history grid awaits with a "thinking…" placeholder: the player to // move while the game is active, but never the viewer themselves (their own pending cell @@ -215,6 +219,13 @@ if (view && e.moveCount > view.game.moveCount) void load(); } else if (e.kind === 'game_over' && e.gameId === id) { applyDelta(applyGameOver(cacheSnapshot(), e.game)); + } else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) { + // The opponent took the empty seat: adopt the new participants and status in place, + // leaving the board, rack and any pending placement untouched (no refetch, no flicker). + if (view) { + view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } }; + setCachedGame(id, view, moves); + } } else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) { // A request the player sent was answered: re-derive the in-game "add friend" state. void loadFriends(); @@ -748,10 +759,21 @@ } } - // canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is - // not yet a friend (an already-requested opponent still shows it, but disabled). + // seatName renders a seat's name: "you" for the viewer, the localized "searching for + // opponent" placeholder for an open game's still-empty seat (no account), otherwise the + // display name. + function seatName(s: { accountId: string; displayName: string } | undefined): string { + if (!s) return ''; + if (s.accountId === app.session?.userId) return t('common.you'); + if (!s.accountId) return t('game.searchingForOpponent'); + return s.displayName; + } + + // canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent + // (not the still-empty seat of an open game) who is not yet a friend (an already-requested + // opponent still shows it, but disabled). function canAddFriend(accountId: string): boolean { - return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId); + return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId); } @@ -763,7 +785,7 @@ {#if (app.chatUnread[id] ?? 0) > 0}{app.chatUnread[id]}{/if} {#each view.game.seats as s (s.seat)}
-
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
+
{seatName(s)}
{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}
{#if historyOpen && canAddFriend(s.accountId)} @@ -788,7 +810,7 @@ {#if gameOver} {:else} - + {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} -
- {:else} {#if !guest}
@@ -214,9 +147,10 @@ {/if}

{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}

+

{t('new.searchHint')}

{:else if friends.length === 0} @@ -266,7 +200,6 @@
{/if} - {/if} @@ -460,31 +393,11 @@ .invite:disabled { opacity: 0.5; } - .searching { - display: grid; - place-items: center; - gap: 14px; - padding: 48px 0; + .searchhint { + margin: 0; + text-align: center; color: var(--text-muted); - } - .spinner { - width: 36px; - height: 36px; - border: 3px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - @keyframes spin { - to { - transform: rotate(360deg); - } - } - .cancel { - padding: 8px 16px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - border-radius: var(--radius-sm); + font-size: 0.85rem; + line-height: 1.4; } -- 2.52.0 From a3cb917ec7b6de1761242ef42432edc76a687546 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 13 Jun 2026 10:29:56 +0200 Subject: [PATCH 106/223] fix(lobby): land in the opened game on enqueue + keep open games active in the lobby Review fixes for open-game auto-match: decodeMatchResult dropped the game when matched=false (an open game awaiting an opponent), so the client never navigated into it - decode the game whenever present. The lobby grouped open games (status != 'active') into 'finished'; treat 'open' as in progress in groupGames/isMyTurn and resultBadge. The under-board status bar now reads "Opponent's turn" while the empty opponent seat is to move (instead of the searching placeholder). The New Game rule toggle is shown from the start when a Russian variant is available, so selecting a variant no longer shifts the layout. Regression tests: codec (game decoded with matched=false), lobbysort + result (open is in progress), and the new-game e2e updated. UI-only; no backend or schema change. --- ui/e2e/game.spec.ts | 12 +++++++----- ui/src/game/Game.svelte | 10 +++++++++- ui/src/lib/codec.test.ts | 30 ++++++++++++++++++++++++++++++ ui/src/lib/codec.ts | 4 +++- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/lobbysort.test.ts | 14 ++++++++++++++ ui/src/lib/lobbysort.ts | 5 +++-- ui/src/lib/result.test.ts | 6 ++++++ ui/src/lib/result.ts | 2 +- ui/src/screens/NewGame.svelte | 2 +- 11 files changed, 76 insertions(+), 11 deletions(-) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index f4ce823..7f464e3 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -70,19 +70,21 @@ test('new game: variant buttons show a rules summary and the move-limit', async await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons }); -test('new game: auto-match selects a variant, then a Russian pick reveals the off-by-default toggle', async ({ page }) => { +test('new game: auto-match shows the off-by-default rule toggle from the start (no layout jump on selection)', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /New/ }).click(); // auto-match - // Several variants are offered, so nothing is selected: Start is disabled and there is no toggle yet. + // Several variants are offered, so nothing is selected: Start is disabled. The rule toggle is shown + // from the start (a Russian variant is available), so selecting one does not shift the layout. const start = page.getByRole('button', { name: /Start game/i }); await expect(start).toBeDisabled(); - await expect(page.getByLabel('Multiple words per turn')).toHaveCount(0); - // Selecting the Russian Scrabble variant highlights it, enables Start, and reveals the rule toggle (off). + const toggle = page.getByLabel('Multiple words per turn'); + await expect(toggle).toBeVisible(); + await expect(toggle).not.toBeChecked(); + // Selecting the Russian Scrabble variant highlights it and enables Start; the toggle stays put. await page.locator('.variant', { hasText: 'Скрэббл' }).click(); await expect(page.locator('.variant.selected')).toHaveCount(1); await expect(start).toBeEnabled(); - const toggle = page.getByLabel('Multiple words per turn'); await expect(toggle).toBeVisible(); await expect(toggle).not.toBeChecked(); }); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 645572e..943a906 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -769,6 +769,14 @@ return s.displayName; } + // turnLabel is the under-board status when it is not the viewer's turn: the opponent's name + // once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting). + function turnLabel(): string { + const s = view?.game.seats[view?.game.toMove ?? -1]; + if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName; + return t('game.opponentsTurn'); + } + // canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent // (not the still-empty seat of an open game) who is not yet a friend (an already-requested // opponent still shows it, but disabled). @@ -877,7 +885,7 @@ {#if gameOver} {t('game.over')} — {resultText()} {:else if placement.pending.length === 0} - {isMyTurn ? t('game.yourTurn') : seatName(view.game.seats[view.game.toMove])} + {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 60ed7c5..121df13 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -9,6 +9,7 @@ import { decodeGameList, decodeInvitation, decodeLinkResult, + decodeMatchResult, decodeOutgoingList, decodeSession, decodeStateView, @@ -278,6 +279,35 @@ describe('codec', () => { state: undefined, }); }); + + it('decodes the match game even when matched is false (an open game awaiting an opponent)', () => { + const b = new Builder(256); + const id = b.createString('g-open'); + const variant = b.createString('scrabble_en'); + const dv = b.createString('v1'); + const status = b.createString('open'); + const er = b.createString(''); + fb.GameView.startGameView(b); + fb.GameView.addId(b, id); + fb.GameView.addVariant(b, variant); + fb.GameView.addDictVersion(b, dv); + fb.GameView.addStatus(b, status); + fb.GameView.addPlayers(b, 2); + fb.GameView.addToMove(b, 0); + fb.GameView.addTurnTimeoutSecs(b, 86400); + fb.GameView.addMoveCount(b, 0); + fb.GameView.addEndReason(b, er); + fb.GameView.addLastActivityUnix(b, BigInt(0)); + const game = fb.GameView.endGameView(b); + fb.MatchResult.startMatchResult(b); + fb.MatchResult.addMatched(b, false); + fb.MatchResult.addGame(b, game); + b.finish(fb.MatchResult.endMatchResult(b)); + const r = decodeMatchResult(b.asUint8Array()); + expect(r.matched).toBe(false); + expect(r.game?.id).toBe('g-open'); + expect(r.game?.status).toBe('open'); + }); }); // The live play loop exchanges alphabet indices, mapped through the per-variant diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index bdc7232..7882a74 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -407,7 +407,9 @@ export function decodeGameList(buf: Uint8Array): GameList { export function decodeMatchResult(buf: Uint8Array): MatchResult { const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf)); const g = m.game(); - return { matched: m.matched(), game: m.matched() && g ? decodeGameView(g) : undefined }; + // Enqueue always lands the caller in a game — an open game awaiting an opponent has + // matched=false but still carries it — so decode the game whenever it is present. + return { matched: m.matched(), game: g ? decodeGameView(g) : undefined }; } export function decodeChatMessage(buf: Uint8Array): ChatMessage { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index b61f6ef..c62bf5a 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -58,6 +58,7 @@ export const en = { 'game.bagEmpty': 'Bag is empty', 'game.hints': 'Hints {n}', 'game.yourTurn': 'Your turn', + 'game.opponentsTurn': "Opponent's turn", 'game.searchingForOpponent': 'Searching for opponent…', 'game.waiting': "Waiting for {name}", 'game.makeMove': 'Make move', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index e5ed9e4..6e01f54 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -59,6 +59,7 @@ export const ru: Record = { 'game.bagEmpty': 'Мешок пуст', 'game.hints': 'Подсказки {n}', 'game.yourTurn': 'Ваш ход', + 'game.opponentsTurn': 'Ход соперника', 'game.searchingForOpponent': 'Поиск соперника...', 'game.waiting': 'Ожидаем {name}', 'game.makeMove': 'Сделать ход', diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index 962ecbb..dd3fea4 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -66,4 +66,18 @@ describe('groupGames', () => { expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false); }); + + it('treats an open game (awaiting an opponent) as in progress, not finished', () => { + const g = groupGames( + [ + game('open_mine', 'open', 0, 100), // my turn while waiting + game('open_wait', 'open', 1, 100), // the empty seat's turn + ], + ME, + ); + expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']); + expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']); + expect(g.finished).toEqual([]); + expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true); + }); }); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts index 8cad94e..b01631b 100644 --- a/ui/src/lib/lobbysort.ts +++ b/ui/src/lib/lobbysort.ts @@ -8,7 +8,8 @@ import type { GameView } from './model'; /** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */ export function isMyTurn(game: GameView, myId: string): boolean { const me = game.seats.find((s) => s.accountId === myId); - return game.status === 'active' && !!me && game.toMove === me.seat; + // 'open' (an auto-match game still awaiting an opponent) is in progress like 'active'. + return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat; } /** LobbyGroups holds the three ordered lobby sections. */ @@ -28,7 +29,7 @@ export function groupGames(games: GameView[], myId: string): LobbyGroups { const theirTurn: GameView[] = []; const finished: GameView[] = []; for (const g of games) { - if (g.status !== 'active') finished.push(g); + if (g.status !== 'active' && g.status !== 'open') finished.push(g); else if (isMyTurn(g, myId)) yourTurn.push(g); else theirTurn.push(g); } diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 87d12f2..be4ebc4 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -35,6 +35,12 @@ describe('resultBadge', () => { expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove'); }); + it('open (awaiting an opponent) reads as in-progress, not a finished result', () => { + const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0); + expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' }); + expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove'); + }); + it('finished two-player: victory / defeat / draw', () => { expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({ key: 'result.victory', diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts index d177b94..19360e2 100644 --- a/ui/src/lib/result.ts +++ b/ui/src/lib/result.ts @@ -12,7 +12,7 @@ export interface ResultBadge { export function resultBadge(game: GameView, myId: string): ResultBadge { const me = game.seats.find((s) => s.accountId === myId); - if (game.status === 'active') { + if (game.status === 'active' || game.status === 'open') { return game.toMove === me?.seat ? { key: 'result.yourMove', emoji: '🟢' } : { key: 'result.oppMove', emoji: '⏳' }; diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 06d1871..7a048a2 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -140,7 +140,7 @@ {/each} - {#if selectedAuto && supportsMultipleWordsToggle(selectedAuto)} + {#if variants.some((v) => supportsMultipleWordsToggle(v.id))}
+

Account block

+{{$uid := .ID}} +{{with .Suspension}}{{if .Blocked}} +
    +
  • Status {{if .Permanent}}blocked (permanent){{else}}blocked until {{.Until}} (UTC){{end}}
  • +{{if .BlockedAt}}
  • Since {{.BlockedAt}}
  • {{end}} +{{if .ReasonEn}}
  • Reason {{.ReasonEn}} / {{.ReasonRu}}
  • {{end}} +
+
+ +
+{{else}}

not blocked

{{end}}{{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 e59d46c..036a6d5 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -135,6 +135,29 @@ type UserDetailView struct { // 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 + // Suspension is the account's current manual-block state, shown with the block/unblock + // form; Reasons is the operator-editable reason picklist offered in the block form. + Suspension SuspensionView + Reasons []ReasonOption +} + +// SuspensionView is an account's current manual-block state shown on the user card: whether it +// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent), +// when it was applied, and the reason snapshot in both languages (empty when none was cited). +type SuspensionView struct { + Blocked bool + Permanent bool + Until string + BlockedAt string + ReasonEn string + ReasonRu string +} + +// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown. +type ReasonOption struct { + ID string + TextEn string + TextRu string } // StatsRow is an account's lifetime statistics. @@ -317,6 +340,19 @@ type FlaggedAccountRow struct { FlaggedAt string } +// ReasonsView is the suspension-reason picklist management page: every editable reason entry. +type ReasonsView struct { + Items []ReasonRow +} + +// ReasonRow is one editable suspension-reason entry, with its English and Russian text. +type ReasonRow struct { + ID string + TextEn string + TextRu string + CreatedAt string +} + // MessageView is the result page shown after a POST action. type MessageView struct { Heading string diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 026cc5b..e69757e 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -378,6 +378,68 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo return MoveResult{Move: rec, Game: post}, nil } +// ForfeitAllForAccount resigns every game the account is currently playing and cancels every game +// it has opened but no opponent has joined, returning how many it acted on. It is the game-side +// effect of an admin block: the blocked player instantly loses each live game (the opponent +// winning, exactly as a manual resignation) and leaves matchmaking so nobody joins a doomed game. +// It reuses the per-game resignation path (its lock, commit and live events), so it is safe to +// call while play continues and is idempotent — an already-resigned or finished game is skipped. +// A per-game failure is logged and skipped so one bad game does not strand the rest; the +// turn-timeout sweeper remains the backstop for anything missed in a race. +func (svc *Service) ForfeitAllForAccount(ctx context.Context, accountID uuid.UUID) (int, error) { + games, err := svc.store.ListGamesForAccount(ctx, accountID) + if err != nil { + return 0, err + } + count := 0 + for _, g := range games { + if g.Status != StatusActive && g.Status != StatusOpen { + continue // a finished game holds no turn to forfeit + } + acted, err := svc.forfeitOne(ctx, g.ID, accountID) + if err != nil { + svc.log.Warn("forfeit on block", zap.String("game", g.ID.String()), zap.Error(err)) + continue + } + if acted { + count++ + } + } + return count, nil +} + +// forfeitOne resigns one game on behalf of accountID, or cancels it when it is an open auto-match +// game still awaiting an opponent. It reports whether it changed the game. A game that has already +// finished, or in which the account no longer holds a seat, is a benign no-op. +func (svc *Service) forfeitOne(ctx context.Context, gameID, accountID uuid.UUID) (bool, error) { + _, err := svc.Resign(ctx, gameID, accountID) + switch { + case err == nil: + return true, nil + case errors.Is(err, ErrNoOpponentYet): + // An open game with no opponent cannot be resigned (there is no one to award the win), so + // delete it to clear it from the matchmaking pool. If it filled in the race window it is + // now active, so resign the seat instead. + deleted, derr := svc.store.DeleteOpenGame(ctx, gameID) + if derr != nil { + return false, derr + } + if deleted { + svc.cache.remove(gameID) + return true, nil + } + if _, err := svc.Resign(ctx, gameID, accountID); err != nil && + !errors.Is(err, ErrFinished) && !errors.Is(err, ErrNoOpponentYet) { + return false, err + } + return true, nil + case errors.Is(err, ErrFinished), errors.Is(err, ErrNotAPlayer), errors.Is(err, ErrNotFound): + return false, nil + default: + return false, err + } +} + // GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet // indices to concrete letters before delegating to the letter-based play, exchange and // word-check methods, keeping a single domain path shared with the robot. diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 4db9f3c..d5ab8e2 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -354,6 +354,26 @@ func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, err return len(rows) > 0, nil } +// DeleteOpenGame removes a game only while it is still open (an auto-match game awaiting an +// opponent), reporting whether a row was deleted. The starter's lone seat and any draft cascade +// away with it. It no-ops (false) once the game has filled and become active, so a caller can +// fall back to resigning the now-active seat. Used to clear a blocked player from matchmaking. +func (s *Store) DeleteOpenGame(ctx context.Context, gameID uuid.UUID) (bool, error) { + stmt := table.Games.DELETE().WHERE( + table.Games.GameID.EQ(postgres.UUID(gameID)). + AND(table.Games.Status.EQ(postgres.String(StatusOpen))), + ) + res, err := stmt.ExecContext(ctx, s.db) + if err != nil { + return false, fmt.Errorf("game: delete open game %s: %w", gameID, err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("game: delete open game rows %s: %w", gameID, err) + } + return n > 0, nil +} + // ListGamesForAccount loads every game the account is seated in (active and // finished), newest first, each joined with its ordered seats. It backs the lobby's // "my games" lists. diff --git a/backend/internal/inttest/forfeit_test.go b/backend/internal/inttest/forfeit_test.go new file mode 100644 index 0000000..e5720f6 --- /dev/null +++ b/backend/internal/inttest/forfeit_test.go @@ -0,0 +1,123 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/game" +) + +// TestForfeitAllResignsActiveGame checks the game-side effect of a block on a two-player game: +// ForfeitAllForAccount resigns the blocked player, finishing the game with the opponent winning. +func TestForfeitAllResignsActiveGame(t *testing.T) { + ctx := context.Background() + svc := newGameService() + gid, seats := newGameWithSeats(t, 2) // seats[0] = blocked, seats[1] = opponent + + n, err := svc.ForfeitAllForAccount(ctx, seats[0]) + if err != nil { + t.Fatalf("forfeit all: %v", err) + } + if n < 1 { + t.Fatalf("forfeit count = %d, want at least 1", n) + } + + g, err := svc.GameByID(ctx, gid) + if err != nil { + t.Fatalf("get game: %v", err) + } + if g.Status != game.StatusFinished { + t.Errorf("status = %q, want finished", g.Status) + } + if w := seatWinner(g, seats[0]); w { + t.Error("the blocked player must not be the winner") + } + if w := seatWinner(g, seats[1]); !w { + t.Error("the opponent must win when the blocked player forfeits") + } +} + +// TestForfeitAllCancelsOpenGame checks that a block deletes the blocked player's open auto-match +// game (no opponent to resign against), clearing it from the matchmaking pool. +func TestForfeitAllCancelsOpenGame(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + starter := provisionAccount(t) + g := openGame(t, svc, starter, evenOpeningSeed(t)) + + n, err := svc.ForfeitAllForAccount(ctx, starter) + if err != nil { + t.Fatalf("forfeit all: %v", err) + } + if n < 1 { + t.Fatalf("forfeit count = %d, want at least 1", n) + } + if c := gameRowCount(t, g.ID); c != 0 { + t.Errorf("open game rows after forfeit = %d, want 0 (deleted)", c) + } +} + +// TestForfeitAllResignsSeatInMultiplayerGame checks that in a 3-player game the blocked player's +// seat is resigned while the game continues for the remaining players. +func TestForfeitAllResignsSeatInMultiplayerGame(t *testing.T) { + ctx := context.Background() + svc := newGameService() + gid, seats := newGameWithSeats(t, 3) + + n, err := svc.ForfeitAllForAccount(ctx, seats[0]) + if err != nil { + t.Fatalf("forfeit all: %v", err) + } + if n < 1 { + t.Fatalf("forfeit count = %d, want at least 1", n) + } + + g, err := svc.GameByID(ctx, gid) + if err != nil { + t.Fatalf("get game: %v", err) + } + if g.Status != game.StatusActive { + t.Errorf("status = %q, want still active (two seats remain)", g.Status) + } + if c := resignMoveCount(t, gid, 0); c != 1 { + t.Errorf("resign moves for seat 0 = %d, want 1", c) + } +} + +// seatWinner reports the is_winner flag for the given account's seat in g. +func seatWinner(g game.Game, accountID uuid.UUID) bool { + for _, s := range g.Seats { + if s.AccountID == accountID { + return s.IsWinner + } + } + return false +} + +// gameRowCount counts the rows in the games table for gameID (0 once it is deleted). +func gameRowCount(t *testing.T, gameID uuid.UUID) int { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.games WHERE game_id = $1`, gameID).Scan(&n); err != nil { + t.Fatalf("count game rows: %v", err) + } + return n +} + +// resignMoveCount counts the resign moves journalled for a seat in a game. +func resignMoveCount(t *testing.T, gameID uuid.UUID, seat int) int { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.game_moves WHERE game_id = $1 AND action = 'resign' AND seat = $2`, + gameID, seat).Scan(&n); err != nil { + t.Fatalf("count resign moves: %v", err) + } + return n +} diff --git a/backend/internal/inttest/suspension_console_test.go b/backend/internal/inttest/suspension_console_test.go new file mode 100644 index 0000000..dea8948 --- /dev/null +++ b/backend/internal/inttest/suspension_console_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "go.uber.org/zap/zaptest" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/game" + "scrabble/backend/internal/server" +) + +// TestConsoleBlockAndUnblock drives the admin console block flow through HTTP: blocking a user +// records the suspension and forfeits their active game, the user card renders both branches, +// and unblocking lifts it. +func TestConsoleBlockAndUnblock(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + games := newGameService() + srv := consoleServer(t, accounts, games) + + gid, seats := newGameWithSeats(t, 2) + blocked := seats[0] + + // The card renders the not-blocked branch before any block. + if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK { + t.Fatalf("user card before block = %d, want 200", rec.Code) + } + + // Block permanently via the console. + if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/block", url.Values{"duration": {"permanent"}}); rec.Code != http.StatusOK { + t.Fatalf("console block = %d, want 200", rec.Code) + } + if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || !ok { + t.Fatalf("after console block: blocked=%v err=%v, want blocked", ok, err) + } + if g, err := games.GameByID(ctx, gid); err != nil || g.Status != game.StatusFinished { + t.Fatalf("game after block: status=%q err=%v, want finished", g.Status, err) + } + + // The card renders the blocked branch. + if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK { + t.Fatalf("user card after block = %d, want 200", rec.Code) + } + + // Unblock via the console. + if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/unblock", url.Values{}); rec.Code != http.StatusOK { + t.Fatalf("console unblock = %d, want 200", rec.Code) + } + if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || ok { + t.Fatalf("after console unblock: blocked=%v err=%v, want not blocked", ok, err) + } +} + +// TestConsoleReasonsCRUD drives reason creation through the console and checks the page renders. +func TestConsoleReasonsCRUD(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + srv := consoleServer(t, accounts, newGameService()) + + if rec := consoleGet(t, srv, "/_gm/reasons"); rec.Code != http.StatusOK { + t.Fatalf("reasons page = %d, want 200", rec.Code) + } + form := url.Values{"text_en": {"Console reason"}, "text_ru": {"Причина из консоли"}} + if rec := consolePost(t, srv, "/_gm/reasons", form); rec.Code != http.StatusOK { + t.Fatalf("console create reason = %d, want 200", rec.Code) + } + reasons, err := accounts.ListReasons(ctx) + if err != nil { + t.Fatalf("list reasons: %v", err) + } + found := false + for _, r := range reasons { + if r.TextEn == "Console reason" && r.TextRu == "Причина из консоли" { + found = true + } + } + if !found { + t.Error("reason created via the console was not found") + } +} + +// consoleServer assembles a server with the admin console mounted (it needs accounts, games and a +// registry). +func consoleServer(t *testing.T, accounts *account.Store, games *game.Service) *server.Server { + t.Helper() + return server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: accounts, + Games: games, + Registry: testRegistry, + DictDir: dictDir(), + }) +} + +// consoleGet issues a console GET (a safe method, so the same-origin guard passes). +func consoleGet(t *testing.T, srv *server.Server, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil)) + return rec +} + +// consolePost issues a same-origin form POST to the console. +func consolePost(t *testing.T, srv *server.Server, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "http://example.com") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} diff --git a/backend/internal/inttest/suspension_gate_test.go b/backend/internal/inttest/suspension_gate_test.go new file mode 100644 index 0000000..27555f8 --- /dev/null +++ b/backend/internal/inttest/suspension_gate_test.go @@ -0,0 +1,151 @@ +//go:build integration + +package inttest + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "go.uber.org/zap/zaptest" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/server" +) + +// blockStatusBody mirrors the backend's /block-status JSON for the test. +type blockStatusBody struct { + Blocked bool `json:"blocked"` + Permanent bool `json:"permanent"` + Until string `json:"until"` + Reason string `json:"reason"` +} + +// TestSuspensionGate drives the gate end-to-end through the assembled HTTP server: a blocked +// account is refused on a normal user route with 403 + account_blocked, the block-status probe +// stays reachable and resolves the reason to the account's language, and an unblock restores +// access. +func TestSuspensionGate(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + srv := server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: accounts, + }) + + acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") + if err != nil { + t.Fatalf("provision: %v", err) + } + id := acc.ID + + // Not blocked: a normal route passes and block-status reports false. + if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK { + t.Fatalf("profile before block = %d, want 200", rec.Code) + } + if bs := blockStatus(t, srv, id); bs.Blocked { + t.Fatal("fresh account should not be blocked") + } + + // Block permanently with a reason. + if _, err := accounts.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil { + t.Fatalf("suspend: %v", err) + } + + // A normal route is now refused with the stable code. + rec := userGet(t, srv, "/api/v1/user/profile", id) + if rec.Code != http.StatusForbidden { + t.Fatalf("profile while blocked = %d, want 403", rec.Code) + } + if code := errorCode(t, rec); code != "account_blocked" { + t.Fatalf("error code = %q, want account_blocked", code) + } + + // The block-status probe stays reachable and resolves the reason to the account's language. + bs := blockStatus(t, srv, id) + if !bs.Blocked || !bs.Permanent { + t.Fatalf("block-status = %+v, want blocked+permanent", bs) + } + if bs.Reason != "Спам" { + t.Errorf("reason = %q, want the Russian snapshot Спам", bs.Reason) + } + if bs.Until != "" { + t.Errorf("until = %q, want empty for a permanent block", bs.Until) + } + + // Unblock restores access. + if err := accounts.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift: %v", err) + } + if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK { + t.Fatalf("profile after unblock = %d, want 200", rec.Code) + } +} + +// TestSuspensionGateTemporaryUntil checks a temporary block reports its expiry through +// block-status as a future RFC3339 instant and not permanent. +func TestSuspensionGateTemporaryUntil(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) + id := provisionAccount(t) + + until := time.Now().Add(24 * time.Hour) + if _, err := accounts.Suspend(ctx, id, &until, "", "", nil); err != nil { + t.Fatalf("suspend: %v", err) + } + bs := blockStatus(t, srv, id) + if !bs.Blocked || bs.Permanent { + t.Fatalf("block-status = %+v, want blocked, not permanent", bs) + } + parsed, err := time.Parse(time.RFC3339, bs.Until) + if err != nil { + t.Fatalf("until %q is not RFC3339: %v", bs.Until, err) + } + if !parsed.After(time.Now()) { + t.Errorf("until = %v, want a future instant", parsed) + } +} + +// userGet issues an authenticated GET (X-User-ID) against the assembled server. +func userGet(t *testing.T, srv *server.Server, path string, id uuid.UUID) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-User-ID", id.String()) + srv.Handler().ServeHTTP(rec, req) + return rec +} + +// blockStatus fetches and decodes the /block-status payload, asserting a 200. +func blockStatus(t *testing.T, srv *server.Server, id uuid.UUID) blockStatusBody { + t.Helper() + rec := userGet(t, srv, "/api/v1/user/block-status", id) + if rec.Code != http.StatusOK { + t.Fatalf("block-status status = %d, want 200", rec.Code) + } + var b blockStatusBody + if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { + t.Fatalf("decode block-status: %v", err) + } + return b +} + +// errorCode extracts the stable code from the backend error envelope. +func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var e struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { + t.Fatalf("decode error envelope: %v", err) + } + return e.Error.Code +} diff --git a/backend/internal/inttest/suspension_test.go b/backend/internal/inttest/suspension_test.go new file mode 100644 index 0000000..32546f4 --- /dev/null +++ b/backend/internal/inttest/suspension_test.go @@ -0,0 +1,209 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// TestSuspensionRoundTrip covers a permanent block: a fresh account is not blocked, Suspend with +// a reason makes CurrentSuspension report it (permanent, with the en/ru snapshot resolved per +// language), and LiftSuspension reverses it. +func TestSuspensionRoundTrip(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + id := provisionAccount(t) + + if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { + t.Fatalf("fresh CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err) + } + + if _, err := store.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil { + t.Fatalf("suspend: %v", err) + } + + susp, ok, err := store.CurrentSuspension(ctx, id) + if err != nil || !ok { + t.Fatalf("CurrentSuspension after suspend = (ok %v, err %v), want (true, nil)", ok, err) + } + if !susp.Permanent() { + t.Errorf("Permanent() = false, want true for a nil-until block") + } + if got := susp.LocalizedReason("en"); got != "Spam" { + t.Errorf("LocalizedReason(en) = %q, want Spam", got) + } + if got := susp.LocalizedReason("ru"); got != "Спам" { + t.Errorf("LocalizedReason(ru) = %q, want Спам", got) + } + if got := susp.LocalizedReason("fr"); got != "Spam" { + t.Errorf("LocalizedReason(fr) = %q, want the English fallback Spam", got) + } + + if err := store.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift: %v", err) + } + if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { + t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err) + } +} + +// TestTemporarySuspensionExpiry checks the until boundary: a future expiry is in force (and not +// permanent), while a past expiry is already not in force. +func TestTemporarySuspensionExpiry(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + future := provisionAccount(t) + until := time.Now().Add(time.Hour) + if _, err := store.Suspend(ctx, future, &until, "", "", nil); err != nil { + t.Fatalf("suspend future: %v", err) + } + susp, ok, err := store.CurrentSuspension(ctx, future) + if err != nil || !ok { + t.Fatalf("future CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) + } + if susp.Permanent() { + t.Error("Permanent() = true, want false for a dated block") + } + if susp.BlockedUntil == nil || !susp.BlockedUntil.After(time.Now()) { + t.Errorf("BlockedUntil = %v, want a future instant", susp.BlockedUntil) + } + + past := provisionAccount(t) + expired := time.Now().Add(-time.Hour) + if _, err := store.Suspend(ctx, past, &expired, "", "", nil); err != nil { + t.Fatalf("suspend past: %v", err) + } + if _, ok, err := store.CurrentSuspension(ctx, past); err != nil || ok { + t.Fatalf("expired CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err) + } +} + +// TestSuspensionStrongestWins checks that with overlapping blocks CurrentSuspension returns the +// strongest — a permanent block outranks any dated one — and that LiftSuspension clears them all. +func TestSuspensionStrongestWins(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + id := provisionAccount(t) + + soon := time.Now().Add(time.Hour) + if _, err := store.Suspend(ctx, id, &soon, "", "", nil); err != nil { + t.Fatalf("suspend dated: %v", err) + } + if _, err := store.Suspend(ctx, id, nil, "", "", nil); err != nil { + t.Fatalf("suspend permanent: %v", err) + } + later := time.Now().Add(2 * time.Hour) + if _, err := store.Suspend(ctx, id, &later, "", "", nil); err != nil { + t.Fatalf("suspend dated-later: %v", err) + } + + susp, ok, err := store.CurrentSuspension(ctx, id) + if err != nil || !ok { + t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) + } + if !susp.Permanent() { + t.Error("strongest block should be the permanent one") + } + + if err := store.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift: %v", err) + } + if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok { + t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err) + } +} + +// TestReasonsCRUD covers the operator-editable picklist: create, list, get, update, delete, and +// the ErrNotFound paths. +func TestReasonsCRUD(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + created, err := store.CreateReason(ctx, "Cheating", "Читерство") + if err != nil { + t.Fatalf("create reason: %v", err) + } + if created.ID == uuid.Nil || created.TextEn != "Cheating" || created.TextRu != "Читерство" { + t.Fatalf("created reason = %+v, want populated en/ru and id", created) + } + + got, err := store.GetReason(ctx, created.ID) + if err != nil { + t.Fatalf("get reason: %v", err) + } + if got.TextEn != "Cheating" { + t.Errorf("get TextEn = %q, want Cheating", got.TextEn) + } + + list, err := store.ListReasons(ctx) + if err != nil { + t.Fatalf("list reasons: %v", err) + } + if !containsReason(list, created.ID) { + t.Error("created reason missing from ListReasons") + } + + updated, err := store.UpdateReason(ctx, created.ID, "Abuse", "Оскорбления") + if err != nil { + t.Fatalf("update reason: %v", err) + } + if updated.TextEn != "Abuse" || updated.TextRu != "Оскорбления" { + t.Errorf("updated reason = %+v, want Abuse/Оскорбления", updated) + } + + if err := store.DeleteReason(ctx, created.ID); err != nil { + t.Fatalf("delete reason: %v", err) + } + if _, err := store.GetReason(ctx, created.ID); !errors.Is(err, account.ErrNotFound) { + t.Errorf("get deleted reason = %v, want ErrNotFound", err) + } + if _, err := store.UpdateReason(ctx, uuid.New(), "x", "y"); !errors.Is(err, account.ErrNotFound) { + t.Errorf("update missing reason = %v, want ErrNotFound", err) + } +} + +// TestSuspensionReasonSnapshotSurvivesDelete checks that a block keeps the reason text it was +// stamped with even after the picklist entry is deleted (the FK nulls reason_id, the snapshot +// stays). +func TestSuspensionReasonSnapshotSurvivesDelete(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + id := provisionAccount(t) + + reason, err := store.CreateReason(ctx, "Toxicity", "Токсичность") + if err != nil { + t.Fatalf("create reason: %v", err) + } + if _, err := store.Suspend(ctx, id, nil, reason.TextEn, reason.TextRu, &reason.ID); err != nil { + t.Fatalf("suspend with reason: %v", err) + } + if err := store.DeleteReason(ctx, reason.ID); err != nil { + t.Fatalf("delete reason: %v", err) + } + + susp, ok, err := store.CurrentSuspension(ctx, id) + if err != nil || !ok { + t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err) + } + if susp.LocalizedReason("en") != "Toxicity" || susp.LocalizedReason("ru") != "Токсичность" { + t.Errorf("reason snapshot lost after delete: en=%q ru=%q", susp.LocalizedReason("en"), susp.LocalizedReason("ru")) + } +} + +// containsReason reports whether list holds a reason with the given id. +func containsReason(list []account.Reason, id uuid.UUID) bool { + for _, r := range list { + if r.ID == id { + return true + } + } + return false +} diff --git a/backend/internal/postgres/jet/backend/model/account_suspensions.go b/backend/internal/postgres/jet/backend/model/account_suspensions.go new file mode 100644 index 0000000..5163231 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/account_suspensions.go @@ -0,0 +1,24 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type AccountSuspensions struct { + SuspensionID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + BlockedAt time.Time + BlockedUntil *time.Time + ReasonEn *string + ReasonRu *string + ReasonID *uuid.UUID + LiftedAt *time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/suspension_reasons.go b/backend/internal/postgres/jet/backend/model/suspension_reasons.go new file mode 100644 index 0000000..176dc17 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/suspension_reasons.go @@ -0,0 +1,21 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type SuspensionReasons struct { + ReasonID uuid.UUID `sql:"primary_key"` + TextEn string + TextRu string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/account_suspensions.go b/backend/internal/postgres/jet/backend/table/account_suspensions.go new file mode 100644 index 0000000..4c66947 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/account_suspensions.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AccountSuspensions = newAccountSuspensionsTable("backend", "account_suspensions", "") + +type accountSuspensionsTable struct { + postgres.Table + + // Columns + SuspensionID postgres.ColumnString + AccountID postgres.ColumnString + BlockedAt postgres.ColumnTimestampz + BlockedUntil postgres.ColumnTimestampz + ReasonEn postgres.ColumnString + ReasonRu postgres.ColumnString + ReasonID postgres.ColumnString + LiftedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AccountSuspensionsTable struct { + accountSuspensionsTable + + EXCLUDED accountSuspensionsTable +} + +// AS creates new AccountSuspensionsTable with assigned alias +func (a AccountSuspensionsTable) AS(alias string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AccountSuspensionsTable with assigned schema name +func (a AccountSuspensionsTable) FromSchema(schemaName string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AccountSuspensionsTable with assigned table prefix +func (a AccountSuspensionsTable) WithPrefix(prefix string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AccountSuspensionsTable with assigned table suffix +func (a AccountSuspensionsTable) WithSuffix(suffix string) *AccountSuspensionsTable { + return newAccountSuspensionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAccountSuspensionsTable(schemaName, tableName, alias string) *AccountSuspensionsTable { + return &AccountSuspensionsTable{ + accountSuspensionsTable: newAccountSuspensionsTableImpl(schemaName, tableName, alias), + EXCLUDED: newAccountSuspensionsTableImpl("", "excluded", ""), + } +} + +func newAccountSuspensionsTableImpl(schemaName, tableName, alias string) accountSuspensionsTable { + var ( + SuspensionIDColumn = postgres.StringColumn("suspension_id") + AccountIDColumn = postgres.StringColumn("account_id") + BlockedAtColumn = postgres.TimestampzColumn("blocked_at") + BlockedUntilColumn = postgres.TimestampzColumn("blocked_until") + ReasonEnColumn = postgres.StringColumn("reason_en") + ReasonRuColumn = postgres.StringColumn("reason_ru") + ReasonIDColumn = postgres.StringColumn("reason_id") + LiftedAtColumn = postgres.TimestampzColumn("lifted_at") + allColumns = postgres.ColumnList{SuspensionIDColumn, AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn} + defaultColumns = postgres.ColumnList{BlockedAtColumn} + ) + + return accountSuspensionsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + SuspensionID: SuspensionIDColumn, + AccountID: AccountIDColumn, + BlockedAt: BlockedAtColumn, + BlockedUntil: BlockedUntilColumn, + ReasonEn: ReasonEnColumn, + ReasonRu: ReasonRuColumn, + ReasonID: ReasonIDColumn, + LiftedAt: LiftedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/suspension_reasons.go b/backend/internal/postgres/jet/backend/table/suspension_reasons.go new file mode 100644 index 0000000..1ca7f2e --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/suspension_reasons.go @@ -0,0 +1,90 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var SuspensionReasons = newSuspensionReasonsTable("backend", "suspension_reasons", "") + +type suspensionReasonsTable struct { + postgres.Table + + // Columns + ReasonID postgres.ColumnString + TextEn postgres.ColumnString + TextRu postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type SuspensionReasonsTable struct { + suspensionReasonsTable + + EXCLUDED suspensionReasonsTable +} + +// AS creates new SuspensionReasonsTable with assigned alias +func (a SuspensionReasonsTable) AS(alias string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new SuspensionReasonsTable with assigned schema name +func (a SuspensionReasonsTable) FromSchema(schemaName string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new SuspensionReasonsTable with assigned table prefix +func (a SuspensionReasonsTable) WithPrefix(prefix string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new SuspensionReasonsTable with assigned table suffix +func (a SuspensionReasonsTable) WithSuffix(suffix string) *SuspensionReasonsTable { + return newSuspensionReasonsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newSuspensionReasonsTable(schemaName, tableName, alias string) *SuspensionReasonsTable { + return &SuspensionReasonsTable{ + suspensionReasonsTable: newSuspensionReasonsTableImpl(schemaName, tableName, alias), + EXCLUDED: newSuspensionReasonsTableImpl("", "excluded", ""), + } +} + +func newSuspensionReasonsTableImpl(schemaName, tableName, alias string) suspensionReasonsTable { + var ( + ReasonIDColumn = postgres.StringColumn("reason_id") + TextEnColumn = postgres.StringColumn("text_en") + TextRuColumn = postgres.StringColumn("text_ru") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{ReasonIDColumn, TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn} + ) + + return suspensionReasonsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ReasonID: ReasonIDColumn, + TextEn: TextEnColumn, + TextRu: TextRuColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 8f79b5d..60f5d15 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -11,6 +11,7 @@ package table // this method only once at the beginning of the program. func UseSchema(schema string) { AccountStats = AccountStats.FromSchema(schema) + AccountSuspensions = AccountSuspensions.FromSchema(schema) Accounts = Accounts.FromSchema(schema) Blocks = Blocks.FromSchema(schema) ChatMessages = ChatMessages.FromSchema(schema) @@ -28,4 +29,5 @@ func UseSchema(schema string) { Games = Games.FromSchema(schema) Identities = Identities.FromSchema(schema) Sessions = Sessions.FromSchema(schema) + SuspensionReasons = SuspensionReasons.FromSchema(schema) } diff --git a/backend/internal/postgres/migrations/00003_account_suspensions.sql b/backend/internal/postgres/migrations/00003_account_suspensions.sql new file mode 100644 index 0000000..26e6639 --- /dev/null +++ b/backend/internal/postgres/migrations/00003_account_suspensions.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- Manual account blocking ("suspension"), the operator's hard counterpart to the soft, +-- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema +-- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another); +-- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to +-- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until +-- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12. +SET search_path = backend, pg_catalog; + +-- The operator-editable reason picklist, one row per reason with its English and Russian text. +-- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or +-- deleting a reason here never changes or breaks a reason already shown to a blocked player. +CREATE TABLE suspension_reasons ( + reason_id uuid PRIMARY KEY, + text_en text NOT NULL, + text_ru text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- The block history: one row per block. blocked_until is NULL for a permanent block and the +-- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early. +-- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was +-- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled +-- if that entry is deleted (the snapshot remains the source of truth shown to the player). An +-- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS +-- NULL OR blocked_until > now()). +CREATE TABLE account_suspensions ( + suspension_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + blocked_at timestamptz NOT NULL DEFAULT now(), + blocked_until timestamptz, + reason_en text, + reason_ru text, + reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL, + lifted_at timestamptz +); +-- The enforcement gate looks up the newest suspension for an account on every authenticated +-- request; this index serves that "latest by account" probe. +CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE IF EXISTS account_suspensions; +DROP TABLE IF EXISTS suspension_reasons; diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 57ac6e3..7f49515 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -46,6 +46,9 @@ func (s *Server) registerRoutes() { u.GET("/profile", s.handleProfile) u.PUT("/profile", s.handleUpdateProfile) u.GET("/stats", s.handleStats) + // Exempt from the suspension gate (see requireNotSuspended): the one endpoint a blocked + // client may still reach, to fetch the block's expiry and reason for the blocked screen. + u.GET("/block-status", s.handleBlockStatus) } if s.links != nil { // Account linking & merge. The request step always mails a code; diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index 27a44ef..1bd974a 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -86,6 +86,48 @@ func (s *Server) handleUpdateProfile(c *gin.Context) { c.JSON(http.StatusOK, profileResponseFor(acc)) } +// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC +// instant for a temporary block and empty for a permanent one (or when not blocked); Reason is +// the operator-set reason resolved to the account's language, empty when none was cited. It is +// the one payload a blocked client can still fetch, behind the suspension gate's exemption. +type blockStatusResponse struct { + Blocked bool `json:"blocked"` + Permanent bool `json:"permanent"` + Until string `json:"until,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// handleBlockStatus reports whether the caller is blocked and, if so, the block's expiry and the +// reason resolved to the account's language. The suspension gate exempts this route so a blocked +// client can render the terminal blocked screen. +func (s *Server) handleBlockStatus(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + susp, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + resp := blockStatusResponse{Blocked: blocked} + if blocked { + resp.Permanent = susp.Permanent() + if susp.BlockedUntil != nil { + resp.Until = susp.BlockedUntil.UTC().Format(time.RFC3339) + } + // Resolve the reason snapshot to the account's interface language; fall back to English + // if the account row cannot be read for some reason. + lang := "en" + if acc, err := s.accounts.GetByID(c.Request.Context(), uid); err == nil { + lang = acc.PreferredLanguage + } + resp.Reason = susp.LocalizedReason(lang) + } + c.JSON(http.StatusOK, resp) +} + // handleStats returns the caller's lifetime statistics. func (s *Server) handleStats(c *gin.Context) { uid, ok := userID(c) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 6a765ea..53dbe96 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -51,6 +51,12 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/users/:id", s.consoleUserDetail) gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) + gm.POST("/users/:id/block", s.consoleBlockUser) + gm.POST("/users/:id/unblock", s.consoleUnblockUser) + gm.GET("/reasons", s.consoleReasons) + gm.POST("/reasons", s.consoleCreateReason) + gm.POST("/reasons/:id/update", s.consoleUpdateReason) + gm.POST("/reasons/:id/delete", s.consoleDeleteReason) gm.GET("/throttled", s.consoleThrottled) gm.GET("/games", s.consoleGames) gm.GET("/games/:id", s.consoleGameDetail) @@ -291,6 +297,20 @@ func (s *Server) consoleUserDetail(c *gin.Context) { } view.MoveChart = adminconsole.MoveDurationChart(cps) } + if susp, blocked, err := s.accounts.CurrentSuspension(ctx, id); err == nil && blocked { + view.Suspension = adminconsole.SuspensionView{ + Blocked: true, Permanent: susp.Permanent(), BlockedAt: fmtTime(susp.BlockedAt), + ReasonEn: susp.ReasonEn, ReasonRu: susp.ReasonRu, + } + if susp.BlockedUntil != nil { + view.Suspension.Until = fmtTime(*susp.BlockedUntil) + } + } + if reasons, err := s.accounts.ListReasons(ctx); err == nil { + for _, r := range reasons { + view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu}) + } + } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } @@ -754,6 +774,157 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) { s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) } +// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a +// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active +// games, removing them from matchmaking. The block takes effect on the player's next request. +func (s *Server) consoleBlockUser(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + until, ok := parseSuspendUntil(trimForm(c, "duration"), trimForm(c, "until")) + if !ok { + s.renderConsoleMessage(c, "Invalid duration", "pick a preset or a future custom date/time (UTC)", back) + return + } + var reasonEn, reasonRu string + var reasonID *uuid.UUID + if rid := trimForm(c, "reason"); rid != "" { + parsed, err := uuid.Parse(rid) + if err != nil { + s.renderConsoleMessage(c, "Invalid reason", "the selected reason is not valid", back) + return + } + reason, err := s.accounts.GetReason(ctx, parsed) + if err != nil { + s.consoleError(c, err) + return + } + reasonEn, reasonRu, reasonID = reason.TextEn, reason.TextRu, &reason.ID + } + if _, err := s.accounts.Suspend(ctx, id, until, reasonEn, reasonRu, reasonID); err != nil { + s.consoleError(c, err) + return + } + forfeited, err := s.games.ForfeitAllForAccount(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back) +} + +// consoleUnblockUser lifts an account's block (temporary or permanent). Games already lost at +// block time are not restored. +func (s *Server) consoleUnblockUser(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + if err := s.accounts.LiftSuspension(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String()) +} + +// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for +// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the +// future. It reports false for an unrecognised choice or an invalid/past custom date. +func parseSuspendUntil(choice, custom string) (*time.Time, bool) { + now := time.Now().UTC() + switch choice { + case "permanent": + return nil, true + case "1d": + t := now.AddDate(0, 0, 1) + return &t, true + case "3d": + t := now.AddDate(0, 0, 3) + return &t, true + case "1w": + t := now.AddDate(0, 0, 7) + return &t, true + case "1m": + t := now.AddDate(0, 1, 0) + return &t, true + case "custom": + for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} { + if t, err := time.Parse(layout, custom); err == nil { + if !t.After(now) { + return nil, false + } + return &t, true + } + } + return nil, false + default: + return nil, false + } +} + +// consoleReasons renders the operator-editable suspension-reason picklist. +func (s *Server) consoleReasons(c *gin.Context) { + reasons, err := s.accounts.ListReasons(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + var view adminconsole.ReasonsView + for _, r := range reasons { + view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)}) + } + s.renderConsole(c, "reasons", "reasons", "Reasons", view) +} + +// consoleCreateReason adds a suspension-reason picklist entry; both languages are required. +func (s *Server) consoleCreateReason(c *gin.Context) { + en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru") + if en == "" || ru == "" { + s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons") + return + } + if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons") +} + +// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their +// snapshot, so the change only affects future blocks. +func (s *Server) consoleUpdateReason(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/reasons") + if !ok { + return + } + en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru") + if en == "" || ru == "" { + s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons") + return + } + if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons") +} + +// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot. +func (s *Server) consoleDeleteReason(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/reasons") + if !ok { + return + } + if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons") +} + // variantVersions builds the per-variant resident-version summary from the registry. func (s *Server) variantVersions() []adminconsole.VariantVersions { out := make([]adminconsole.VariantVersions, 0, len(engine.Variants())) diff --git a/backend/internal/server/middleware.go b/backend/internal/server/middleware.go index f1a1e84..56a5e41 100644 --- a/backend/internal/server/middleware.go +++ b/backend/internal/server/middleware.go @@ -77,3 +77,40 @@ func sameOrigin(r *http.Request) bool { } return false } + +// codeAccountBlocked is the stable error code the suspension gate returns for a blocked account. +// It threads through the gateway unchanged as the Execute result_code, so the UI can detect the +// block from any call and switch to the terminal blocked screen. +const codeAccountBlocked = "account_blocked" + +// blockStatusPath is the one /api/v1/user route exempt from the suspension gate: a blocked client +// must still reach it to fetch the block's expiry and reason for the blocked screen. +const blockStatusPath = "/api/v1/user/block-status" + +// requireNotSuspended returns middleware that rejects a blocked account's requests with 403 and +// code "account_blocked", so the UI can detect an active block from any call. The block-status +// probe is exempt. It is a no-op when the account store is not wired. It runs after RequireUserID +// (which has already placed the account id in the context). +func (s *Server) requireNotSuspended() gin.HandlerFunc { + return func(c *gin.Context) { + if s.accounts == nil || c.FullPath() == blockStatusPath { + c.Next() + return + } + id, ok := userID(c) + if !ok { + c.Next() // RequireUserID runs first and has already rejected a missing id + return + } + _, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), id) + if err != nil { + s.abortErr(c, err) + return + } + if blocked { + c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: codeAccountBlocked, Message: "account is blocked"}}) + return + } + c.Next() + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 640a9dc..5c681be 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -185,6 +185,9 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) { s.public = v1.Group("/public") s.user = v1.Group("/user") s.user.Use(RequireUserID()) + // The suspension gate runs after identity is established: a blocked account is refused on + // every user route (except the block-status probe) so the UI can show the blocked screen. + s.user.Use(s.requireNotSuspended()) s.internal = v1.Group("/internal") } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a4e26f3..ed0d524 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -700,6 +700,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) | +| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) | | 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, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. 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) | @@ -707,6 +708,25 @@ This is an explicit, accepted MVP risk: compromise of the gateway↔backend network segment defeats backend authentication. Mitigated by network isolation; mutual auth is a future hardening step. +**Manual account block (suspension).** Beyond the soft, reversible high-rate flag (§11, never a +gate), an operator can hard-block an account from the admin console — permanently or until a +date, with an optional reason chosen from an editable en+ru picklist. A block is a row in +`account_suspensions` (the chosen reason's text is **snapshotted**, so editing or deleting a +picklist entry never changes what an already-blocked player is shown); it is named *suspension* +to stay distinct from the peer-to-peer `blocks` table. Enforcement is a backend middleware gate +after `X-User-ID`: every `/api/v1/user/*` route except the block-status probe refuses a blocked +account with **HTTP 403 + code `account_blocked`**, which threads through the gateway unchanged as +the Execute `result_code`, so the UI detects the block from *any* call and replaces every screen +with a terminal "blocked" screen, stopping all push/poll. The one exempt route, +`GET /api/v1/user/block-status`, returns the expiry and the reason resolved to the account's +language so the blocked client can render the message. Sessions are **not** revoked on block (a +revoked token would fail session resolution at the gateway *before* the gate, sending the UI to +login instead of the blocked screen). A block instantly **forfeits** every active game the player +is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels +their open matchmaking games; a temporary block lapses automatically once its expiry passes (no +sweeper — the gate recomputes against `now`). No operator identity is recorded (shared +Basic-Auth). + **Short numeric codes** (email confirm-codes and friend codes) are stored only as SHA-256 hashes and are short-lived and single-use. The unauthenticated email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem** diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1637271..cd9475c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -208,3 +208,15 @@ account sustaining rejections past a tunable threshold is flagged automatically the marker is reversible, shown as a badge in the user list and on the user card, and **never blocks play**; the operator reviews and clears it from the user card. There is no automatic ban. + +The console also lets an operator **manually block** an account — the hard counterpart to the +soft high-rate flag. From the user card the operator blocks the account **permanently** or +**until a date** (presets: 1 day / 3 days / 1 week / 1 month, or a custom date in UTC), optionally +citing a **reason** chosen from an editable **en+ru picklist** managed on a **Reasons** page; the +blocked player sees the reason in their own language. Blocking instantly **forfeits** the player's +active games (each opponent wins, as if the player resigned) and removes them from matchmaking. A +blocked player — on opening the app or on any action — is shown a terminal screen: *"Your account +is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary, in their timezone), +plus the reason when one was given, and the app stops all background traffic with the server. A +temporary block lifts itself when it expires; the operator can also **unblock** from the user card +at any time (games already lost stay lost). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 3d547d1..1ecdbad 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -213,3 +213,16 @@ Telegram-identity) или **отправить пост в игровой кан автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с карточки пользователя. Автоматического бана нет. + +Консоль также позволяет оператору **вручную заблокировать** аккаунт — жёсткий аналог мягкого +high-rate флага. С карточки пользователя оператор блокирует аккаунт **навсегда** или **до даты** +(пресеты: 1 день / 3 дня / 1 неделя / 1 месяц либо произвольная дата в UTC), при желании указав +**причину** из редактируемого **списка en+ru**, который ведётся на странице **Reasons**; +заблокированный игрок видит причину на своём языке. Блокировка мгновенно засчитывает **поражение** +во всех активных партиях игрока (каждый соперник побеждает, как при сдаче) и убирает его из +матчмейкинга. Заблокированный игрок — при открытии приложения или любом действии — видит +терминальный экран: *«Ваша учётная запись заблокирована»* (постоянная) или *«…заблокирована до +<дата, время>»* (временная, в его часовом поясе), плюс причину, если она была указана, и +приложение прекращает любое фоновое общение с сервером. Временная блокировка снимается сама по +истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент +(уже проигранные партии не возвращаются). diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 91e421e..b6eeb25 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -224,6 +224,25 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error return out, err } +// BlockStatusResp is the caller's current manual-block state. Until is an RFC3339 UTC instant for +// a temporary block, empty for a permanent one or when not blocked; Reason is resolved to the +// account's language, empty when none was cited. +type BlockStatusResp struct { + Blocked bool `json:"blocked"` + Permanent bool `json:"permanent"` + Until string `json:"until"` + Reason string `json:"reason"` +} + +// BlockStatus fetches the caller's current block. It is the one user endpoint a blocked account +// can still reach (the backend's suspension gate exempts it), so the UI can render the blocked +// screen. +func (c *Client) BlockStatus(ctx context.Context, userID string) (BlockStatusResp, error) { + var out BlockStatusResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/block-status", userID, "", nil, &out) + return out, err +} + // SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet // index. func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) { diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index ae99486..95f5de2 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -81,6 +81,20 @@ func encodeProfile(p backendclient.ProfileResp) []byte { return b.FinishedBytes() } +// encodeBlockStatus builds a BlockStatus payload. +func encodeBlockStatus(s backendclient.BlockStatusResp) []byte { + b := flatbuffers.NewBuilder(128) + until := b.CreateString(s.Until) + reason := b.CreateString(s.Reason) + fb.BlockStatusStart(b) + fb.BlockStatusAddBlocked(b, s.Blocked) + fb.BlockStatusAddPermanent(b, s.Permanent) + fb.BlockStatusAddUntil(b, until) + fb.BlockStatusAddReason(b, reason) + b.Finish(fb.BlockStatusEnd(b)) + return b.FinishedBytes() +} + // encodeLinkResult builds a LinkResult payload. A switched-session token // (a guest initiator whose durable counterpart won) is carried as a nested Session // for the client to adopt; it is omitted otherwise. supportedLangs is the variant diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 9cfd7bc..035cccc 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -22,6 +22,7 @@ const ( MsgAuthEmailReq = "auth.email.request" MsgAuthEmailLogin = "auth.email.login" MsgProfileGet = "profile.get" + MsgBlockStatus = "account.block_status" MsgGameSubmitPlay = "game.submit_play" MsgGameState = "game.state" MsgLobbyEnqueue = "lobby.enqueue" @@ -95,6 +96,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true} r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), Email: true} r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true} + r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} 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} @@ -199,6 +201,16 @@ func profileHandler(backend *backendclient.Client) Handler { } } +func blockStatusHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + bs, err := backend.BlockStatus(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeBlockStatus(bs), nil + } +} + func submitPlayHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0) diff --git a/gateway/internal/transcode/transcode_block_test.go b/gateway/internal/transcode/transcode_block_test.go new file mode 100644 index 0000000..8b7bd70 --- /dev/null +++ b/gateway/internal/transcode/transcode_block_test.go @@ -0,0 +1,64 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestBlockStatusRoundTrip checks the account.block_status op forwards to the backend block-status +// endpoint and encodes its JSON into the BlockStatus FlatBuffer. +func TestBlockStatusRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/block-status" { + t.Errorf("path = %q, want /api/v1/user/block-status", r.URL.Path) + } + _, _ = w.Write([]byte(`{"blocked":true,"permanent":false,"until":"2026-07-01T12:00:00Z","reason":"Спам"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgBlockStatus) + if !ok { + t.Fatal("account.block_status not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + bs := fb.GetRootAsBlockStatus(payload, 0) + if !bs.Blocked() || bs.Permanent() { + t.Fatalf("blocked=%v permanent=%v, want true/false", bs.Blocked(), bs.Permanent()) + } + if string(bs.Until()) != "2026-07-01T12:00:00Z" { + t.Errorf("until = %q, want the forwarded instant", bs.Until()) + } + if string(bs.Reason()) != "Спам" { + t.Errorf("reason = %q, want Спам", bs.Reason()) + } +} + +// TestBlockedBackendSurfacesDomainCode checks that a backend 403 with code account_blocked (the +// suspension gate) surfaces as a domain code, which the Execute layer turns into the envelope +// result_code the UI keys off. +func TestBlockedBackendSurfacesDomainCode(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":"account_blocked","message":"account is blocked"}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) // any gated op hits the same gate + _, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err == nil { + t.Fatal("expected an error from a blocked backend response") + } + code, ok := transcode.DomainCode(err) + if !ok || code != "account_blocked" { + t.Fatalf("DomainCode = (%q, %v), want (account_blocked, true)", code, ok) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index e786445..b45f2c5 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -150,6 +150,18 @@ table Profile { notifications_in_app_only:bool = true; } +// BlockStatus reports the caller's current manual block. The UI fetches it after any operation +// returns the "account_blocked" result code, then replaces every screen with the terminal blocked +// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339 +// UTC instant for a temporary block and empty for a permanent one; reason is the operator-set text +// resolved to the account's language, empty when none was cited. +table BlockStatus { + blocked:bool; + permanent:bool; + until:string; + reason:string; +} + // --- game (authenticated) --- // SubmitPlayRequest places tiles on the player's turn; the backend infers the play's diff --git a/pkg/fbs/scrabblefb/BlockStatus.go b/pkg/fbs/scrabblefb/BlockStatus.go new file mode 100644 index 0000000..48ea4bf --- /dev/null +++ b/pkg/fbs/scrabblefb/BlockStatus.go @@ -0,0 +1,101 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BlockStatus struct { + _tab flatbuffers.Table +} + +func GetRootAsBlockStatus(buf []byte, offset flatbuffers.UOffsetT) *BlockStatus { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BlockStatus{} + x.Init(buf, n+offset) + return x +} + +func FinishBlockStatusBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBlockStatus(buf []byte, offset flatbuffers.UOffsetT) *BlockStatus { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BlockStatus{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBlockStatusBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BlockStatus) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BlockStatus) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BlockStatus) Blocked() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *BlockStatus) MutateBlocked(n bool) bool { + return rcv._tab.MutateBoolSlot(4, n) +} + +func (rcv *BlockStatus) Permanent() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *BlockStatus) MutatePermanent(n bool) bool { + return rcv._tab.MutateBoolSlot(6, n) +} + +func (rcv *BlockStatus) Until() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BlockStatus) Reason() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func BlockStatusStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func BlockStatusAddBlocked(builder *flatbuffers.Builder, blocked bool) { + builder.PrependBoolSlot(0, blocked, false) +} +func BlockStatusAddPermanent(builder *flatbuffers.Builder, permanent bool) { + builder.PrependBoolSlot(1, permanent, false) +} +func BlockStatusAddUntil(builder *flatbuffers.Builder, until flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(until), 0) +} +func BlockStatusAddReason(builder *flatbuffers.Builder, reason flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(reason), 0) +} +func BlockStatusEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/e2e/blocked.spec.ts b/ui/e2e/blocked.spec.ts new file mode 100644 index 0000000..37836d4 --- /dev/null +++ b/ui/e2e/blocked.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from './fixtures'; + +// The terminal blocked screen replaces every screen. The mock never blocks of its own accord, so +// the e2e drives it through the mock-mode __block seam (lib/app.svelte.ts), mirroring what +// enterBlocked does when any call returns the account_blocked code. + +test('a temporary block shows the dated message and reason, replacing the lobby', async ({ + page, +}) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Your turn')).toBeVisible(); // reached the lobby + + await page.evaluate(() => + (window as unknown as { __block: (b: unknown) => void }).__block({ + permanent: false, + until: '2026-07-01T12:00:00Z', + reason: 'Спам', + }), + ); + + const blocked = page.locator('.blocked'); + await expect(blocked).toBeVisible(); + await expect(blocked).toContainText('2026'); // the expiry rendered in the user's locale/timezone + await expect(blocked).toContainText('Спам'); // the operator reason + // The lobby is gone: the blocked screen overlays every screen, stopping its push/poll. + await expect(page.getByText('Your turn')).toHaveCount(0); +}); + +test('a permanent block shows the permanent message with no date', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Your turn')).toBeVisible(); + + await page.evaluate(() => + (window as unknown as { __block: (b: unknown) => void }).__block({ permanent: true }), + ); + + const blocked = page.locator('.blocked'); + await expect(blocked).toBeVisible(); + await expect(blocked).not.toContainText('2026'); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 9e42fb6..7c6348e 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -13,6 +13,7 @@ import Stats from './screens/Stats.svelte'; import Game from './game/Game.svelte'; import CommsHub from './game/CommsHub.svelte'; + import Blocked from './screens/Blocked.svelte'; onMount(() => { void bootstrap(); @@ -68,6 +69,8 @@ {#if !app.ready}
{t('common.loading')}
+{:else if app.blocked} + {:else}
{#key routeKey} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 198e0d0..8bca894 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -4,6 +4,7 @@ export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; export { BlockList } from './scrabblefb/block-list.js'; +export { BlockStatus } from './scrabblefb/block-status.js'; export { ChatList } from './scrabblefb/chat-list.js'; export { ChatMessage } from './scrabblefb/chat-message.js'; export { ChatPostRequest } from './scrabblefb/chat-post-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/block-status.ts b/ui/src/gen/fbs/scrabblefb/block-status.ts new file mode 100644 index 0000000..d955cde --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/block-status.ts @@ -0,0 +1,80 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class BlockStatus { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BlockStatus { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus { + return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +blocked():boolean { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +permanent():boolean { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +until():string|null +until(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +until(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +reason():string|null +reason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +reason(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startBlockStatus(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addBlocked(builder:flatbuffers.Builder, blocked:boolean) { + builder.addFieldInt8(0, +blocked, +false); +} + +static addPermanent(builder:flatbuffers.Builder, permanent:boolean) { + builder.addFieldInt8(1, +permanent, +false); +} + +static addUntil(builder:flatbuffers.Builder, untilOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, untilOffset, 0); +} + +static addReason(builder:flatbuffers.Builder, reasonOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, reasonOffset, 0); +} + +static endBlockStatus(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBlockStatus(builder:flatbuffers.Builder, blocked:boolean, permanent:boolean, untilOffset:flatbuffers.Offset, reasonOffset:flatbuffers.Offset):flatbuffers.Offset { + BlockStatus.startBlockStatus(builder); + BlockStatus.addBlocked(builder, blocked); + BlockStatus.addPermanent(builder, permanent); + BlockStatus.addUntil(builder, untilOffset); + BlockStatus.addReason(builder, reasonOffset); + return BlockStatus.endBlockStatus(builder); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 4994b71..a53ad61 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -3,7 +3,7 @@ // gateway calls funnel through here so errors map to one user-facing toast and an // expired session logs out. -import type { LinkResult, Profile, PushEvent, Session } from './model'; +import type { BlockStatus, LinkResult, Profile, PushEvent, Session } from './model'; import { gateway } from './gateway'; import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; @@ -43,6 +43,9 @@ export const app = $state<{ streamAlive: boolean; session: Session | null; profile: Profile | null; + /** The caller's active manual block, or null. When set, App.svelte replaces every screen with + * the terminal blocked screen and all push/poll is stopped. */ + blocked: BlockStatus | null; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; @@ -65,6 +68,7 @@ export const app = $state<{ streamAlive: false, session: null, profile: null, + blocked: null, toast: null, lastEvent: null, theme: 'auto', @@ -140,11 +144,39 @@ export function handleError(err: unknown): void { void logout(); return; } + if (code === 'account_blocked') { + void enterBlocked(); + return; + } if (isConnectionCode(code) || !connection.online) return; telegramHaptic('error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } +/** + * enterBlocked switches the app to the terminal blocked screen and stops all push/poll. It is + * triggered whenever any call reports the "account_blocked" code. It flips the screen + * synchronously (a generic placeholder), then fetches the block's expiry and reason — the one + * endpoint a blocked client may still reach — to render the precise message. If that fetch reports + * the block was already lifted (a race), it recovers: clears the blocked screen and reopens the + * stream. + */ +export async function enterBlocked(): Promise { + closeStream(); // stop the live stream; the blocked screen makes no further calls + app.blocked ??= { blocked: true, permanent: true, until: '', reason: '' }; + try { + const s = await gateway.blockStatus(); + if (s.blocked) { + app.blocked = s; + } else { + app.blocked = null; + if (app.session) openStream(); + } + } catch { + // Keep the placeholder blocked screen even if the details cannot be fetched. + } +} + /** * viewingGame reports whether the game board for the given game id is the current route. That screen * maintains its own cache from live events, so the global stream handler must not also advance it (a @@ -284,6 +316,8 @@ async function adoptSession(s: Session): Promise { } catch (err) { handleError(err); } + // A blocked account stays on the blocked screen: no live stream, no notification poll. + if (app.blocked) return; openStream(); void refreshNotifications(); } @@ -392,7 +426,8 @@ export async function bootstrap(): Promise { telegramDisableVerticalSwipes(); try { await adoptSession(await gateway.authTelegram(launch.initData)); - await routeStartParam(launch.startParam); + // A blocked account skips deep-link routing — the blocked screen overlays every route. + if (!app.blocked) await routeStartParam(launch.startParam); } catch (err) { handleError(err); navigate('/login'); @@ -568,4 +603,10 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') { drop: closeStream, restore: openStream, }; + // Drive the terminal blocked screen from the e2e (the mock never blocks of its own accord). It + // mirrors enterBlocked: flip the screen and stop the live stream. + (window as unknown as { __block?: (b?: Partial) => void }).__block = (b) => { + closeStream(); + app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b }; + }; } diff --git a/ui/src/lib/blocked.test.ts b/ui/src/lib/blocked.test.ts new file mode 100644 index 0000000..dd51004 --- /dev/null +++ b/ui/src/lib/blocked.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { formatBlockUntil } from './blocked'; + +describe('formatBlockUntil', () => { + const instant = '2026-07-01T12:00:00Z'; + + it('formats a valid instant in the given timezone', () => { + const out = formatBlockUntil(instant, 'en', 'UTC'); + expect(out).not.toBe(instant); // it was localized, not echoed + expect(out).toContain('2026'); + }); + + it('shifts the rendered time by timezone', () => { + const utc = formatBlockUntil(instant, 'en', 'UTC'); + const ny = formatBlockUntil(instant, 'en', 'America/New_York'); + expect(ny).not.toBe(utc); // 12:00 UTC renders as a different wall-clock in New York + }); + + it('falls back to the raw string for an unparseable instant', () => { + expect(formatBlockUntil('not-a-date', 'en', 'UTC')).toBe('not-a-date'); + }); + + it('does not throw on an invalid timezone, still rendering the date', () => { + const out = formatBlockUntil(instant, 'en', 'Not/AZone'); + expect(out).toContain('2026'); + }); +}); diff --git a/ui/src/lib/blocked.ts b/ui/src/lib/blocked.ts new file mode 100644 index 0000000..a26e2de --- /dev/null +++ b/ui/src/lib/blocked.ts @@ -0,0 +1,27 @@ +// Pure helpers for the terminal blocked screen, kept out of the .svelte component so they are +// unit-testable in the node vitest env. + +import type { Locale } from './i18n/index.svelte'; + +/** + * formatBlockUntil renders a temporary block's RFC3339 UTC expiry in the user's locale and + * timezone, e.g. "1 Jul 2026, 15:00". It falls back to UTC formatting when the timezone is + * unknown/invalid, and to the raw string when the instant cannot be parsed at all. + */ +export function formatBlockUntil(until: string, locale: Locale, timeZone: string): string { + const d = new Date(until); + if (Number.isNaN(d.getTime())) return until; + try { + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: timeZone || undefined, + }).format(d); + } catch { + try { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d); + } catch { + return until; + } + } +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index efe63fb..c73a775 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -6,6 +6,7 @@ import type { AccountRef, + BlockStatus, ChatMessage, EvalResult, FriendCode, @@ -60,6 +61,9 @@ export interface GatewayClient { // --- profile / lists --- profileGet(): Promise; + /** The caller's current manual block. Exempt from the backend's suspension gate, so a blocked + * client can still fetch it to render the blocked screen. */ + blockStatus(): Promise; gamesList(): Promise; // --- lobby --- diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 121df13..5717c63 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 { + decodeBlockStatus, decodeDraftView, decodeEvent, decodeFriendList, @@ -37,6 +38,38 @@ describe('codec', () => { expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}'); }); + it('decodes a temporary BlockStatus with reason', () => { + const b = new Builder(64); + const until = b.createString('2026-07-01T12:00:00Z'); + const reason = b.createString('Спам'); + fb.BlockStatus.startBlockStatus(b); + fb.BlockStatus.addBlocked(b, true); + fb.BlockStatus.addPermanent(b, false); + fb.BlockStatus.addUntil(b, until); + fb.BlockStatus.addReason(b, reason); + b.finish(fb.BlockStatus.endBlockStatus(b)); + expect(decodeBlockStatus(b.asUint8Array())).toEqual({ + blocked: true, + permanent: false, + until: '2026-07-01T12:00:00Z', + reason: 'Спам', + }); + }); + + it('decodes a permanent BlockStatus with empty until/reason', () => { + const b = new Builder(32); + fb.BlockStatus.startBlockStatus(b); + fb.BlockStatus.addBlocked(b, true); + fb.BlockStatus.addPermanent(b, true); + b.finish(fb.BlockStatus.endBlockStatus(b)); + expect(decodeBlockStatus(b.asUint8Array())).toEqual({ + blocked: true, + permanent: true, + until: '', + reason: '', + }); + }); + it('encodes a SubmitPlayRequest with alphabet indices', () => { setAlphabet('scrabble_en', [ { index: 0, letter: 'a', value: 1 }, diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 7882a74..5d7e29d 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -9,6 +9,7 @@ import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } f import type { PlacedTile } from './client'; import type { AccountRef, + BlockStatus, ChatMessage, EvalResult, FriendCode, @@ -314,6 +315,16 @@ export function decodeProfile(buf: Uint8Array): Profile { }; } +export function decodeBlockStatus(buf: Uint8Array): BlockStatus { + const b = fb.BlockStatus.getRootAsBlockStatus(new ByteBuffer(buf)); + return { + blocked: b.blocked(), + permanent: b.permanent(), + until: s(b.until()), + reason: s(b.reason()), + }; +} + // decodeStateViewTable projects a StateView table (a root or one nested in an event) to the // model. It caches the alphabet when present (a per-variant cache miss) and decodes the index // rack to display letters with it. diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index f834b3c..3c86897 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -6,6 +6,11 @@ export const en = { 'app.title': 'Scrabble', 'connection.connecting': 'Connecting…', + 'blocked.title': 'Account blocked', + 'blocked.permanent': 'Your account is blocked.', + 'blocked.temporary': 'Your account is blocked until {until}.', + 'blocked.reason': 'Reason:', + 'common.back': 'Back', 'common.cancel': 'Cancel', 'common.ok': 'OK', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 4ade79d..925fd80 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -7,6 +7,11 @@ export const ru: Record = { 'app.title': 'Scrabble', 'connection.connecting': 'Подключение…', + 'blocked.title': 'Учётная запись заблокирована', + 'blocked.permanent': 'Ваша учётная запись заблокирована.', + 'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.', + 'blocked.reason': 'Причина:', + 'common.back': 'Назад', 'common.cancel': 'Отмена', 'common.ok': 'ОК', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 5c40716..3f86e52 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -13,6 +13,7 @@ import type { import { GatewayError } from '../client'; import type { AccountRef, + BlockStatus, ChatMessage, EvalResult, FriendCode, @@ -139,6 +140,10 @@ export class MockGateway implements GatewayClient { async profileGet(): Promise { return { ...this.profile }; } + async blockStatus(): Promise { + // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam. + return { blocked: false, permanent: false, until: '', reason: '' }; + } async gamesList(): Promise { return { games: [...this.games.values()].map((g) => structuredClone(g.view)) }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 72fd8f2..806a334 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -121,6 +121,18 @@ export interface Profile { notificationsInAppOnly: boolean; } +/** BlockStatus is the caller's current manual block, fetched after any call reports + * the "account_blocked" code. When blocked the app shows the terminal blocked screen + * and stops all push/poll. */ +export interface BlockStatus { + blocked: boolean; + permanent: boolean; + /** RFC3339 UTC instant for a temporary block; "" for a permanent one or when not blocked. */ + until: string; + /** Operator reason resolved to the account's language; "" when none was cited. */ + reason: string; +} + /** The full editable profile sent to profileUpdate (overwrites every field). */ export interface ProfileUpdate { displayName: string; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index a976a21..94df8af 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -77,6 +77,9 @@ export function createTransport(baseUrl: string): GatewayClient { async profileGet() { return codec.decodeProfile(await exec('profile.get', codec.empty())); }, + async blockStatus() { + return codec.decodeBlockStatus(await exec('account.block_status', codec.empty())); + }, async gamesList() { return codec.decodeGameList(await exec('games.list', codec.empty())); }, diff --git a/ui/src/screens/Blocked.svelte b/ui/src/screens/Blocked.svelte new file mode 100644 index 0000000..e6b9431 --- /dev/null +++ b/ui/src/screens/Blocked.svelte @@ -0,0 +1,53 @@ + + +
+
+

{t('blocked.title')}

+

{message}

+ {#if app.blocked?.reason} +

{t('blocked.reason')} {app.blocked.reason}

+ {/if} +
+
+ + -- 2.52.0 From 290874720f93f59f0cf12992cb03d3415fe30950 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 22:24:57 +0200 Subject: [PATCH 127/223] perf(admin): cache the suspension gate lookup The suspension gate runs CurrentSuspension on every authenticated request. Add a write-through in-memory cache on account.Store keyed by account id, invalidated on Suspend/LiftSuspension, with the cached entry re-evaluated against the wall clock so a temporary block lapses without an explicit invalidation. Single-instance, matching the deployment (one shared Store). Keeps the gate off the database on the hot path while a block still takes effect on the next request. --- backend/internal/account/account.go | 5 +- backend/internal/account/suspension.go | 82 +++++++++++++++++++ .../internal/account/suspension_cache_test.go | 52 ++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 backend/internal/account/suspension_cache_test.go diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index a436da6..16d7d22 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -99,12 +99,15 @@ type Identity struct { type Store struct { db *sql.DB metrics *accountMetrics + // suspensions caches each account's current manual block, read by the suspension gate on + // every authenticated request and invalidated on Suspend/LiftSuspension. See suspension.go. + suspensions *suspensionCache } // 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, metrics: defaultAccountMetrics()} + return &Store{db: db, metrics: defaultAccountMetrics(), suspensions: newSuspensionCache()} } // ProvisionByIdentity returns the account bound to (kind, externalID), creating diff --git a/backend/internal/account/suspension.go b/backend/internal/account/suspension.go index e1e2039..155f289 100644 --- a/backend/internal/account/suspension.go +++ b/backend/internal/account/suspension.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "time" "github.com/go-jet/jet/v2/postgres" @@ -82,6 +83,7 @@ func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Ti if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err) } + s.invalidateSuspension(accountID) return modelToSuspension(row), nil } @@ -100,6 +102,7 @@ func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error { if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("account: lift suspension %s: %w", accountID, err) } + s.invalidateSuspension(accountID) return nil } @@ -115,6 +118,30 @@ func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Sus return Suspension{}, false, nil } now := time.Now().UTC() + if s.suspensions != nil { + if e, ok := s.suspensions.get(accountID); ok { + if !e.found { + return Suspension{}, false, nil // cached: not blocked + } + if suspensionActiveAt(e.susp, now) { + return e.susp, true, nil // cached block still in force + } + // The cached block has lapsed since it was cached; refresh from the database below. + } + } + susp, found, err := s.queryCurrentSuspension(ctx, accountID, now) + if err != nil { + return Suspension{}, false, err + } + if s.suspensions != nil { + s.suspensions.put(accountID, suspensionCacheEntry{susp: susp, found: found}) + } + return susp, found, nil +} + +// queryCurrentSuspension reads the account's strongest in-force block straight from the database +// (no cache), as of now. It backs CurrentSuspension on a cache miss or a lapsed entry. +func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, now time.Time) (Suspension, bool, error) { stmt := postgres.SELECT(table.AccountSuspensions.AllColumns). FROM(table.AccountSuspensions). WHERE( @@ -134,6 +161,61 @@ func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Sus return modelToSuspension(row), true, nil } +// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it. +// Called after Suspend and LiftSuspension. +func (s *Store) invalidateSuspension(accountID uuid.UUID) { + if s.suspensions != nil { + s.suspensions.invalidate(accountID) + } +} + +// suspensionActiveAt reports whether a suspension is in force at now: permanent, or not yet +// expired. The lifted check is implicit — only non-lifted blocks are ever cached or returned. +func suspensionActiveAt(susp Suspension, now time.Time) bool { + return susp.BlockedUntil == nil || susp.BlockedUntil.After(now) +} + +// suspensionCache is the gate's write-through cache of each account's current block, keyed by +// account id. The suspension gate reads it on every authenticated request; Suspend and +// LiftSuspension invalidate the account's entry. An entry holds the strongest active block at +// query time (or a not-blocked marker), re-evaluated against the wall clock on read, so a +// temporary block lapses without an explicit invalidation. It is single-instance, matching the +// deployment (one shared Store); a multi-instance deployment would need a shared cache. +type suspensionCache struct { + mu sync.RWMutex + m map[uuid.UUID]suspensionCacheEntry +} + +// suspensionCacheEntry is a cached lookup: the strongest active block when found, else a +// not-blocked marker (found=false). +type suspensionCacheEntry struct { + susp Suspension + found bool +} + +func newSuspensionCache() *suspensionCache { + return &suspensionCache{m: make(map[uuid.UUID]suspensionCacheEntry)} +} + +func (c *suspensionCache) get(id uuid.UUID) (suspensionCacheEntry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.m[id] + return e, ok +} + +func (c *suspensionCache) put(id uuid.UUID, e suspensionCacheEntry) { + c.mu.Lock() + defer c.mu.Unlock() + c.m[id] = e +} + +func (c *suspensionCache) invalidate(id uuid.UUID) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.m, id) +} + // activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and // either permanent or not yet expired. func activeSuspensionPredicate(now time.Time) postgres.BoolExpression { diff --git a/backend/internal/account/suspension_cache_test.go b/backend/internal/account/suspension_cache_test.go new file mode 100644 index 0000000..9ec24b3 --- /dev/null +++ b/backend/internal/account/suspension_cache_test.go @@ -0,0 +1,52 @@ +package account + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +// TestSuspensionActiveAt covers the wall-clock re-evaluation the cache relies on: a permanent +// block is always active, a future-dated one is active, a past-dated one is not. +func TestSuspensionActiveAt(t *testing.T) { + now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) + future := now.Add(time.Hour) + past := now.Add(-time.Hour) + + if !suspensionActiveAt(Suspension{BlockedUntil: nil}, now) { + t.Error("a permanent block must be active") + } + if !suspensionActiveAt(Suspension{BlockedUntil: &future}, now) { + t.Error("a future-dated block must be active") + } + if suspensionActiveAt(Suspension{BlockedUntil: &past}, now) { + t.Error("a past-dated block must be inactive") + } +} + +// TestSuspensionCache covers the cache primitives: a miss on an empty cache, a hit after put for +// both the not-blocked and blocked markers, and a miss after invalidate. +func TestSuspensionCache(t *testing.T) { + c := newSuspensionCache() + id := uuid.New() + + if _, ok := c.get(id); ok { + t.Fatal("empty cache must miss") + } + + c.put(id, suspensionCacheEntry{found: false}) + if e, ok := c.get(id); !ok || e.found { + t.Fatalf("cached not-blocked entry = (%+v, ok %v), want hit with found=false", e, ok) + } + + c.put(id, suspensionCacheEntry{susp: Suspension{AccountID: id}, found: true}) + if e, ok := c.get(id); !ok || !e.found { + t.Fatalf("cached blocked entry = (%+v, ok %v), want hit with found=true", e, ok) + } + + c.invalidate(id) + if _, ok := c.get(id); ok { + t.Fatal("invalidated entry must miss") + } +} -- 2.52.0 From ac62d29ef738271112b49a82759eabcae6dc4e07 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 22:52:15 +0200 Subject: [PATCH 128/223] feat(ui): name the previous player in the your-turn toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app "your turn" toast (shown when it becomes your turn in another game) now reads ": Your turn!", naming the player who moved just before this one — the previous seat in turn order, so it reads the same in games with any number of players. Falls back to the bare "Your turn" label when no name is present (an older peer, or a gap event without one). YourTurnEvent already carries opponent_name end to end: the backend sets it (game.displayName of the last mover) and the gateway forwards the payload opaquely, so this is a client-only change — decode the field, thread it through PushEvent, and pick the localized string. --- ui/src/lib/app.svelte.ts | 5 ++++- ui/src/lib/codec.test.ts | 33 +++++++++++++++++++++++++++++++++ ui/src/lib/codec.ts | 2 +- ui/src/lib/gamedelta.test.ts | 2 +- ui/src/lib/i18n.test.ts | 5 +++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/mock/client.ts | 2 +- ui/src/lib/model.ts | 2 +- 9 files changed, 48 insertions(+), 5 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index a53ad61..8b219ae 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -210,7 +210,10 @@ function openStream(): void { } else if (e.kind === 'nudge') { showToast(t('chat.nudge'), 'info'); } else if (e.kind === 'your_turn') { - showToast(t('game.yourTurn'), 'info'); + // Name the player who moved just before this one (the previous seat in turn order), so the + // toast reads the same in games with any number of players. Fall back to the bare label when + // no name is present (an older peer, or a gap event without one). + showToast(e.opponentName ? t('game.yourTurnBy', { name: e.opponentName }) : t('game.yourTurn'), 'info'); } else if (e.kind === 'opponent_moved' || e.kind === 'game_over') { // Keep both caches fresh from any screen, so neither the lobby nor the game flashes a stale // frame on the next navigation. The lobby snapshot is patched from the event's own GameView diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 5717c63..af3e8c0 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -300,6 +300,39 @@ describe('codec', () => { expect(inv.variant).toBe('scrabble_en'); }); + it('decodes a your_turn event, carrying the previous player name for the toast', () => { + const b = new Builder(64); + const gid = b.createString('g-1'); + const name = b.createString('Ann'); + fb.YourTurnEvent.startYourTurnEvent(b); + fb.YourTurnEvent.addGameId(b, gid); + fb.YourTurnEvent.addOpponentName(b, name); + fb.YourTurnEvent.addMoveCount(b, 7); + b.finish(fb.YourTurnEvent.endYourTurnEvent(b)); + expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({ + kind: 'your_turn', + gameId: 'g-1', + deadlineUnix: 0, + opponentName: 'Ann', + moveCount: 7, + }); + }); + + it('decodes a your_turn event with no opponent name as an empty string', () => { + const b = new Builder(64); + const gid = b.createString('g-2'); + fb.YourTurnEvent.startYourTurnEvent(b); + fb.YourTurnEvent.addGameId(b, gid); + b.finish(fb.YourTurnEvent.endYourTurnEvent(b)); + expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({ + kind: 'your_turn', + gameId: 'g-2', + deadlineUnix: 0, + opponentName: '', + moveCount: 0, + }); + }); + it('decodes an opponent_joined event (reusing the match_found payload layout)', () => { const b = new Builder(64); const gid = b.createString('g-open'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 5d7e29d..aac290f 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -442,7 +442,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null switch (kind) { case 'your_turn': { const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb); - return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), moveCount: e.moveCount() }; + return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), opponentName: s(e.opponentName()), moveCount: e.moveCount() }; } case 'opponent_moved': { const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb); diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index f31f9d4..57318e7 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -137,7 +137,7 @@ describe('advanceCached', () => { }); it('ignores event kinds that do not advance a game board', () => { - const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, moveCount: 4 }; + const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, opponentName: 'Ann', moveCount: 4 }; expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined(); }); }); diff --git a/ui/src/lib/i18n.test.ts b/ui/src/lib/i18n.test.ts index ae65a56..fd41b77 100644 --- a/ui/src/lib/i18n.test.ts +++ b/ui/src/lib/i18n.test.ts @@ -13,6 +13,11 @@ describe('i18n catalog', () => { expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке'); }); + it('names the previous player in the cross-game your-turn toast', () => { + expect(translate('ru', 'game.yourTurnBy', { name: 'Аня' })).toBe('Аня: Ваш ход!'); + expect(translate('en', 'game.yourTurnBy', { name: 'Ann' })).toBe('Ann: Your turn!'); + }); + it('localizes move-history action labels', () => { expect(translate('ru', 'move.exchange')).toBe('обмен'); expect(translate('ru', 'move.pass')).toBe('пас'); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3c86897..5d38e95 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -61,6 +61,7 @@ export const en = { 'game.bagEmpty': 'Bag is empty', 'game.hints': 'Hints {n}', 'game.yourTurn': 'Your turn', + 'game.yourTurnBy': '{name}: Your turn!', 'game.opponentsTurn': "Opponent's turn", 'game.searchingForOpponent': 'Searching for opponent…', 'game.waiting': "Waiting for {name}", diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 925fd80..93ae48b 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -62,6 +62,7 @@ export const ru: Record = { 'game.bagEmpty': 'Мешок пуст', 'game.hints': 'Подсказки {n}', 'game.yourTurn': 'Ваш ход', + 'game.yourTurnBy': '{name}: Ваш ход!', 'game.opponentsTurn': 'Ход соперника', 'game.searchingForOpponent': 'Поиск соперника...', 'game.waiting': 'Ожидаем {name}', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 3f86e52..964e251 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -598,7 +598,7 @@ export class MockGateway implements GatewayClient { g.view.moveCount += 1; g.view.toMove = this.mySeat(g); this.emit({ kind: 'opponent_moved', gameId, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen }); - this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, moveCount: g.view.moveCount }); + this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, opponentName: g.view.seats[opp].displayName, moveCount: g.view.moveCount }); }, 1600); } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 806a334..eb16c78 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -255,7 +255,7 @@ export interface GameList { * so a client falls back to a refetch when a payload is absent (a gap, or an older peer). */ export type PushEvent = - | { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number } + | { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number } | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'chat_message'; message: ChatMessage } -- 2.52.0 From 192e4a24330107c56b7ff6b98a8ff4d529ee89dd Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 23:21:30 +0200 Subject: [PATCH 129/223] feat(admin): grant hints to a user's wallet from the console Add an "Add hints" form on the admin user card that additively tops up a player's hint wallet (1-100 per grant). The grant is raise-only by construction (an additive UPDATE never lowers the balance) and stays correct under a concurrent in-game spend; a per-grant cap bounds a fat-finger, since the console can never reduce a wallet. The in-game hint policy is unchanged and already correct: a game offers the per-seat allowance plus the wallet, spending the allowance first and the wallet only after (covered by TestHintPolicy). --- backend/internal/account/account.go | 24 +++++++ .../templates/pages/user_detail.gohtml | 4 ++ backend/internal/adminconsole/views.go | 17 +++-- backend/internal/inttest/admin_test.go | 71 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 31 +++++++- docs/ARCHITECTURE.md | 4 +- docs/FUNCTIONAL.md | 5 ++ docs/FUNCTIONAL_ru.md | 5 ++ 8 files changed, 151 insertions(+), 10 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 16d7d22..a0a7a28 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -430,6 +430,30 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { return n > 0, nil } +// GrantHints adds n hints to the account's wallet and returns the new balance. n must be +// positive: the additive update can only raise the balance, never lower it, so it enforces the +// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint. +// It returns ErrNotFound when no account matches. +func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) { + if n <= 0 { + return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n) + } + stmt := table.Accounts. + UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt). + SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())). + WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). + RETURNING(table.Accounts.HintBalance) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return 0, ErrNotFound + } + return 0, fmt.Errorf("account: grant hints %s: %w", id, err) + } + return int(row.HintBalance), nil +} + // FlagHighRate stamps the soft "suspected high-rate" marker with at, only when // the account is not already flagged — the first sustained episode wins, and a // re-flag after an operator clear starts a fresh timestamp. An infra marker, not diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 89a324e..aa18410 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -21,6 +21,10 @@ {{end}} +
+ + +

Statistics

{{if .HasStats}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 036a6d5..4a3ef41 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -125,13 +125,16 @@ type UserDetailView struct { // empty for an unflagged account; the card shows it with the Clear action. FlaggedHighRateAt string HintBalance int - CreatedAt string - HasStats bool - Stats StatsRow - Identities []IdentityRow - Games []GameRow - TelegramID string - ConnectorEnabled bool + // HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the + // server's maxHintGrant), passed through so the policy value lives in one place. + HintGrantMax int + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow + 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 diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 0531685..75354cf 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -4,6 +4,7 @@ package inttest import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -273,6 +274,76 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) { } } +// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form, +// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again +// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or +// non-numeric amount is refused without changing the balance. +func TestConsoleGrantHints(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + id := provisionAccount(t) + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), + }) + h := srv.Handler() + base := "http://admin.test/_gm/users/" + id.String() + + if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") { + t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints")) + } + // The grant POST is CSRF-guarded like every console action. + if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden { + t.Fatalf("grant without origin = %d, want 403", code) + } + // A same-origin grant adds to the wallet. + if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") { + t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5")) + } + if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 { + t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err) + } + // A second grant adds again rather than replacing. + if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") { + t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8")) + } + // An out-of-range or non-numeric amount is refused; the balance is left untouched. + for _, bad := range []string{"0", "-1", "101", "x", ""} { + if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") { + t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount")) + } + } + if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 { + t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err) + } + // The inclusive per-grant cap (100) is accepted. + if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK { + t.Fatalf("grant 100 = %d, want 200", code) + } + if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 { + t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err) + } +} + +// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the +// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound. +func TestGrantHintsStore(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + id := provisionAccount(t) + if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 { + t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err) + } + if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 { + t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err) + } + if _, err := accounts.GrantHints(ctx, id, 0); err == nil { + t.Error("grant 0 should be rejected (non-positive)") + } + if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) { + t.Errorf("grant unknown account = %v, want ErrNotFound", err) + } +} + // 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/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 53dbe96..8bcbf15 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -28,6 +28,10 @@ import ( // adminPageSize is the page size of the admin console's paginated lists. const adminPageSize = 50 +// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a +// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake. +const maxHintGrant = 100 + // registerConsole mounts the server-rendered admin console under /_gm. The gateway // puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the // backend trusts the gateway (as for all of /api) and adds only a same-origin guard @@ -51,6 +55,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/users/:id", s.consoleUserDetail) gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) + gm.POST("/users/:id/grant-hints", s.consoleGrantHints) gm.POST("/users/:id/block", s.consoleBlockUser) gm.POST("/users/:id/unblock", s.consoleUnblockUser) gm.GET("/reasons", s.consoleReasons) @@ -263,8 +268,8 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view := adminconsole.UserDetailView{ ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, - PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt), - HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, + PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant, + CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, } if acc.MergedInto != uuid.Nil { view.MergedInto = acc.MergedInto.String() @@ -774,6 +779,28 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) { s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) } +// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a +// player up and can never lower what they already hold, so blocking a reduction is inherent rather +// than a separate guard. A single grant is bounded by maxHintGrant. +func (s *Server) consoleGrantHints(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + n, err := strconv.Atoi(trimForm(c, "amount")) + if err != nil || n < 1 || n > maxHintGrant { + s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back) + return + } + balance, err := s.accounts.GrantHints(c.Request.Context(), id, n) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back) +} + // consoleBlockUser manually blocks an account: it records the suspension (permanent or until a // parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active // games, removing them from matchmaking. The block takes effect on the player's next request. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed0d524..07a84a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -477,7 +477,9 @@ English game the Latin pool. Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary keys are application-generated **UUIDv7**. - Tables: `accounts` (durable internal accounts, carrying the away-window - columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest` + columns `away_start`/`away_end`, the hint wallet `hint_balance` (spent after a + game's per-seat allowance; an operator tops it up with an additive, raise-only + grant from the admin console), the `is_guest` flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push toggle, the `paid_account` service flag and the merge-tombstone columns `merged_into`/`merged_at`), diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index cd9475c..d8f292f 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -220,3 +220,8 @@ is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary, plus the reason when one was given, and the app stops all background traffic with the server. A temporary block lifts itself when it expires; the operator can also **unblock** from the user card at any time (games already lost stay lost). + +From the user card the operator can also **top up a player's hint wallet**: an additive grant +(1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** — +the console can never lower a wallet (a player only loses hints by spending them in a game), so an +over-grant cannot be reversed there. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1ecdbad..95083c6 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -226,3 +226,8 @@ high-rate флага. С карточки пользователя операт приложение прекращает любое фоновое общение с сервером. Временная блокировка снимается сама по истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент (уже проигранные партии не возвращаются). + +С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное +начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления +**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в +партии), поэтому ошибочно начисленное через консоль там не отнять. -- 2.52.0 From 419ea11b1402ce2f8de0a2c71dc1645c1b92b0fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:23:10 +0200 Subject: [PATCH 130/223] feat(feedback): in-app user feedback with admin review and account roles User-facing Feedback screen (Settings -> Info, registered accounts only): a message (<=1024 runes) plus one optional attachment, an anti-spam gate (one unreviewed message at a time), and the operator's inline reply with a Settings/Info badge. Server-rendered admin console section (/_gm/feedback): unread/read/archived queue with per-user search, detail with read/reply/ archive/delete/delete-all, safe attachment serving (nosniff, images inline via , others download-only). Introduces account_roles, the first per-account role table; feedback_banned blocks only feedback submission, granted/revoked from /users and the delete-with-block action. - migration 00004_feedback (feedback_messages + account_roles) + jetgen - backend internal/feedback (store+service), internal/account/roles.go - wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest, is_guest via session resolve) -> guest_forbidden before any backend call - reply push reuses NotificationEvent with a new admin_reply sub-kind - UI: /feedback route + screen, attachment picker, badge, channel detection, i18n - tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e - docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs --- PLAN.md | 45 +++ backend/README.md | 1 + backend/cmd/backend/main.go | 4 + backend/internal/account/roles.go | 92 +++++ .../internal/adminconsole/assets/console.css | 5 + backend/internal/adminconsole/render_test.go | 3 + .../adminconsole/templates/layout.gohtml | 1 + .../templates/pages/dashboard.gohtml | 1 + .../templates/pages/feedback.gohtml | 38 ++ .../templates/pages/feedback_detail.gohtml | 46 +++ .../templates/pages/user_detail.gohtml | 19 +- backend/internal/adminconsole/views.go | 60 +++ backend/internal/feedback/attachment.go | 54 +++ backend/internal/feedback/attachment_test.go | 81 ++++ backend/internal/feedback/service.go | 256 ++++++++++++ backend/internal/feedback/store.go | 370 ++++++++++++++++++ backend/internal/inttest/feedback_test.go | 227 +++++++++++ backend/internal/notify/notify.go | 4 + .../jet/backend/model/account_roles.go | 19 + .../jet/backend/model/feedback_messages.go | 29 ++ .../jet/backend/table/account_roles.go | 84 ++++ .../jet/backend/table/feedback_messages.go | 114 ++++++ .../jet/backend/table/table_use_schema.go | 2 + .../postgres/migrations/00004_feedback.sql | 54 +++ backend/internal/server/dto.go | 6 +- backend/internal/server/handlers.go | 20 + .../internal/server/handlers_admin_console.go | 19 + .../server/handlers_admin_feedback.go | 293 ++++++++++++++ backend/internal/server/handlers_auth.go | 9 +- backend/internal/server/handlers_feedback.go | 105 +++++ backend/internal/server/server.go | 6 + docs/ARCHITECTURE.md | 39 ++ docs/FUNCTIONAL.md | 21 + docs/FUNCTIONAL_ru.md | 22 ++ docs/TESTING.md | 9 + docs/UI_DESIGN.md | 12 +- gateway/internal/backendclient/api.go | 10 +- .../internal/backendclient/api_feedback.go | 56 +++ gateway/internal/connectsrv/server.go | 28 +- gateway/internal/connectsrv/server_test.go | 39 ++ gateway/internal/session/cache.go | 42 +- gateway/internal/session/cache_test.go | 33 +- gateway/internal/transcode/encode.go | 31 ++ gateway/internal/transcode/transcode.go | 40 ++ pkg/fbs/scrabble.fbs | 34 ++ pkg/fbs/scrabblefb/FeedbackReply.go | 75 ++++ pkg/fbs/scrabblefb/FeedbackState.go | 91 +++++ pkg/fbs/scrabblefb/FeedbackSubmitRequest.go | 122 ++++++ pkg/fbs/scrabblefb/FeedbackUnread.go | 64 +++ ui/e2e/feedback.spec.ts | 45 +++ ui/src/App.svelte | 10 +- ui/src/gen/fbs/scrabblefb.ts | 4 + ui/src/gen/fbs/scrabblefb/feedback-reply.ts | 58 +++ ui/src/gen/fbs/scrabblefb/feedback-state.ts | 64 +++ .../fbs/scrabblefb/feedback-submit-request.ts | 104 +++++ ui/src/gen/fbs/scrabblefb/feedback-unread.ts | 46 +++ ui/src/lib/app.svelte.ts | 26 ++ ui/src/lib/channel.test.ts | 20 + ui/src/lib/channel.ts | 30 ++ ui/src/lib/client.ts | 9 + ui/src/lib/codec.test.ts | 60 +++ ui/src/lib/codec.ts | 34 ++ ui/src/lib/feedback.test.ts | 24 ++ ui/src/lib/feedback.ts | 29 ++ ui/src/lib/gateway.ts | 7 +- ui/src/lib/i18n/en.ts | 11 + ui/src/lib/i18n/ru.ts | 11 + ui/src/lib/mock/client.ts | 31 ++ ui/src/lib/model.ts | 17 + ui/src/lib/routeparse.ts | 3 + ui/src/lib/transport.ts | 9 + ui/src/screens/About.svelte | 5 + ui/src/screens/Feedback.svelte | 189 +++++++++ ui/src/screens/Lobby.svelte | 10 +- ui/src/screens/SettingsHub.svelte | 2 +- 75 files changed, 3638 insertions(+), 55 deletions(-) create mode 100644 backend/internal/account/roles.go create mode 100644 backend/internal/adminconsole/templates/pages/feedback.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/feedback_detail.gohtml create mode 100644 backend/internal/feedback/attachment.go create mode 100644 backend/internal/feedback/attachment_test.go create mode 100644 backend/internal/feedback/service.go create mode 100644 backend/internal/feedback/store.go create mode 100644 backend/internal/inttest/feedback_test.go create mode 100644 backend/internal/postgres/jet/backend/model/account_roles.go create mode 100644 backend/internal/postgres/jet/backend/model/feedback_messages.go create mode 100644 backend/internal/postgres/jet/backend/table/account_roles.go create mode 100644 backend/internal/postgres/jet/backend/table/feedback_messages.go create mode 100644 backend/internal/postgres/migrations/00004_feedback.sql create mode 100644 backend/internal/server/handlers_admin_feedback.go create mode 100644 backend/internal/server/handlers_feedback.go create mode 100644 gateway/internal/backendclient/api_feedback.go create mode 100644 pkg/fbs/scrabblefb/FeedbackReply.go create mode 100644 pkg/fbs/scrabblefb/FeedbackState.go create mode 100644 pkg/fbs/scrabblefb/FeedbackSubmitRequest.go create mode 100644 pkg/fbs/scrabblefb/FeedbackUnread.go create mode 100644 ui/e2e/feedback.spec.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-reply.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-state.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/feedback-unread.ts create mode 100644 ui/src/lib/channel.test.ts create mode 100644 ui/src/lib/channel.ts create mode 100644 ui/src/lib/feedback.test.ts create mode 100644 ui/src/lib/feedback.ts create mode 100644 ui/src/screens/Feedback.svelte diff --git a/PLAN.md b/PLAN.md index 7ccf1c1..45badb9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -52,6 +52,7 @@ independent (see ARCHITECTURE §9.1). | 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** | | 17 | Test-contour verification & defect fixes | **done** | | 18 | Prod contour deploy (SSH export/import, manual after merge) | todo | +| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** | Scaffolding is incremental: `go.work` lists only existing modules; each stage adds the modules it needs. @@ -419,6 +420,37 @@ 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. +### Stage 19 — User feedback *(done)* +A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18 +prod deploy. Scope: a **Feedback** screen under Settings → Info where a registered player sends a message +(≤1024 runes) with an optional single attachment; the operator reviews and replies in the server-rendered +**admin console**; the reply is delivered back in-app with a Settings/Info badge. Introduces the project's +**first per-account role** (`account_roles`), the reusable replacement for per-feature flags: the +`feedback_banned` role blocks **only** feedback submission (not the whole account, unlike a suspension), +granted from the feedback section's delete-with-block and granted/revoked from `/users`. + +- **Data:** migration `00004_feedback` — `feedback_messages` (body, attachment `bytea`, sender_ip, channel, + read/archived/reply/reply-read timestamps) + `account_roles` (account_id, role). New backend domain + `internal/feedback` (store + service), modelled on `internal/social` + the admin chat-moderation surface. +- **Anti-spam gate:** a player with an unreviewed message cannot send another ("Ожидаем рассмотрения вашего + последнего обращения"); enforced server-side. This *is* the rate limit — no separate one. +- **Reply lifecycle:** the reply lives on the message row; shown on the feedback screen ("Ответ на ваше + последнее сообщение") for the player's latest replied message; it becomes read the moment the screen + fetches it (delivery = read) and is hidden one week after. The badge (lobby ⚙️, combined with friend + requests; Settings → Info "1") rides the existing `NotificationEvent` with a new `admin_reply` sub-kind — + no wire-schema change for the push. +- **Wire:** new FlatBuffers `feedback.submit` / `feedback.get` / `feedback.unread`; `feedback.submit` is + **guest-gated at the gateway** via a new `Op.NonGuest` flag (the session resolve now carries `is_guest`), + so a guest's submission never reaches the backend; the backend rejects guests too. +- **Security:** body + attachment limits enforced server-side (the UI limits are UX only); attachments + served with `X-Content-Type-Options: nosniff`, images inline-previewed via `` (which never + executes), everything else download-only; the UI gates the attachment by extension (allowed types not + listed) and the backend mirrors the allow-list + size as the trust boundary; the console renders user + content as auto-escaped text (`html/template`). +Open details settled at planning: attachment ≤1,000,000 B (keeps the 1 MiB edge cap); allowed types +(images + pdf/txt/log/doc/docx/rtf/zip/gz/7z); channel from the client (telegram/ios/android/web); guests +cannot submit; three-way admin filter. + ## Refinements logged during implementation - **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet; @@ -1476,6 +1508,19 @@ provided cert) at the contour caddy; prod VPN; rollback. `proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`, emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test. +### Stage 19 — user feedback +- Dropped the planned `feedback_messages.attachment_type` column: the safe content-type is derived from the + file-name extension at both validate and serve time (one source of truth), so a stored column was + redundant. +- The admin filter is **three-way** (Непрочитанные / Прочитанные / Архив) rather than the two first + sketched, mirroring complaints' `open·resolved·all`, so a read-but-unarchived message is never + unreachable (owner-approved at planning). +- The guest gate was extended to the **gateway** at the owner's request: a new `Op.NonGuest` flag plus + `is_guest` threaded through the session resolve + gateway cache reject a guest's `feedback.submit` with + the `guest_forbidden` result code before any backend call (the backend keeps its own guest check as the + backstop). `handleResolveSession` now loads the account best-effort to report `is_guest` (a read error + falls back to false so the auth hot path stays resilient). + ## Deferred TODOs (cross-stage) - ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is diff --git a/backend/README.md b/backend/README.md index ba62f5d..5c7bc03 100644 --- a/backend/README.md +++ b/backend/README.md @@ -130,6 +130,7 @@ internal/server/ # gin engine, route groups, X-User-ID middleware, probes internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter +internal/feedback/ # user feedback: messages + attachment (bytea), anti-spam gate, admin review/reply internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 81d2cb2..a0185d8 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -22,6 +22,7 @@ import ( "scrabble/backend/internal/config" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" + "scrabble/backend/internal/feedback" "scrabble/backend/internal/game" "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" @@ -167,6 +168,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc.SetNotifier(hub) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) + feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts) + feedbackSvc.SetNotifier(hub) // Robot opponent: provision its durable account pool (a hard startup // dependency, like the dictionaries) and start its move driver. The matchmaker @@ -202,6 +205,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Sessions: sessions, Accounts: accounts, Games: games, + Feedback: feedbackSvc, Social: socialSvc, Matchmaker: matchmaker, Invitations: invitations, diff --git a/backend/internal/account/roles.go b/backend/internal/account/roles.go new file mode 100644 index 0000000..8c2f432 --- /dev/null +++ b/backend/internal/account/roles.go @@ -0,0 +1,92 @@ +package account + +import ( + "context" + "fmt" + + "github.com/go-jet/jet/v2/postgres" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Per-account roles. A role is a named capability or restriction attached to an +// account, the reusable replacement for per-feature boolean flags. The set is +// expected to grow; roles are validated against KnownRoles in Go so adding one +// needs no migration. Granted/revoked from the admin console (/users and the +// feedback section). +const ( + // RoleFeedbackBanned forbids the account from submitting feedback (only that; + // it is not a full account suspension). See internal/feedback. + RoleFeedbackBanned = "feedback_banned" +) + +// KnownRoles is the set of roles the console may grant or revoke; an operator +// cannot assign an unrecognised role. +var KnownRoles = []string{RoleFeedbackBanned} + +// IsKnownRole reports whether role is a recognised account role. +func IsKnownRole(role string) bool { + for _, r := range KnownRoles { + if r == role { + return true + } + } + return false +} + +// GrantRole gives the account the role, idempotently (a repeat grant is a no-op). +func (s *Store) GrantRole(ctx context.Context, accountID uuid.UUID, role string) error { + stmt := table.AccountRoles. + INSERT(table.AccountRoles.AccountID, table.AccountRoles.Role). + VALUES(accountID, role). + ON_CONFLICT(table.AccountRoles.AccountID, table.AccountRoles.Role).DO_NOTHING() + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: grant role %q to %s: %w", role, accountID, err) + } + return nil +} + +// RevokeRole removes the role from the account, idempotently. +func (s *Store) RevokeRole(ctx context.Context, accountID uuid.UUID, role string) error { + stmt := table.AccountRoles. + DELETE(). + WHERE(table.AccountRoles.AccountID.EQ(postgres.UUID(accountID)). + AND(table.AccountRoles.Role.EQ(postgres.String(role)))) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: revoke role %q from %s: %w", role, accountID, err) + } + return nil +} + +// HasRole reports whether the account holds the role. +func (s *Store) HasRole(ctx context.Context, accountID uuid.UUID, role string) (bool, error) { + var ok bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.account_roles WHERE account_id = $1 AND role = $2)`, + accountID, role).Scan(&ok) + if err != nil { + return false, fmt.Errorf("account: has-role %q %s: %w", role, accountID, err) + } + return ok, nil +} + +// ListRoles returns the account's roles, oldest grant first. +func (s *Store) ListRoles(ctx context.Context, accountID uuid.UUID) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT role FROM backend.account_roles WHERE account_id = $1 ORDER BY granted_at ASC`, + accountID) + if err != nil { + return nil, fmt.Errorf("account: list roles %s: %w", accountID, err) + } + defer rows.Close() + var out []string + for rows.Next() { + var role string + if err := rows.Scan(&role); err != nil { + return nil, fmt.Errorf("account: scan role: %w", err) + } + out = append(out, role) + } + return out, rows.Err() +} diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 0c802d7..8002a48 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -116,3 +116,8 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } .lg-min { color: var(--ok); } .lg-avg { color: var(--accent); } .lg-max { color: var(--danger); } + +/* Feedback: user-controlled message bodies are wrapped and escaped (never HTML); + an image attachment is previewed inline, bounded so it cannot dominate the page. */ +.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; } +.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; } diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 8d9d7b9..b6539bb 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -25,6 +25,7 @@ func TestRendererRendersEveryPage(t *testing.T) { {"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"}, + {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"}, {"throttled", ThrottledView{ Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}}, Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}}, @@ -35,6 +36,8 @@ func TestRendererRendersEveryPage(t *testing.T) { {"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"}, {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"}, + {"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"}, + {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "ios", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "please fix the board"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, {"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"}, {"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"}, diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 770ac90..421cb71 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -16,6 +16,7 @@ Users Games Complaints + Feedback Messages Throttled Reasons diff --git a/backend/internal/adminconsole/templates/pages/dashboard.gohtml b/backend/internal/adminconsole/templates/pages/dashboard.gohtml index ed835a3..267a74d 100644 --- a/backend/internal/adminconsole/templates/pages/dashboard.gohtml +++ b/backend/internal/adminconsole/templates/pages/dashboard.gohtml @@ -7,6 +7,7 @@

Games

{{.Games}}

Active games

{{.ActiveGames}}

Open complaints

{{.OpenComplaints}}

+

Unread feedback

{{.OpenFeedback}}

Pending dict changes

{{.PendingChanges}}

diff --git a/backend/internal/adminconsole/templates/pages/feedback.gohtml b/backend/internal/adminconsole/templates/pages/feedback.gohtml new file mode 100644 index 0000000..75ce714 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/feedback.gohtml @@ -0,0 +1,38 @@ +{{define "content" -}} +

Feedback

+{{with .Data}} + +
+ +{{if .UserID}}{{end}} + + + +
+ + + +{{range .Items}} + + + + + + + + + +{{else}}{{end}} + +
SenderSourceChannelAttachReplyStateFiled
{{.SenderName}}{{.Source}}{{.Channel}}{{if .HasAttachment}}yes{{else}}{{end}}{{if .Replied}}replied{{else}}{{end}}{{if .Archived}}archived{{else if .Read}}read{{else}}unread{{end}}{{.CreatedAt}}
no feedback
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml new file mode 100644 index 0000000..76a4ef4 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -0,0 +1,46 @@ +{{define "content" -}} +{{with .Data}} +

Feedback

+ +

Message

+
    +
  • From {{.SenderName}} ({{.Source}})
  • +
  • Channel {{.Channel}}
  • +
  • IP {{if .IP}}{{.IP}}{{else}}none{{end}}
  • +
  • Filed {{.CreatedAt}}
  • +
  • State {{if .Archived}}archived{{else if .Read}}read{{else}}unread{{end}}
  • +{{if .Banned}}
  • Feedback sender is banned from feedback
  • {{end}} +
+
{{.Body}}
+{{if .HasAttachment}} +

Attachment

+{{if .IsImage}}

attachment

{{end}} +

download {{.AttachmentName}}

+{{end}} +
+{{if .Replied}} +

Current reply

+
{{.ReplyBody}}
+

sent {{.RepliedAt}}

+
+{{end}} +

{{if .Replied}}Re-reply{{else}}Reply{{end}}

+
+ +
+
+
+

Actions

+
+
+
+ +
+
+
+ +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index aa18410..5d4b14b 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -1,7 +1,7 @@ {{define "content" -}} {{with .Data}}

{{.DisplayName}}

- +

Account

    @@ -67,6 +67,23 @@
+

Roles

+{{$id := .ID}} +{{if .Roles}} + + + +{{range .Roles}} + +{{end}} + +
Role
{{.}}
+{{else}}

no roles

{{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 4a3ef41..7eb2a17 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -42,6 +42,7 @@ type DashboardView struct { Games int ActiveGames int OpenComplaints int + OpenFeedback int PendingChanges int // ActiveVersion is the dictionary version new games pin (the persisted active // version), distinct from the per-variant resident versions. @@ -142,6 +143,10 @@ type UserDetailView struct { // form; Reasons is the operator-editable reason picklist offered in the block form. Suspension SuspensionView Reasons []ReasonOption + // Roles is the account's current roles (each revocable); KnownRoles is the set the + // grant form offers. The first role is the feedback ban (see internal/account). + Roles []string + KnownRoles []string } // SuspensionView is an account's current manual-block state shown on the user card: whether it @@ -362,3 +367,58 @@ type MessageView struct { Body string Back string } + +// FeedbackView is the paginated user-feedback queue. Status is the active +// unread/read/archived filter; NameMask/ExtMask are the sender glob filters; +// UserID pins the list to one account (the per-user link from /users); +// FilterQuery is the active filters URL-encoded for the pager links (already +// escaped, hence template.URL — see UsersView.FilterQuery). +type FeedbackView struct { + Items []FeedbackRow + Status string + NameMask string + ExtMask string + UserID string + Pager Pager + FilterQuery template.URL +} + +// FeedbackRow is one feedback message in the queue: its sender (linked to the user +// card), source, channel, whether it has an attachment / a reply, its state and +// time. +type FeedbackRow struct { + ID string + AccountID string + SenderName string + Source string + Channel string + HasAttachment bool + Read bool + Replied bool + Archived bool + CreatedAt string +} + +// FeedbackDetailView is one feedback message with its body, attachment, state and +// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are +// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline +// preview; Banned shows whether the sender already holds the feedback ban. +type FeedbackDetailView struct { + ID string + AccountID string + SenderName string + Source string + Channel string + IP string + Body string + HasAttachment bool + AttachmentName string + IsImage bool + Read bool + Archived bool + Replied bool + ReplyBody string + RepliedAt string + CreatedAt string + Banned bool +} diff --git a/backend/internal/feedback/attachment.go b/backend/internal/feedback/attachment.go new file mode 100644 index 0000000..50c36be --- /dev/null +++ b/backend/internal/feedback/attachment.go @@ -0,0 +1,54 @@ +package feedback + +import ( + "path/filepath" + "strings" +) + +// maxAttachmentBytes caps a single attachment's raw size. Chosen to fit, with the +// message text and the FlatBuffers framing, under the gateway's 1 MiB edge body +// cap, so the whole submit request passes without weakening that cap. +const maxAttachmentBytes = 1_000_000 + +// allowedExt is the attachment extension allow-list. It is mirrored on the UI as a +// pre-upload gate; the server re-checks here as the trust boundary (metadata only, +// the file content is never parsed). Images render inline in the console; the rest +// are download-only. +var allowedExt = map[string]bool{ + "png": true, "jpg": true, "jpeg": true, "webp": true, "gif": true, // images + "pdf": true, "txt": true, "log": true, "doc": true, "docx": true, + "rtf": true, "zip": true, "gz": true, "7z": true, +} + +// imageType maps an image extension to the content-type the console serves it with +// (loaded only via , which never executes, so a renamed non-image is inert). +var imageType = map[string]string{ + "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", + "webp": "image/webp", "gif": "image/gif", +} + +// ext returns name's lower-cased extension without the leading dot. +func ext(name string) string { + return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) +} + +// AllowedAttachment reports whether name's extension is on the allow-list. +func AllowedAttachment(name string) bool { + return allowedExt[ext(name)] +} + +// IsImage reports whether name is an inline-previewable image by its extension. +func IsImage(name string) bool { + _, ok := imageType[ext(name)] + return ok +} + +// ContentType returns the safe content-type the console serves the attachment +// with: the matching image type for an image, else application/octet-stream so a +// non-image is downloaded rather than rendered. +func ContentType(name string) string { + if t, ok := imageType[ext(name)]; ok { + return t + } + return "application/octet-stream" +} diff --git a/backend/internal/feedback/attachment_test.go b/backend/internal/feedback/attachment_test.go new file mode 100644 index 0000000..4d77c4e --- /dev/null +++ b/backend/internal/feedback/attachment_test.go @@ -0,0 +1,81 @@ +package feedback + +import "testing" + +func TestAllowedAttachment(t *testing.T) { + tests := []struct { + name string + file string + want bool + }{ + {"png image", "shot.png", true}, + {"jpeg upper-case ext", "Photo.JPG", true}, + {"pdf doc", "report.pdf", true}, + {"archive 7z", "logs.7z", true}, + {"doc with dotted name", "my.notes.docx", true}, + {"disallowed exe", "evil.exe", false}, + {"disallowed svg (xss vector)", "x.svg", false}, + {"disallowed html", "x.html", false}, + {"no extension", "README", false}, + {"empty name", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AllowedAttachment(tt.file); got != tt.want { + t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want) + } + }) + } +} + +func TestIsImageAndContentType(t *testing.T) { + tests := []struct { + file string + isImage bool + ctype string + }{ + {"a.png", true, "image/png"}, + {"a.jpg", true, "image/jpeg"}, + {"a.jpeg", true, "image/jpeg"}, + {"a.webp", true, "image/webp"}, + {"a.gif", true, "image/gif"}, + {"a.pdf", false, "application/octet-stream"}, + {"a.zip", false, "application/octet-stream"}, + {"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml + {"noext", false, "application/octet-stream"}, + } + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + if got := IsImage(tt.file); got != tt.isImage { + t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage) + } + if got := ContentType(tt.file); got != tt.ctype { + t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype) + } + }) + } +} + +func TestNormalizeChannel(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"telegram", "telegram"}, + {"ios", "ios"}, + {"android", "android"}, + {"web", "web"}, + {" iOS ", "ios"}, // trimmed + lower-cased + {"TELEGRAM", "telegram"}, + {"", "web"}, // unknown -> web + {"windows", "web"}, // unknown -> web + {"'; DROP", "web"}, // junk -> web + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := normalizeChannel(tt.in); got != tt.want { + t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go new file mode 100644 index 0000000..2f1eed1 --- /dev/null +++ b/backend/internal/feedback/service.go @@ -0,0 +1,256 @@ +package feedback + +import ( + "context" + "errors" + "net/netip" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/notify" +) + +const ( + // maxBodyRunes caps a feedback message (and an operator reply) length. + maxBodyRunes = 1024 + // replyVisibleFor is how long an operator reply stays shown to the player after + // it is delivered (read). + replyVisibleFor = 7 * 24 * time.Hour +) + +// Submit / reply validation errors. The transport layer maps them to stable result +// codes; the UI maps those to the messages shown on the feedback screen. +var ( + ErrEmptyMessage = errors.New("feedback: empty message") + ErrMessageTooLong = errors.New("feedback: message too long") + ErrAttachmentTooLarge = errors.New("feedback: attachment too large") + ErrAttachmentType = errors.New("feedback: attachment type not allowed") + ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback") + ErrBanned = errors.New("feedback: account is banned from feedback") + ErrPendingReview = errors.New("feedback: previous message still pending review") +) + +// validChannels enumerates the submitting platforms a client may report; anything +// else is normalised to "web" (the channel is informational, never a gate). +var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true} + +// Service is the feedback domain: the only writer of feedback_messages. It reads +// accounts for the guest/role gates and publishes the reply notification. +type Service struct { + store *Store + accounts *account.Store + pub notify.Publisher + now func() time.Time +} + +// NewService constructs a Service. store owns feedback_messages; accounts supplies +// the guest flag and the feedback-ban role. +func NewService(store *Store, accounts *account.Store) *Service { + return &Service{ + store: store, + accounts: accounts, + pub: notify.Nop{}, + now: func() time.Time { return time.Now().UTC() }, + } +} + +// SetNotifier installs the live-event publisher used to push the "you have a reply" +// signal to the player. It must be called during startup wiring; the default is +// notify.Nop (no live events). +func (svc *Service) SetNotifier(p notify.Publisher) { + if p != nil { + svc.pub = p + } +} + +// Submit stores a feedback message from accountID. It rejects guests, feedback- +// banned accounts and a sender who still has a message pending review, then +// validates the body (non-empty, within the rune limit) and the optional +// attachment (size and extension allow-list). senderIP is the gateway-forwarded +// client IP (validated); channel is the submitting platform. +func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error { + acc, err := svc.accounts.GetByID(ctx, accountID) + if err != nil { + return err + } + if acc.IsGuest { + return ErrGuestForbidden + } + banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned) + if err != nil { + return err + } + if banned { + return ErrBanned + } + pending, err := svc.store.HasUnread(ctx, accountID) + if err != nil { + return err + } + if pending { + return ErrPendingReview + } + body = strings.TrimSpace(body) + if body == "" { + return ErrEmptyMessage + } + if utf8.RuneCountInString(body) > maxBodyRunes { + return ErrMessageTooLong + } + if len(attachment) > 0 { + if len(attachment) > maxAttachmentBytes { + return ErrAttachmentTooLarge + } + if !AllowedAttachment(attachmentName) { + return ErrAttachmentType + } + } else { + attachmentName = "" // a name without bytes carries no attachment + } + _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), parseIP(senderIP)) + return err +} + +// State is the player's feedback screen state. Reason is "" (can send), "pending" +// (a previous message is unreviewed) or "banned". Reply is the operator's answer to +// show, or nil. Fetching it delivers any pending replies (delivery counts as read), +// clearing the badge. +type State struct { + CanSend bool + BlockedReason string + Reply *Reply +} + +// Reply is the operator's answer shown back to the player. +type Reply struct { + Body string + RepliedAt time.Time +} + +// State computes the feedback screen state for accountID and marks any pending +// replies delivered. +func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) { + banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned) + if err != nil { + return State{}, err + } + pending, err := svc.store.HasUnread(ctx, accountID) + if err != nil { + return State{}, err + } + st := State{CanSend: !banned && !pending} + switch { + case banned: + st.BlockedReason = "banned" + case pending: + st.BlockedReason = "pending" + } + vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor)) + if err != nil { + return State{}, err + } + if ok { + st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt} + } + // Opening the screen delivers every pending reply; mark them read to clear the + // badge (only the latest is shown, but all are now delivered). + if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil { + return State{}, err + } + return st, nil +} + +// ReplyUnread reports whether the account has an operator reply not yet delivered — +// the lobby/Info badge condition. It has no side effect (the lobby may poll it). +func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) { + return svc.store.HasUnreadReply(ctx, accountID) +} + +// AdminList returns the filtered console feedback list, paginated. +func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) { + return svc.store.AdminList(ctx, f, limit, offset) +} + +// AdminCount counts the filtered console feedback list. +func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) { + return svc.store.AdminCount(ctx, f) +} + +// AdminGet loads one message for the console detail view, or ErrNotFound. +func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) { + return svc.store.AdminGet(ctx, id) +} + +// CountUnread counts the active (unread, not archived) feedback queue for the +// dashboard. +func (svc *Service) CountUnread(ctx context.Context) (int, error) { + return svc.store.CountUnread(ctx) +} + +// Attachment returns a message's file name and bytes, reporting false when absent. +func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) { + return svc.store.Attachment(ctx, id) +} + +// MarkRead marks a message dealt-with (the manual "read" action). +func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error { + return svc.store.MarkRead(ctx, id, svc.now()) +} + +// Reply sets the operator reply on a message (marking it read), then pushes the +// "you have a reply" notification to the player. +func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error { + body = strings.TrimSpace(body) + if body == "" { + return ErrEmptyMessage + } + if utf8.RuneCountInString(body) > maxBodyRunes { + return ErrMessageTooLong + } + accountID, err := svc.store.Reply(ctx, id, body, svc.now()) + if err != nil { + return err + } + svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply)) + return nil +} + +// Archive files a handled message away (marking it read). +func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error { + return svc.store.Archive(ctx, id, svc.now()) +} + +// Delete physically removes a message and its attachment. +func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error { + return svc.store.Delete(ctx, id) +} + +// DeleteAllByAccount physically removes every message of an account. +func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error { + return svc.store.DeleteAllByAccount(ctx, accountID) +} + +// parseIP returns a validated canonical IP string, or nil when raw is empty or not +// a valid address. +func parseIP(raw string) *string { + addr, err := netip.ParseAddr(strings.TrimSpace(raw)) + if err != nil { + return nil + } + canon := addr.String() + return &canon +} + +// normalizeChannel lower-cases and validates the client-reported channel, falling +// back to "web" for anything unrecognised. +func normalizeChannel(c string) string { + c = strings.ToLower(strings.TrimSpace(c)) + if validChannels[c] { + return c + } + return "web" +} diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go new file mode 100644 index 0000000..7d63cb3 --- /dev/null +++ b/backend/internal/feedback/store.go @@ -0,0 +1,370 @@ +// Package feedback owns user feedback: the flat list of messages a registered +// player sends to the operators (each with an optional single attachment), the +// anti-spam "one pending message at a time" gate, and the operator's inline reply +// delivered back to the player's app. It is modelled on the admin chat-moderation +// surface (internal/social/adminchat.go) and uses raw SQL throughout for the +// attachment bytea, the COALESCE-based "set once" stamps, and the reply-visibility +// window. +package feedback + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" +) + +// ErrNotFound is returned when no feedback message matches the lookup. +var ErrNotFound = errors.New("feedback: not found") + +// Store is the Postgres-backed query surface for feedback_messages. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// Insert stores one feedback message from accountID and returns its id. attachment +// is the raw file bytes (nil for none); attachmentName, ip and a non-default +// channel are stored as given. created_at defaults to now() in the database. +func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) { + id, err := uuid.NewV7() + if err != nil { + return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) + } + var att []byte // a nil []byte binds as bytea NULL + if len(attachment) > 0 { + att = attachment + } + var name *string + if attachmentName != "" { + name = &attachmentName + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO backend.feedback_messages + (message_id, account_id, body, attachment, attachment_name, channel, sender_ip) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + id, accountID, body, att, name, channel, ip); err != nil { + return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) + } + return id, nil +} + +// HasUnread reports whether the account has any message the operator has not yet +// dealt with (read_at IS NULL) — the anti-spam gate's condition. +func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + if err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.feedback_messages WHERE account_id = $1 AND read_at IS NULL)`, + accountID).Scan(&ok); err != nil { + return false, fmt.Errorf("feedback: has-unread %s: %w", accountID, err) + } + return ok, nil +} + +// HasUnreadReply reports whether the account has an operator reply not yet +// delivered to its app (reply_read_at IS NULL) — the badge's condition. +func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + if err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.feedback_messages + WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL)`, + accountID).Scan(&ok); err != nil { + return false, fmt.Errorf("feedback: has-unread-reply %s: %w", accountID, err) + } + return ok, nil +} + +// VisibleReply is the operator reply shown back to the player: the reply on their +// most recent replied message still inside the visibility window. +type VisibleReply struct { + MessageID uuid.UUID + Body string + RepliedAt time.Time + ReplyReadAt sql.NullTime +} + +// LatestVisibleReply returns the account's most recent message that carries a reply +// still visible to the player — not yet delivered, or delivered after cutoff (one +// week ago). Reports false when there is none. +func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) { + var vr VisibleReply + var repliedAt sql.NullTime + err := s.db.QueryRowContext(ctx, + `SELECT message_id, COALESCE(reply_body, ''), replied_at, reply_read_at + FROM backend.feedback_messages + WHERE account_id = $1 AND reply_body IS NOT NULL + AND (reply_read_at IS NULL OR reply_read_at > $2) + ORDER BY created_at DESC LIMIT 1`, + accountID, cutoff).Scan(&vr.MessageID, &vr.Body, &repliedAt, &vr.ReplyReadAt) + if errors.Is(err, sql.ErrNoRows) { + return VisibleReply{}, false, nil + } + if err != nil { + return VisibleReply{}, false, fmt.Errorf("feedback: latest visible reply %s: %w", accountID, err) + } + if repliedAt.Valid { + vr.RepliedAt = repliedAt.Time + } + return vr, true, nil +} + +// MarkRepliesDelivered stamps reply_read_at on every not-yet-delivered reply of the +// account (delivery counts as read), clearing the badge when the player opens the +// feedback screen. +func (s *Store) MarkRepliesDelivered(ctx context.Context, accountID uuid.UUID, at time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.feedback_messages SET reply_read_at = $2 + WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL`, + accountID, at); err != nil { + return fmt.Errorf("feedback: mark replies delivered %s: %w", accountID, err) + } + return nil +} + +// MarkRead stamps read_at the first time, marking the message dealt-with without any +// other action; a re-mark keeps the original time. +func (s *Store) MarkRead(ctx context.Context, id uuid.UUID, at time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.feedback_messages SET read_at = COALESCE(read_at, $2) WHERE message_id = $1`, + id, at); err != nil { + return fmt.Errorf("feedback: mark read %s: %w", id, err) + } + return nil +} + +// Reply sets (or replaces) the operator reply on a message, marks it read, and +// resets its delivery so the player is notified again. Returns the message's +// account_id for the live notification, or ErrNotFound. +func (s *Store) Reply(ctx context.Context, id uuid.UUID, body string, at time.Time) (uuid.UUID, error) { + var accountID uuid.UUID + err := s.db.QueryRowContext(ctx, + `UPDATE backend.feedback_messages + SET reply_body = $2, replied_at = $3, reply_read_at = NULL, read_at = COALESCE(read_at, $3) + WHERE message_id = $1 + RETURNING account_id`, + id, body, at).Scan(&accountID) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, ErrNotFound + } + if err != nil { + return uuid.Nil, fmt.Errorf("feedback: reply %s: %w", id, err) + } + return accountID, nil +} + +// Archive files a handled message away and marks it read. +func (s *Store) Archive(ctx context.Context, id uuid.UUID, at time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE backend.feedback_messages + SET archived_at = COALESCE(archived_at, $2), read_at = COALESCE(read_at, $2) + WHERE message_id = $1`, + id, at); err != nil { + return fmt.Errorf("feedback: archive %s: %w", id, err) + } + return nil +} + +// Delete physically removes a message (with its attachment). +func (s *Store) Delete(ctx context.Context, id uuid.UUID) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM backend.feedback_messages WHERE message_id = $1`, id); err != nil { + return fmt.Errorf("feedback: delete %s: %w", id, err) + } + return nil +} + +// DeleteAllByAccount physically removes every message of an account. +func (s *Store) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM backend.feedback_messages WHERE account_id = $1`, accountID); err != nil { + return fmt.Errorf("feedback: delete all %s: %w", accountID, err) + } + return nil +} + +// Attachment returns a message's stored file name and bytes, reporting false when +// the message has no attachment or does not exist. +func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) { + var name string + var data []byte + err := s.db.QueryRowContext(ctx, + `SELECT COALESCE(attachment_name, ''), attachment FROM backend.feedback_messages WHERE message_id = $1`, + id).Scan(&name, &data) + if errors.Is(err, sql.ErrNoRows) { + return "", nil, false, nil + } + if err != nil { + return "", nil, false, fmt.Errorf("feedback: attachment %s: %w", id, err) + } + if data == nil { + return "", nil, false, nil + } + return name, data, true, nil +} + +// AdminMessage is one feedback message in the operator console detail view: the +// message with its sender's resolved display name and source. The attachment bytes +// are not loaded here (served separately); only their presence and name are. +type AdminMessage struct { + ID uuid.UUID + AccountID uuid.UUID + SenderName string + Source string + Body string + Channel string + SenderIP string + HasAttachment bool + AttachmentName string + Read bool + Archived bool + Replied bool + ReplyBody string + RepliedAt time.Time + CreatedAt time.Time +} + +// AdminRow is one feedback message in the console list (a lighter projection). +type AdminRow struct { + ID uuid.UUID + AccountID uuid.UUID + SenderName string + Source string + Channel string + HasAttachment bool + Read bool + Replied bool + Archived bool + CreatedAt time.Time +} + +// AdminFilter narrows the console feedback list. Status is one of "unread" +// (default), "read" or "archived". NameMask/ExtMask are glob masks +// (account.LikePattern) on the sender's display name / any identity's external id; +// AccountID, when set, restricts to one account (the per-user link from /users). +type AdminFilter struct { + Status string + NameMask string + ExtMask string + AccountID uuid.UUID +} + +// feedbackSource projects a sender's source: guest, robot, or its oldest identity +// kind ("—" when it has none). Mirrors social.adminMessageSource. +const feedbackSource = `CASE + WHEN a.is_guest THEN 'guest' + WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot' + ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—') +END` + +// adminWhere builds the shared WHERE clause and its positional args (from $1). +func adminWhere(f AdminFilter) (string, []any) { + var where string + switch f.Status { + case "archived": + where = `m.archived_at IS NOT NULL` + case "read": + where = `m.read_at IS NOT NULL AND m.archived_at IS NULL` + default: // unread + where = `m.read_at IS NULL AND m.archived_at IS NULL` + } + var args []any + if f.AccountID != uuid.Nil { + args = append(args, f.AccountID) + where += fmt.Sprintf(` AND m.account_id = $%d`, len(args)) + } + if name := account.LikePattern(f.NameMask); name != "" { + args = append(args, name) + where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) + } + if ext := account.LikePattern(f.ExtMask); ext != "" { + args = append(args, ext) + where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args)) + } + return where, args +} + +// AdminList returns the filtered console feedback list, newest first, paginated. +func (s *Store) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) { + where, args := adminWhere(f) + q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.channel, + (m.attachment IS NOT NULL), (m.read_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.archived_at IS NOT NULL), m.created_at + FROM backend.feedback_messages m + JOIN backend.accounts a ON a.account_id = m.account_id + WHERE ` + where + + fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("feedback: admin list: %w", err) + } + defer rows.Close() + var out []AdminRow + for rows.Next() { + var r AdminRow + if err := rows.Scan(&r.ID, &r.AccountID, &r.SenderName, &r.Source, &r.Channel, + &r.HasAttachment, &r.Read, &r.Replied, &r.Archived, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("feedback: scan admin row: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// AdminCount counts the filtered console feedback list, for the pager. +func (s *Store) AdminCount(ctx context.Context, f AdminFilter) (int, error) { + where, args := adminWhere(f) + var n int + q := `SELECT COUNT(*) FROM backend.feedback_messages m JOIN backend.accounts a ON a.account_id = m.account_id WHERE ` + where + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("feedback: admin count: %w", err) + } + return n, nil +} + +// AdminGet loads one message for the console detail view, or ErrNotFound. +func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) { + var m AdminMessage + var repliedAt sql.NullTime + q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, + COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''), + (m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL), + COALESCE(m.reply_body, ''), m.replied_at, m.created_at + FROM backend.feedback_messages m + JOIN backend.accounts a ON a.account_id = m.account_id + WHERE m.message_id = $1` + err := s.db.QueryRowContext(ctx, q, id).Scan( + &m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, + &m.SenderIP, &m.HasAttachment, &m.AttachmentName, + &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return AdminMessage{}, ErrNotFound + } + if err != nil { + return AdminMessage{}, fmt.Errorf("feedback: admin get %s: %w", id, err) + } + if repliedAt.Valid { + m.RepliedAt = repliedAt.Time + } + return m, nil +} + +// CountUnread counts the active (unread, not archived) feedback queue, for the +// console dashboard. +func (s *Store) CountUnread(ctx context.Context) (int, error) { + var n int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM backend.feedback_messages WHERE read_at IS NULL AND archived_at IS NULL`, + ).Scan(&n); err != nil { + return 0, fmt.Errorf("feedback: count unread: %w", err) + } + return n, nil +} diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go new file mode 100644 index 0000000..57bec32 --- /dev/null +++ b/backend/internal/inttest/feedback_test.go @@ -0,0 +1,227 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/feedback" +) + +func newFeedbackService() *feedback.Service { + return feedback.NewService(feedback.NewStore(testDB), account.NewStore(testDB)) +} + +// latestFeedbackID returns the id of the account's single message (across states). +func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.UUID { + t.Helper() + ctx := context.Background() + for _, status := range []string{"unread", "read", "archived"} { + rows, err := svc.AdminList(ctx, feedback.AdminFilter{AccountID: acc, Status: status}, 50, 0) + if err != nil { + t.Fatalf("admin list %s: %v", status, err) + } + if len(rows) > 0 { + return rows[0].ID + } + } + t.Fatal("no feedback message for account") + return uuid.Nil +} + +func TestFeedbackGuestRejected(t *testing.T) { + svc := newFeedbackService() + guest := provisionGuest(t) + if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) { + t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err) + } +} + +func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil { + t.Fatalf("submit: %v", err) + } + // Anti-spam gate: a second message is refused while the first is unreviewed. + if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) { + t.Fatalf("second submit err = %v, want ErrPendingReview", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.CanSend || st.BlockedReason != "pending" || st.Reply != nil { + t.Fatalf("state = %+v, want pending / no reply", st) + } + + id := latestFeedbackID(t, svc, acc) + m, err := svc.AdminGet(ctx, id) + if err != nil { + t.Fatalf("admin get: %v", err) + } + if m.Body != "please fix the board" { // trimmed + t.Fatalf("body = %q, want trimmed", m.Body) + } + if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" { + t.Fatalf("admin message = %+v", m) + } + if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" { + t.Fatalf("attachment = (%q, %q, %v, %v)", name, data, ok, err) + } + + // Reply: marks the message read (so the player can send again) and is undelivered. + if err := svc.Reply(ctx, id, "We are on it."); err != nil { + t.Fatalf("reply: %v", err) + } + if unread, err := svc.ReplyUnread(ctx, acc); err != nil || !unread { + t.Fatalf("reply unread = %v (err %v), want true", unread, err) + } + st, err := svc.State(ctx, acc) + if err != nil { + t.Fatal(err) + } + if !st.CanSend { + t.Fatal("want canSend true after the message was read via reply") + } + if st.Reply == nil || st.Reply.Body != "We are on it." { + t.Fatalf("reply not shown: %+v", st.Reply) + } + // Fetching the state delivered the reply: the badge clears. + if unread, err := svc.ReplyUnread(ctx, acc); err != nil || unread { + t.Fatalf("reply unread = %v (err %v), want false after State", unread, err) + } + + // The reply is hidden one week after it was delivered. + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.feedback_messages SET reply_read_at = now() - interval '8 days' WHERE message_id = $1`, id); err != nil { + t.Fatalf("age reply: %v", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.Reply != nil { + t.Fatalf("reply should be hidden after a week, got %+v", st.Reply) + } +} + +func TestFeedbackBanRole(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + accounts := account.NewStore(testDB) + acc := provisionAccount(t) + + if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil { + t.Fatalf("grant role: %v", err) + } + if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) { + t.Fatalf("banned submit err = %v, want ErrBanned", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.CanSend || st.BlockedReason != "banned" { + t.Fatalf("state = %+v, want banned", st) + } + // Revoke restores submission (the /users unblock path). + if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil { + t.Fatalf("revoke role: %v", err) + } + if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil { + t.Fatalf("submit after unban: %v", err) + } +} + +func TestFeedbackValidation(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + tests := []struct { + name string + body string + attachment []byte + attachmentName string + want error + }{ + {"empty body", " ", nil, "", feedback.ErrEmptyMessage}, + {"too long", strings.Repeat("a", 1025), nil, "", feedback.ErrMessageTooLong}, + {"bad type", "ok", []byte("x"), "evil.exe", feedback.ErrAttachmentType}, + {"too large", "ok", make([]byte, 1_000_001), "a.png", feedback.ErrAttachmentTooLarge}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acc := provisionAccount(t) // fresh account so the pending gate never fires first + if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) { + t.Fatalf("submit err = %v, want %v", err, tt.want) + } + }) + } +} + +func TestFeedbackAdminLifecycle(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil { + t.Fatalf("submit: %v", err) + } + id := latestFeedbackID(t, svc, acc) + + inCount := func(status string) int { + t.Helper() + n, err := svc.AdminCount(ctx, feedback.AdminFilter{AccountID: acc, Status: status}) + if err != nil { + t.Fatalf("count %s: %v", status, err) + } + return n + } + + if inCount("unread") != 1 || inCount("read") != 0 || inCount("archived") != 0 { + t.Fatalf("after submit: unread=%d read=%d archived=%d", inCount("unread"), inCount("read"), inCount("archived")) + } + // Marking read moves it from the unread queue to the read list. + if err := svc.MarkRead(ctx, id); err != nil { + t.Fatalf("mark read: %v", err) + } + if inCount("unread") != 0 || inCount("read") != 1 { + t.Fatalf("after read: unread=%d read=%d", inCount("unread"), inCount("read")) + } + // Archiving moves it to the archive. + if err := svc.Archive(ctx, id); err != nil { + t.Fatalf("archive: %v", err) + } + if inCount("read") != 0 || inCount("archived") != 1 { + t.Fatalf("after archive: read=%d archived=%d", inCount("read"), inCount("archived")) + } + // Deleting removes it entirely. + if err := svc.Delete(ctx, id); err != nil { + t.Fatalf("delete: %v", err) + } + if inCount("archived") != 0 { + t.Fatalf("after delete: archived=%d, want 0", inCount("archived")) + } +} + +func TestFeedbackDeleteAllByAccount(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil { + t.Fatalf("submit: %v", err) + } + if err := svc.DeleteAllByAccount(ctx, acc); err != nil { + t.Fatalf("delete all: %v", err) + } + // The pending gate is clear again (no messages left), so the account can submit. + if has, err := svc.ReplyUnread(ctx, acc); err != nil || has { + t.Fatalf("reply unread after delete-all = %v (err %v)", has, err) + } + if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil { + t.Fatalf("submit after delete-all: %v", err) + } +} diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 2ecc030..dbe1bec 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -53,6 +53,10 @@ const ( // becomes an out-of-app push. NotifyInvitationUpdate = "invitation_update" NotifyGameStarted = "game_started" + // NotifyAdminReply tells the player an operator has answered their feedback, so + // the client raises the Settings/Info badge and re-fetches the reply. It carries + // no payload (the reply is fetched on the feedback screen). In-app only. + NotifyAdminReply = "admin_reply" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/postgres/jet/backend/model/account_roles.go b/backend/internal/postgres/jet/backend/model/account_roles.go new file mode 100644 index 0000000..bb1a858 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/account_roles.go @@ -0,0 +1,19 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type AccountRoles struct { + AccountID uuid.UUID `sql:"primary_key"` + Role string `sql:"primary_key"` + GrantedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/feedback_messages.go b/backend/internal/postgres/jet/backend/model/feedback_messages.go new file mode 100644 index 0000000..a4b2607 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/feedback_messages.go @@ -0,0 +1,29 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type FeedbackMessages struct { + MessageID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + Body string + Attachment *[]byte + AttachmentName *string + SenderIP *string + Channel string + ReadAt *time.Time + ArchivedAt *time.Time + ReplyBody *string + RepliedAt *time.Time + ReplyReadAt *time.Time + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/account_roles.go b/backend/internal/postgres/jet/backend/table/account_roles.go new file mode 100644 index 0000000..2948bbf --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/account_roles.go @@ -0,0 +1,84 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AccountRoles = newAccountRolesTable("backend", "account_roles", "") + +type accountRolesTable struct { + postgres.Table + + // Columns + AccountID postgres.ColumnString + Role postgres.ColumnString + GrantedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AccountRolesTable struct { + accountRolesTable + + EXCLUDED accountRolesTable +} + +// AS creates new AccountRolesTable with assigned alias +func (a AccountRolesTable) AS(alias string) *AccountRolesTable { + return newAccountRolesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AccountRolesTable with assigned schema name +func (a AccountRolesTable) FromSchema(schemaName string) *AccountRolesTable { + return newAccountRolesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AccountRolesTable with assigned table prefix +func (a AccountRolesTable) WithPrefix(prefix string) *AccountRolesTable { + return newAccountRolesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AccountRolesTable with assigned table suffix +func (a AccountRolesTable) WithSuffix(suffix string) *AccountRolesTable { + return newAccountRolesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAccountRolesTable(schemaName, tableName, alias string) *AccountRolesTable { + return &AccountRolesTable{ + accountRolesTable: newAccountRolesTableImpl(schemaName, tableName, alias), + EXCLUDED: newAccountRolesTableImpl("", "excluded", ""), + } +} + +func newAccountRolesTableImpl(schemaName, tableName, alias string) accountRolesTable { + var ( + AccountIDColumn = postgres.StringColumn("account_id") + RoleColumn = postgres.StringColumn("role") + GrantedAtColumn = postgres.TimestampzColumn("granted_at") + allColumns = postgres.ColumnList{AccountIDColumn, RoleColumn, GrantedAtColumn} + mutableColumns = postgres.ColumnList{GrantedAtColumn} + defaultColumns = postgres.ColumnList{GrantedAtColumn} + ) + + return accountRolesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + AccountID: AccountIDColumn, + Role: RoleColumn, + GrantedAt: GrantedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/feedback_messages.go b/backend/internal/postgres/jet/backend/table/feedback_messages.go new file mode 100644 index 0000000..ebf1c71 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/feedback_messages.go @@ -0,0 +1,114 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var FeedbackMessages = newFeedbackMessagesTable("backend", "feedback_messages", "") + +type feedbackMessagesTable struct { + postgres.Table + + // Columns + MessageID postgres.ColumnString + AccountID postgres.ColumnString + Body postgres.ColumnString + Attachment postgres.ColumnBytea + AttachmentName postgres.ColumnString + SenderIP postgres.ColumnString + Channel postgres.ColumnString + ReadAt postgres.ColumnTimestampz + ArchivedAt postgres.ColumnTimestampz + ReplyBody postgres.ColumnString + RepliedAt postgres.ColumnTimestampz + ReplyReadAt postgres.ColumnTimestampz + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type FeedbackMessagesTable struct { + feedbackMessagesTable + + EXCLUDED feedbackMessagesTable +} + +// AS creates new FeedbackMessagesTable with assigned alias +func (a FeedbackMessagesTable) AS(alias string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new FeedbackMessagesTable with assigned schema name +func (a FeedbackMessagesTable) FromSchema(schemaName string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new FeedbackMessagesTable with assigned table prefix +func (a FeedbackMessagesTable) WithPrefix(prefix string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new FeedbackMessagesTable with assigned table suffix +func (a FeedbackMessagesTable) WithSuffix(suffix string) *FeedbackMessagesTable { + return newFeedbackMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newFeedbackMessagesTable(schemaName, tableName, alias string) *FeedbackMessagesTable { + return &FeedbackMessagesTable{ + feedbackMessagesTable: newFeedbackMessagesTableImpl(schemaName, tableName, alias), + EXCLUDED: newFeedbackMessagesTableImpl("", "excluded", ""), + } +} + +func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackMessagesTable { + var ( + MessageIDColumn = postgres.StringColumn("message_id") + AccountIDColumn = postgres.StringColumn("account_id") + BodyColumn = postgres.StringColumn("body") + AttachmentColumn = postgres.ByteaColumn("attachment") + AttachmentNameColumn = postgres.StringColumn("attachment_name") + SenderIPColumn = postgres.StringColumn("sender_ip") + ChannelColumn = postgres.StringColumn("channel") + ReadAtColumn = postgres.TimestampzColumn("read_at") + ArchivedAtColumn = postgres.TimestampzColumn("archived_at") + ReplyBodyColumn = postgres.StringColumn("reply_body") + RepliedAtColumn = postgres.TimestampzColumn("replied_at") + ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn} + ) + + return feedbackMessagesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + MessageID: MessageIDColumn, + AccountID: AccountIDColumn, + Body: BodyColumn, + Attachment: AttachmentColumn, + AttachmentName: AttachmentNameColumn, + SenderIP: SenderIPColumn, + Channel: ChannelColumn, + ReadAt: ReadAtColumn, + ArchivedAt: ArchivedAtColumn, + ReplyBody: ReplyBodyColumn, + RepliedAt: RepliedAtColumn, + ReplyReadAt: ReplyReadAtColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 60f5d15..18e9bec 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -10,6 +10,7 @@ package table // UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke // this method only once at the beginning of the program. func UseSchema(schema string) { + AccountRoles = AccountRoles.FromSchema(schema) AccountStats = AccountStats.FromSchema(schema) AccountSuspensions = AccountSuspensions.FromSchema(schema) Accounts = Accounts.FromSchema(schema) @@ -18,6 +19,7 @@ func UseSchema(schema string) { Complaints = Complaints.FromSchema(schema) DictionaryState = DictionaryState.FromSchema(schema) EmailConfirmations = EmailConfirmations.FromSchema(schema) + FeedbackMessages = FeedbackMessages.FromSchema(schema) FriendCodes = FriendCodes.FromSchema(schema) Friendships = Friendships.FromSchema(schema) GameDrafts = GameDrafts.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00004_feedback.sql b/backend/internal/postgres/migrations/00004_feedback.sql new file mode 100644 index 0000000..8785c16 --- /dev/null +++ b/backend/internal/postgres/migrations/00004_feedback.sql @@ -0,0 +1,54 @@ +-- +goose Up +-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the +-- operators from Settings -> Info, each with an optional single attachment, plus the operator's +-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer) +-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first +-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md. +SET search_path = backend, pg_catalog; + +-- One feedback message. read_at marks "the operator has dealt with this" (set by every console +-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids +-- a new submission while the player has any read_at IS NULL message. archived_at files a handled +-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the +-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for +-- display ("delivered = read"), and the reply is hidden from the player one week after that. +-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original +-- file name (its extension drives the safe content-type at serve time). sender_ip is the +-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform +-- (telegram/ios/android/web, validated in Go). +CREATE TABLE feedback_messages ( + message_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + body text NOT NULL, + attachment bytea, + attachment_name text, + sender_ip text, + channel text NOT NULL, + read_at timestamptz, + archived_at timestamptz, + reply_body text, + replied_at timestamptz, + reply_read_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the +-- console's user-scoped search all look an account up newest-first. +CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC); +-- The console queue lists messages newest-first across all accounts. +CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC); + +-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role, +-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a +-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted +-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users. +CREATE TABLE account_roles ( + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + role text NOT NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, role) +); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE IF EXISTS account_roles; +DROP TABLE IF EXISTS feedback_messages; diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 87eee89..bf92a15 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -27,9 +27,11 @@ type okResponse struct { OK bool `json:"ok"` } -// resolveResponse maps a session token to its account. +// resolveResponse maps a session token to its account. IsGuest lets the gateway +// gate guest-forbidden operations without an extra round-trip. type resolveResponse struct { - UserID string `json:"user_id"` + UserID string `json:"user_id"` + IsGuest bool `json:"is_guest"` } // profileResponse is the authenticated account's own profile. AwayStart and AwayEnd diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 7f49515..671f5c1 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -12,6 +12,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/accountmerge" "scrabble/backend/internal/engine" + "scrabble/backend/internal/feedback" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/session" @@ -77,6 +78,11 @@ func (s *Server) registerRoutes() { u.PUT("/games/:id/draft", s.handleSaveDraft) u.POST("/games/:id/hide", s.handleHideGame) } + if s.feedback != nil { + u.POST("/feedback", s.handleFeedbackSubmit) + u.GET("/feedback", s.handleFeedbackState) + u.GET("/feedback/unread", s.handleFeedbackUnread) + } if s.matchmaker != nil { u.POST("/lobby/enqueue", s.handleEnqueue) } @@ -232,6 +238,20 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "request_declined" case errors.Is(err, social.ErrFriendCodeInvalid): return http.StatusUnprocessableEntity, "friend_code_invalid" + case errors.Is(err, feedback.ErrGuestForbidden): + return http.StatusForbidden, "feedback_guest_forbidden" + case errors.Is(err, feedback.ErrBanned): + return http.StatusForbidden, "feedback_banned" + case errors.Is(err, feedback.ErrPendingReview): + return http.StatusConflict, "feedback_pending" + case errors.Is(err, feedback.ErrEmptyMessage): + return http.StatusUnprocessableEntity, "feedback_empty" + case errors.Is(err, feedback.ErrMessageTooLong): + return http.StatusUnprocessableEntity, "feedback_too_long" + case errors.Is(err, feedback.ErrAttachmentTooLarge): + return http.StatusUnprocessableEntity, "feedback_attachment_too_large" + case errors.Is(err, feedback.ErrAttachmentType): + return http.StatusUnprocessableEntity, "feedback_attachment_type" default: return http.StatusInternalServerError, "internal" } diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 8bcbf15..6619d6a 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/grant-hints", s.consoleGrantHints) gm.POST("/users/:id/block", s.consoleBlockUser) gm.POST("/users/:id/unblock", s.consoleUnblockUser) + gm.POST("/users/:id/grant-role", s.consoleGrantRole) + gm.POST("/users/:id/revoke-role", s.consoleRevokeRole) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) gm.POST("/reasons/:id/update", s.consoleUpdateReason) @@ -68,6 +70,16 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/complaints", s.consoleComplaints) gm.GET("/complaints/:id", s.consoleComplaintDetail) gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint) + if s.feedback != nil { + gm.GET("/feedback", s.consoleFeedback) + gm.GET("/feedback/:id", s.consoleFeedbackDetail) + gm.GET("/feedback/:id/attachment", s.consoleFeedbackAttachment) + gm.POST("/feedback/:id/read", s.consoleFeedbackRead) + gm.POST("/feedback/:id/reply", s.consoleFeedbackReply) + gm.POST("/feedback/:id/archive", s.consoleFeedbackArchive) + gm.POST("/feedback/:id/delete", s.consoleFeedbackDelete) + gm.POST("/feedback/:id/delete-all", s.consoleFeedbackDeleteAll) + } gm.GET("/messages", s.consoleMessages) gm.GET("/messages.csv", s.consoleMessagesCSV) gm.GET("/dictionary", s.consoleDictionary) @@ -87,6 +99,9 @@ func (s *Server) consoleDashboard(c *gin.Context) { view.Games, _ = s.games.CountGames(ctx, "") view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive) view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen) + if s.feedback != nil { + view.OpenFeedback, _ = s.feedback.CountUnread(ctx) + } if changes, err := s.games.DictionaryChanges(ctx); err == nil { view.PendingChanges = len(changes) } @@ -316,6 +331,10 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu}) } } + if roles, err := s.accounts.ListRoles(ctx, id); err == nil { + view.Roles = roles + } + view.KnownRoles = account.KnownRoles s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go new file mode 100644 index 0000000..70b0ebf --- /dev/null +++ b/backend/internal/server/handlers_admin_feedback.go @@ -0,0 +1,293 @@ +package server + +import ( + "context" + "html/template" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/feedback" +) + +// consoleFeedback renders the paginated feedback queue, filtered by state +// (unread/read/archived), optionally pinned to one sender (?user=) and narrowed by +// sender glob masks (?name / ?ext). +func (s *Server) consoleFeedback(c *gin.Context) { + ctx := c.Request.Context() + page := consolePage(c) + status := normalizeFeedbackStatus(c.Query("status")) + user, _ := uuid.Parse(strings.TrimSpace(c.Query("user"))) + filter := feedback.AdminFilter{ + Status: status, + NameMask: c.Query("name"), + ExtMask: c.Query("ext"), + AccountID: user, + } + total, _ := s.feedback.AdminCount(ctx, filter) + rows, err := s.feedback.AdminList(ctx, filter, adminPageSize, (page-1)*adminPageSize) + if err != nil { + s.consoleError(c, err) + return + } + q := url.Values{} + q.Set("status", status) + if filter.AccountID != uuid.Nil { + q.Set("user", filter.AccountID.String()) + } + if strings.TrimSpace(filter.NameMask) != "" { + q.Set("name", filter.NameMask) + } + if strings.TrimSpace(filter.ExtMask) != "" { + q.Set("ext", filter.ExtMask) + } + view := adminconsole.FeedbackView{ + Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total), + NameMask: filter.NameMask, ExtMask: filter.ExtMask, + FilterQuery: template.URL(q.Encode()), + } + if filter.AccountID != uuid.Nil { + view.UserID = filter.AccountID.String() + } + for _, r := range rows { + view.Items = append(view.Items, adminconsole.FeedbackRow{ + ID: r.ID.String(), AccountID: r.AccountID.String(), SenderName: r.SenderName, + Source: r.Source, Channel: r.Channel, HasAttachment: r.HasAttachment, + Read: r.Read, Replied: r.Replied, Archived: r.Archived, CreatedAt: fmtTime(r.CreatedAt), + }) + } + s.renderConsole(c, "feedback", "feedback", "Feedback", view) +} + +// consoleFeedbackDetail renders one feedback message with its actions. Opening it +// does NOT mark it read (only the explicit actions do). +func (s *Server) consoleFeedbackDetail(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + m, err := s.feedback.AdminGet(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + view := adminconsole.FeedbackDetailView{ + ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, + Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body, + HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName), + Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody, + RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt), + } + if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { + view.Banned = banned + } + s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view) +} + +// consoleFeedbackAttachment serves a message's attachment. It is always sent with +// X-Content-Type-Options: nosniff; images are served with their type for an inline +// preview (which never executes), everything else as an octet-stream download +// (so a non-image — including a renamed one — is downloaded, never rendered). +func (s *Server) consoleFeedbackAttachment(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + name, data, ok, err := s.feedback.Attachment(c.Request.Context(), id) + if err != nil { + s.consoleError(c, err) + return + } + if !ok { + s.renderConsoleMessage(c, "No attachment", "this message has no attachment", "/_gm/feedback/"+id.String()) + return + } + ctype := "application/octet-stream" + disposition := `attachment; filename="` + sanitizeFilename(name) + `"` + if feedback.IsImage(name) { + ctype = feedback.ContentType(name) + disposition = "inline" + } + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Disposition", disposition) + c.Data(http.StatusOK, ctype, data) +} + +// consoleFeedbackRead marks a message dealt-with without any other action. +func (s *Server) consoleFeedbackRead(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + back := "/_gm/feedback/" + id.String() + if err := s.feedback.MarkRead(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Marked read", "the message is marked read", back) +} + +// consoleFeedbackReply sends the operator's reply to the player (marking the +// message read and notifying the player's app). +func (s *Server) consoleFeedbackReply(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + back := "/_gm/feedback/" + id.String() + body := trimForm(c, "reply") + if body == "" { + s.renderConsoleMessage(c, "Empty reply", "enter a reply before sending", back) + return + } + if err := s.feedback.Reply(c.Request.Context(), id, body); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Replied", "the reply was sent to the player", back) +} + +// consoleFeedbackArchive files a handled message away (marking it read). +func (s *Server) consoleFeedbackArchive(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + if err := s.feedback.Archive(c.Request.Context(), id); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Archived", "the message was archived", "/_gm/feedback") +} + +// consoleFeedbackDelete physically deletes one message, optionally banning the +// sender from feedback (the "block" checkbox). +func (s *Server) consoleFeedbackDelete(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + m, err := s.feedback.AdminGet(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + if err := s.feedback.Delete(ctx, id); err != nil { + s.consoleError(c, err) + return + } + extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "") + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Deleted", "the message was deleted"+extra, "/_gm/feedback") +} + +// consoleFeedbackDeleteAll physically deletes every message from the sender of the +// given message, optionally banning the sender from feedback. +func (s *Server) consoleFeedbackDeleteAll(c *gin.Context) { + ctx := c.Request.Context() + id, ok := s.consoleUUID(c, "/_gm/feedback") + if !ok { + return + } + m, err := s.feedback.AdminGet(ctx, id) + if err != nil { + s.consoleError(c, err) + return + } + if err := s.feedback.DeleteAllByAccount(ctx, m.AccountID); err != nil { + s.consoleError(c, err) + return + } + extra, err := s.maybeBan(ctx, m.AccountID, trimForm(c, "block") != "") + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Deleted", "all messages from the player were deleted"+extra, "/_gm/feedback") +} + +// maybeBan grants the feedback-ban role to accountID when ban is set, returning a +// suffix for the result message. +func (s *Server) maybeBan(ctx context.Context, accountID uuid.UUID, ban bool) (string, error) { + if !ban { + return "", nil + } + if err := s.accounts.GrantRole(ctx, accountID, account.RoleFeedbackBanned); err != nil { + return "", err + } + return " and the player was banned from feedback", nil +} + +// consoleGrantRole grants a known role to an account (the /users role panel). +func (s *Server) consoleGrantRole(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + role := trimForm(c, "role") + if !account.IsKnownRole(role) { + s.renderConsoleMessage(c, "Invalid role", "the selected role is not recognised", back) + return + } + if err := s.accounts.GrantRole(c.Request.Context(), id, role); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Role granted", "granted "+role, back) +} + +// consoleRevokeRole removes a role from an account (the /users role panel). +func (s *Server) consoleRevokeRole(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + role := trimForm(c, "role") + if role == "" { + s.renderConsoleMessage(c, "Invalid role", "no role given", back) + return + } + if err := s.accounts.RevokeRole(c.Request.Context(), id, role); err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back) +} + +// normalizeFeedbackStatus keeps only a recognised feedback status filter, else +// "unread" (the default queue). +func normalizeFeedbackStatus(s string) string { + switch s { + case "read", "archived": + return s + default: + return "unread" + } +} + +// sanitizeFilename strips characters unsafe in a Content-Disposition filename +// (control chars, quotes and path separators) to prevent header injection. +func sanitizeFilename(name string) string { + name = strings.Map(func(r rune) rune { + if r < 0x20 || r == '"' || r == '\\' || r == '/' { + return -1 + } + return r + }, name) + if name == "" { + return "attachment" + } + return name +} diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 9e93957..8a9b993 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -176,7 +176,14 @@ func (s *Server) handleResolveSession(c *gin.Context) { s.abortErr(c, err) return } - c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()}) + // is_guest is best-effort: a transient account read must not fail an otherwise + // valid resolve (the auth hot path), so a read error falls back to false; the + // per-operation backend gate remains the authoritative guest check. + resp := resolveResponse{UserID: sess.AccountID.String()} + if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil { + resp.IsGuest = acc.IsGuest + } + c.JSON(http.StatusOK, resp) } // handleRevokeSession revokes the session for a token (idempotent). diff --git a/backend/internal/server/handlers_feedback.go b/backend/internal/server/handlers_feedback.go new file mode 100644 index 0000000..235c884 --- /dev/null +++ b/backend/internal/server/handlers_feedback.go @@ -0,0 +1,105 @@ +package server + +import ( + "encoding/base64" + "net/http" + + "github.com/gin-gonic/gin" +) + +// feedbackSubmitRequest is the player's feedback submission. Attachment is the +// base64-encoded file bytes (empty for none); AttachmentName carries the original +// file name (its extension is the allow-list key); Channel is the submitting +// platform (telegram/ios/android/web). +type feedbackSubmitRequest struct { + Body string `json:"body"` + Attachment string `json:"attachment"` + AttachmentName string `json:"attachment_name"` + Channel string `json:"channel"` +} + +// feedbackReplyDTO is the operator's reply shown back to the player. +type feedbackReplyDTO struct { + Body string `json:"body"` + RepliedAtUnix int64 `json:"replied_at_unix"` +} + +// feedbackStateResponse is the player's feedback screen state. BlockedReason is +// "" (can send), "pending" or "banned"; Reply is omitted when there is none. +type feedbackStateResponse struct { + CanSend bool `json:"can_send"` + BlockedReason string `json:"blocked_reason"` + Reply *feedbackReplyDTO `json:"reply,omitempty"` +} + +// feedbackUnreadResponse reports whether the player has an undelivered reply, for +// the lobby/Info badge. +type feedbackUnreadResponse struct { + ReplyUnread bool `json:"reply_unread"` +} + +// handleFeedbackSubmit stores a feedback message from the authenticated player. +// The sender IP comes from the gateway-forwarded X-Forwarded-For header. Guests +// and feedback-banned accounts are refused (also gated at the gateway). +func (s *Server) handleFeedbackSubmit(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req feedbackSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + var attachment []byte + if req.Attachment != "" { + data, err := base64.StdEncoding.DecodeString(req.Attachment) + if err != nil { + abortBadRequest(c, "invalid attachment encoding") + return + } + attachment = data + } + if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleFeedbackState returns the player's feedback screen state and marks any +// pending operator replies delivered (clearing the badge). +func (s *Server) handleFeedbackState(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + st, err := s.feedback.State(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + resp := feedbackStateResponse{CanSend: st.CanSend, BlockedReason: st.BlockedReason} + if st.Reply != nil { + resp.Reply = &feedbackReplyDTO{Body: st.Reply.Body, RepliedAtUnix: st.Reply.RepliedAt.Unix()} + } + c.JSON(http.StatusOK, resp) +} + +// handleFeedbackUnread reports whether the player has an undelivered operator +// reply, for the lobby/Info badge. It has no side effect. +func (s *Server) handleFeedbackUnread(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + unread, err := s.feedback.ReplyUnread(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, feedbackUnreadResponse{ReplyUnread: unread}) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 5c681be..981397e 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -21,6 +21,7 @@ import ( "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" + "scrabble/backend/internal/feedback" "scrabble/backend/internal/game" "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" @@ -54,6 +55,9 @@ type Deps struct { Sessions *session.Service Accounts *account.Store Games *game.Service + // Feedback is the user-feedback domain service (the /api/v1/user/feedback + // endpoints and the console section). A nil Feedback disables them. + Feedback *feedback.Service // Social, Matchmaker, Invitations and Emails are the domain services // the REST handlers route to. Social *social.Service @@ -91,6 +95,7 @@ type Server struct { sessions *session.Service accounts *account.Store games *game.Service + feedback *feedback.Service social *social.Service matchmaker *lobby.Matchmaker invitations *lobby.InvitationService @@ -132,6 +137,7 @@ func New(addr string, deps Deps) *Server { sessions: deps.Sessions, accounts: deps.Accounts, games: deps.Games, + feedback: deps.Feedback, social: deps.Social, matchmaker: deps.Matchmaker, invitations: deps.Invitations, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07a84a5..0e80f7d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -703,6 +703,7 @@ promotions) is future work and would deliver short markdown messages (text + lin | Session → `user_id` resolution, `X-User-ID` injection | gateway | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | | Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) | +| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) | | 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, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. 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) | @@ -817,3 +818,41 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): `BACKEND_DICT_DIR`. - After any push, the run is watched to green before a stage is declared done (`python3 ~/.claude/bin/gitea-ci-watch.py`). + +## 15. User feedback & account roles + +Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts +only). A message (≤1024 runes) plus an optional single attachment is stored in +`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting +**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain +is `internal/feedback` (store + service), modelled on the admin chat-moderation surface. + +**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the +gate is server-side. Because the operator must act before the next message, this is itself the +rate limit — there is no separate per-user feedback limiter. + +**Operator review** happens in the server-rendered console (`/_gm/feedback`): an +unread / read / archived queue with per-user search (the `/users` glob masks), a detail card +(user content rendered as auto-escaped `html/template` text), and the read / reply / archive / +delete / delete-all actions — each marks the message read; merely opening the detail does not. +The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options: +nosniff`: images inline (loaded only via ``, which never executes — a renamed non-image is +inert), everything else as an `application/octet-stream` download. The UI gates the attachment by +file extension (the allow-list is not shown to the user) and the backend mirrors that allow-list +plus the ≤1,000,000-byte size cap as the trust boundary; file *content* is not inspected. The +1,000,000-byte cap keeps the whole `feedback.submit` request under the gateway's 1 MiB edge body +cap (§12) without weakening it. + +**Reply delivery.** The operator's reply lives on the message row and is shown back on the +feedback screen ("Ответ на ваше последнее сообщение") for the player's most recent replied +message. It becomes "read by the player" the instant the screen fetches it (delivery = read) and +is hidden one week after. A Settings → Info badge — folded into the lobby ⚙️ badge together with +the friend-request count — signals an undelivered reply; it rides the existing `NotificationEvent` +with a new `admin_reply` sub-kind (no new push schema) plus an authoritative poll on lobby load. + +**Account roles.** `account_roles` (account_id, role) is the project's first per-account role +table — the reusable replacement for per-feature boolean flags. The first role, `feedback_banned`, +blocks **only** feedback submission (unlike a suspension, the whole-account block of §12). It is +granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the +`/users` console card. Roles are validated against a known set in Go, so adding one needs no +migration. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index d8f292f..efefead 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -172,6 +172,18 @@ block toggles. The profile form is edited inline (no separate edit mode). Linkin an email or Telegram and merging accounts are covered under "Accounts, linking & merge". +### Feedback +A registered player reaches the operators from Settings → Info: a **Feedback** screen with a +message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images, +PDF, text/log, office documents, RTF or archives; an unsupported file is refused on the form +without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение +отправлено", and sending is blocked until the operator has dealt with that message — on re-entry +the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears +below the form as "Ответ на ваше последнее сообщение"; it is marked read once the screen shows it +and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an +unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has +barred from feedback (a role, not a full account block) sees the send control disabled. + ### History & statistics Finished games are archived in a dictionary-independent form and exportable to GCG; the export is offered **only once a game is finished** (exporting a live game @@ -225,3 +237,12 @@ From the user card the operator can also **top up a player's hint wallet**: an a (1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** — the console can never lower a wallet (a player only loses hints by spending them in a game), so an over-grant cannot be reversed there. + +The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered +**unread / read / archived** with per-user search, each shown with its sender, source, channel, IP +and any attachment. The operator can mark a message read, **reply** to the player (delivered +in-app), archive it, delete it, or delete every message from that player — and, alongside a delete, +**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it +stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a +message does not mark it read — only the explicit actions do; message bodies and attachments are +shown defensively (text escaped, attachments downloaded rather than rendered). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 95083c6..dad4291 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -177,6 +177,18 @@ UTC), суточного окна отсутствия (away; сетка по 10 сразу (без отдельного режима редактирования). Привязка email и Telegram, а также слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». +### Обратная связь +Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с +сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF, +текст/лог, офисные документы, RTF или архивы; неподходящий файл отклоняется на форме без перечисления +допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а +повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране +«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как +«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и +исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном +ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил +обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. + ### История и статистика Завершённые партии архивируются в независимом от словаря виде и экспортируются в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии @@ -231,3 +243,13 @@ high-rate флага. С карточки пользователя операт начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления **только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в партии), поэтому ошибочно начисленное через консоль там не отнять. + +Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром +**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, +каналом, IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка +в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением +**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: +останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке +пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело +сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не +рендерятся). diff --git a/docs/TESTING.md b/docs/TESTING.md index aaa1933..4429c89 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -136,6 +136,15 @@ tests or touching CI. postgres_exporter** Grafana dashboard on the contour. Two passes are recorded — the early [`REPORT-R2.md`](../loadtest/REPORT-R2.md) and the final, tuned [`REPORT-R7.md`](../loadtest/REPORT-R7.md). See [`../loadtest/README.md`](../loadtest/README.md). +- **User feedback** — `internal/feedback` unit tests cover the attachment allow-list / + content-type and the channel normaliser; the UI covers `detectChannel`, the attachment gate and + the feedback wire round-trip (`channel` / `feedback` / `codec` tests) plus a Playwright e2e + (submit → confirm + resend blocked; an operator reply raises the badge and shows on the screen). + The gateway `connectsrv` test asserts the **guest gate** (a guest's `feedback.submit` → + `guest_forbidden` with no backend call; a non-gated authenticated op still passes). Postgres-backed + `inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply + read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with + delete / delete-all. The admin-console render test renders the feedback list + detail pages. ## Principles diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d69f44a..fec6d72 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -33,7 +33,9 @@ Login uses `Screen`. hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests); comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub - entry points (so a Telegram friend-code deep-link still lands on the Friends tab). + entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The + **Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so + it slides in/out like a sub-screen rather than switching a tab in place. - **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab @@ -252,6 +254,14 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use row is turn-driven: on your turn the message field shows until your one allowed message is sent, then a short caption replaces it until the next turn; on the opponent's turn the 🛎️ nudge takes the field's place. +- **Feedback** (`screens/Feedback.svelte`, reached from a button at the bottom of the ℹ️ Info + tab, registered accounts only): a multi-line message field over a row that pairs a **ghost** + attach button (a hidden ``, with a chosen-file name + a remove control) with + the filled accent **Send** button. A rejected file shows one generic notice (the allowed types + are not advertised). The send controls disable while a previous message is under review or the + account is barred; the operator's reply renders below as a titled block. An undelivered reply + raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the + Info tab. ## Caveat diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index b6eeb25..e1e034f 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -207,14 +207,16 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes return out, err } -// ResolveSession maps a token to its account id (gateway session-cache miss). -func (c *Client) ResolveSession(ctx context.Context, token string) (string, error) { +// ResolveSession maps a token to its account id and guest flag (gateway +// session-cache miss). The guest flag lets the edge gate guest-forbidden ops. +func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) { var out struct { - UserID string `json:"user_id"` + UserID string `json:"user_id"` + IsGuest bool `json:"is_guest"` } err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "", map[string]string{"token": token}, &out) - return out.UserID, err + return out.UserID, out.IsGuest, err } // Profile returns the authenticated account's profile. diff --git a/gateway/internal/backendclient/api_feedback.go b/gateway/internal/backendclient/api_feedback.go new file mode 100644 index 0000000..2894c3d --- /dev/null +++ b/gateway/internal/backendclient/api_feedback.go @@ -0,0 +1,56 @@ +package backendclient + +import ( + "context" + "encoding/base64" + "net/http" +) + +// FeedbackReplyResp is the operator reply shown back to the player. +type FeedbackReplyResp struct { + Body string `json:"body"` + RepliedAtUnix int64 `json:"replied_at_unix"` +} + +// FeedbackStateResp is the player's feedback screen state. Reply is nil when there +// is none to show. +type FeedbackStateResp struct { + CanSend bool `json:"can_send"` + BlockedReason string `json:"blocked_reason"` + Reply *FeedbackReplyResp `json:"reply,omitempty"` +} + +// FeedbackUnreadResp reports whether the player has an undelivered operator reply. +type FeedbackUnreadResp struct { + ReplyUnread bool `json:"reply_unread"` +} + +// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded +// into the JSON body for the internal hop; clientIP rides X-Forwarded-For. +func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error { + payload := map[string]string{ + "body": body, + "attachment": "", + "attachment_name": attachmentName, + "channel": channel, + } + if len(attachment) > 0 { + payload["attachment"] = base64.StdEncoding.EncodeToString(attachment) + } + return c.do(ctx, http.MethodPost, "/api/v1/user/feedback", userID, clientIP, payload, nil) +} + +// FeedbackGet returns the player's feedback screen state, marking any pending reply +// delivered (clearing the badge). +func (c *Client) FeedbackGet(ctx context.Context, userID string) (FeedbackStateResp, error) { + var out FeedbackStateResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback", userID, "", nil, &out) + return out, err +} + +// FeedbackUnread reports whether the player has an undelivered operator reply. +func (c *Client) FeedbackUnread(ctx context.Context, userID string) (FeedbackUnreadResp, error) { + var out FeedbackUnreadResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/feedback/unread", userID, "", nil, &out) + return out, err +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index dd2bdd1..5796e88 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -207,11 +207,20 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP} if op.Auth { - uid, err := s.resolve(ctx, req.Header()) + uid, isGuest, err := s.resolve(ctx, req.Header()) if err != nil { result = "unauthenticated" return nil, err } + // A guest may not perform a non-guest operation: reject it here as a domain + // outcome, before any backend call. + if op.NonGuest && isGuest { + result = "domain" + return connect.NewResponse(&edgev1.ExecuteResponse{ + RequestId: req.Msg.GetRequestId(), + ResultCode: "guest_forbidden", + }), nil + } // 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) @@ -254,7 +263,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut // Subscribe streams the authenticated user's live events with a keep-alive // heartbeat until the client disconnects. func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error { - uid, err := s.resolve(ctx, req.Header()) + uid, _, err := s.resolve(ctx, req.Header()) if err != nil { return err } @@ -332,14 +341,15 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler { }) } -// resolve extracts and resolves the Authorization bearer token to an account id, -// returning a Connect Unauthenticated error when it is missing or unknown. -func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) { +// resolve extracts and resolves the Authorization bearer token to an account id +// and its guest flag, returning a Connect Unauthenticated error when it is missing +// or unknown. +func (s *Server) resolve(ctx context.Context, h http.Header) (string, bool, error) { token := bearerToken(h.Get("Authorization")) if token == "" { - return "", connect.NewError(connect.CodeUnauthenticated, errMissingToken) + return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken) } - uid, err := s.sessions.Resolve(ctx, token) + uid, isGuest, err := s.sessions.Resolve(ctx, token) if err != nil { // An unknown or expired token (a backend 4xx) is the client's problem and // stays silent; anything else — a resolve timeout, a refused connection, a @@ -350,9 +360,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header) (string, error) { if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError { s.log.Warn("session resolve failed", zap.Error(err)) } - return "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession) + return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession) } - return uid, nil + return uid, isGuest, nil } // bearerToken extracts the token from an "Authorization: Bearer " header, diff --git a/gateway/internal/connectsrv/server_test.go b/gateway/internal/connectsrv/server_test.go index 0a4f605..17dc3bc 100644 --- a/gateway/internal/connectsrv/server_test.go +++ b/gateway/internal/connectsrv/server_test.go @@ -69,6 +69,45 @@ func TestExecuteGuestAuthOK(t *testing.T) { } } +// TestExecuteGuestGate verifies the NonGuest op flag: a guest is rejected with the +// guest_forbidden result code before any backend call, while a guest may still run +// an authenticated op that is not guest-gated. +func TestExecuteGuestGate(t *testing.T) { + client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/internal/sessions/resolve": + _, _ = w.Write([]byte(`{"user_id":"g-1","is_guest":true}`)) + case "/api/v1/user/feedback/unread": + _, _ = w.Write([]byte(`{"reply_unread":false}`)) + case "/api/v1/user/feedback": + t.Errorf("guest feedback.submit must not reach the backend") + default: + t.Errorf("unexpected backend path %s", r.URL.Path) + } + }) + defer cleanup() + + submit := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackSubmit, RequestId: "rq-1"}) + submit.Header().Set("Authorization", "Bearer tok") + resp, err := client.Execute(context.Background(), submit) + if err != nil { + t.Fatalf("execute submit: %v", err) + } + if resp.Msg.GetResultCode() != "guest_forbidden" { + t.Fatalf("submit result = %q, want guest_forbidden", resp.Msg.GetResultCode()) + } + + unread := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread}) + unread.Header().Set("Authorization", "Bearer tok") + resp, err = client.Execute(context.Background(), unread) + if err != nil { + t.Fatalf("execute unread: %v", err) + } + if resp.Msg.GetResultCode() != "ok" { + t.Fatalf("unread result = %q, want ok", resp.Msg.GetResultCode()) + } +} + func TestExecuteAuthedRequiresSession(t *testing.T) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { t.Error("backend must not be called without a session") diff --git a/gateway/internal/session/cache.go b/gateway/internal/session/cache.go index 6e254a5..061b4f3 100644 --- a/gateway/internal/session/cache.go +++ b/gateway/internal/session/cache.go @@ -11,10 +11,10 @@ import ( "time" ) -// Resolver resolves a token to an account id at the backend (the cache miss -// path). backendclient.Client satisfies it. +// Resolver resolves a token to an account id and its guest flag at the backend +// (the cache miss path). backendclient.Client satisfies it. type Resolver interface { - ResolveSession(ctx context.Context, token string) (string, error) + ResolveSession(ctx context.Context, token string) (string, bool, error) } // Cache resolves session tokens to account ids, caching hits for ttl. @@ -30,6 +30,7 @@ type Cache struct { type entry struct { userID string + isGuest bool expires time.Time } @@ -47,19 +48,19 @@ func NewCache(backend Resolver, ttl time.Duration, max int) *Cache { } } -// Resolve returns the account id for token, consulting the cache first and the -// backend on a miss (caching the result). An empty token is rejected by the -// backend like any unknown token. -func (c *Cache) Resolve(ctx context.Context, token string) (string, error) { - if uid, ok := c.lookup(token); ok { - return uid, nil +// Resolve returns the account id and guest flag for token, consulting the cache +// first and the backend on a miss (caching the result). An empty token is rejected +// by the backend like any unknown token. +func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) { + if uid, guest, ok := c.lookup(token); ok { + return uid, guest, nil } - uid, err := c.backend.ResolveSession(ctx, token) + uid, guest, err := c.backend.ResolveSession(ctx, token) if err != nil { - return "", err + return "", false, err } - c.store(token, uid) - return uid, nil + c.store(token, uid, guest) + return uid, guest, nil } // Invalidate drops a token from the cache (e.g. after a revoke). @@ -69,25 +70,26 @@ func (c *Cache) Invalidate(token string) { delete(c.entries, token) } -// lookup returns a live cached account id for token. -func (c *Cache) lookup(token string) (string, bool) { +// lookup returns a live cached account id and guest flag for token. +func (c *Cache) lookup(token string) (string, bool, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[token] if !ok || !c.now().Before(e.expires) { - return "", false + return "", false, false } - return e.userID, true + return e.userID, e.isGuest, true } -// store caches token -> userID, sweeping expired entries and bounding the size. -func (c *Cache) store(token, userID string) { +// store caches token -> (userID, isGuest), sweeping expired entries and bounding +// the size. +func (c *Cache) store(token, userID string, isGuest bool) { c.mu.Lock() defer c.mu.Unlock() if len(c.entries) >= c.max { c.evictLocked() } - c.entries[token] = entry{userID: userID, expires: c.now().Add(c.ttl)} + c.entries[token] = entry{userID: userID, isGuest: isGuest, expires: c.now().Add(c.ttl)} } // evictLocked removes expired entries and, if still at capacity, drops arbitrary diff --git a/gateway/internal/session/cache_test.go b/gateway/internal/session/cache_test.go index 173e601..8dc1607 100644 --- a/gateway/internal/session/cache_test.go +++ b/gateway/internal/session/cache_test.go @@ -9,16 +9,17 @@ import ( type fakeResolver struct { uid string + guest bool err error calls int } -func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, error) { +func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, error) { f.calls++ if f.err != nil { - return "", f.err + return "", false, f.err } - return f.uid, nil + return f.uid, f.guest, nil } func TestResolveCachesBackendHit(t *testing.T) { @@ -26,7 +27,7 @@ func TestResolveCachesBackendHit(t *testing.T) { c := NewCache(r, time.Minute, 10) for i := 0; i < 3; i++ { - uid, err := c.Resolve(context.Background(), "tok") + uid, _, err := c.Resolve(context.Background(), "tok") if err != nil || uid != "user-1" { t.Fatalf("resolve #%d = (%q, %v)", i, uid, err) } @@ -36,10 +37,24 @@ func TestResolveCachesBackendHit(t *testing.T) { } } +func TestResolveCarriesAndCachesGuestFlag(t *testing.T) { + r := &fakeResolver{uid: "guest-1", guest: true} + c := NewCache(r, time.Minute, 10) + for i := 0; i < 2; i++ { + uid, guest, err := c.Resolve(context.Background(), "tok") + if err != nil || uid != "guest-1" || !guest { + t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err) + } + } + if r.calls != 1 { + t.Fatalf("backend calls = %d, want 1 (guest flag cached)", r.calls) + } +} + func TestResolvePropagatesBackendError(t *testing.T) { r := &fakeResolver{err: errors.New("nope")} c := NewCache(r, time.Minute, 10) - if _, err := c.Resolve(context.Background(), "tok"); err == nil { + if _, _, err := c.Resolve(context.Background(), "tok"); err == nil { t.Fatal("expected backend error to propagate") } } @@ -50,11 +65,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) { base := time.Now() c.now = func() time.Time { return base } - if _, err := c.Resolve(context.Background(), "tok"); err != nil { + if _, _, err := c.Resolve(context.Background(), "tok"); err != nil { t.Fatal(err) } c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL - if _, err := c.Resolve(context.Background(), "tok"); err != nil { + if _, _, err := c.Resolve(context.Background(), "tok"); err != nil { t.Fatal(err) } if r.calls != 2 { @@ -65,9 +80,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) { func TestInvalidateForcesReResolve(t *testing.T) { r := &fakeResolver{uid: "user-1"} c := NewCache(r, time.Minute, 10) - _, _ = c.Resolve(context.Background(), "tok") + _, _, _ = c.Resolve(context.Background(), "tok") c.Invalidate("tok") - _, _ = c.Resolve(context.Background(), "tok") + _, _, _ = c.Resolve(context.Background(), "tok") if r.calls != 2 { t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls) } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 95f5de2..0a293f2 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -324,6 +324,37 @@ func encodeChatList(r backendclient.ChatListResp) []byte { return b.FinishedBytes() } +// encodeFeedbackState builds a FeedbackState payload, nesting the reply when present. +func encodeFeedbackState(st backendclient.FeedbackStateResp) []byte { + b := flatbuffers.NewBuilder(128) + var reply flatbuffers.UOffsetT + if st.Reply != nil { + body := b.CreateString(st.Reply.Body) + fb.FeedbackReplyStart(b) + fb.FeedbackReplyAddBody(b, body) + fb.FeedbackReplyAddRepliedAtUnix(b, st.Reply.RepliedAtUnix) + reply = fb.FeedbackReplyEnd(b) + } + reason := b.CreateString(st.BlockedReason) + fb.FeedbackStateStart(b) + fb.FeedbackStateAddCanSend(b, st.CanSend) + fb.FeedbackStateAddBlockedReason(b, reason) + if st.Reply != nil { + fb.FeedbackStateAddReply(b, reply) + } + b.Finish(fb.FeedbackStateEnd(b)) + return b.FinishedBytes() +} + +// encodeFeedbackUnread builds a FeedbackUnread payload. +func encodeFeedbackUnread(u backendclient.FeedbackUnreadResp) []byte { + b := flatbuffers.NewBuilder(16) + fb.FeedbackUnreadStart(b) + fb.FeedbackUnreadAddReplyUnread(b, u.ReplyUnread) + b.Finish(fb.FeedbackUnreadEnd(b)) + return b.FinishedBytes() +} + // buildGameView builds a GameView table and returns its offset. func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT { return wire.BuildGameView(b, toWireGame(g)) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 035cccc..b176709 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -43,6 +43,9 @@ const ( MsgDraftGet = "draft.get" MsgDraftSave = "draft.save" MsgGameHide = "game.hide" + MsgFeedbackSubmit = "feedback.submit" + MsgFeedbackGet = "feedback.get" + MsgFeedbackUnread = "feedback.unread" ) // Request is one decoded Execute call. @@ -62,6 +65,10 @@ type Op struct { Auth bool // Email marks the costly email-code path that gets a stricter rate sub-limit. Email bool + // NonGuest marks an operation a guest account may not perform; the gateway + // rejects it with the guest_forbidden result code after resolving the session, + // before any backend call. + NonGuest bool } // Registry maps message types to their operations. @@ -117,6 +124,9 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true} r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true} r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true} + r.ops[MsgFeedbackSubmit] = Op{Handler: feedbackSubmitHandler(backend), Auth: true, NonGuest: true} + r.ops[MsgFeedbackGet] = Op{Handler: feedbackGetHandler(backend), Auth: true} + r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true} registerSocialOps(r, backend) registerLinkOps(r, backend, tg, defaultLanguages) return r @@ -477,3 +487,33 @@ func hideGameHandler(backend *backendclient.Client) Handler { return encodeAck(true), nil } } + +func feedbackSubmitHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0) + if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), req.ClientIP); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func feedbackGetHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + st, err := backend.FeedbackGet(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeFeedbackState(st), nil + } +} + +func feedbackUnreadHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + u, err := backend.FeedbackUnread(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeFeedbackUnread(u), nil + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b45f2c5..d451d79 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -321,6 +321,40 @@ table ChatList { messages:[ChatMessage]; } +// --- feedback (authenticated) --- + +// FeedbackSubmitRequest submits a feedback message with an optional single +// attachment. attachment is the raw file bytes (empty for none); attachment_name +// carries the original file name (its extension keys the allow-list); channel is +// the submitting platform (telegram/ios/android/web). The response is an Ack. +table FeedbackSubmitRequest { + body:string; + attachment:[ubyte]; + attachment_name:string; + channel:string; +} + +// FeedbackReply is the operator's answer shown back to the player. +table FeedbackReply { + body:string; + replied_at_unix:long; +} + +// FeedbackState is the player's feedback screen state. blocked_reason is "" (can +// send), "pending" or "banned"; reply is null when there is none to show. Fetching +// it (feedback.get) delivers any pending reply, clearing the badge. +table FeedbackState { + can_send:bool; + blocked_reason:string; + reply:FeedbackReply; +} + +// FeedbackUnread reports whether the player has an undelivered operator reply, for +// the lobby/Info badge (feedback.unread; no side effect). +table FeedbackUnread { + reply_unread:bool; +} + // --- account, statistics, friends, blocks, invitations, history --- // AccountRef is a referenced account with its display name resolved — the shared diff --git a/pkg/fbs/scrabblefb/FeedbackReply.go b/pkg/fbs/scrabblefb/FeedbackReply.go new file mode 100644 index 0000000..a4b2b7c --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackReply.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type FeedbackReply struct { + _tab flatbuffers.Table +} + +func GetRootAsFeedbackReply(buf []byte, offset flatbuffers.UOffsetT) *FeedbackReply { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &FeedbackReply{} + x.Init(buf, n+offset) + return x +} + +func FinishFeedbackReplyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsFeedbackReply(buf []byte, offset flatbuffers.UOffsetT) *FeedbackReply { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &FeedbackReply{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedFeedbackReplyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *FeedbackReply) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *FeedbackReply) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *FeedbackReply) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *FeedbackReply) RepliedAtUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *FeedbackReply) MutateRepliedAtUnix(n int64) bool { + return rcv._tab.MutateInt64Slot(6, n) +} + +func FeedbackReplyStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func FeedbackReplyAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0) +} +func FeedbackReplyAddRepliedAtUnix(builder *flatbuffers.Builder, repliedAtUnix int64) { + builder.PrependInt64Slot(1, repliedAtUnix, 0) +} +func FeedbackReplyEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/FeedbackState.go b/pkg/fbs/scrabblefb/FeedbackState.go new file mode 100644 index 0000000..80ea818 --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackState.go @@ -0,0 +1,91 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type FeedbackState struct { + _tab flatbuffers.Table +} + +func GetRootAsFeedbackState(buf []byte, offset flatbuffers.UOffsetT) *FeedbackState { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &FeedbackState{} + x.Init(buf, n+offset) + return x +} + +func FinishFeedbackStateBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsFeedbackState(buf []byte, offset flatbuffers.UOffsetT) *FeedbackState { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &FeedbackState{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedFeedbackStateBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *FeedbackState) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *FeedbackState) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *FeedbackState) CanSend() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *FeedbackState) MutateCanSend(n bool) bool { + return rcv._tab.MutateBoolSlot(4, n) +} + +func (rcv *FeedbackState) BlockedReason() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *FeedbackState) Reply(obj *FeedbackReply) *FeedbackReply { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(FeedbackReply) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func FeedbackStateStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func FeedbackStateAddCanSend(builder *flatbuffers.Builder, canSend bool) { + builder.PrependBoolSlot(0, canSend, false) +} +func FeedbackStateAddBlockedReason(builder *flatbuffers.Builder, blockedReason flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(blockedReason), 0) +} +func FeedbackStateAddReply(builder *flatbuffers.Builder, reply flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(reply), 0) +} +func FeedbackStateEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go new file mode 100644 index 0000000..0b18c4c --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackSubmitRequest.go @@ -0,0 +1,122 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type FeedbackSubmitRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsFeedbackSubmitRequest(buf []byte, offset flatbuffers.UOffsetT) *FeedbackSubmitRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &FeedbackSubmitRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishFeedbackSubmitRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsFeedbackSubmitRequest(buf []byte, offset flatbuffers.UOffsetT) *FeedbackSubmitRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &FeedbackSubmitRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedFeedbackSubmitRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *FeedbackSubmitRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *FeedbackSubmitRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *FeedbackSubmitRequest) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *FeedbackSubmitRequest) Attachment(j int) byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.GetByte(a + flatbuffers.UOffsetT(j*1)) + } + return 0 +} + +func (rcv *FeedbackSubmitRequest) AttachmentLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *FeedbackSubmitRequest) AttachmentBytes() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *FeedbackSubmitRequest) MutateAttachment(j int, n byte) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.MutateByte(a+flatbuffers.UOffsetT(j*1), n) + } + return false +} + +func (rcv *FeedbackSubmitRequest) AttachmentName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *FeedbackSubmitRequest) Channel() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0) +} +func FeedbackSubmitRequestAddAttachment(builder *flatbuffers.Builder, attachment flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(attachment), 0) +} +func FeedbackSubmitRequestStartAttachmentVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(1, numElems, 1) +} +func FeedbackSubmitRequestAddAttachmentName(builder *flatbuffers.Builder, attachmentName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(attachmentName), 0) +} +func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0) +} +func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/FeedbackUnread.go b/pkg/fbs/scrabblefb/FeedbackUnread.go new file mode 100644 index 0000000..5dc2035 --- /dev/null +++ b/pkg/fbs/scrabblefb/FeedbackUnread.go @@ -0,0 +1,64 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type FeedbackUnread struct { + _tab flatbuffers.Table +} + +func GetRootAsFeedbackUnread(buf []byte, offset flatbuffers.UOffsetT) *FeedbackUnread { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &FeedbackUnread{} + x.Init(buf, n+offset) + return x +} + +func FinishFeedbackUnreadBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsFeedbackUnread(buf []byte, offset flatbuffers.UOffsetT) *FeedbackUnread { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &FeedbackUnread{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedFeedbackUnreadBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *FeedbackUnread) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *FeedbackUnread) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *FeedbackUnread) ReplyUnread() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *FeedbackUnread) MutateReplyUnread(n bool) bool { + return rcv._tab.MutateBoolSlot(4, n) +} + +func FeedbackUnreadStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func FeedbackUnreadAddReplyUnread(builder *flatbuffers.Builder, replyUnread bool) { + builder.PrependBoolSlot(0, replyUnread, false) +} +func FeedbackUnreadEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts new file mode 100644 index 0000000..e4f9d97 --- /dev/null +++ b/ui/e2e/feedback.spec.ts @@ -0,0 +1,45 @@ +import { expect, test, type Page } from './fixtures'; + +// User feedback against the mock transport (no backend). The mock profile is a +// durable (non-guest) account, so the Settings → Info "Feedback" entry is shown. + +async function loginLobby(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Your turn')).toBeVisible(); +} + +// openFeedback navigates lobby ⚙️ → Info tab → the Feedback screen. +async function openFeedback(page: Page): Promise { + await page.getByRole('button', { name: /Settings/ }).click(); + await expect(page.locator('.pane')).toHaveCount(1); + await page.getByRole('button', { name: 'Info', exact: true }).click(); + await page.getByRole('button', { name: 'Feedback', exact: true }).click(); +} + +test('feedback: submit a message, then resend is blocked', async ({ page }) => { + await loginLobby(page); + await openFeedback(page); + + await expect(page.getByPlaceholder(/Describe the problem/)).toBeVisible(); + await page.locator('textarea').fill('The board does not render on my phone.'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + + await expect(page.getByText('Your message has been sent.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Send', exact: true })).toBeDisabled(); +}); + +test('feedback: an operator reply raises the badge and shows on the screen', async ({ page }) => { + await loginLobby(page); + + // Simulate the operator answering: clears any pending message, sets the reply and + // pushes the admin_reply live event. + await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply()); + + // The lobby ⚙️ badge folds the awaiting reply into its count. + await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toBeVisible(); + + await openFeedback(page); + await expect(page.getByText('Reply to your last message')).toBeVisible(); + await expect(page.getByText(/looking into it/)).toBeVisible(); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 7c6348e..e07701e 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -13,6 +13,7 @@ import Stats from './screens/Stats.svelte'; import Game from './game/Game.svelte'; import CommsHub from './game/CommsHub.svelte'; + import Feedback from './screens/Feedback.svelte'; import Blocked from './screens/Blocked.svelte'; onMount(() => { @@ -26,8 +27,9 @@ if (!insideTelegram()) return; const r = router.route; // The chat / check sub-screens step back to their game; every other sub-screen to the lobby. - const sub = r.name === 'gameChat' || r.name === 'gameCheck'; - const target = sub ? `/game/${r.params.id}` : '/'; + let target = '/'; + if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`; + else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target)); }); @@ -40,7 +42,7 @@ // back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it // only after the transition has captured its sign). function routeDepth(name: RouteName): number { - if (name === 'gameChat' || name === 'gameCheck') return 2; + if (name === 'gameChat' || name === 'gameCheck' || name === 'feedback') return 2; if (name === 'lobby' || name === 'login') return 0; return 1; } @@ -93,6 +95,8 @@ {:else if router.route.name === 'friends'} + {:else if router.route.name === 'feedback'} + {:else if router.route.name === 'stats'} {:else} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 8bca894..ae8efe2 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -19,6 +19,10 @@ export { EnqueueRequest } from './scrabblefb/enqueue-request.js'; export { EvalRequest } from './scrabblefb/eval-request.js'; export { EvalResult } from './scrabblefb/eval-result.js'; export { ExchangeRequest } from './scrabblefb/exchange-request.js'; +export { FeedbackReply } from './scrabblefb/feedback-reply.js'; +export { FeedbackState } from './scrabblefb/feedback-state.js'; +export { FeedbackSubmitRequest } from './scrabblefb/feedback-submit-request.js'; +export { FeedbackUnread } from './scrabblefb/feedback-unread.js'; export { FriendCode } from './scrabblefb/friend-code.js'; export { FriendList } from './scrabblefb/friend-list.js'; export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/feedback-reply.ts b/ui/src/gen/fbs/scrabblefb/feedback-reply.ts new file mode 100644 index 0000000..5726835 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-reply.ts @@ -0,0 +1,58 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class FeedbackReply { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):FeedbackReply { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply { + return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsFeedbackReply(bb:flatbuffers.ByteBuffer, obj?:FeedbackReply):FeedbackReply { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new FeedbackReply()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +body():string|null +body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +body(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; +} + +repliedAtUnix():bigint { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); +} + +static startFeedbackReply(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, bodyOffset, 0); +} + +static addRepliedAtUnix(builder:flatbuffers.Builder, repliedAtUnix:bigint) { + builder.addFieldInt64(1, repliedAtUnix, BigInt('0')); +} + +static endFeedbackReply(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createFeedbackReply(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, repliedAtUnix:bigint):flatbuffers.Offset { + FeedbackReply.startFeedbackReply(builder); + FeedbackReply.addBody(builder, bodyOffset); + FeedbackReply.addRepliedAtUnix(builder, repliedAtUnix); + return FeedbackReply.endFeedbackReply(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/feedback-state.ts b/ui/src/gen/fbs/scrabblefb/feedback-state.ts new file mode 100644 index 0000000..358f487 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-state.ts @@ -0,0 +1,64 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { FeedbackReply } from '../scrabblefb/feedback-reply.js'; + + +export class FeedbackState { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):FeedbackState { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState { + return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsFeedbackState(bb:flatbuffers.ByteBuffer, obj?:FeedbackState):FeedbackState { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new FeedbackState()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +canSend():boolean { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +blockedReason():string|null +blockedReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +blockedReason(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; +} + +reply(obj?:FeedbackReply):FeedbackReply|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? (obj || new FeedbackReply()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +static startFeedbackState(builder:flatbuffers.Builder) { + builder.startObject(3); +} + +static addCanSend(builder:flatbuffers.Builder, canSend:boolean) { + builder.addFieldInt8(0, +canSend, +false); +} + +static addBlockedReason(builder:flatbuffers.Builder, blockedReasonOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, blockedReasonOffset, 0); +} + +static addReply(builder:flatbuffers.Builder, replyOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, replyOffset, 0); +} + +static endFeedbackState(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +} diff --git a/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts new file mode 100644 index 0000000..db72963 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-submit-request.ts @@ -0,0 +1,104 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class FeedbackSubmitRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):FeedbackSubmitRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest { + return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsFeedbackSubmitRequest(bb:flatbuffers.ByteBuffer, obj?:FeedbackSubmitRequest):FeedbackSubmitRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new FeedbackSubmitRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +body():string|null +body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +body(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; +} + +attachment(index: number):number|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readUint8(this.bb!.__vector(this.bb_pos + offset) + index) : 0; +} + +attachmentLength():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +attachmentArray():Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? new Uint8Array(this.bb!.bytes().buffer, this.bb!.bytes().byteOffset + this.bb!.__vector(this.bb_pos + offset), this.bb!.__vector_len(this.bb_pos + offset)) : null; +} + +attachmentName():string|null +attachmentName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +attachmentName(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +channel():string|null +channel(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +channel(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startFeedbackSubmitRequest(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, bodyOffset, 0); +} + +static addAttachment(builder:flatbuffers.Builder, attachmentOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, attachmentOffset, 0); +} + +static createAttachmentVector(builder:flatbuffers.Builder, data:number[]|Uint8Array):flatbuffers.Offset { + builder.startVector(1, data.length, 1); + for (let i = data.length - 1; i >= 0; i--) { + builder.addInt8(data[i]!); + } + return builder.endVector(); +} + +static startAttachmentVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(1, numElems, 1); +} + +static addAttachmentName(builder:flatbuffers.Builder, attachmentNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, attachmentNameOffset, 0); +} + +static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, channelOffset, 0); +} + +static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset { + FeedbackSubmitRequest.startFeedbackSubmitRequest(builder); + FeedbackSubmitRequest.addBody(builder, bodyOffset); + FeedbackSubmitRequest.addAttachment(builder, attachmentOffset); + FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset); + FeedbackSubmitRequest.addChannel(builder, channelOffset); + return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/feedback-unread.ts b/ui/src/gen/fbs/scrabblefb/feedback-unread.ts new file mode 100644 index 0000000..5d830e7 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/feedback-unread.ts @@ -0,0 +1,46 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class FeedbackUnread { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):FeedbackUnread { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread { + return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsFeedbackUnread(bb:flatbuffers.ByteBuffer, obj?:FeedbackUnread):FeedbackUnread { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new FeedbackUnread()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +replyUnread():boolean { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +static startFeedbackUnread(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addReplyUnread(builder:flatbuffers.Builder, replyUnread:boolean) { + builder.addFieldInt8(0, +replyUnread, +false); +} + +static endFeedbackUnread(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createFeedbackUnread(builder:flatbuffers.Builder, replyUnread:boolean):flatbuffers.Offset { + FeedbackUnread.startFeedbackUnread(builder); + FeedbackUnread.addReplyUnread(builder, replyUnread); + return FeedbackUnread.endFeedbackUnread(builder); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 8b219ae..a079f9f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -59,6 +59,9 @@ export const app = $state<{ notifications: number; /** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */ chatUnread: Record; + /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined + * with friend requests) and the Settings → Info badge. */ + feedbackReplyUnread: boolean; /** Monotonic counter bumped when the app returns to the foreground without the live stream * having dropped. An open game watches it to refetch once, recovering an in-game event shed * from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */ @@ -79,6 +82,7 @@ export const app = $state<{ localeLocked: false, notifications: 0, chatUnread: {}, + feedbackReplyUnread: false, resync: 0, }); @@ -257,6 +261,10 @@ function openStream(): void { if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) { patchLobbyInvitation(e.invitation); } + // An operator answered the player's feedback: raise the Settings/Info badge. + if (e.sub === 'admin_reply') { + app.feedbackReplyUnread = true; + } void refreshNotifications(); } }, @@ -299,6 +307,24 @@ export async function refreshNotifications(): Promise { } } +/** + * refreshFeedbackBadge recomputes whether an operator feedback reply awaits the + * player (the Settings → Info badge, folded into the lobby ⚙️ badge). Authoritative + * poll, complementing the live 'admin_reply' push. Guests cannot submit feedback, so + * it is a no-op for them. + */ +export async function refreshFeedbackBadge(): Promise { + if (!app.session || app.profile?.isGuest) { + app.feedbackReplyUnread = false; + return; + } + try { + app.feedbackReplyUnread = await gateway.feedbackUnread(); + } catch { + // Best-effort; leave the previous flag on a transient failure. + } +} + function closeStream(): void { if (reconnectTimer) { clearTimeout(reconnectTimer); diff --git a/ui/src/lib/channel.test.ts b/ui/src/lib/channel.test.ts new file mode 100644 index 0000000..a6bc071 --- /dev/null +++ b/ui/src/lib/channel.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { detectChannel } from './channel'; + +describe('detectChannel', () => { + it('prefers telegram over everything', () => { + expect(detectChannel({ telegram: true })).toBe('telegram'); + expect(detectChannel({ telegram: true, capacitorPlatform: 'ios' })).toBe('telegram'); + }); + + it('maps the native Capacitor platforms', () => { + expect(detectChannel({ telegram: false, capacitorPlatform: 'ios' })).toBe('ios'); + expect(detectChannel({ telegram: false, capacitorPlatform: 'android' })).toBe('android'); + }); + + it('falls back to web for capacitor web, missing, or unknown platforms', () => { + expect(detectChannel({ telegram: false, capacitorPlatform: 'web' })).toBe('web'); + expect(detectChannel({ telegram: false })).toBe('web'); + expect(detectChannel({ telegram: false, capacitorPlatform: 'desktop' })).toBe('web'); + }); +}); diff --git a/ui/src/lib/channel.ts b/ui/src/lib/channel.ts new file mode 100644 index 0000000..ccfb8c6 --- /dev/null +++ b/ui/src/lib/channel.ts @@ -0,0 +1,30 @@ +// The submitting platform ("channel") reported with a feedback message. Detected +// from the runtime environment: Telegram Mini App, the Capacitor native shell +// (iOS/Android), or a plain web browser. No new dependency — the Capacitor runtime +// global is feature-detected. + +import { insideTelegram } from './telegram'; + +export type Channel = 'telegram' | 'ios' | 'android' | 'web'; + +/** + * detectChannel picks the channel from the runtime signals: telegram wins, then a + * native Capacitor platform (ios/android), else web. Pure, so it is unit-tested + * without a DOM. + */ +export function detectChannel(signals: { telegram: boolean; capacitorPlatform?: string }): Channel { + if (signals.telegram) return 'telegram'; + if (signals.capacitorPlatform === 'ios' || signals.capacitorPlatform === 'android') { + return signals.capacitorPlatform; + } + return 'web'; +} + +/** clientChannel returns the submitting platform from the current runtime. */ +export function clientChannel(): Channel { + const capacitorPlatform = + typeof window === 'undefined' + ? undefined + : (window as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + return detectChannel({ telegram: insideTelegram(), capacitorPlatform }); +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index c73a775..8b6849d 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -9,6 +9,7 @@ import type { BlockStatus, ChatMessage, EvalResult, + FeedbackState, FriendCode, GameList, GameView, @@ -101,6 +102,14 @@ export interface GatewayClient { chatList(gameId: string): Promise; nudge(gameId: string): Promise; + // --- feedback --- + /** feedbackSubmit sends a feedback message with an optional single attachment. */ + feedbackSubmit(body: string, attachment: Uint8Array | null, attachmentName: string, channel: string): Promise; + /** feedbackGet returns the feedback screen state and marks any pending reply delivered. */ + feedbackGet(): Promise; + /** feedbackUnread reports whether an operator reply awaits delivery (the badge). */ + feedbackUnread(): Promise; + // --- friends --- friendsList(): Promise; friendsIncoming(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index af3e8c0..5bd3516 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -13,9 +13,12 @@ import { decodeMatchResult, decodeOutgoingList, decodeSession, + decodeFeedbackState, + decodeFeedbackUnread, decodeStateView, decodeStats, encodeCheckWord, + encodeFeedbackSubmit, encodeDraftSave, encodeExchange, encodeStateRequest, @@ -38,6 +41,63 @@ describe('codec', () => { expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}'); }); + it('round-trips a feedback submit and decodes state + unread', () => { + const att = new Uint8Array([1, 2, 3, 4]); + const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( + new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')), + ); + expect(req.body()).toBe('please fix'); + expect(req.attachmentName()).toBe('shot.png'); + expect(req.channel()).toBe('ios'); + expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]); + + // No attachment: the vector is empty. + const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( + new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')), + ); + expect(req2.body()).toBe('hi'); + expect(req2.attachmentLength()).toBe(0); + + // State carrying a reply. + const b = new Builder(128); + const replyBody = b.createString('we are on it'); + fb.FeedbackReply.startFeedbackReply(b); + fb.FeedbackReply.addBody(b, replyBody); + fb.FeedbackReply.addRepliedAtUnix(b, BigInt(1700000000)); + const reply = fb.FeedbackReply.endFeedbackReply(b); + const reason = b.createString(''); + fb.FeedbackState.startFeedbackState(b); + fb.FeedbackState.addCanSend(b, true); + fb.FeedbackState.addBlockedReason(b, reason); + fb.FeedbackState.addReply(b, reply); + b.finish(fb.FeedbackState.endFeedbackState(b)); + expect(decodeFeedbackState(b.asUint8Array())).toEqual({ + canSend: true, + blockedReason: '', + reply: { body: 'we are on it', repliedAtUnix: 1700000000 }, + }); + + // State with no reply, blocked as pending. + const b2 = new Builder(64); + const reason2 = b2.createString('pending'); + fb.FeedbackState.startFeedbackState(b2); + fb.FeedbackState.addCanSend(b2, false); + fb.FeedbackState.addBlockedReason(b2, reason2); + b2.finish(fb.FeedbackState.endFeedbackState(b2)); + expect(decodeFeedbackState(b2.asUint8Array())).toEqual({ + canSend: false, + blockedReason: 'pending', + reply: null, + }); + + // Unread badge flag. + const b3 = new Builder(16); + fb.FeedbackUnread.startFeedbackUnread(b3); + fb.FeedbackUnread.addReplyUnread(b3, true); + b3.finish(fb.FeedbackUnread.endFeedbackUnread(b3)); + expect(decodeFeedbackUnread(b3.asUint8Array())).toBe(true); + }); + it('decodes a temporary BlockStatus with reason', () => { const b = new Builder(64); const until = b.createString('2026-07-01T12:00:00Z'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index aac290f..4b8f916 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -12,6 +12,7 @@ import type { BlockStatus, ChatMessage, EvalResult, + FeedbackState, FriendCode, GameList, GameView, @@ -437,6 +438,39 @@ export function decodeChatList(buf: Uint8Array): ChatMessage[] { return out; } +export function encodeFeedbackSubmit( + body: string, + attachment: Uint8Array | null, + attachmentName: string, + channel: string, +): Uint8Array { + const b = new Builder(256); + const bodyOff = b.createString(body); + const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0; + const nameOff = b.createString(attachmentName); + const chOff = b.createString(channel); + fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b); + fb.FeedbackSubmitRequest.addBody(b, bodyOff); + if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff); + fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff); + fb.FeedbackSubmitRequest.addChannel(b, chOff); + return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b)); +} + +export function decodeFeedbackState(buf: Uint8Array): FeedbackState { + const st = fb.FeedbackState.getRootAsFeedbackState(new ByteBuffer(buf)); + const r = st.reply(); + return { + canSend: st.canSend(), + blockedReason: s(st.blockedReason()), + reply: r ? { body: s(r.body()), repliedAtUnix: Number(r.repliedAtUnix()) } : null, + }; +} + +export function decodeFeedbackUnread(buf: Uint8Array): boolean { + return fb.FeedbackUnread.getRootAsFeedbackUnread(new ByteBuffer(buf)).replyUnread(); +} + export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null { const bb = new ByteBuffer(payload); switch (kind) { diff --git a/ui/src/lib/feedback.test.ts b/ui/src/lib/feedback.test.ts new file mode 100644 index 0000000..dac96a5 --- /dev/null +++ b/ui/src/lib/feedback.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback'; + +describe('attachmentError', () => { + it('accepts allowed extensions within the size cap', () => { + expect(attachmentError('shot.png', 1000)).toBe(''); + expect(attachmentError('Photo.JPG', 1000)).toBe(''); // case-insensitive + expect(attachmentError('report.pdf', 1000)).toBe(''); + expect(attachmentError('my.notes.docx', 1000)).toBe(''); // dotted name + expect(attachmentError('logs.7z', MAX_ATTACHMENT_BYTES)).toBe(''); + }); + + it('rejects disallowed or extensionless names', () => { + expect(attachmentError('evil.exe', 10)).toBe('type'); + expect(attachmentError('x.svg', 10)).toBe('type'); // XSS vector, not allowed + expect(attachmentError('x.html', 10)).toBe('type'); + expect(attachmentError('README', 10)).toBe('type'); + expect(attachmentError('', 10)).toBe('type'); + }); + + it('rejects too-large files', () => { + expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size'); + }); +}); diff --git a/ui/src/lib/feedback.ts b/ui/src/lib/feedback.ts new file mode 100644 index 0000000..18bea07 --- /dev/null +++ b/ui/src/lib/feedback.ts @@ -0,0 +1,29 @@ +// Feedback client-side limits and the attachment pre-upload gate. The gate is by +// file extension only (the UI does not list the allowed types and never uploads a +// rejected file); the server re-checks the same limits as the trust boundary. + +/** MAX_BODY caps the feedback message length, in characters (matches the backend). */ +export const MAX_BODY = 1024; + +/** MAX_ATTACHMENT_BYTES caps a single attachment's size (matches the backend). */ +export const MAX_ATTACHMENT_BYTES = 1_000_000; + +// Allowed attachment extensions, mirrored from the backend allow-list. +const ALLOWED_EXT = new Set([ + 'png', 'jpg', 'jpeg', 'webp', 'gif', // images + 'pdf', 'txt', 'log', 'doc', 'docx', 'rtf', 'zip', 'gz', '7z', +]); + +/** + * attachmentError validates a picked file by name and size, returning 'type' for a + * disallowed (or missing) extension, 'size' for a too-large file, or '' when it is + * acceptable. The caller shows a single generic "cannot attach" message regardless + * of which non-empty reason it is, so the allowed types are never revealed. + */ +export function attachmentError(name: string, size: number): '' | 'type' | 'size' { + const dot = name.lastIndexOf('.'); + const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; + if (!ext || !ALLOWED_EXT.has(ext)) return 'type'; + if (size > MAX_ATTACHMENT_BYTES) return 'size'; + return ''; +} diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 43dd0f8..3a47386 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -22,8 +22,13 @@ if (isMock && typeof window !== 'undefined') { }; // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise // attaches a robot on a timer). - (window as unknown as { __mock?: { joinOpponent(): void; joinOpponentSilently(): void } }).__mock = { + ( + window as unknown as { + __mock?: { joinOpponent(): void; joinOpponentSilently(): void; adminReply(): void }; + } + ).__mock = { joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(), joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(), + adminReply: () => (gateway as MockGateway).mockAdminReply(), }; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 5d38e95..1235164 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -170,6 +170,17 @@ export const en = { 'about.description': 'A multiplatform Scrabble game.', 'about.version': 'Version {v}', + 'feedback.title': 'Feedback', + 'feedback.placeholder': 'Describe the problem or share your suggestion…', + 'feedback.attach': 'Attach file', + 'feedback.removeFile': 'Remove', + 'feedback.send': 'Send', + 'feedback.fileRejected': 'This file can’t be attached.', + 'feedback.sent': 'Your message has been sent.', + 'feedback.waiting': 'We are reviewing your last message.', + 'feedback.banned': 'Feedback submission is unavailable.', + 'feedback.replyTitle': 'Reply to your last message', + 'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', 'landing.playTelegram': 'Play in Telegram', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 93ae48b..b667fee 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -171,6 +171,17 @@ export const ru: Record = { 'about.description': 'Мультиплатформенная игра в скрабл.', 'about.version': 'Версия {v}', + 'feedback.title': 'Обратная связь', + 'feedback.placeholder': 'Опишите проблему или поделитесь предложением…', + 'feedback.attach': 'Прикрепить файл', + 'feedback.removeFile': 'Удалить', + 'feedback.send': 'Отправить', + 'feedback.fileRejected': 'Этот файл нельзя прикрепить.', + 'feedback.sent': 'Ваше сообщение отправлено', + 'feedback.waiting': 'Ожидаем рассмотрения вашего последнего обращения', + 'feedback.banned': 'Отправка обратной связи недоступна', + 'feedback.replyTitle': 'Ответ на ваше последнее сообщение', + 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', 'landing.playTelegram': 'Играть в Telegram', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 964e251..8afd844 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -16,6 +16,8 @@ import type { BlockStatus, ChatMessage, EvalResult, + FeedbackReply, + FeedbackState, FriendCode, GameList, GcgExport, @@ -89,6 +91,11 @@ export class MockGateway implements GatewayClient { private readonly profile: Profile = { ...PROFILE }; private readonly subs = new Set<(e: PushEvent) => void>(); private pendingMatch: string | null = null; + // Feedback slice: a submission marks a message pending (blocks resend); mockAdminReply + // clears it and raises the badge. + private feedbackPending = false; + private feedbackReply: FeedbackReply | null = null; + private feedbackReplyUnread = false; // The most recently opened auto-match game still awaiting an opponent, for the e2e join hook. private openGameId: string | null = null; private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f })); @@ -409,6 +416,30 @@ export class MockGateway implements GatewayClient { async chatList(gameId: string): Promise { return [...this.game(gameId).chat]; } + async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise { + this.feedbackPending = true; + } + async feedbackGet(): Promise { + this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply + return { + canSend: !this.feedbackPending, + blockedReason: this.feedbackPending ? 'pending' : '', + reply: this.feedbackReply, + }; + } + async feedbackUnread(): Promise { + return this.feedbackReplyUnread; + } + + // mockAdminReply simulates an operator reply: it clears the pending message, sets the + // reply and raises the badge via a live admin_reply notification. e2e hook: + // window.__mock.adminReply. + mockAdminReply(body = 'Thanks — we are looking into it.'): void { + this.feedbackPending = false; + this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) }; + this.feedbackReplyUnread = true; + this.emit({ kind: 'notify', sub: 'admin_reply' }); + } async nudge(gameId: string): Promise { const g = this.game(gameId); const msg: ChatMessage = { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index eb16c78..5726802 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -105,6 +105,23 @@ export interface ChatMessage { createdAtUnix: number; } +/** FeedbackReply is the operator's answer shown on the feedback screen. */ +export interface FeedbackReply { + body: string; + repliedAtUnix: number; +} + +/** + * FeedbackState is the feedback screen state. blockedReason is "" (can send), + * "pending" (a previous message is still under review) or "banned"; reply is the + * operator's answer to show, or null. + */ +export interface FeedbackState { + canSend: boolean; + blockedReason: string; + reply: FeedbackReply | null; +} + export interface Profile { userId: string; displayName: string; diff --git a/ui/src/lib/routeparse.ts b/ui/src/lib/routeparse.ts index 8e4146b..434c902 100644 --- a/ui/src/lib/routeparse.ts +++ b/ui/src/lib/routeparse.ts @@ -13,6 +13,7 @@ export type RouteName = | 'settings' | 'about' | 'friends' + | 'feedback' | 'stats' | 'notfound'; @@ -53,6 +54,8 @@ export function parse(hash: string): Route { return { name: 'about', params: {} }; case 'friends': return { name: 'friends', params: {} }; + case 'feedback': + return { name: 'feedback', params: {} }; case 'stats': return { name: 'stats', params: {} }; default: diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 94df8af..3b2874b 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -143,6 +143,15 @@ export function createTransport(baseUrl: string): GatewayClient { async nudge(id) { return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id))); }, + async feedbackSubmit(body, attachment, attachmentName, channel) { + await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel)); + }, + async feedbackGet() { + return codec.decodeFeedbackState(await exec('feedback.get', codec.empty())); + }, + async feedbackUnread() { + return codec.decodeFeedbackUnread(await exec('feedback.unread', codec.empty())); + }, async friendsList() { return codec.decodeFriendList(await exec('friends.list', codec.empty())); diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index 6189239..f80242a 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -1,6 +1,7 @@ + + +
+ + +
+ + {#if file} + {file.name} + + {:else} + + {/if} + +
+ + {#if fileError}

{t('feedback.fileRejected')}

{/if} + + {#if sent} +

{t('feedback.sent')}

+ {:else if reason === 'pending'} +

{t('feedback.waiting')}

+ {:else if reason === 'banned'} +

{t('feedback.banned')}

+ {/if} + + {#if view?.reply} +
+

{t('feedback.replyTitle')}

+

{view.reply.body}

+
+ {/if} +
+
+ + diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 847f896..166c805 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import Screen from '../components/Screen.svelte'; import TabBar from '../components/TabBar.svelte'; - import { app, handleError } from '../lib/app.svelte'; + import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; @@ -18,15 +18,19 @@ let incoming = $state([]); const guest = $derived(app.profile?.isGuest ?? true); + // The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting + // operator feedback reply (the Settings → Info badge folds in here). + const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0)); async function load() { try { games = (await gateway.gamesList()).games; if (!guest) { [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); - // The ⚙️ badge counts only what lives behind it (incoming friend requests); + // The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply; // invitations surface in their own lobby section above. app.notifications = incoming.length; + void refreshFeedbackBadge(); } setLobby({ games, invitations, incoming }); // Warm the cache for the ongoing games so opening one from the lobby is instant. The list @@ -234,7 +238,7 @@ 📊{t('lobby.stats')} diff --git a/ui/src/screens/SettingsHub.svelte b/ui/src/screens/SettingsHub.svelte index 1bd18fa..bdc1d9c 100644 --- a/ui/src/screens/SettingsHub.svelte +++ b/ui/src/screens/SettingsHub.svelte @@ -57,7 +57,7 @@ {/if} {/snippet} -- 2.52.0 From 5287794a72e1e2c1e7b64d01a9c09df9a60114da Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:45:00 +0200 Subject: [PATCH 131/223] fix(feedback): theme buttons, badge the Info button, simplify admin actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - style the About feedback button and the form's Send/attach/remove buttons with the accent/border tokens used by the New Game CTA, so they follow light/dark theme (the previous .btn/.ghost classes were not defined globally); the attach button is a neutral 📎 icon button with an aria-label - show a round badge on the About 'Feedback' button when a reply is waiting - admin: one 'ban from feedback' checkbox shared by Delete and Delete-all (via button formaction); hide Mark read when already read and Archive when archived - e2e: match the About button by substring (its name gains the badge) --- .../templates/pages/feedback_detail.gohtml | 13 +++---- ui/e2e/feedback.spec.ts | 4 +- ui/src/screens/About.svelte | 27 ++++++++++++- ui/src/screens/Feedback.svelte | 38 ++++++++++++++++--- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 76a4ef4..9f85def 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -31,15 +31,14 @@

Actions

-
-
+{{if not .Read}}
{{end}} +{{if not .Archived}}
{{end}}
-
-
-
- -
+
+ + +
{{end}} diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts index e4f9d97..d08cb5f 100644 --- a/ui/e2e/feedback.spec.ts +++ b/ui/e2e/feedback.spec.ts @@ -14,7 +14,9 @@ async function openFeedback(page: Page): Promise { await page.getByRole('button', { name: /Settings/ }).click(); await expect(page.locator('.pane')).toHaveCount(1); await page.getByRole('button', { name: 'Info', exact: true }).click(); - await page.getByRole('button', { name: 'Feedback', exact: true }).click(); + // The About "Feedback" button: its accessible name gains the "1" badge once a reply + // is waiting, so match by substring rather than exact. + await page.getByRole('button', { name: /Feedback/ }).click(); } test('feedback: submit a message, then resend is blocked', async ({ page }) => { diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index f80242a..0daa6f3 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -34,7 +34,9 @@

{t('about.version', { v: version })}

{#if !(app.profile?.isGuest ?? true)} - + {/if}
@@ -78,4 +80,27 @@ font-size: 0.9rem; margin: 0; } + .feedback { + align-self: flex-start; + padding: 12px 18px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-text); + border-radius: var(--radius); + font-weight: 600; + } + /* The reply badge sits on the accent button, so it inverts the accent colours. */ + .fbadge { + display: inline-block; + margin-left: 8px; + min-width: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--accent-text); + color: var(--accent); + font-size: 0.78rem; + font-weight: 700; + line-height: 1.55; + text-align: center; + } diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 748b884..1877065 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -94,13 +94,17 @@ {#if file} {file.name} - + {:else} - + {/if} - + {#if fileError}

{t('feedback.fileRejected')}

{/if} @@ -151,8 +155,30 @@ color: var(--text-muted); font-size: 0.9rem; } - .row .btn { + .cta { margin-left: auto; + padding: 12px 18px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-text); + border-radius: var(--radius); + font-weight: 600; + } + .neutral { + padding: 12px 14px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + border-radius: var(--radius); + font-weight: 600; + } + .cta:disabled, + .neutral:disabled { + opacity: 0.5; + } + .attach { + font-size: 1.15rem; + line-height: 1; } .hidden { display: none; -- 2.52.0 From 1ae43080ec6218869ff0eca4ca4137da13fcb421 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 12:59:18 +0200 Subject: [PATCH 132/223] fix(feedback): show the operator reply only on the player's latest message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply was bound to 'the latest message that has a reply', so after the player read a reply and sent a new (unanswered) message, the old reply kept showing as 'Ответ на ваше последнее сообщение'. Bind it to the single most-recent message instead: sending any new message immediately drops the previous reply (the new message has no reply yet), well before the one-week window. Client clears the reply optimistically on submit; the mock mirrors it; inttest covers the case. --- backend/internal/feedback/store.go | 26 ++++++++--------- backend/internal/inttest/feedback_test.go | 35 +++++++++++++++++++++++ ui/e2e/feedback.spec.ts | 13 +++++++++ ui/src/lib/mock/client.ts | 1 + ui/src/screens/Feedback.svelte | 3 +- 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go index 7d63cb3..7d9c4a6 100644 --- a/backend/internal/feedback/store.go +++ b/backend/internal/feedback/store.go @@ -86,25 +86,25 @@ func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, // VisibleReply is the operator reply shown back to the player: the reply on their // most recent replied message still inside the visibility window. type VisibleReply struct { - MessageID uuid.UUID - Body string - RepliedAt time.Time - ReplyReadAt sql.NullTime + Body string + RepliedAt time.Time } -// LatestVisibleReply returns the account's most recent message that carries a reply -// still visible to the player — not yet delivered, or delivered after cutoff (one -// week ago). Reports false when there is none. +// LatestVisibleReply returns the reply on the account's **most recent** message, if +// that message carries one still visible to the player — not yet delivered, or +// delivered after cutoff (one week ago). Binding it to the single latest message +// means that the moment the player sends a newer message the previous reply stops +// showing (the new message has no reply yet). Reports false when there is none. func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) { var vr VisibleReply var repliedAt sql.NullTime err := s.db.QueryRowContext(ctx, - `SELECT message_id, COALESCE(reply_body, ''), replied_at, reply_read_at - FROM backend.feedback_messages - WHERE account_id = $1 AND reply_body IS NOT NULL - AND (reply_read_at IS NULL OR reply_read_at > $2) - ORDER BY created_at DESC LIMIT 1`, - accountID, cutoff).Scan(&vr.MessageID, &vr.Body, &repliedAt, &vr.ReplyReadAt) + `SELECT COALESCE(reply_body, ''), replied_at FROM ( + SELECT reply_body, replied_at, reply_read_at FROM backend.feedback_messages + WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1 + ) latest + WHERE reply_body IS NOT NULL AND (reply_read_at IS NULL OR reply_read_at > $2)`, + accountID, cutoff).Scan(&vr.Body, &repliedAt) if errors.Is(err, sql.ErrNoRows) { return VisibleReply{}, false, nil } diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go index 57bec32..98b3ee3 100644 --- a/backend/internal/inttest/feedback_test.go +++ b/backend/internal/inttest/feedback_test.go @@ -110,6 +110,41 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) { } } +func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) { + ctx := context.Background() + svc := newFeedbackService() + acc := provisionAccount(t) + + // msg1, replied → the player can send again and currently sees the reply. + if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil { + t.Fatalf("submit msg1: %v", err) + } + if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil { + t.Fatalf("reply: %v", err) + } + if st, err := svc.State(ctx, acc); err != nil { + t.Fatal(err) + } else if st.Reply == nil || st.Reply.Body != "the answer" { + t.Fatalf("reply not shown before the new message: %+v", st.Reply) + } + + // Sending a new message immediately drops the previous reply (it now belongs to an + // older message), even though it is well within the one-week window. + if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil { + t.Fatalf("submit msg2: %v", err) + } + st, err := svc.State(ctx, acc) + if err != nil { + t.Fatal(err) + } + if st.Reply != nil { + t.Fatalf("reply should be hidden after a new message, got %+v", st.Reply) + } + if st.CanSend || st.BlockedReason != "pending" { + t.Fatalf("state after new message = %+v, want pending", st) + } +} + func TestFeedbackBanRole(t *testing.T) { ctx := context.Background() svc := newFeedbackService() diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts index d08cb5f..8982ce3 100644 --- a/ui/e2e/feedback.spec.ts +++ b/ui/e2e/feedback.spec.ts @@ -45,3 +45,16 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy await expect(page.getByText('Reply to your last message')).toBeVisible(); await expect(page.getByText(/looking into it/)).toBeVisible(); }); + +test('feedback: sending a new message clears the previous reply', async ({ page }) => { + await loginLobby(page); + await page.evaluate(() => (window as unknown as { __mock: { adminReply(): void } }).__mock.adminReply()); + await openFeedback(page); + await expect(page.getByText('Reply to your last message')).toBeVisible(); + + // A new message has no reply yet, so the previous reply must stop showing at once. + await page.locator('textarea').fill('a follow-up question'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText('Your message has been sent.')).toBeVisible(); + await expect(page.getByText('Reply to your last message')).toBeHidden(); +}); diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 8afd844..5970bd1 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -418,6 +418,7 @@ export class MockGateway implements GatewayClient { } async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise { this.feedbackPending = true; + this.feedbackReply = null; // a new message: the previous operator reply no longer shows } async feedbackGet(): Promise { this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 1877065..7c69b2e 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -71,7 +71,8 @@ removeFile(); sent = true; // The message is now under review: block resending until the operator handles it. - view = { canSend: false, blockedReason: 'pending', reply: view?.reply ?? null }; + // A new message has no reply yet, so the previous operator reply stops showing. + view = { canSend: false, blockedReason: 'pending', reply: null }; } catch (e) { handleError(e); } finally { -- 2.52.0 From 2a4ce319d95a0b34bedb61aa805f73ca7291e9e9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 13:07:47 +0200 Subject: [PATCH 133/223] feat(feedback): show the sender's interface language and connector bot in admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the console feedback detail, show the sender's interface language (account preferred_language) always, and — for a message that arrived through an external connector (currently Telegram) — the bot they last used (en/ru, from the account's service_language). --- backend/internal/adminconsole/render_test.go | 2 +- .../templates/pages/feedback_detail.gohtml | 3 +- backend/internal/adminconsole/views.go | 39 +++++++++++-------- .../server/handlers_admin_feedback.go | 8 ++++ 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index b6539bb..f7bee46 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -37,7 +37,7 @@ func TestRendererRendersEveryPage(t *testing.T) { {"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"}, {"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"}, - {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "ios", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "please fix the board"}, + {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, {"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"}, {"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"}, diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 9f85def..3ba3e4d 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -5,7 +5,8 @@

Message

+

Messages

+

Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.

+{{range .Messages}} +
+
+ + +
+ +
+
+
+
+
+
+
+
+{{else}}

no messages yet

{{end}} +

Add message

+
+ + +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/banner_settings.gohtml b/backend/internal/adminconsole/templates/pages/banner_settings.gohtml new file mode 100644 index 0000000..79e3d17 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/banner_settings.gohtml @@ -0,0 +1,18 @@ +{{define "content" -}} +

← all campaigns

+

Banner display timings

+{{with .Data}} +

Global timings the client's banner rotator reads. Out-of-range values are clamped on save. The transition between messages is fade-out, then a gap, then fade-in (skipped under reduce-motion).

+
+
+ + + + + + +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/banners.gohtml b/backend/internal/adminconsole/templates/pages/banners.gohtml new file mode 100644 index 0000000..5a74958 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/banners.gohtml @@ -0,0 +1,32 @@ +{{define "content" -}} +

Advertising banners

+{{with .Data}} +

Campaigns compete for the one-line banner shown to free users (no paid account, an empty hint wallet, and no no_banner role). A campaign's weight is a show percent; the perpetual default campaign fills the remainder up to 100% and drops out of the rotation when timed campaigns already total 100%. Display timings →

+

Add campaign

+
+ + + + + +
+
+
+

Campaigns

+ + + +{{range .Items}} + + + + + + + +{{else}}{{end}} + +
NameWeightWindowMessagesStatus
{{.Name}}{{if .IsDefault}} default{{end}}{{if .IsDefault}}remainder{{else}}{{.Weight}}%{{end}}{{.Window}}{{.Messages}}{{if .ActiveNow}}live{{else if .Enabled}}idle{{else}}disabled{{end}}
no campaigns
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index f05c9e0..12c3fba 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -372,6 +372,58 @@ type MessageView struct { Back string } +// BannersView is the advertising-campaign list page. +type BannersView struct { + Items []BannerCampaignRow +} + +// BannerCampaignRow is one campaign in the list. Window is a human-readable +// validity window ("perpetual" for the default); ActiveNow reports whether it +// would rotate right now (enabled and within its window). +type BannerCampaignRow struct { + ID string + Name string + Weight int + IsDefault bool + Enabled bool + Window string + Messages int + ActiveNow bool +} + +// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the +// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open. +type BannerDetailView struct { + ID string + Name string + Weight int + IsDefault bool + Enabled bool + StartsAt string + EndsAt string + Messages []BannerMessageRow +} + +// BannerMessageRow is one bilingual message of a campaign. First/Last drive the +// reorder buttons (disabled at the ends). +type BannerMessageRow struct { + ID string + BodyEn string + BodyRu string + First bool + Last bool +} + +// BannerSettingsView is the global display-timings form. +type BannerSettingsView struct { + HoldMs int + EdgePauseMs int + ScrollPxPerSec int + FadeOutMs int + GapMs int + FadeInMs int +} + // FeedbackView is the paginated user-feedback queue. Status is the active // unread/read/archived filter; NameMask/ExtMask are the sender glob filters; // UserID pins the list to one account (the per-user link from /users); diff --git a/backend/internal/ads/ads.go b/backend/internal/ads/ads.go new file mode 100644 index 0000000..91c3d80 --- /dev/null +++ b/backend/internal/ads/ads.go @@ -0,0 +1,188 @@ +// Package ads owns the server-driven advertising banner ("advertising network"): +// operator-managed campaigns, their bilingual messages and the global display +// timings the client's one-line announcement strip rotates through. +// +// A campaign is one placement order with a show weight (an integer percent). +// Simultaneously active campaigns compete for display slots in proportion to +// their weights; the single perpetual default campaign fills the unsold +// remainder up to 100%. The actual rotation runs client-side (a smooth weighted +// round-robin over campaigns, round-robin over a campaign's messages); the server +// only computes the effective weighted, language-resolved set a viewer should +// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible. +package ads + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +// ErrNotFound is returned when a campaign or message does not exist. +var ErrNotFound = errors.New("ads: not found") + +// ErrDefaultImmutable is returned when an operation is refused on the perpetual +// default campaign (it cannot be deleted, disabled, windowed, or have its +// weight or default flag changed). +var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way") + +// ErrValidation wraps an operator-facing validation failure (a bad weight, +// window or message body). +var ErrValidation = errors.New("ads: validation") + +// Campaign is one advertising placement order with its messages. +type Campaign struct { + ID uuid.UUID // uuid.Nil on create + Name string + Weight int // show percent, 1..100; nominal (ignored) for the default + IsDefault bool + Enabled bool + StartsAt *time.Time // nil = open-ended start; always nil for the default + EndsAt *time.Time // nil = open-ended end; always nil for the default + Messages []Message + CreatedAt time.Time + UpdatedAt time.Time +} + +// Message is one bilingual creative of a campaign. Both bodies are mandatory; +// the client shows the variant for the viewer's bot (service) language. Position +// orders the messages within a campaign (the round-robin order). +type Message struct { + ID uuid.UUID // uuid.Nil on create + CampaignID uuid.UUID + Position int + BodyEn string + BodyRu string +} + +// Timings are the global banner display timings, in milliseconds except +// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and +// ScrollPxPerSec drive the scroll of a message wider than the strip; a +// transition between messages is FadeOutMs then GapMs then FadeInMs. +type Timings struct { + HoldMs int + EdgePauseMs int + ScrollPxPerSec int + FadeOutMs int + GapMs int + FadeInMs int +} + +// ActiveCampaign is one campaign in the resolved rotation feed sent to a client: +// its GCD-reduced show weight and its messages, already resolved to the viewer's +// language and in display (round-robin) order. +type ActiveCampaign struct { + Weight int + Messages []string +} + +// Eligible reports whether an account should be shown the advertising banner: a +// free account (not paid) with an empty hint wallet and without the no_banner +// role. The no_banner role suppresses the banner unconditionally; buying a paid +// account or any hints also removes it. +func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool { + return !paidAccount && hintBalance <= 0 && !hasNoBanner +} + +// computeActiveSet builds the resolved rotation feed from the enabled campaigns +// at time now, in language lang. Campaigns outside their validity window, and +// campaigns with no messages, are dropped. The default campaign's effective +// weight is the remainder up to 100% — max(0, 100 - sum of active timed +// weights) — so it fills unsold inventory and is dropped entirely when timed +// campaigns already reach 100%. Weights are then reduced by their GCD so the +// fair rotation cycle stays short. The input is expected to be the enabled +// campaigns (ActiveCampaigns); disabled ones must already be excluded. +func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign { + var timed []Campaign + var def *Campaign + for i := range campaigns { + c := campaigns[i] + if len(c.Messages) == 0 { + continue // a campaign with no creative cannot fill a slot + } + if c.IsDefault { + def = &campaigns[i] + continue + } + if withinWindow(c, now) { + timed = append(timed, c) + } + } + sumTimed := 0 + for _, c := range timed { + sumTimed += c.Weight + } + out := make([]ActiveCampaign, 0, len(timed)+1) + for _, c := range timed { + out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)}) + } + if def != nil { + if dw := 100 - sumTimed; dw > 0 { + out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)}) + } + } + reduceByGCD(out) + return out +} + +// ActiveAt reports whether the campaign would rotate at time now: enabled, and +// either the perpetual default or within its validity window. It mirrors the +// filter computeActiveSet applies (a campaign with no messages still reports +// active here — that is a content gap the console surfaces, not an inactive +// campaign). +func (c Campaign) ActiveAt(now time.Time) bool { + return c.Enabled && (c.IsDefault || withinWindow(c, now)) +} + +// withinWindow reports whether now lies inside the campaign's validity window. A +// nil bound is open-ended on that side. +func withinWindow(c Campaign, now time.Time) bool { + if c.StartsAt != nil && now.Before(*c.StartsAt) { + return false + } + if c.EndsAt != nil && now.After(*c.EndsAt) { + return false + } + return true +} + +// resolveBodies projects each message to the body for lang ("ru" picks the +// Russian body; anything else picks English), preserving display order. +func resolveBodies(msgs []Message, lang string) []string { + out := make([]string, len(msgs)) + for i, m := range msgs { + if lang == "ru" { + out[i] = m.BodyRu + } else { + out[i] = m.BodyEn + } + } + return out +} + +// reduceByGCD divides every weight by the greatest common divisor of all weights +// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A +// no-op when the set is empty or already coprime. +func reduceByGCD(cs []ActiveCampaign) { + g := 0 + for _, c := range cs { + g = gcd(g, c.Weight) + } + if g <= 1 { + return + } + for i := range cs { + cs[i].Weight /= g + } +} + +// gcd returns the greatest common divisor of a and b (gcd(0, n) == n). +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + return a +} diff --git a/backend/internal/ads/ads_test.go b/backend/internal/ads/ads_test.go new file mode 100644 index 0000000..9cb1efe --- /dev/null +++ b/backend/internal/ads/ads_test.go @@ -0,0 +1,183 @@ +package ads + +import ( + "reflect" + "testing" + "time" +) + +func TestEligible(t *testing.T) { + tests := []struct { + name string + paidAccount bool + hintBalance int + hasNoBanner bool + want bool + }{ + {name: "free, empty wallet, no role", want: true}, + {name: "paid", paidAccount: true, want: false}, + {name: "has hints", hintBalance: 3, want: false}, + {name: "no_banner role", hasNoBanner: true, want: false}, + {name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false}, + {name: "no_banner overrides everything", hasNoBanner: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want { + t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want) + } + }) + } +} + +func TestComputeActiveSet(t *testing.T) { + now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + past := now.Add(-24 * time.Hour) + future := now.Add(24 * time.Hour) + + // msg builds a one-message slice with distinct bodies so resolution and + // campaign identity are visible in assertions. + msg := func(tag string) []Message { + return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}} + } + def := func(msgs []Message) Campaign { + return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs} + } + timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign { + return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs} + } + + tests := []struct { + name string + campaigns []Campaign + lang string + want []ActiveCampaign + }{ + { + name: "default only reduces to weight 1", + campaigns: []Campaign{def(msg("house"))}, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, + }, + { + name: "default fills the remainder", + campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))}, + lang: "en", + // timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last. + want: []ActiveCampaign{ + {Weight: 3, Messages: []string{"promo-en"}}, + {Weight: 7, Messages: []string{"house-en"}}, + }, + }, + { + name: "timed fills 100, default dropped", + campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))}, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}}, + }, + { + name: "timed over 100, default dropped, proportional", + campaigns: []Campaign{ + def(msg("house")), + timed("a", 60, nil, nil, msg("a")), + timed("b", 80, nil, nil, msg("b")), + }, + lang: "en", + // default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4. + want: []ActiveCampaign{ + {Weight: 3, Messages: []string{"a-en"}}, + {Weight: 4, Messages: []string{"b-en"}}, + }, + }, + { + name: "future and past windows exclude timed", + campaigns: []Campaign{ + def(msg("house")), + timed("future", 50, &future, nil, msg("future")), + timed("past", 50, nil, &past, msg("past")), + }, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, + }, + { + name: "active window included", + campaigns: []Campaign{ + def(msg("house")), + timed("live", 25, &past, &future, msg("live")), + }, + lang: "en", + // timed 25, default 75; gcd 25 -> 1 and 3. + want: []ActiveCampaign{ + {Weight: 1, Messages: []string{"live-en"}}, + {Weight: 3, Messages: []string{"house-en"}}, + }, + }, + { + name: "campaign without messages is skipped and not counted", + campaigns: []Campaign{ + def(msg("house")), + timed("empty", 40, nil, nil, nil), + }, + lang: "en", + // empty campaign skipped; default fills full 100 -> reduced to 1. + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, + }, + { + name: "russian bodies resolved", + campaigns: []Campaign{def(msg("house"))}, + lang: "ru", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}}, + }, + { + name: "three timed split by gcd", + campaigns: []Campaign{ + def(msg("house")), + timed("a", 50, nil, nil, msg("a")), + timed("b", 30, nil, nil, msg("b")), + timed("c", 20, nil, nil, msg("c")), + }, + lang: "en", + // sum 100, default dropped; gcd 10 -> 5,3,2. + want: []ActiveCampaign{ + {Weight: 5, Messages: []string{"a-en"}}, + {Weight: 3, Messages: []string{"b-en"}}, + {Weight: 2, Messages: []string{"c-en"}}, + }, + }, + { + name: "campaign with multiple messages keeps order", + campaigns: []Campaign{ + def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}), + }, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeActiveSet(tt.campaigns, now, tt.lang) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want) + } + }) + } +} + +func TestComputeActiveSetEmpty(t *testing.T) { + // No campaigns at all yields an empty (non-nil-or-nil) feed without panicking. + if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 { + t.Errorf("computeActiveSet(nil) = %#v, want empty", got) + } +} + +func TestGCD(t *testing.T) { + tests := []struct{ a, b, want int }{ + {0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1}, + } + for _, tt := range tests { + if got := gcd(tt.a, tt.b); got != tt.want { + t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} diff --git a/backend/internal/ads/service.go b/backend/internal/ads/service.go new file mode 100644 index 0000000..ff6a7e0 --- /dev/null +++ b/backend/internal/ads/service.go @@ -0,0 +1,265 @@ +package ads + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// Operator-input bounds and the display-timing clamps. The timings are clamped +// (not rejected) on update so the client rotator can never be driven into a +// broken state by a typo in the console. +const ( + maxCampaignName = 80 + maxMessageBody = 500 + + minHoldMs = 3000 // 3s — below this the fade transition would dominate + maxHoldMs = 600000 // 10 min + + maxEdgePauseMs = 60000 + + minScrollPxPerSec = 5 + maxScrollPxPerSec = 1000 + + maxFadeMs = 5000 +) + +// Service is the domain layer over Store: it validates operator input, enforces +// the default campaign's invariants, clamps the display timings, and assembles +// the resolved rotation feed (ActiveSet) for a viewer. +type Service struct { + store *Store +} + +// NewService constructs a Service over store. +func NewService(store *Store) *Service { return &Service{store: store} } + +// ActiveSet returns the resolved rotation feed for a viewer in language lang +// (en/ru) together with the global display timings: the currently-active +// campaigns, each with its GCD-reduced show weight and its messages resolved to +// lang, ready for the client's weighted round-robin. +func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) { + campaigns, err := s.store.ActiveCampaigns(ctx) + if err != nil { + return nil, Timings{}, err + } + timings, err := s.store.Settings(ctx) + if err != nil { + return nil, Timings{}, err + } + return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil +} + +// ListCampaigns returns every campaign with its messages, for the admin console. +func (s *Service) ListCampaigns(ctx context.Context) ([]Campaign, error) { + return s.store.ListCampaigns(ctx) +} + +// Campaign returns one campaign with its messages, or ErrNotFound. +func (s *Service) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) { + return s.store.Campaign(ctx, id) +} + +// CreateCampaign validates and creates a new time-limited campaign (never the +// default) and returns its id. +func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) { + name, err := validName(c.Name) + if err != nil { + return uuid.Nil, err + } + if err := validWeight(c.Weight); err != nil { + return uuid.Nil, err + } + if err := validWindow(c.StartsAt, c.EndsAt); err != nil { + return uuid.Nil, err + } + return s.store.CreateCampaign(ctx, Campaign{ + Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt, + }) +} + +// UpdateCampaign validates and updates a campaign. The default campaign keeps its +// weight (nominal), enabled flag and (empty) window — only its name is editable; +// any submitted weight/window/enabled are ignored for it. +func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error { + existing, err := s.store.Campaign(ctx, c.ID) + if err != nil { + return err + } + name, err := validName(c.Name) + if err != nil { + return err + } + upd := Campaign{ID: existing.ID, Name: name} + if existing.IsDefault { + upd.Weight = existing.Weight + upd.Enabled = true + upd.StartsAt = nil + upd.EndsAt = nil + } else { + if err := validWeight(c.Weight); err != nil { + return err + } + if err := validWindow(c.StartsAt, c.EndsAt); err != nil { + return err + } + upd.Weight = c.Weight + upd.Enabled = c.Enabled + upd.StartsAt = c.StartsAt + upd.EndsAt = c.EndsAt + } + return s.store.UpdateCampaign(ctx, upd) +} + +// DeleteCampaign removes a campaign, refusing the perpetual default. +func (s *Service) DeleteCampaign(ctx context.Context, id uuid.UUID) error { + existing, err := s.store.Campaign(ctx, id) + if err != nil { + return err + } + if existing.IsDefault { + return ErrDefaultImmutable + } + return s.store.DeleteCampaign(ctx, id) +} + +// AddMessage validates a bilingual message and appends it to a campaign. +func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, bodyRu string) (uuid.UUID, error) { + en, ru, err := validBodies(bodyEn, bodyRu) + if err != nil { + return uuid.Nil, err + } + c, err := s.store.Campaign(ctx, campaignID) + if err != nil { + return uuid.Nil, err + } + return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru}) +} + +// EditMessage validates and updates a message's bilingual bodies (its position is +// unchanged). +func (s *Service) EditMessage(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error { + en, ru, err := validBodies(bodyEn, bodyRu) + if err != nil { + return err + } + return s.store.UpdateMessageBodies(ctx, id, en, ru) +} + +// MoveMessage reorders a message within its campaign by swapping its position +// with the adjacent message in direction dir (-1 up, +1 down). A move past an +// edge is a no-op. +func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UUID, dir int) error { + c, err := s.store.Campaign(ctx, campaignID) + if err != nil { + return err + } + idx := -1 + for i, m := range c.Messages { + if m.ID == messageID { + idx = i + break + } + } + if idx < 0 { + return ErrNotFound + } + j := idx + dir + if j < 0 || j >= len(c.Messages) { + return nil // already at the edge + } + a, b := c.Messages[idx], c.Messages[j] + if err := s.store.SetMessagePosition(ctx, a.ID, j); err != nil { + return err + } + return s.store.SetMessagePosition(ctx, b.ID, idx) +} + +// DeleteMessage removes a message, refusing to remove the default campaign's last +// remaining message (the default must always have a creative to show). +func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error { + c, err := s.store.Campaign(ctx, campaignID) + if err != nil { + return err + } + if c.IsDefault && len(c.Messages) <= 1 { + return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation) + } + return s.store.DeleteMessage(ctx, messageID) +} + +// Settings returns the global display timings. +func (s *Service) Settings(ctx context.Context) (Timings, error) { + return s.store.Settings(ctx) +} + +// UpdateSettings clamps every timing into its safe range and stores them. +func (s *Service) UpdateSettings(ctx context.Context, t Timings) error { + return s.store.UpdateSettings(ctx, clampTimings(t)) +} + +// validName trims and bounds a campaign name. +func validName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("%w: the campaign name is required", ErrValidation) + } + if len([]rune(name)) > maxCampaignName { + return "", fmt.Errorf("%w: the campaign name must be at most %d characters", ErrValidation, maxCampaignName) + } + return name, nil +} + +// validWeight bounds a campaign show weight to the 1..100 percent range. +func validWeight(w int) error { + if w < 1 || w > 100 { + return fmt.Errorf("%w: the weight must be a percent between 1 and 100", ErrValidation) + } + return nil +} + +// validWindow rejects an inverted validity window. +func validWindow(starts, ends *time.Time) error { + if starts != nil && ends != nil && ends.Before(*starts) { + return fmt.Errorf("%w: the end must not precede the start", ErrValidation) + } + return nil +} + +// validBodies trims and bounds both mandatory language bodies of a message. +func validBodies(bodyEn, bodyRu string) (string, string, error) { + en := strings.TrimSpace(bodyEn) + ru := strings.TrimSpace(bodyRu) + if en == "" || ru == "" { + return "", "", fmt.Errorf("%w: both the English and Russian message bodies are required", ErrValidation) + } + if len([]rune(en)) > maxMessageBody || len([]rune(ru)) > maxMessageBody { + return "", "", fmt.Errorf("%w: a message body must be at most %d characters", ErrValidation, maxMessageBody) + } + return en, ru, nil +} + +// clampTimings forces every display timing into its safe range. +func clampTimings(t Timings) Timings { + return Timings{ + HoldMs: clampInt(t.HoldMs, minHoldMs, maxHoldMs), + EdgePauseMs: clampInt(t.EdgePauseMs, 0, maxEdgePauseMs), + ScrollPxPerSec: clampInt(t.ScrollPxPerSec, minScrollPxPerSec, maxScrollPxPerSec), + FadeOutMs: clampInt(t.FadeOutMs, 0, maxFadeMs), + GapMs: clampInt(t.GapMs, 0, maxFadeMs), + FadeInMs: clampInt(t.FadeInMs, 0, maxFadeMs), + } +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/backend/internal/ads/store.go b/backend/internal/ads/store.go new file mode 100644 index 0000000..4074418 --- /dev/null +++ b/backend/internal/ads/store.go @@ -0,0 +1,289 @@ +package ads + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Store is the Postgres-backed query surface for advertising campaigns, their +// messages and the single global display-settings row. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// ListCampaigns returns every campaign with its messages, the default first then +// by creation time, for the admin console. +func (s *Store) ListCampaigns(ctx context.Context) ([]Campaign, error) { + return s.loadCampaigns(ctx, false) +} + +// ActiveCampaigns returns the enabled campaigns with their messages, the default +// first then by creation time. Window filtering and the default-remainder weight +// are applied by the Service (ActiveSet), not here. +func (s *Store) ActiveCampaigns(ctx context.Context) ([]Campaign, error) { + return s.loadCampaigns(ctx, true) +} + +// loadCampaigns reads campaigns (optionally only the enabled ones) and attaches +// each one's messages in display order, with a single messages query (the table +// is small, so this avoids both an N+1 and a join projection). +func (s *Store) loadCampaigns(ctx context.Context, enabledOnly bool) ([]Campaign, error) { + sel := postgres.SELECT(table.AdCampaigns.AllColumns).FROM(table.AdCampaigns) + if enabledOnly { + sel = sel.WHERE(table.AdCampaigns.Enabled.EQ(postgres.Bool(true))) + } + sel = sel.ORDER_BY(table.AdCampaigns.IsDefault.DESC(), table.AdCampaigns.CreatedAt.ASC()) + + var rows []model.AdCampaigns + if err := sel.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("ads: list campaigns: %w", err) + } + byID, err := s.messagesByCampaign(ctx) + if err != nil { + return nil, err + } + out := make([]Campaign, 0, len(rows)) + for _, r := range rows { + c := modelToCampaign(r) + c.Messages = byID[r.CampaignID] + out = append(out, c) + } + return out, nil +} + +// Campaign returns one campaign with its messages, or ErrNotFound. +func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) { + sel := postgres.SELECT(table.AdCampaigns.AllColumns). + FROM(table.AdCampaigns). + WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.AdCampaigns + if err := sel.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Campaign{}, ErrNotFound + } + return Campaign{}, fmt.Errorf("ads: get campaign %s: %w", id, err) + } + c := modelToCampaign(row) + msgs, err := s.messagesFor(ctx, id) + if err != nil { + return Campaign{}, err + } + c.Messages = msgs + return c, nil +} + +// CreateCampaign inserts a campaign (always non-default) and returns its new id. +func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) { + id := uuid.New() + now := time.Now().UTC() + stmt := table.AdCampaigns.INSERT( + table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight, + table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled, + table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, + table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt, + ).VALUES( + postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)), + postgres.Bool(false), postgres.Bool(c.Enabled), + tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), + postgres.TimestampzT(now), postgres.TimestampzT(now), + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return uuid.Nil, fmt.Errorf("ads: create campaign: %w", err) + } + return id, nil +} + +// UpdateCampaign updates a campaign's name, weight, enabled flag and validity +// window. The default flag is never touched here. Returns ErrNotFound when no +// campaign matches. +func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error { + stmt := table.AdCampaigns.UPDATE( + table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled, + table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt, + ).SET( + postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled), + tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID))) + return execOne(ctx, s.db, stmt, "update campaign") +} + +// DeleteCampaign removes a campaign (its messages cascade). Returns ErrNotFound +// when no campaign matches. The Service refuses to delete the default. +func (s *Store) DeleteCampaign(ctx context.Context, id uuid.UUID) error { + stmt := table.AdCampaigns.DELETE().WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "delete campaign") +} + +// AddMessage inserts a message into a campaign and returns its new id. +func (s *Store) AddMessage(ctx context.Context, m Message) (uuid.UUID, error) { + id := uuid.New() + now := time.Now().UTC() + stmt := table.AdMessages.INSERT( + table.AdMessages.MessageID, table.AdMessages.CampaignID, table.AdMessages.Position, + table.AdMessages.BodyEn, table.AdMessages.BodyRu, + table.AdMessages.CreatedAt, table.AdMessages.UpdatedAt, + ).VALUES( + postgres.UUID(id), postgres.UUID(m.CampaignID), postgres.Int(int64(m.Position)), + postgres.String(m.BodyEn), postgres.String(m.BodyRu), + postgres.TimestampzT(now), postgres.TimestampzT(now), + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return uuid.Nil, fmt.Errorf("ads: add message: %w", err) + } + return id, nil +} + +// UpdateMessageBodies updates a message's bilingual bodies (its position is +// unchanged). Returns ErrNotFound when no message matches. +func (s *Store) UpdateMessageBodies(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error { + stmt := table.AdMessages.UPDATE( + table.AdMessages.BodyEn, table.AdMessages.BodyRu, table.AdMessages.UpdatedAt, + ).SET( + postgres.String(bodyEn), postgres.String(bodyRu), postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "update message") +} + +// SetMessagePosition updates only a message's position (used to reorder). +func (s *Store) SetMessagePosition(ctx context.Context, id uuid.UUID, position int) error { + stmt := table.AdMessages.UPDATE(table.AdMessages.Position, table.AdMessages.UpdatedAt). + SET(postgres.Int(int64(position)), postgres.TimestampzT(time.Now().UTC())). + WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "reorder message") +} + +// DeleteMessage removes a message. Returns ErrNotFound when no message matches. +func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) error { + stmt := table.AdMessages.DELETE().WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id))) + return execOne(ctx, s.db, stmt, "delete message") +} + +// Settings returns the global display timings. +func (s *Store) Settings(ctx context.Context) (Timings, error) { + sel := postgres.SELECT(table.AdSettings.AllColumns).FROM(table.AdSettings).LIMIT(1) + var row model.AdSettings + if err := sel.QueryContext(ctx, s.db, &row); err != nil { + return Timings{}, fmt.Errorf("ads: get settings: %w", err) + } + return Timings{ + HoldMs: int(row.HoldMs), + EdgePauseMs: int(row.EdgePauseMs), + ScrollPxPerSec: int(row.ScrollPxPerSec), + FadeOutMs: int(row.FadeOutMs), + GapMs: int(row.GapMs), + FadeInMs: int(row.FadeInMs), + }, nil +} + +// UpdateSettings overwrites the single global display-settings row. +func (s *Store) UpdateSettings(ctx context.Context, t Timings) error { + stmt := table.AdSettings.UPDATE( + table.AdSettings.HoldMs, table.AdSettings.EdgePauseMs, table.AdSettings.ScrollPxPerSec, + table.AdSettings.FadeOutMs, table.AdSettings.GapMs, table.AdSettings.FadeInMs, table.AdSettings.UpdatedAt, + ).SET( + postgres.Int(int64(t.HoldMs)), postgres.Int(int64(t.EdgePauseMs)), postgres.Int(int64(t.ScrollPxPerSec)), + postgres.Int(int64(t.FadeOutMs)), postgres.Int(int64(t.GapMs)), postgres.Int(int64(t.FadeInMs)), + postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.AdSettings.ID.EQ(postgres.Bool(true))) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("ads: update settings: %w", err) + } + return nil +} + +// messagesByCampaign loads every message grouped by campaign id, in display order. +func (s *Store) messagesByCampaign(ctx context.Context) (map[uuid.UUID][]Message, error) { + sel := postgres.SELECT(table.AdMessages.AllColumns). + FROM(table.AdMessages). + ORDER_BY(table.AdMessages.CampaignID.ASC(), table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC()) + var rows []model.AdMessages + if err := sel.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("ads: list messages: %w", err) + } + out := make(map[uuid.UUID][]Message, len(rows)) + for _, r := range rows { + out[r.CampaignID] = append(out[r.CampaignID], modelToMessage(r)) + } + return out, nil +} + +// messagesFor loads one campaign's messages in display order. +func (s *Store) messagesFor(ctx context.Context, campaignID uuid.UUID) ([]Message, error) { + sel := postgres.SELECT(table.AdMessages.AllColumns). + FROM(table.AdMessages). + WHERE(table.AdMessages.CampaignID.EQ(postgres.UUID(campaignID))). + ORDER_BY(table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC()) + var rows []model.AdMessages + if err := sel.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("ads: list messages for %s: %w", campaignID, err) + } + out := make([]Message, 0, len(rows)) + for _, r := range rows { + out = append(out, modelToMessage(r)) + } + return out, nil +} + +// execOne runs a statement expected to touch exactly one row, mapping a zero +// row-count to ErrNotFound. +func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) error { + res, err := stmt.ExecContext(ctx, db) + if err != nil { + return fmt.Errorf("ads: %s: %w", what, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("ads: %s rows: %w", what, err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + +func modelToCampaign(r model.AdCampaigns) Campaign { + return Campaign{ + ID: r.CampaignID, + Name: r.Name, + Weight: int(r.Weight), + IsDefault: r.IsDefault, + Enabled: r.Enabled, + StartsAt: r.StartsAt, + EndsAt: r.EndsAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func modelToMessage(r model.AdMessages) Message { + return Message{ + ID: r.MessageID, + CampaignID: r.CampaignID, + Position: int(r.Position), + BodyEn: r.BodyEn, + BodyRu: r.BodyRu, + } +} + +// tsOrNull renders a nullable validity-window bound: NULL for a nil pointer, +// otherwise the UTC timestamp. +func tsOrNull(t *time.Time) postgres.Expression { + if t == nil { + return postgres.NULL + } + return postgres.TimestampzT(t.UTC()) +} diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go new file mode 100644 index 0000000..1cf0095 --- /dev/null +++ b/backend/internal/inttest/banner_console_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package inttest + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/ads" + "scrabble/backend/internal/server" +) + +// bannerServer assembles a console-capable server with the ads domain wired. +func bannerServer(t *testing.T) (*server.Server, *ads.Service) { + t.Helper() + adsSvc := ads.NewService(ads.NewStore(testDB)) + srv := server.New(":0", server.Deps{ + Logger: zap.NewNop(), + Accounts: account.NewStore(testDB), + Games: newGameService(), + Registry: testRegistry, + DictDir: dictDir(), + Ads: adsSvc, + }) + return srv, adsSvc +} + +// findCampaign returns the id of the campaign with the given name, or fails. +func findCampaign(t *testing.T, svc *ads.Service, name string) uuid.UUID { + t.Helper() + all, err := svc.ListCampaigns(context.Background()) + if err != nil { + t.Fatalf("list campaigns: %v", err) + } + for _, c := range all { + if c.Name == name { + return c.ID + } + } + t.Fatalf("campaign %q not found", name) + return uuid.Nil +} + +// defaultCampaign returns the seeded perpetual default campaign. +func defaultCampaign(t *testing.T, svc *ads.Service) ads.Campaign { + t.Helper() + all, err := svc.ListCampaigns(context.Background()) + if err != nil { + t.Fatalf("list campaigns: %v", err) + } + for _, c := range all { + if c.IsDefault { + return c + } + } + t.Fatal("no default campaign seeded") + return ads.Campaign{} +} + +// TestBannerConsoleCRUD drives the /_gm/banners console over HTTP against real +// stores: pages render, the CSRF guard bites, validation and the default-campaign +// protection hold, and a campaign + message round-trips through create/edit/ +// reorder/delete. +func TestBannerConsoleCRUD(t *testing.T) { + ctx := context.Background() + srv, adsSvc := bannerServer(t) + h := srv.Handler() + base := "http://admin.test/_gm" + const origin = "http://admin.test" + + // The list renders and shows the seeded default campaign. + if code, body := consoleDo(h, http.MethodGet, base+"/banners", "", ""); code != http.StatusOK || !strings.Contains(body, "Default (house)") { + t.Fatalf("banners list = %d, has default=%v", code, strings.Contains(body, "Default (house)")) + } + + name := "Promo-" + uuid.NewString()[:8] + // CSRF: a create without a same-origin header is rejected. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", ""); code != http.StatusForbidden { + t.Fatalf("create without origin = %d, want 403", code) + } + // Validation: weight out of range is refused. + if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name=Bad&weight=0&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { + t.Fatalf("create weight=0 = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) + } + // A valid create persists. + if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") { + t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created")) + } + id := findCampaign(t, adsSvc, name) + + // The detail page renders. + if code, body := consoleDo(h, http.MethodGet, base+"/banners/"+id.String(), "", ""); code != http.StatusOK || !strings.Contains(body, name) { + t.Fatalf("detail = %d, has name=%v", code, strings.Contains(body, name)) + } + // A message needs both languages. + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { + t.Fatalf("add half message = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) + } + // A bilingual message is added. + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=Привет", origin); code != http.StatusOK || !strings.Contains(body, "Added") { + t.Fatalf("add message = %d, has Added=%v", code, strings.Contains(body, "Added")) + } + cm, err := adsSvc.Campaign(ctx, id) + if err != nil || len(cm.Messages) != 1 { + t.Fatalf("campaign messages = %d (err %v), want 1", len(cm.Messages), err) + } + mid := cm.Messages[0].ID + // Edit it. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String(), "body_en=Hi&body_ru=Привет!", origin); code != http.StatusOK { + t.Fatalf("edit message = %d", code) + } + if cm, _ := adsSvc.Campaign(ctx, id); cm.Messages[0].BodyEn != "Hi" { + t.Fatalf("edit did not persist: %q", cm.Messages[0].BodyEn) + } + // Reorder is a no-op for a single message but still succeeds. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/move", "dir=down", origin); code != http.StatusOK { + t.Fatalf("move message = %d", code) + } + // Delete the message, then the campaign. + if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/delete", "", origin); code != http.StatusOK { + t.Fatalf("delete message = %d", code) + } + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") { + t.Fatalf("delete campaign = %d, has Deleted=%v", code, strings.Contains(body, "Deleted")) + } + if _, err := adsSvc.Campaign(ctx, id); err == nil { + t.Fatal("campaign still present after delete") + } + + // The default campaign cannot be deleted. + def := defaultCampaign(t, adsSvc) + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { + t.Fatalf("delete default = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) + } + if _, err := adsSvc.Campaign(ctx, def.ID); err != nil { + t.Fatalf("default campaign gone after refused delete: %v", err) + } +} + +// profileBanner is the banner block of the profile.get JSON response. +type profileBanner struct { + Banner *struct { + Campaigns []struct { + Weight int `json:"weight"` + Messages []string `json:"messages"` + } `json:"campaigns"` + Timings struct { + HoldMs int `json:"hold_ms"` + } `json:"timings"` + } `json:"banner"` +} + +// TestBannerProfileEligibility checks the profile.get banner block follows +// eligibility: a free account sees it, the no_banner role and a non-empty hint +// wallet each suppress it. +func TestBannerProfileEligibility(t *testing.T) { + ctx := context.Background() + srv, _ := bannerServer(t) + accounts := account.NewStore(testDB) + id := provisionAccount(t) + + getBanner := func() profileBanner { + t.Helper() + rec := userGet(t, srv, "/api/v1/user/profile", id) + if rec.Code != http.StatusOK { + t.Fatalf("profile = %d, want 200", rec.Code) + } + var p profileBanner + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode profile: %v", err) + } + return p + } + + // A free account with an empty hint wallet and no role is eligible. + p := getBanner() + if p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 { + t.Fatalf("eligible account: banner=%v", p.Banner) + } + + // The no_banner role suppresses it unconditionally. + if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("grant role: %v", err) + } + if p := getBanner(); p.Banner != nil { + t.Fatal("no_banner role still shows the banner") + } + if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("revoke role: %v", err) + } + + // A non-empty hint wallet suppresses it. + if _, err := accounts.GrantHints(ctx, id, 5); err != nil { + t.Fatalf("grant hints: %v", err) + } + if p := getBanner(); p.Banner != nil { + t.Fatal("a non-empty hint wallet still shows the banner") + } +} diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 65ee14e..4c97aec 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -205,6 +205,13 @@ func notificationInvitation(userID uuid.UUID, sub string, inv InvitationSummary) return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} } +// BannerChanged tells userID that its advertising-banner eligibility may have +// changed, so the client re-fetches profile.get to show or hide the banner. It +// carries no payload (the banner set rides the profile response). +func BannerChanged(userID uuid.UUID) Intent { + return Notification(userID, NotifyBanner) +} + // eventID returns a best-effort correlation id for one emitted event. func eventID() string { if id, err := uuid.NewV7(); err == nil { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index dbe1bec..16b3ca0 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -57,6 +57,11 @@ const ( // the client raises the Settings/Info badge and re-fetches the reply. It carries // no payload (the reply is fetched on the feedback screen). In-app only. NotifyAdminReply = "admin_reply" + // NotifyBanner tells the client that the viewer's advertising-banner eligibility + // may have changed (an operator granted hints or toggled the no_banner role), so + // it re-fetches profile.get to show or hide the banner. It carries no payload (the + // banner set rides the profile response). In-app only. + NotifyBanner = "banner" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/postgres/jet/backend/model/ad_campaigns.go b/backend/internal/postgres/jet/backend/model/ad_campaigns.go new file mode 100644 index 0000000..718bab8 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/ad_campaigns.go @@ -0,0 +1,25 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type AdCampaigns struct { + CampaignID uuid.UUID `sql:"primary_key"` + Name string + Weight int32 + IsDefault bool + Enabled bool + StartsAt *time.Time + EndsAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/ad_messages.go b/backend/internal/postgres/jet/backend/model/ad_messages.go new file mode 100644 index 0000000..2b0e9ec --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/ad_messages.go @@ -0,0 +1,23 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type AdMessages struct { + MessageID uuid.UUID `sql:"primary_key"` + CampaignID uuid.UUID + Position int32 + BodyEn string + BodyRu string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/ad_settings.go b/backend/internal/postgres/jet/backend/model/ad_settings.go new file mode 100644 index 0000000..454e7c2 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/ad_settings.go @@ -0,0 +1,23 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "time" +) + +type AdSettings struct { + ID bool `sql:"primary_key"` + HoldMs int32 + EdgePauseMs int32 + ScrollPxPerSec int32 + FadeOutMs int32 + GapMs int32 + FadeInMs int32 + UpdatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/ad_campaigns.go b/backend/internal/postgres/jet/backend/table/ad_campaigns.go new file mode 100644 index 0000000..891586b --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/ad_campaigns.go @@ -0,0 +1,102 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AdCampaigns = newAdCampaignsTable("backend", "ad_campaigns", "") + +type adCampaignsTable struct { + postgres.Table + + // Columns + CampaignID postgres.ColumnString + Name postgres.ColumnString + Weight postgres.ColumnInteger + IsDefault postgres.ColumnBool + Enabled postgres.ColumnBool + StartsAt postgres.ColumnTimestampz + EndsAt postgres.ColumnTimestampz + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AdCampaignsTable struct { + adCampaignsTable + + EXCLUDED adCampaignsTable +} + +// AS creates new AdCampaignsTable with assigned alias +func (a AdCampaignsTable) AS(alias string) *AdCampaignsTable { + return newAdCampaignsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AdCampaignsTable with assigned schema name +func (a AdCampaignsTable) FromSchema(schemaName string) *AdCampaignsTable { + return newAdCampaignsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AdCampaignsTable with assigned table prefix +func (a AdCampaignsTable) WithPrefix(prefix string) *AdCampaignsTable { + return newAdCampaignsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AdCampaignsTable with assigned table suffix +func (a AdCampaignsTable) WithSuffix(suffix string) *AdCampaignsTable { + return newAdCampaignsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable { + return &AdCampaignsTable{ + adCampaignsTable: newAdCampaignsTableImpl(schemaName, tableName, alias), + EXCLUDED: newAdCampaignsTableImpl("", "excluded", ""), + } +} + +func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable { + var ( + CampaignIDColumn = postgres.StringColumn("campaign_id") + NameColumn = postgres.StringColumn("name") + WeightColumn = postgres.IntegerColumn("weight") + IsDefaultColumn = postgres.BoolColumn("is_default") + EnabledColumn = postgres.BoolColumn("enabled") + StartsAtColumn = postgres.TimestampzColumn("starts_at") + EndsAtColumn = postgres.TimestampzColumn("ends_at") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return adCampaignsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + CampaignID: CampaignIDColumn, + Name: NameColumn, + Weight: WeightColumn, + IsDefault: IsDefaultColumn, + Enabled: EnabledColumn, + StartsAt: StartsAtColumn, + EndsAt: EndsAtColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/ad_messages.go b/backend/internal/postgres/jet/backend/table/ad_messages.go new file mode 100644 index 0000000..c3a570a --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/ad_messages.go @@ -0,0 +1,96 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AdMessages = newAdMessagesTable("backend", "ad_messages", "") + +type adMessagesTable struct { + postgres.Table + + // Columns + MessageID postgres.ColumnString + CampaignID postgres.ColumnString + Position postgres.ColumnInteger + BodyEn postgres.ColumnString + BodyRu postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AdMessagesTable struct { + adMessagesTable + + EXCLUDED adMessagesTable +} + +// AS creates new AdMessagesTable with assigned alias +func (a AdMessagesTable) AS(alias string) *AdMessagesTable { + return newAdMessagesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AdMessagesTable with assigned schema name +func (a AdMessagesTable) FromSchema(schemaName string) *AdMessagesTable { + return newAdMessagesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AdMessagesTable with assigned table prefix +func (a AdMessagesTable) WithPrefix(prefix string) *AdMessagesTable { + return newAdMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AdMessagesTable with assigned table suffix +func (a AdMessagesTable) WithSuffix(suffix string) *AdMessagesTable { + return newAdMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAdMessagesTable(schemaName, tableName, alias string) *AdMessagesTable { + return &AdMessagesTable{ + adMessagesTable: newAdMessagesTableImpl(schemaName, tableName, alias), + EXCLUDED: newAdMessagesTableImpl("", "excluded", ""), + } +} + +func newAdMessagesTableImpl(schemaName, tableName, alias string) adMessagesTable { + var ( + MessageIDColumn = postgres.StringColumn("message_id") + CampaignIDColumn = postgres.StringColumn("campaign_id") + PositionColumn = postgres.IntegerColumn("position") + BodyEnColumn = postgres.StringColumn("body_en") + BodyRuColumn = postgres.StringColumn("body_ru") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{MessageIDColumn, CampaignIDColumn, PositionColumn, BodyEnColumn, BodyRuColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{CampaignIDColumn, PositionColumn, BodyEnColumn, BodyRuColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{PositionColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return adMessagesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + MessageID: MessageIDColumn, + CampaignID: CampaignIDColumn, + Position: PositionColumn, + BodyEn: BodyEnColumn, + BodyRu: BodyRuColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/ad_settings.go b/backend/internal/postgres/jet/backend/table/ad_settings.go new file mode 100644 index 0000000..29de5bb --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/ad_settings.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var AdSettings = newAdSettingsTable("backend", "ad_settings", "") + +type adSettingsTable struct { + postgres.Table + + // Columns + ID postgres.ColumnBool + HoldMs postgres.ColumnInteger + EdgePauseMs postgres.ColumnInteger + ScrollPxPerSec postgres.ColumnInteger + FadeOutMs postgres.ColumnInteger + GapMs postgres.ColumnInteger + FadeInMs postgres.ColumnInteger + UpdatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type AdSettingsTable struct { + adSettingsTable + + EXCLUDED adSettingsTable +} + +// AS creates new AdSettingsTable with assigned alias +func (a AdSettingsTable) AS(alias string) *AdSettingsTable { + return newAdSettingsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new AdSettingsTable with assigned schema name +func (a AdSettingsTable) FromSchema(schemaName string) *AdSettingsTable { + return newAdSettingsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new AdSettingsTable with assigned table prefix +func (a AdSettingsTable) WithPrefix(prefix string) *AdSettingsTable { + return newAdSettingsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new AdSettingsTable with assigned table suffix +func (a AdSettingsTable) WithSuffix(suffix string) *AdSettingsTable { + return newAdSettingsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newAdSettingsTable(schemaName, tableName, alias string) *AdSettingsTable { + return &AdSettingsTable{ + adSettingsTable: newAdSettingsTableImpl(schemaName, tableName, alias), + EXCLUDED: newAdSettingsTableImpl("", "excluded", ""), + } +} + +func newAdSettingsTableImpl(schemaName, tableName, alias string) adSettingsTable { + var ( + IDColumn = postgres.BoolColumn("id") + HoldMsColumn = postgres.IntegerColumn("hold_ms") + EdgePauseMsColumn = postgres.IntegerColumn("edge_pause_ms") + ScrollPxPerSecColumn = postgres.IntegerColumn("scroll_px_per_sec") + FadeOutMsColumn = postgres.IntegerColumn("fade_out_ms") + GapMsColumn = postgres.IntegerColumn("gap_ms") + FadeInMsColumn = postgres.IntegerColumn("fade_in_ms") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + allColumns = postgres.ColumnList{IDColumn, HoldMsColumn, EdgePauseMsColumn, ScrollPxPerSecColumn, FadeOutMsColumn, GapMsColumn, FadeInMsColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{HoldMsColumn, EdgePauseMsColumn, ScrollPxPerSecColumn, FadeOutMsColumn, GapMsColumn, FadeInMsColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, UpdatedAtColumn} + ) + + return adSettingsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + HoldMs: HoldMsColumn, + EdgePauseMs: EdgePauseMsColumn, + ScrollPxPerSec: ScrollPxPerSecColumn, + FadeOutMs: FadeOutMsColumn, + GapMs: GapMsColumn, + FadeInMs: FadeInMsColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 18e9bec..46921db 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -14,6 +14,9 @@ func UseSchema(schema string) { AccountStats = AccountStats.FromSchema(schema) AccountSuspensions = AccountSuspensions.FromSchema(schema) Accounts = Accounts.FromSchema(schema) + AdCampaigns = AdCampaigns.FromSchema(schema) + AdMessages = AdMessages.FromSchema(schema) + AdSettings = AdSettings.FromSchema(schema) Blocks = Blocks.FromSchema(schema) ChatMessages = ChatMessages.FromSchema(schema) Complaints = Complaints.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00006_ad_banners.sql b/backend/internal/postgres/migrations/00006_ad_banners.sql new file mode 100644 index 0000000..3b2ecac --- /dev/null +++ b/backend/internal/postgres/migrations/00006_ad_banners.sql @@ -0,0 +1,86 @@ +-- +goose Up +-- Server-driven advertising banner ("advertising network"): operator-managed campaigns rotated in +-- the client's one-line announcement strip. A campaign is one placement order with a show weight +-- (percent); simultaneously active campaigns compete for shows in proportion to their weights. The +-- single perpetual default campaign fills the unsold remainder up to 100%. Each campaign carries +-- one or more bilingual messages (en + ru, both mandatory), shown by the viewer's bot (service) +-- language. Display timings are global (one settings row). Eligibility (who sees a banner at all) +-- reuses account fields + the no_banner role, so it needs no schema here. See docs/ARCHITECTURE.md +-- and internal/ads. +SET search_path = backend, pg_catalog; + +-- One advertising campaign = one placement order. weight is the show percentage (1..100). is_default +-- marks the single perpetual house campaign that fills the remainder up to 100% and is never deleted; +-- it has no validity window (its stored weight is nominal — the rotation derives its effective weight +-- as max(0, 100 - sum of active timed weights)). A time-limited campaign is active while now() lies in +-- [starts_at, ends_at] (a null bound is open-ended on that side). enabled toggles a campaign without +-- deleting it. +CREATE TABLE ad_campaigns ( + campaign_id uuid PRIMARY KEY, + name text NOT NULL, + weight integer NOT NULL, + is_default boolean NOT NULL DEFAULT false, + enabled boolean NOT NULL DEFAULT true, + starts_at timestamptz, + ends_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ad_campaigns_weight_chk CHECK (weight BETWEEN 1 AND 100), + -- The default campaign is perpetual (no window). + CONSTRAINT ad_campaigns_default_window_chk CHECK (NOT is_default OR (starts_at IS NULL AND ends_at IS NULL)), + -- A closed window must not be inverted. + CONSTRAINT ad_campaigns_window_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at <= ends_at) +); +-- At most one default campaign. +CREATE UNIQUE INDEX ad_campaigns_default_idx ON ad_campaigns (is_default) WHERE is_default; + +-- One bilingual message of a campaign. Both languages are mandatory: the client shows the variant for +-- the viewer's bot (service) language. position orders the messages within a campaign (the round-robin +-- order when the campaign wins a display slot). body_* is minimal markdown (text + links). +CREATE TABLE ad_messages ( + message_id uuid PRIMARY KEY, + campaign_id uuid NOT NULL REFERENCES ad_campaigns (campaign_id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + body_en text NOT NULL, + body_ru text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +-- Messages of a campaign are read in display order. +CREATE INDEX ad_messages_campaign_idx ON ad_messages (campaign_id, position); + +-- Global banner display timings, a single row (id is always TRUE). The client rotator reads these: +-- hold_ms is how long one message shows; edge_pause_ms and scroll_px_per_sec drive the scroll of a +-- message wider than the strip; the transition between messages is fade_out_ms -> gap_ms -> fade_in_ms. +-- All are clamped in Go on update. +CREATE TABLE ad_settings ( + id boolean PRIMARY KEY DEFAULT true, + hold_ms integer NOT NULL, + edge_pause_ms integer NOT NULL, + scroll_px_per_sec integer NOT NULL, + fade_out_ms integer NOT NULL, + gap_ms integer NOT NULL, + fade_in_ms integer NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ad_settings_singleton_chk CHECK (id) +); + +-- Seed the perpetual default campaign with one bilingual house message, and the default timings +-- (the previous hardcoded UI defaults plus the new fade sequence). Fixed UUIDs keep the seed +-- deterministic across environments. +INSERT INTO ad_campaigns (campaign_id, name, weight, is_default, enabled) +VALUES ('00000000-0000-0000-0000-0000000000ad', 'Default (house)', 100, true, true); +INSERT INTO ad_messages (message_id, campaign_id, position, body_en, body_ru) +VALUES ( + '00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0, + 'Tip: a play using all 7 tiles earns a +50 bonus.', + 'Совет: ход всеми 7 фишками приносит бонус +50 очков.' +); +INSERT INTO ad_settings (id, hold_ms, edge_pause_ms, scroll_px_per_sec, fade_out_ms, gap_ms, fade_in_ms) +VALUES (true, 60000, 5000, 40, 1000, 250, 1000); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE IF EXISTS ad_settings; +DROP TABLE IF EXISTS ad_messages; +DROP TABLE IF EXISTS ad_campaigns; diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go new file mode 100644 index 0000000..1fa0f94 --- /dev/null +++ b/backend/internal/server/banner.go @@ -0,0 +1,100 @@ +package server + +import ( + "context" + + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/ads" + "scrabble/backend/internal/notify" +) + +// bannerDTO is the advertising-banner block attached to an eligible viewer's +// profile: the campaigns to rotate (each already resolved to the viewer's bot +// language) and the global display timings the client rotator reads. It is +// absent for a viewer who should see no banner. +type bannerDTO struct { + Campaigns []bannerCampaignDTO `json:"campaigns"` + Timings bannerTimingsDTO `json:"timings"` +} + +// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show +// weight (for the client's smooth weighted round-robin) and its messages, in +// display order, already resolved to one language. +type bannerCampaignDTO struct { + Weight int `json:"weight"` + Messages []string `json:"messages"` +} + +// bannerTimingsDTO mirrors ads.Timings on the wire. +type bannerTimingsDTO struct { + HoldMs int `json:"hold_ms"` + EdgePauseMs int `json:"edge_pause_ms"` + ScrollPxPerSec int `json:"scroll_px_per_sec"` + FadeOutMs int `json:"fade_out_ms"` + GapMs int `json:"gap_ms"` + FadeInMs int `json:"fade_in_ms"` +} + +// bannerFor builds the advertising-banner block for the account's profile, or +// nil when the ads service is not configured or the viewer is not eligible to +// see a banner. The message language follows the account's bot (service) +// language, falling back to its interface language and then English. A failure +// reading roles or campaigns is logged and treated as "no banner" so the profile +// response still succeeds. +func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO { + if s.ads == nil { + return nil + } + hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner) + if err != nil { + s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err)) + return nil + } + if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) { + return nil + } + set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc)) + if err != nil { + s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err)) + return nil + } + campaigns := make([]bannerCampaignDTO, 0, len(set)) + for _, c := range set { + campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}) + } + return &bannerDTO{ + Campaigns: campaigns, + Timings: bannerTimingsDTO{ + HoldMs: timings.HoldMs, + EdgePauseMs: timings.EdgePauseMs, + ScrollPxPerSec: timings.ScrollPxPerSec, + FadeOutMs: timings.FadeOutMs, + GapMs: timings.GapMs, + FadeInMs: timings.FadeInMs, + }, + } +} + +// bannerLang resolves the message language for a viewer: the bot (service) +// language they last signed in through, falling back to the interface language +// and then English. +func bannerLang(acc account.Account) string { + if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" { + return acc.ServiceLanguage + } + if acc.PreferredLanguage == "ru" { + return "ru" + } + return "en" +} + +// publishBannerChange emits the in-app "banner eligibility may have changed" +// re-poll signal to the account, so an open client re-fetches profile.get and +// shows or hides the banner without a reload. Best-effort (notify.Nop when no +// notifier is wired). +func (s *Server) publishBannerChange(id uuid.UUID) { + s.notifier.Publish(notify.BannerChanged(id)) +} diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 98def50..b73c3b5 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -49,6 +49,10 @@ type profileResponse struct { BlockFriendRequests bool `json:"block_friend_requests"` IsGuest bool `json:"is_guest"` NotificationsInAppOnly bool `json:"notifications_in_app_only"` + // Banner is the advertising-banner block: present only for a viewer eligible to + // see the banner (a free account with an empty hint wallet and without the + // no_banner role), absent otherwise. See banner.go. + Banner *bannerDTO `json:"banner,omitempty"` } // tileDTO is one placed (or to-place) tile. diff --git a/backend/internal/server/handlers_admin_banners.go b/backend/internal/server/handlers_admin_banners.go new file mode 100644 index 0000000..86b89a5 --- /dev/null +++ b/backend/internal/server/handlers_admin_banners.go @@ -0,0 +1,323 @@ +package server + +import ( + "errors" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/ads" +) + +// bannersBack is the campaign-list path the banner console actions return to. +const bannersBack = "/_gm/banners" + +// localTimeLayout is the datetime-local input/round-trip form of a validity-window +// bound; the operator's entry is interpreted as UTC. +const localTimeLayout = "2006-01-02T15:04" + +// consoleBanners lists the advertising campaigns (default first), each with its +// weight, validity window, enabled flag, message count and live-now status. +func (s *Server) consoleBanners(c *gin.Context) { + campaigns, err := s.ads.ListCampaigns(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + now := time.Now().UTC() + var view adminconsole.BannersView + for _, cm := range campaigns { + view.Items = append(view.Items, adminconsole.BannerCampaignRow{ + ID: cm.ID.String(), + Name: cm.Name, + Weight: cm.Weight, + IsDefault: cm.IsDefault, + Enabled: cm.Enabled, + Window: windowLabel(cm), + Messages: len(cm.Messages), + ActiveNow: cm.ActiveAt(now), + }) + } + s.renderConsole(c, "banners", "banners", "Banners", view) +} + +// consoleBannerDetail renders one campaign's edit form and its messages. +func (s *Server) consoleBannerDetail(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + cm, err := s.ads.Campaign(c.Request.Context(), id) + if err != nil { + s.bannerError(c, err, bannersBack) + return + } + view := adminconsole.BannerDetailView{ + ID: cm.ID.String(), + Name: cm.Name, + Weight: cm.Weight, + IsDefault: cm.IsDefault, + Enabled: cm.Enabled, + StartsAt: localInput(cm.StartsAt), + EndsAt: localInput(cm.EndsAt), + } + for i, m := range cm.Messages { + view.Messages = append(view.Messages, adminconsole.BannerMessageRow{ + ID: m.ID.String(), + BodyEn: m.BodyEn, + BodyRu: m.BodyRu, + First: i == 0, + Last: i == len(cm.Messages)-1, + }) + } + s.renderConsole(c, "banner_detail", "banners", cm.Name, view) +} + +// consoleCreateBanner creates a new time-limited campaign. +func (s *Server) consoleCreateBanner(c *gin.Context) { + starts, ends, err := parseWindow(c) + if err != nil { + s.renderConsoleMessage(c, "Invalid window", err.Error(), bannersBack) + return + } + id, err := s.ads.CreateCampaign(c.Request.Context(), ads.Campaign{ + Name: trimForm(c, "name"), + Weight: atoiForm(c, "weight"), + Enabled: c.PostForm("enabled") != "", + StartsAt: starts, + EndsAt: ends, + }) + if err != nil { + s.bannerError(c, err, bannersBack) + return + } + s.renderConsoleMessage(c, "Created", "campaign created", "/_gm/banners/"+id.String()) +} + +// consoleUpdateBanner saves a campaign's editable fields. For the default +// campaign the service ignores everything but the name. +func (s *Server) consoleUpdateBanner(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + back := "/_gm/banners/" + id.String() + starts, ends, err := parseWindow(c) + if err != nil { + s.renderConsoleMessage(c, "Invalid window", err.Error(), back) + return + } + err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{ + ID: id, + Name: trimForm(c, "name"), + Weight: atoiForm(c, "weight"), + Enabled: c.PostForm("enabled") != "", + StartsAt: starts, + EndsAt: ends, + }) + if err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Saved", "campaign updated", back) +} + +// consoleDeleteBanner removes a campaign (the service refuses the default). +func (s *Server) consoleDeleteBanner(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + if err := s.ads.DeleteCampaign(c.Request.Context(), id); err != nil { + s.bannerError(c, err, "/_gm/banners/"+id.String()) + return + } + s.renderConsoleMessage(c, "Deleted", "campaign deleted", bannersBack) +} + +// consoleAddBannerMessage appends a bilingual message to a campaign. +func (s *Server) consoleAddBannerMessage(c *gin.Context) { + id, ok := s.consoleUUID(c, bannersBack) + if !ok { + return + } + back := "/_gm/banners/" + id.String() + if _, err := s.ads.AddMessage(c.Request.Context(), id, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Added", "message added", back) +} + +// consoleEditBannerMessage rewrites a message's bilingual bodies. +func (s *Server) consoleEditBannerMessage(c *gin.Context) { + cid, mid, ok := s.bannerMessageIDs(c) + if !ok { + return + } + back := "/_gm/banners/" + cid.String() + if err := s.ads.EditMessage(c.Request.Context(), mid, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Saved", "message updated", back) +} + +// consoleDeleteBannerMessage removes a message (the service keeps the default's +// last one). +func (s *Server) consoleDeleteBannerMessage(c *gin.Context) { + cid, mid, ok := s.bannerMessageIDs(c) + if !ok { + return + } + back := "/_gm/banners/" + cid.String() + if err := s.ads.DeleteMessage(c.Request.Context(), cid, mid); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Deleted", "message deleted", back) +} + +// consoleMoveBannerMessage reorders a message up or down within its campaign. +func (s *Server) consoleMoveBannerMessage(c *gin.Context) { + cid, mid, ok := s.bannerMessageIDs(c) + if !ok { + return + } + back := "/_gm/banners/" + cid.String() + dir := 1 + if trimForm(c, "dir") == "up" { + dir = -1 + } + if err := s.ads.MoveMessage(c.Request.Context(), cid, mid, dir); err != nil { + s.bannerError(c, err, back) + return + } + s.renderConsoleMessage(c, "Moved", "message reordered", back) +} + +// consoleBannerSettings renders the global display-timings form. +func (s *Server) consoleBannerSettings(c *gin.Context) { + t, err := s.ads.Settings(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsole(c, "banner_settings", "banners", "Banner settings", adminconsole.BannerSettingsView{ + HoldMs: t.HoldMs, + EdgePauseMs: t.EdgePauseMs, + ScrollPxPerSec: t.ScrollPxPerSec, + FadeOutMs: t.FadeOutMs, + GapMs: t.GapMs, + FadeInMs: t.FadeInMs, + }) +} + +// consoleUpdateBannerSettings saves the global display timings (clamped by the +// service into their safe ranges). +func (s *Server) consoleUpdateBannerSettings(c *gin.Context) { + err := s.ads.UpdateSettings(c.Request.Context(), ads.Timings{ + HoldMs: atoiForm(c, "hold_ms"), + EdgePauseMs: atoiForm(c, "edge_pause_ms"), + ScrollPxPerSec: atoiForm(c, "scroll_px_per_sec"), + FadeOutMs: atoiForm(c, "fade_out_ms"), + GapMs: atoiForm(c, "gap_ms"), + FadeInMs: atoiForm(c, "fade_in_ms"), + }) + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Saved", "display timings updated", "/_gm/banner-settings") +} + +// bannerMessageIDs parses the campaign :id and message :mid path parameters, +// rendering an invalid-id message and returning false when either is not a UUID. +func (s *Server) bannerMessageIDs(c *gin.Context) (campaignID, messageID uuid.UUID, ok bool) { + cid, err := uuid.Parse(c.Param("id")) + if err != nil { + s.renderConsoleMessage(c, "Invalid id", "the campaign id in the URL is not valid", bannersBack) + return uuid.Nil, uuid.Nil, false + } + mid, err := uuid.Parse(c.Param("mid")) + if err != nil { + s.renderConsoleMessage(c, "Invalid id", "the message id in the URL is not valid", "/_gm/banners/"+cid.String()) + return uuid.Nil, uuid.Nil, false + } + return cid, mid, true +} + +// bannerError maps an ads validation/immutability/not-found error to a friendly +// console message, and anything else to the generic error page. +func (s *Server) bannerError(c *gin.Context, err error, back string) { + switch { + case errors.Is(err, ads.ErrValidation), errors.Is(err, ads.ErrDefaultImmutable): + s.renderConsoleMessage(c, "Not saved", err.Error(), back) + case errors.Is(err, ads.ErrNotFound): + s.renderConsoleMessage(c, "Not found", "no such campaign or message", back) + default: + s.consoleError(c, err) + } +} + +// parseWindow reads the optional starts_at/ends_at datetime-local form fields, +// interpreting each as UTC; an empty field is an open-ended bound (nil). +func parseWindow(c *gin.Context) (starts, ends *time.Time, err error) { + if starts, err = parseLocalTime(trimForm(c, "starts_at")); err != nil { + return nil, nil, errors.New("the start time is not a valid date/time") + } + if ends, err = parseLocalTime(trimForm(c, "ends_at")); err != nil { + return nil, nil, errors.New("the end time is not a valid date/time") + } + return starts, ends, nil +} + +// parseLocalTime parses a datetime-local value as UTC, or returns nil for an +// empty string. +func parseLocalTime(s string) (*time.Time, error) { + if s == "" { + return nil, nil + } + t, err := time.Parse(localTimeLayout, s) + if err != nil { + return nil, err + } + t = t.UTC() + return &t, nil +} + +// localInput formats a validity-window bound for a datetime-local input, or "" +// when open-ended. +func localInput(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(localTimeLayout) +} + +// windowLabel builds the human-readable validity window for the campaign list. +func windowLabel(c ads.Campaign) string { + if c.IsDefault { + return "perpetual" + } + switch { + case c.StartsAt == nil && c.EndsAt == nil: + return "always" + case c.StartsAt == nil: + return "until " + fmtTimePtr(c.EndsAt) + case c.EndsAt == nil: + return "from " + fmtTimePtr(c.StartsAt) + default: + return fmtTimePtr(c.StartsAt) + " – " + fmtTimePtr(c.EndsAt) + } +} + +// atoiForm returns the integer value of a posted form field, or 0 when missing +// or non-numeric (the ads service clamps timings and validates weights). +func atoiForm(c *gin.Context, name string) int { + n, _ := strconv.Atoi(trimForm(c, name)) + return n +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 51cd16c..3ad776f 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -88,6 +88,19 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) gm.GET("/broadcast", s.consoleBroadcast) gm.POST("/broadcast", s.consolePostBroadcast) + if s.ads != nil { + gm.GET("/banners", s.consoleBanners) + gm.POST("/banners", s.consoleCreateBanner) + gm.GET("/banners/:id", s.consoleBannerDetail) + gm.POST("/banners/:id", s.consoleUpdateBanner) + gm.POST("/banners/:id/delete", s.consoleDeleteBanner) + gm.POST("/banners/:id/messages", s.consoleAddBannerMessage) + gm.POST("/banners/:id/messages/:mid", s.consoleEditBannerMessage) + gm.POST("/banners/:id/messages/:mid/delete", s.consoleDeleteBannerMessage) + gm.POST("/banners/:id/messages/:mid/move", s.consoleMoveBannerMessage) + gm.GET("/banner-settings", s.consoleBannerSettings) + gm.POST("/banner-settings", s.consoleUpdateBannerSettings) + } } // consoleDashboard renders the landing page: the top-line counts and the resident @@ -817,6 +830,8 @@ func (s *Server) consoleGrantHints(c *gin.Context) { s.consoleError(c, err) return } + // A non-empty hint wallet removes the banner: nudge an open client to re-check. + s.publishBannerChange(id) s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back) } diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index 76e7e2f..4a04384 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -245,6 +245,9 @@ func (s *Server) consoleGrantRole(c *gin.Context) { s.consoleError(c, err) return } + if role == account.RoleNoBanner { + s.publishBannerChange(id) + } s.renderConsoleMessage(c, "Role granted", "granted "+role, back) } @@ -264,6 +267,9 @@ func (s *Server) consoleRevokeRole(c *gin.Context) { s.consoleError(c, err) return } + if role == account.RoleNoBanner { + s.publishBannerChange(id) + } s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back) } diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 642f759..e46e408 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -23,7 +23,9 @@ func (s *Server) handleProfile(c *gin.Context) { s.abortErr(c, err) return } - c.JSON(http.StatusOK, profileResponseFor(acc)) + resp := profileResponseFor(acc) + resp.Banner = s.bannerFor(c.Request.Context(), acc) + c.JSON(http.StatusOK, resp) } // submitPlayRequest places tiles on the player's turn; the engine infers the diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 981397e..81d1287 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -19,12 +19,14 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/ads" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" "scrabble/backend/internal/feedback" "scrabble/backend/internal/game" "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" + "scrabble/backend/internal/notify" "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/session" "scrabble/backend/internal/social" @@ -81,6 +83,14 @@ type Deps struct { // admin console's throttled view + the high-rate auto-flag. A nil RateWatch // disables the internal report endpoint and the console view. RateWatch *ratewatch.Watch + // Ads is the advertising-banner domain service: campaign rotation feeding the + // profile.get banner block, plus the banner admin console section. A nil Ads + // omits the banner block and disables the banner console. + Ads *ads.Service + // Notifier publishes live-event intents — here the banner-eligibility re-poll + // signal the banner/hint/role console actions emit. A nil Notifier discards + // them (notify.Nop). + Notifier notify.Publisher } // Server owns the gin engine, the underlying HTTP server and the readiness @@ -105,6 +115,8 @@ type Server struct { dictDir string connector *connector.Client ratewatch *ratewatch.Watch + ads *ads.Service + notifier notify.Publisher console *adminconsole.Renderer public *gin.RouterGroup @@ -129,6 +141,11 @@ func New(addr string, deps Deps) *Server { engine.Use(gin.Recovery()) engine.Use(telemetry.Middleware(log)) + notifier := deps.Notifier + if notifier == nil { + notifier = notify.Nop{} + } + s := &Server{ log: log, db: deps.DB, @@ -147,6 +164,8 @@ func New(addr string, deps Deps) *Server { dictDir: deps.DictDir, connector: deps.Connector, ratewatch: deps.RateWatch, + ads: deps.Ads, + notifier: notifier, http: &http.Server{Addr: addr, Handler: engine}, } s.registerProbes(engine) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b6fab0..fc19c3f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,8 +40,8 @@ Three executables plus per-platform side-services: in-memory mock transport (`pnpm start`) runs the whole slice with no backend. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), - a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off - in the build until polished after release** — a server-driven channel later, §10), + a one-line **advertising banner** under the nav (server-driven campaigns shown to eligible free + users, a weighted fair rotation — §10), and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in [`UI_DESIGN.md`](UI_DESIGN.md). @@ -658,9 +658,34 @@ response/withdrawal lobby sync) never becomes a platform push. Operator broadcas **operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP). -A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md). -It is a client-side **mock** rotation today; a server-driven source (operational notices, -promotions) is future work and would deliver short markdown messages (text + links). +A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md), +server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in +the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an +optional validity **window**, an `enabled` flag and one or more **bilingual messages** (en + ru, +both mandatory, minimal markdown). A single perpetual **default** campaign fills the unsold +remainder up to 100% and is undeletable. Eligibility — who sees a banner at all — is +`!paid_account && hint_balance == 0 && !has(no_banner)` (the `no_banner` account role suppresses +it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`** +response (the one bootstrap every client fetches on open, authed or guest — no separate request, +nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's +**service language** (the bot they signed in through, falling back to the interface language) and +computes the active set — window-filtered campaigns, the default's effective weight +(`max(0, 100 − Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set +with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per +cycle), round-robining a campaign's messages within its slots; the global display **timings** (hold, +edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are operator-set +(`/_gm/banner-settings`, clamped) and ride the same block. When an operator changes a viewer's +eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets +`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal), +and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content* +edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. + +> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into +> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap +> RPCs already multiplex over one reused connection — the saving would be per-request fixed overhead, +> not connections, and it is a high-blast-radius cross-cutting refactor. Revisit only with evidence +> (`edge_request_duration`); if ever built, as a gateway-side fan-out/merge that keeps the per-domain +> backend handlers intact. ## 11. Observability diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f83986f..a5449d8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -264,3 +264,27 @@ in-app), archive it, delete it, or delete every message from that player — and stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a message does not mark it read — only the explicit actions do; message bodies and attachments are shown defensively (text escaped, attachments downloaded rather than rendered). + +### Advertising banner + +A one-line banner under the nav shows short promotional or operational messages to **free +players**: anyone who has **not** paid for a lifetime account, holds **no purchased hints**, and +does not carry the **`no_banner`** role (which suppresses it unconditionally). Buying a paid account +or any hints — or being granted `no_banner` — removes it; guests, the freest users, see it. When an +operator changes one of those properties, the banner appears or disappears **in place**, without a +reload. + +Messages come from operator-run **campaigns**, each a placement order with a **show weight** (a +percent) and an optional start/end **window**. Campaigns running at the same time **compete** for the +banner in proportion to their weights; a permanent **house (default)** campaign fills whatever share +paid campaigns leave unsold and steps aside entirely when they sell the full 100%. Each message is +written in **both languages** (English + Russian); a player sees the variant for the **bot they play +through**, regardless of their interface language. The client rotates the eligible campaigns +**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random — +and a campaign with several messages shows them in turn. + +Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable +and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set +the global display **timings** (how long a message holds, the scroll of an over-long message, and the +fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and +keeps at least one message. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 060b2c4..575ca4c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -270,3 +270,26 @@ IP и вложением. Оператор может пометить сооб пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не рендерятся). + +### Рекламный баннер + +Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным +игрокам**: всем, кто **не** оплатил пожизненный аккаунт, **не** имеет купленных подсказок и **не** +несёт роль **`no_banner`** (она отключает баннер безусловно). Покупка платного аккаунта или подсказок — +либо выдача `no_banner` — убирает его; гости как самые «бесплатные» пользователи баннер видят. Когда +оператор меняет одно из этих свойств, баннер появляется или исчезает **на месте**, без перезагрузки. + +Сообщения берутся из управляемых оператором **кампаний**, каждая из которых — заказ на размещение со +**своим весом показа** (процент) и необязательным **окном** действия (начало/конец). Одновременно +идущие кампании **конкурируют** за баннер пропорционально весам; постоянная **дефолтная (домашняя)** +кампания добивает остаток, не выкупленный платными, и полностью уходит из ротации, когда те выкупают +все 100%. Каждое сообщение написано на **двух языках** (английский + русский); игрок видит вариант для +**бота, через которого играет**, независимо от языка интерфейса. Клиент ротирует подходящие кампании +**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а +кампания с несколькими сообщениями показывает их по очереди. + +Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует, +включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет +их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и +переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней +всегда остаётся хотя бы одно сообщение. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 4255fcd..9b404b4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -34,6 +34,33 @@ type ProfileResp struct { BlockFriendRequests bool `json:"block_friend_requests"` IsGuest bool `json:"is_guest"` NotificationsInAppOnly bool `json:"notifications_in_app_only"` + // Banner is the advertising-banner block, present only for a viewer eligible to + // see the banner. The gateway forwards it verbatim into the Profile payload. + Banner *BannerResp `json:"banner,omitempty"` +} + +// BannerResp is the advertising-banner block of an eligible viewer's profile: the +// campaigns to rotate and the global display timings the client rotator reads. +type BannerResp struct { + Campaigns []BannerCampaignResp `json:"campaigns"` + Timings BannerTimingsResp `json:"timings"` +} + +// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show +// weight and its messages, in display order, already resolved to one language. +type BannerCampaignResp struct { + Weight int `json:"weight"` + Messages []string `json:"messages"` +} + +// BannerTimingsResp mirrors the backend's global display timings. +type BannerTimingsResp struct { + HoldMs int `json:"hold_ms"` + EdgePauseMs int `json:"edge_pause_ms"` + ScrollPxPerSec int `json:"scroll_px_per_sec"` + FadeOutMs int `json:"fade_out_ms"` + GapMs int `json:"gap_ms"` + FadeInMs int `json:"fade_in_ms"` } // LinkResultResp is the result of an account link/merge step. Status is diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index d3c8ee0..98560e6 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -58,7 +58,8 @@ func encodeAck(ok bool) []byte { return b.FinishedBytes() } -// encodeProfile builds a Profile payload. +// encodeProfile builds a Profile payload, including the advertising-banner block +// when the backend marked the viewer eligible. func encodeProfile(p backendclient.ProfileResp) []byte { b := flatbuffers.NewBuilder(192) uid := b.CreateString(p.UserID) @@ -67,6 +68,12 @@ func encodeProfile(p backendclient.ProfileResp) []byte { tz := b.CreateString(p.TimeZone) awayStart := b.CreateString(p.AwayStart) awayEnd := b.CreateString(p.AwayEnd) + // Build the banner table (and its children) before opening Profile: FlatBuffers + // forbids a nested table while another is under construction. + var banner flatbuffers.UOffsetT + if p.Banner != nil { + banner = encodeBanner(b, *p.Banner) + } fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -79,10 +86,51 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddAwayStart(b, awayStart) fb.ProfileAddAwayEnd(b, awayEnd) fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly) + if p.Banner != nil { + fb.ProfileAddBanner(b, banner) + } b.Finish(fb.ProfileEnd(b)) return b.FinishedBytes() } +// encodeBanner builds a BannerInfo table from the resolved banner block and +// returns its offset. It is bottom-up: each campaign's messages vector and table +// are built first, then the campaigns vector, then the BannerInfo table. The +// caller must invoke it with no table under construction. +func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatbuffers.UOffsetT { + campOffsets := make([]flatbuffers.UOffsetT, len(banner.Campaigns)) + for i, c := range banner.Campaigns { + msgOffsets := make([]flatbuffers.UOffsetT, len(c.Messages)) + for j, m := range c.Messages { + msgOffsets[j] = b.CreateString(m) + } + fb.BannerCampaignStartMessagesVector(b, len(msgOffsets)) + for j := len(msgOffsets) - 1; j >= 0; j-- { + b.PrependUOffsetT(msgOffsets[j]) + } + msgs := b.EndVector(len(msgOffsets)) + fb.BannerCampaignStart(b) + fb.BannerCampaignAddWeight(b, int32(c.Weight)) + fb.BannerCampaignAddMessages(b, msgs) + campOffsets[i] = fb.BannerCampaignEnd(b) + } + fb.BannerInfoStartCampaignsVector(b, len(campOffsets)) + for i := len(campOffsets) - 1; i >= 0; i-- { + b.PrependUOffsetT(campOffsets[i]) + } + camps := b.EndVector(len(campOffsets)) + t := banner.Timings + fb.BannerInfoStart(b) + fb.BannerInfoAddCampaigns(b, camps) + fb.BannerInfoAddHoldMs(b, int32(t.HoldMs)) + fb.BannerInfoAddEdgePauseMs(b, int32(t.EdgePauseMs)) + fb.BannerInfoAddScrollPxPerSec(b, int32(t.ScrollPxPerSec)) + fb.BannerInfoAddFadeOutMs(b, int32(t.FadeOutMs)) + fb.BannerInfoAddGapMs(b, int32(t.GapMs)) + fb.BannerInfoAddFadeInMs(b, int32(t.FadeInMs)) + return fb.BannerInfoEnd(b) +} + // encodeBlockStatus builds a BlockStatus payload. func encodeBlockStatus(s backendclient.BlockStatusResp) []byte { b := flatbuffers.NewBuilder(128) diff --git a/gateway/internal/transcode/transcode_banner_test.go b/gateway/internal/transcode/transcode_banner_test.go new file mode 100644 index 0000000..9c27dcf --- /dev/null +++ b/gateway/internal/transcode/transcode_banner_test.go @@ -0,0 +1,81 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestProfileGetEncodesBanner verifies the gateway forwards the backend's banner +// block verbatim into the Profile payload: the campaigns (weight + resolved +// messages, in order) and the global display timings. +func TestProfileGetEncodesBanner(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" { + t.Errorf("unexpected %s %q", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` + + `"banner":{"campaigns":[` + + `{"weight":3,"messages":["promo-en"]},` + + `{"weight":7,"messages":["house-a","house-b"]}],` + + `"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` + + `"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgProfileGet) + if !ok { + t.Fatal("profile.get not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + + p := fb.GetRootAsProfile(payload, 0) + banner := p.Banner(nil) + if banner == nil { + t.Fatal("profile carries no banner block") + } + if got := banner.CampaignsLength(); got != 2 { + t.Fatalf("campaigns = %d, want 2", got) + } + var c fb.BannerCampaign + banner.Campaigns(&c, 0) + if c.Weight() != 3 || c.MessagesLength() != 1 || string(c.Messages(0)) != "promo-en" { + t.Errorf("campaign 0 = weight %d, %d msgs, msg0=%q", c.Weight(), c.MessagesLength(), c.Messages(0)) + } + banner.Campaigns(&c, 1) + if c.Weight() != 7 || c.MessagesLength() != 2 || string(c.Messages(1)) != "house-b" { + t.Errorf("campaign 1 = weight %d, %d msgs, msg1=%q", c.Weight(), c.MessagesLength(), c.Messages(1)) + } + if banner.HoldMs() != 60000 || banner.EdgePauseMs() != 5000 || banner.ScrollPxPerSec() != 40 { + t.Errorf("timings = hold %d edge %d scroll %d", banner.HoldMs(), banner.EdgePauseMs(), banner.ScrollPxPerSec()) + } + if banner.FadeOutMs() != 1000 || banner.GapMs() != 250 || banner.FadeInMs() != 1000 { + t.Errorf("fade timings = out %d gap %d in %d", banner.FadeOutMs(), banner.GapMs(), banner.FadeInMs()) + } +} + +// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no +// banner block (the backend omits it). +func TestProfileGetNoBanner(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if p := fb.GetRootAsProfile(payload, 0); p.Banner(nil) != nil { + t.Error("ineligible profile unexpectedly carries a banner block") + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 1c88274..d10d3ad 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -137,12 +137,39 @@ table Ack { ok:bool; } +// --- advertising banner --- + +// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight +// (the client runs a smooth weighted round-robin over campaigns by this weight) +// and its messages in display order, each already resolved to the viewer's bot +// language (the stored en/ru pair is picked server-side). +table BannerCampaign { + weight:int; + messages:[string]; +} + +// BannerInfo is the advertising-banner block of an eligible viewer's profile: the +// campaigns to rotate and the global display timings the client rotator reads. +// It is present (set on Profile.banner) only when the viewer is eligible to see a +// banner; absent otherwise. The transition between messages is fade_out_ms, then +// gap_ms, then fade_in_ms. +table BannerInfo { + campaigns:[BannerCampaign]; + hold_ms:int; + edge_pause_ms:int; + scroll_px_per_sec:int; + fade_out_ms:int; + gap_ms:int; + fade_in_ms:int; +} + // --- profile (authenticated) --- // Profile is the authenticated account's own profile view. away_start/away_end are // the "HH:MM" daily away-window bounds. notifications_in_app_only (default true) -// suppresses out-of-app platform push, leaving only the in-app live stream (both -// added trailing — backward-compatible). +// suppresses out-of-app platform push, leaving only the in-app live stream. banner +// carries the advertising-banner block for an eligible viewer, absent otherwise +// (all added trailing — backward-compatible). table Profile { user_id:string; display_name:string; @@ -155,6 +182,7 @@ table Profile { away_start:string; away_end:string; notifications_in_app_only:bool = true; + banner:BannerInfo; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation diff --git a/pkg/fbs/scrabblefb/BannerCampaign.go b/pkg/fbs/scrabblefb/BannerCampaign.go new file mode 100644 index 0000000..2a01ac7 --- /dev/null +++ b/pkg/fbs/scrabblefb/BannerCampaign.go @@ -0,0 +1,87 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BannerCampaign struct { + _tab flatbuffers.Table +} + +func GetRootAsBannerCampaign(buf []byte, offset flatbuffers.UOffsetT) *BannerCampaign { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BannerCampaign{} + x.Init(buf, n+offset) + return x +} + +func FinishBannerCampaignBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBannerCampaign(buf []byte, offset flatbuffers.UOffsetT) *BannerCampaign { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BannerCampaign{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBannerCampaignBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BannerCampaign) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BannerCampaign) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BannerCampaign) Weight() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerCampaign) MutateWeight(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *BannerCampaign) Messages(j int) []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + } + return nil +} + +func (rcv *BannerCampaign) MessagesLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func BannerCampaignStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) { + builder.PrependInt32Slot(0, weight, 0) +} +func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(messages), 0) +} +func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/BannerInfo.go b/pkg/fbs/scrabblefb/BannerInfo.go new file mode 100644 index 0000000..dacd2f1 --- /dev/null +++ b/pkg/fbs/scrabblefb/BannerInfo.go @@ -0,0 +1,165 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BannerInfo struct { + _tab flatbuffers.Table +} + +func GetRootAsBannerInfo(buf []byte, offset flatbuffers.UOffsetT) *BannerInfo { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BannerInfo{} + x.Init(buf, n+offset) + return x +} + +func FinishBannerInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBannerInfo(buf []byte, offset flatbuffers.UOffsetT) *BannerInfo { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BannerInfo{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBannerInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BannerInfo) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BannerInfo) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BannerInfo) Campaigns(obj *BannerCampaign, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *BannerInfo) CampaignsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *BannerInfo) HoldMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateHoldMs(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *BannerInfo) EdgePauseMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateEdgePauseMs(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *BannerInfo) ScrollPxPerSec() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateScrollPxPerSec(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *BannerInfo) FadeOutMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateFadeOutMs(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *BannerInfo) GapMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateGapMs(n int32) bool { + return rcv._tab.MutateInt32Slot(14, n) +} + +func (rcv *BannerInfo) FadeInMs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BannerInfo) MutateFadeInMs(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + +func BannerInfoStart(builder *flatbuffers.Builder) { + builder.StartObject(7) +} +func BannerInfoAddCampaigns(builder *flatbuffers.Builder, campaigns flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(campaigns), 0) +} +func BannerInfoStartCampaignsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func BannerInfoAddHoldMs(builder *flatbuffers.Builder, holdMs int32) { + builder.PrependInt32Slot(1, holdMs, 0) +} +func BannerInfoAddEdgePauseMs(builder *flatbuffers.Builder, edgePauseMs int32) { + builder.PrependInt32Slot(2, edgePauseMs, 0) +} +func BannerInfoAddScrollPxPerSec(builder *flatbuffers.Builder, scrollPxPerSec int32) { + builder.PrependInt32Slot(3, scrollPxPerSec, 0) +} +func BannerInfoAddFadeOutMs(builder *flatbuffers.Builder, fadeOutMs int32) { + builder.PrependInt32Slot(4, fadeOutMs, 0) +} +func BannerInfoAddGapMs(builder *flatbuffers.Builder, gapMs int32) { + builder.PrependInt32Slot(5, gapMs, 0) +} +func BannerInfoAddFadeInMs(builder *flatbuffers.Builder, fadeInMs int32) { + builder.PrependInt32Slot(6, fadeInMs, 0) +} +func BannerInfoEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 5619a81..180e6bf 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -149,8 +149,21 @@ func (rcv *Profile) MutateNotificationsInAppOnly(n bool) bool { return rcv._tab.MutateBoolSlot(24, n) } +func (rcv *Profile) Banner(obj *BannerInfo) *BannerInfo { + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(BannerInfo) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(11) + builder.StartObject(12) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -185,6 +198,9 @@ func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffset func ProfileAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) { builder.PrependBoolSlot(10, notificationsInAppOnly, true) } +func ProfileAddBanner(builder *flatbuffers.Builder, banner flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(11, flatbuffers.UOffsetT(banner), 0) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index ae8efe2..414ac35 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -3,6 +3,8 @@ export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; +export { BannerCampaign } from './scrabblefb/banner-campaign.js'; +export { BannerInfo } from './scrabblefb/banner-info.js'; export { BlockList } from './scrabblefb/block-list.js'; export { BlockStatus } from './scrabblefb/block-status.js'; export { ChatList } from './scrabblefb/chat-list.js'; diff --git a/ui/src/gen/fbs/scrabblefb/banner-campaign.ts b/ui/src/gen/fbs/scrabblefb/banner-campaign.ts new file mode 100644 index 0000000..a8ccfc8 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/banner-campaign.ts @@ -0,0 +1,75 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class BannerCampaign { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BannerCampaign { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBannerCampaign(bb:flatbuffers.ByteBuffer, obj?:BannerCampaign):BannerCampaign { + return (obj || new BannerCampaign()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBannerCampaign(bb:flatbuffers.ByteBuffer, obj?:BannerCampaign):BannerCampaign { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BannerCampaign()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +weight():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +messages(index: number):string +messages(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array +messages(index: number,optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null; +} + +messagesLength():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startBannerCampaign(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addWeight(builder:flatbuffers.Builder, weight:number) { + builder.addFieldInt32(0, weight, 0); +} + +static addMessages(builder:flatbuffers.Builder, messagesOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, messagesOffset, 0); +} + +static createMessagesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startMessagesVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset):flatbuffers.Offset { + BannerCampaign.startBannerCampaign(builder); + BannerCampaign.addWeight(builder, weight); + BannerCampaign.addMessages(builder, messagesOffset); + return BannerCampaign.endBannerCampaign(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/banner-info.ts b/ui/src/gen/fbs/scrabblefb/banner-info.ts new file mode 100644 index 0000000..17b7a1b --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/banner-info.ts @@ -0,0 +1,126 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { BannerCampaign } from '../scrabblefb/banner-campaign.js'; + + +export class BannerInfo { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BannerInfo { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBannerInfo(bb:flatbuffers.ByteBuffer, obj?:BannerInfo):BannerInfo { + return (obj || new BannerInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBannerInfo(bb:flatbuffers.ByteBuffer, obj?:BannerInfo):BannerInfo { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BannerInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +campaigns(index: number, obj?:BannerCampaign):BannerCampaign|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new BannerCampaign()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +campaignsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +holdMs():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +edgePauseMs():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +scrollPxPerSec():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +fadeOutMs():number { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +gapMs():number { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +fadeInMs():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +static startBannerInfo(builder:flatbuffers.Builder) { + builder.startObject(7); +} + +static addCampaigns(builder:flatbuffers.Builder, campaignsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, campaignsOffset, 0); +} + +static createCampaignsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startCampaignsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static addHoldMs(builder:flatbuffers.Builder, holdMs:number) { + builder.addFieldInt32(1, holdMs, 0); +} + +static addEdgePauseMs(builder:flatbuffers.Builder, edgePauseMs:number) { + builder.addFieldInt32(2, edgePauseMs, 0); +} + +static addScrollPxPerSec(builder:flatbuffers.Builder, scrollPxPerSec:number) { + builder.addFieldInt32(3, scrollPxPerSec, 0); +} + +static addFadeOutMs(builder:flatbuffers.Builder, fadeOutMs:number) { + builder.addFieldInt32(4, fadeOutMs, 0); +} + +static addGapMs(builder:flatbuffers.Builder, gapMs:number) { + builder.addFieldInt32(5, gapMs, 0); +} + +static addFadeInMs(builder:flatbuffers.Builder, fadeInMs:number) { + builder.addFieldInt32(6, fadeInMs, 0); +} + +static endBannerInfo(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBannerInfo(builder:flatbuffers.Builder, campaignsOffset:flatbuffers.Offset, holdMs:number, edgePauseMs:number, scrollPxPerSec:number, fadeOutMs:number, gapMs:number, fadeInMs:number):flatbuffers.Offset { + BannerInfo.startBannerInfo(builder); + BannerInfo.addCampaigns(builder, campaignsOffset); + BannerInfo.addHoldMs(builder, holdMs); + BannerInfo.addEdgePauseMs(builder, edgePauseMs); + BannerInfo.addScrollPxPerSec(builder, scrollPxPerSec); + BannerInfo.addFadeOutMs(builder, fadeOutMs); + BannerInfo.addGapMs(builder, gapMs); + BannerInfo.addFadeInMs(builder, fadeInMs); + return BannerInfo.endBannerInfo(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 92493d6..8f13d8e 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -2,6 +2,9 @@ import * as flatbuffers from 'flatbuffers'; +import { BannerInfo } from '../scrabblefb/banner-info.js'; + + export class Profile { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -87,8 +90,13 @@ notificationsInAppOnly():boolean { return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true; } +banner(obj?:BannerInfo):BannerInfo|null { + const offset = this.bb!.__offset(this.bb_pos, 26); + return offset ? (obj || new BannerInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startProfile(builder:flatbuffers.Builder) { - builder.startObject(11); + builder.startObject(12); } static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { @@ -135,24 +143,13 @@ static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInApp builder.addFieldInt8(10, +notificationsInAppOnly, +true); } +static addBanner(builder:flatbuffers.Builder, bannerOffset:flatbuffers.Offset) { + builder.addFieldOffset(11, bannerOffset, 0); +} + static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, notificationsInAppOnly:boolean):flatbuffers.Offset { - Profile.startProfile(builder); - Profile.addUserId(builder, userIdOffset); - Profile.addDisplayName(builder, displayNameOffset); - Profile.addPreferredLanguage(builder, preferredLanguageOffset); - Profile.addTimeZone(builder, timeZoneOffset); - Profile.addHintBalance(builder, hintBalance); - Profile.addBlockChat(builder, blockChat); - Profile.addBlockFriendRequests(builder, blockFriendRequests); - Profile.addIsGuest(builder, isGuest); - Profile.addAwayStart(builder, awayStartOffset); - Profile.addAwayEnd(builder, awayEndOffset); - Profile.addNotificationsInAppOnly(builder, notificationsInAppOnly); - return Profile.endProfile(builder); -} } -- 2.52.0 From 00a33c227b9826d83300daccf34642988b0fac5d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 23:05:41 +0200 Subject: [PATCH 151/223] fix(ads): verify a message belongs to its campaign before edit/delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeleteMessage/EditMessage acted on the message id without checking it belonged to the campaign id in the URL. A mismatched pair could edit an unrelated campaign's message or — worse — delete the default campaign's last message via another campaign's URL, bypassing the "default keeps >=1 message" guard. Both now load the campaign and return ErrNotFound unless it owns the message (MoveMessage already did). Operator-only, but a real business-logic bypass. Regression test: TestBannerMessageOwnership. --- backend/internal/ads/service.go | 37 +++++++++++++--- .../internal/inttest/banner_console_test.go | 43 +++++++++++++++++++ .../internal/server/handlers_admin_banners.go | 2 +- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/backend/internal/ads/service.go b/backend/internal/ads/service.go index ff6a7e0..939771d 100644 --- a/backend/internal/ads/service.go +++ b/backend/internal/ads/service.go @@ -139,14 +139,23 @@ func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru}) } -// EditMessage validates and updates a message's bilingual bodies (its position is -// unchanged). -func (s *Service) EditMessage(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error { +// EditMessage validates and updates the bilingual bodies of a message that +// belongs to campaignID (its position is unchanged). It returns ErrNotFound when +// the message is not part of that campaign, so a mismatched campaign/message pair +// never edits an unrelated campaign's message. +func (s *Service) EditMessage(ctx context.Context, campaignID, messageID uuid.UUID, bodyEn, bodyRu string) error { en, ru, err := validBodies(bodyEn, bodyRu) if err != nil { return err } - return s.store.UpdateMessageBodies(ctx, id, en, ru) + c, err := s.store.Campaign(ctx, campaignID) + if err != nil { + return err + } + if !campaignOwnsMessage(c, messageID) { + return ErrNotFound + } + return s.store.UpdateMessageBodies(ctx, messageID, en, ru) } // MoveMessage reorders a message within its campaign by swapping its position @@ -178,19 +187,35 @@ func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UU return s.store.SetMessagePosition(ctx, b.ID, idx) } -// DeleteMessage removes a message, refusing to remove the default campaign's last -// remaining message (the default must always have a creative to show). +// DeleteMessage removes a message that belongs to campaignID, refusing to remove +// the default campaign's last remaining message (the default must always have a +// creative to show). It returns ErrNotFound when the message is not part of that +// campaign — so a mismatched campaign/message pair can neither delete an +// unrelated campaign's message nor bypass the default's last-message guard. func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error { c, err := s.store.Campaign(ctx, campaignID) if err != nil { return err } + if !campaignOwnsMessage(c, messageID) { + return ErrNotFound + } if c.IsDefault && len(c.Messages) <= 1 { return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation) } return s.store.DeleteMessage(ctx, messageID) } +// campaignOwnsMessage reports whether messageID is one of the campaign's messages. +func campaignOwnsMessage(c Campaign, messageID uuid.UUID) bool { + for _, m := range c.Messages { + if m.ID == messageID { + return true + } + } + return false +} + // Settings returns the global display timings. func (s *Service) Settings(ctx context.Context) (Timings, error) { return s.store.Settings(ctx) diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go index 1cf0095..67ee9fa 100644 --- a/backend/internal/inttest/banner_console_test.go +++ b/backend/internal/inttest/banner_console_test.go @@ -144,6 +144,49 @@ func TestBannerConsoleCRUD(t *testing.T) { } } +// TestBannerMessageOwnership guards the campaign/message pairing: a message may +// only be edited or deleted through the URL of the campaign it belongs to. This +// closes a default-protection bypass — deleting the default's last message via an +// unrelated campaign's URL must be refused. +func TestBannerMessageOwnership(t *testing.T) { + ctx := context.Background() + srv, adsSvc := bannerServer(t) + h := srv.Handler() + base := "http://admin.test/_gm" + const origin = "http://admin.test" + + def := defaultCampaign(t, adsSvc) + if len(def.Messages) == 0 { + t.Fatal("default campaign has no seeded message") + } + defMsg := def.Messages[0].ID + + // A separate timed campaign whose URL we will (mis)use. + otherID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{Name: "Own-" + uuid.NewString()[:8], Weight: 10, Enabled: true}) + if err != nil { + t.Fatalf("create campaign: %v", err) + } + t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, otherID) }) + + // Deleting the default's message through the other campaign's URL is refused, + // and the default keeps its message (the bypass is closed). + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not found") { + t.Fatalf("cross-campaign delete = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found")) + } + // Editing it through the wrong campaign is refused too. + if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String(), "body_en=Hijacked&body_ru=Взлом", origin); code != http.StatusOK || !strings.Contains(body, "Not found") { + t.Fatalf("cross-campaign edit = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found")) + } + // The default's message is intact. + again := defaultCampaign(t, adsSvc) + if len(again.Messages) != len(def.Messages) { + t.Fatalf("default messages = %d, want %d (unchanged)", len(again.Messages), len(def.Messages)) + } + if again.Messages[0].BodyEn != def.Messages[0].BodyEn { + t.Fatalf("default message body changed: %q -> %q", def.Messages[0].BodyEn, again.Messages[0].BodyEn) + } +} + // profileBanner is the banner block of the profile.get JSON response. type profileBanner struct { Banner *struct { diff --git a/backend/internal/server/handlers_admin_banners.go b/backend/internal/server/handlers_admin_banners.go index 86b89a5..0a8ecc6 100644 --- a/backend/internal/server/handlers_admin_banners.go +++ b/backend/internal/server/handlers_admin_banners.go @@ -159,7 +159,7 @@ func (s *Server) consoleEditBannerMessage(c *gin.Context) { return } back := "/_gm/banners/" + cid.String() - if err := s.ads.EditMessage(c.Request.Context(), mid, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { + if err := s.ads.EditMessage(c.Request.Context(), cid, mid, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil { s.bannerError(c, err, back) return } -- 2.52.0 From cb4a31a860b4486694f8402065a0a285b921679a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 23:25:27 +0200 Subject: [PATCH 152/223] feat(ads): client banner rotation, fade UX & live toggle (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the server-driven banner block (PR1) in the UI and retire the gate. - banner.ts: createScheduler — a smooth weighted round-robin over campaigns (each appears its weight share per cycle, evenly interleaved) with round-robin over a campaign's messages; the rotator drives fade-in -> hold/scroll -> fade-out -> gap -> fade-in, a lone message stays put, reduce-motion swaps instantly without scroll. - model.ts/codec.ts: Profile.banner (Banner/BannerCampaign/BannerTimings) decoded from the fbs block. - Screen.svelte: drop the compile-time SHOW_AD_BANNER; render AdBanner from app.profile.banner (campaigns + timings + reduceMotion). - AdBanner.svelte: opacity-driven fades + scroll host; the rotator is recreated when the campaigns/timings change (a `banner` notify re-fetch swaps them in place). - app.svelte.ts: on the `notify` `banner` sub-kind, refreshProfile() so the banner shows/hides in place. - tests: scheduler distribution + round-robin, the fade sequence, single-message, reduce-motion, stop(); codec banner decode. UI_DESIGN.md + trackers updated. --- PLAN.md | 6 +- PRERELEASE.md | 2 +- docs/UI_DESIGN.md | 20 +++- ui/src/components/AdBanner.svelte | 77 +++++++------ ui/src/components/Screen.svelte | 15 +-- ui/src/lib/app.svelte.ts | 19 ++++ ui/src/lib/banner.test.ts | 179 +++++++++++++++++++++--------- ui/src/lib/banner.ts | 156 +++++++++++++++----------- ui/src/lib/codec.test.ts | 35 ++++++ ui/src/lib/codec.ts | 29 +++++ ui/src/lib/model.ts | 26 +++++ 11 files changed, 399 insertions(+), 165 deletions(-) diff --git a/PLAN.md b/PLAN.md index c4214b1..fee5f9b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1428,9 +1428,9 @@ cannot submit; three-way admin filter. docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host caddy → public clients untrusted → real peer used). `peerIP` unit-tested. - - **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte` - — the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod - bundle (code kept for post-release polish). + - **Ad banner** was gated off here behind `SHOW_AD_BANNER=false`; the **AD** phase + (PRERELEASE.md) later turned it into the real server-driven advertising network and removed + the gate (`Screen` renders it from `app.profile.banner`). - **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption). - **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's diff --git a/PRERELEASE.md b/PRERELEASE.md index 9bf751a..accdc65 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -32,7 +32,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** | | AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** | -| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | PR1 backend+admin **done**; PR2 UI rotation **next** | +| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 83b1a85..28c8151 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -195,16 +195,24 @@ Login uses `Screen`. 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`) +## Advertising banner (`components/AdBanner.svelte`, `lib/banner.ts`) A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is -minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message -holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip -pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats -until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short -message; the source becomes a server-driven channel later (see ARCHITECTURE). +minimal markdown (text + links, escaped + linkified). It is **server-driven**: the campaigns and +display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible +viewer — see ARCHITECTURE §10), so `Screen` renders the strip only when that block is present, and +a `notify` `banner` event re-fetches the profile to show or hide it in place. + +A **smooth weighted round-robin** (`createScheduler`) picks the next message: campaigns compete by +their weight (each appears its weight share per cycle, evenly interleaved, not at random), and a +campaign's own messages advance round-robin. The **rotator** then drives one message: it fades in +(`fadeInMs`), holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its +right edge at `scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — when more than one +message exists — fades out (`fadeOutMs`), waits `gapMs`, and fades the next in. A lone message stays +put (a long one keeps scrolling). All timings are operator-set (`/_gm/banner-settings`). Under +**reduce-motion** the fades collapse to an instant swap and a long message does not scroll. ## Result / status iconography (`lib/result.ts`) diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index b9e37fc..a49baa4 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -1,43 +1,57 @@
- {#key current} -
- {@html linkify(items[current]?.md ?? '')} -
- {/key} +
+ {@html linkify(current)} +
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index b098ea3..75edd9c 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -18,6 +18,7 @@ import { centre, premiumGrid } from '../lib/premiums'; import { variantNameKey } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; + import { hintsLeft } from '../lib/hints'; import { shareOrDownloadGcg } from '../lib/share'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; @@ -132,6 +133,10 @@ const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open')); const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat); const gameOver = $derived(!!view && view.game.status === 'finished'); + // The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the + // wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet + // hint was spent in another game (see lib/hints). + const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0)); // RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange // gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied // exchange regardless (engine rejects when bag.Len() < rules.RackSize). @@ -154,6 +159,13 @@ return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action; } + // syncWallet adopts the server's authoritative hint-wallet balance into the global profile. + // The wallet is global, so keeping it live here (rather than per-game) is what stops the hint + // badge from going stale when a wallet hint was spent in another game. + function syncWallet(walletBalance: number) { + if (app.profile) app.profile.hintBalance = walletBalance; + } + async function load() { try { // Ask for the alphabet table only on a per-variant cache miss (the first open of a @@ -167,6 +179,7 @@ gateway.draftGet(id).catch(() => ''), ]); view = st; + syncWallet(st.walletBalance); // Seed the unread flag from the authoritative state (the live stream only raises it). seedChatUnread(id, st.game.unreadChat); moves = hist.moves; @@ -615,7 +628,16 @@ // applyMoveResult renders the actor's own just-committed move from the response — the move, the // post-move game and the refilled rack — without a follow-up game.state + game.history. function applyMoveResult(r: MoveResult) { - view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 }; + view = { + game: r.game, + seat: r.move.player, + rack: r.rack, + bagLen: r.bagLen, + // A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both + // forward (their difference is the stable allowance; the badge adds the live wallet). + hintsRemaining: view?.hintsRemaining ?? 0, + walletBalance: view?.walletBalance ?? 0, + }; // The move result is an authoritative per-viewer view: a nudge the actor just answered by // moving is already cleared server-side, so reconcile the unread flag from it. seedChatUnread(id, r.game.unreadChat); @@ -698,7 +720,8 @@ recenter++; } if (isCoarse()) zoomed = true; - view = { ...view, hintsRemaining: h.hintsRemaining }; + view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance }; + syncWallet(h.walletBalance); recompute(); } } catch (e) { @@ -1067,7 +1090,7 @@
{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })} {#if gameOver} - {t('game.over')} — {resultText()} + {resultText()} {:else if placement.pending.length === 0} {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} @@ -1107,10 +1130,10 @@ - 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} + 🛟{#if hintCount > 0}{hintCount}{/if} {t('game.hint')} {#if placement.pending.length > 0} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 414ac35..25f0ac7 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -5,6 +5,8 @@ export { Ack } from './scrabblefb/ack.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; export { BannerCampaign } from './scrabblefb/banner-campaign.js'; export { BannerInfo } from './scrabblefb/banner-info.js'; +export { BestMoveTile } from './scrabblefb/best-move-tile.js'; +export { BestMoveView } from './scrabblefb/best-move-view.js'; export { BlockList } from './scrabblefb/block-list.js'; export { BlockStatus } from './scrabblefb/block-status.js'; export { ChatList } from './scrabblefb/chat-list.js'; diff --git a/ui/src/gen/fbs/scrabblefb/best-move-tile.ts b/ui/src/gen/fbs/scrabblefb/best-move-tile.ts new file mode 100644 index 0000000..1e9b8f0 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/best-move-tile.ts @@ -0,0 +1,68 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class BestMoveTile { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BestMoveTile { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile { + return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +letter():string|null +letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +letter(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; +} + +value():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +blank():boolean { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +static startBestMoveTile(builder:flatbuffers.Builder) { + builder.startObject(3); +} + +static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, letterOffset, 0); +} + +static addValue(builder:flatbuffers.Builder, value:number) { + builder.addFieldInt32(1, value, 0); +} + +static addBlank(builder:flatbuffers.Builder, blank:boolean) { + builder.addFieldInt8(2, +blank, +false); +} + +static endBestMoveTile(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBestMoveTile(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset, value:number, blank:boolean):flatbuffers.Offset { + BestMoveTile.startBestMoveTile(builder); + BestMoveTile.addLetter(builder, letterOffset); + BestMoveTile.addValue(builder, value); + BestMoveTile.addBlank(builder, blank); + return BestMoveTile.endBestMoveTile(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/best-move-view.ts b/ui/src/gen/fbs/scrabblefb/best-move-view.ts new file mode 100644 index 0000000..800a5db --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/best-move-view.ts @@ -0,0 +1,88 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { BestMoveTile } from '../scrabblefb/best-move-tile.js'; + + +export class BestMoveView { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BestMoveView { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView { + return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +variant():string|null +variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +variant(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; +} + +score():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +word(index: number, obj?:BestMoveTile):BestMoveTile|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? (obj || new BestMoveTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +wordLength():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startBestMoveView(builder:flatbuffers.Builder) { + builder.startObject(3); +} + +static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, variantOffset, 0); +} + +static addScore(builder:flatbuffers.Builder, score:number) { + builder.addFieldInt32(1, score, 0); +} + +static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, wordOffset, 0); +} + +static createWordVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startWordVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endBestMoveView(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBestMoveView(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, score:number, wordOffset:flatbuffers.Offset):flatbuffers.Offset { + BestMoveView.startBestMoveView(builder); + BestMoveView.addVariant(builder, variantOffset); + BestMoveView.addScore(builder, score); + BestMoveView.addWord(builder, wordOffset); + return BestMoveView.endBestMoveView(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/hint-result.ts b/ui/src/gen/fbs/scrabblefb/hint-result.ts index 511412f..7a6a5f9 100644 --- a/ui/src/gen/fbs/scrabblefb/hint-result.ts +++ b/ui/src/gen/fbs/scrabblefb/hint-result.ts @@ -33,8 +33,13 @@ hintsRemaining():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +walletBalance():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startHintResult(builder:flatbuffers.Builder) { - builder.startObject(2); + builder.startObject(3); } static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) { @@ -45,15 +50,20 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) { builder.addFieldInt32(1, hintsRemaining, 0); } +static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) { + builder.addFieldInt32(2, walletBalance, 0); +} + static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number):flatbuffers.Offset { +static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number, walletBalance:number):flatbuffers.Offset { HintResult.startHintResult(builder); HintResult.addMove(builder, moveOffset); HintResult.addHintsRemaining(builder, hintsRemaining); + HintResult.addWalletBalance(builder, walletBalance); return HintResult.endHintResult(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts index 690e12a..f7a1ac8 100644 --- a/ui/src/gen/fbs/scrabblefb/state-view.ts +++ b/ui/src/gen/fbs/scrabblefb/state-view.ts @@ -69,8 +69,13 @@ alphabetLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +walletBalance():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startStateView(builder:flatbuffers.Builder) { - builder.startObject(6); + builder.startObject(7); } static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) { @@ -121,12 +126,16 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) { + builder.addFieldInt32(6, walletBalance, 0); +} + static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset):flatbuffers.Offset { +static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset { StateView.startStateView(builder); StateView.addGame(builder, gameOffset); StateView.addSeat(builder, seat); @@ -134,6 +143,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse StateView.addBagLen(builder, bagLen); StateView.addHintsRemaining(builder, hintsRemaining); StateView.addAlphabet(builder, alphabetOffset); + StateView.addWalletBalance(builder, walletBalance); return StateView.endStateView(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/stats-view.ts b/ui/src/gen/fbs/scrabblefb/stats-view.ts index dc49989..56a22b9 100644 --- a/ui/src/gen/fbs/scrabblefb/stats-view.ts +++ b/ui/src/gen/fbs/scrabblefb/stats-view.ts @@ -2,6 +2,9 @@ import * as flatbuffers from 'flatbuffers'; +import { BestMoveView } from '../scrabblefb/best-move-view.js'; + + export class StatsView { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; @@ -45,8 +48,28 @@ maxWordPoints():number { return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; } +bestMoves(index: number, obj?:BestMoveView):BestMoveView|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? (obj || new BestMoveView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +bestMovesLength():number { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +moves():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +hintsUsed():number { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + static startStatsView(builder:flatbuffers.Builder) { - builder.startObject(5); + builder.startObject(8); } static addWins(builder:flatbuffers.Builder, wins:number) { @@ -69,18 +92,45 @@ static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) { builder.addFieldInt32(4, maxWordPoints, 0); } +static addBestMoves(builder:flatbuffers.Builder, bestMovesOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, bestMovesOffset, 0); +} + +static createBestMovesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startBestMovesVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static addMoves(builder:flatbuffers.Builder, moves:number) { + builder.addFieldInt32(6, moves, 0); +} + +static addHintsUsed(builder:flatbuffers.Builder, hintsUsed:number) { + builder.addFieldInt32(7, hintsUsed, 0); +} + static endStatsView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number):flatbuffers.Offset { +static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number, bestMovesOffset:flatbuffers.Offset, moves:number, hintsUsed:number):flatbuffers.Offset { StatsView.startStatsView(builder); StatsView.addWins(builder, wins); StatsView.addLosses(builder, losses); StatsView.addDraws(builder, draws); StatsView.addMaxGamePoints(builder, maxGamePoints); StatsView.addMaxWordPoints(builder, maxWordPoints); + StatsView.addBestMoves(builder, bestMovesOffset); + StatsView.addMoves(builder, moves); + StatsView.addHintsUsed(builder, hintsUsed); return StatsView.endStatsView(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index e05b7b9..089a465 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -284,6 +284,8 @@ describe('codec', () => { fb.StatsView.addDraws(b, 1); fb.StatsView.addMaxGamePoints(b, 420); fb.StatsView.addMaxWordPoints(b, 90); + fb.StatsView.addMoves(b, 248); + fb.StatsView.addHintsUsed(b, 12); b.finish(fb.StatsView.endStatsView(b)); expect(decodeStats(b.asUint8Array())).toEqual({ wins: 7, @@ -291,6 +293,57 @@ describe('codec', () => { draws: 1, maxGamePoints: 420, maxWordPoints: 90, + moves: 248, + hintsUsed: 12, + bestMoves: [], + }); + }); + + it('decodes a StatsView carrying a per-variant best move with a blank tile', () => { + const b = new Builder(256); + // Word "ca" where the 'a' is a blank: it carries its letter but scores 0. + const cLetter = b.createString('c'); + fb.BestMoveTile.startBestMoveTile(b); + fb.BestMoveTile.addLetter(b, cLetter); + fb.BestMoveTile.addValue(b, 3); + fb.BestMoveTile.addBlank(b, false); + const tileC = fb.BestMoveTile.endBestMoveTile(b); + const aLetter = b.createString('a'); + fb.BestMoveTile.startBestMoveTile(b); + fb.BestMoveTile.addLetter(b, aLetter); + fb.BestMoveTile.addValue(b, 0); + fb.BestMoveTile.addBlank(b, true); + const tileA = fb.BestMoveTile.endBestMoveTile(b); + const word = fb.BestMoveView.createWordVector(b, [tileC, tileA]); + const variant = b.createString('scrabble_en'); + fb.BestMoveView.startBestMoveView(b); + fb.BestMoveView.addVariant(b, variant); + fb.BestMoveView.addScore(b, 90); + fb.BestMoveView.addWord(b, word); + const bm = fb.BestMoveView.endBestMoveView(b); + const bestMoves = fb.StatsView.createBestMovesVector(b, [bm]); + fb.StatsView.startStatsView(b); + fb.StatsView.addWins(b, 7); + fb.StatsView.addBestMoves(b, bestMoves); + b.finish(fb.StatsView.endStatsView(b)); + expect(decodeStats(b.asUint8Array())).toEqual({ + wins: 7, + losses: 0, + draws: 0, + maxGamePoints: 0, + maxWordPoints: 0, + moves: 0, + hintsUsed: 0, + bestMoves: [ + { + variant: 'scrabble_en', + score: 90, + word: [ + { letter: 'c', value: 3, blank: false }, + { letter: 'a', value: 0, blank: true }, + ], + }, + ], }); }); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 3184f92..f155747 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -11,6 +11,8 @@ import type { AccountRef, Banner, BannerCampaign, + BestMove, + BestMoveTile, BlockStatus, ChatMessage, EvalResult, @@ -381,6 +383,7 @@ function decodeStateViewTable(v: fb.StateView): StateView { rack, bagLen: v.bagLen(), hintsRemaining: v.hintsRemaining(), + walletBalance: v.walletBalance(), }; } @@ -407,7 +410,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult { export function decodeHintResult(buf: Uint8Array): HintResult { const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf)); const m = r.move(); - return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining() }; + return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining(), walletBalance: r.walletBalance() }; } export function decodeEvalResult(buf: Uint8Array): EvalResult { @@ -742,12 +745,26 @@ export function decodeRedeemResult(buf: Uint8Array): AccountRef { export function decodeStats(buf: Uint8Array): Stats { const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf)); + const bestMoves: BestMove[] = []; + for (let i = 0; i < v.bestMovesLength(); i++) { + const m = v.bestMoves(i); + if (!m) continue; + const word: BestMoveTile[] = []; + for (let j = 0; j < m.wordLength(); j++) { + const t = m.word(j); + if (t) word.push({ letter: s(t.letter()), value: t.value(), blank: t.blank() }); + } + bestMoves.push({ variant: s(m.variant()) as Variant, score: m.score(), word }); + } return { wins: v.wins(), losses: v.losses(), draws: v.draws(), maxGamePoints: v.maxGamePoints(), maxWordPoints: v.maxWordPoints(), + moves: v.moves(), + hintsUsed: v.hintsUsed(), + bestMoves, }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index d3b0715..ef3344f 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -22,7 +22,7 @@ function gameView(id: string): GameView { } function view(id: string, rack: string[] = ['A', 'B']): StateView { - return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 }; + return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; } function move(player: number): MoveRecord { diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 6881605..2f25759 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -27,7 +27,7 @@ function move(player: number): MoveRecord { } function cache(moveCount: number, seat = 0, over = false): CachedGame { - const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }; + const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; return { view, moves: [] }; } @@ -37,7 +37,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta { describe('seedInitialState', () => { it('wraps an initial view with an empty journal', () => { - const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 }; + const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 }; expect(seedInitialState(view)).toEqual({ view, moves: [] }); }); }); @@ -152,12 +152,12 @@ describe('applyOpponentJoined', () => { { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false }, ] }; - return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 }; + return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0, walletBalance: 0 }; } it("adopts the joined seats and status while preserving the cached rack and moves", () => { // The cached open game is still "searching": empty seats, status open, the starter's own rack. - const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] }; + const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }, moves: [move(0)] }; const res = applyOpponentJoined(cached, joinedState()); expect(res?.view.game.status).toBe('active'); expect(res?.view.game.seats).toHaveLength(2); diff --git a/ui/src/lib/hints.test.ts b/ui/src/lib/hints.test.ts new file mode 100644 index 0000000..66937e4 --- /dev/null +++ b/ui/src/lib/hints.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { hintsLeft } from './hints'; + +// view carries only the two fields hintsLeft reads. +const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance }); + +describe('hintsLeft', () => { + it('is zero without a view', () => { + expect(hintsLeft(null, 5)).toBe(0); + }); + + it('adds the per-game allowance to the live wallet (fresh view)', () => { + // hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3. + expect(hintsLeft(view(4, 3), 3)).toBe(4); + }); + + it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => { + // The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent + // in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4. + expect(hintsLeft(view(4, 3), 2)).toBe(3); + }); + + it('shows just the wallet when the per-game allowance is used up', () => { + // allowance 0 (hints_remaining 3 == snapshot wallet 3); live wallet 3 → 0 + 3. + expect(hintsLeft(view(3, 3), 3)).toBe(3); + }); + + it('clamps a non-negative allowance and wallet', () => { + expect(hintsLeft(view(2, 3), 0)).toBe(0); + expect(hintsLeft(view(1, 0), -5)).toBe(1); + }); +}); diff --git a/ui/src/lib/hints.ts b/ui/src/lib/hints.ts new file mode 100644 index 0000000..8862d4a --- /dev/null +++ b/ui/src/lib/hints.ts @@ -0,0 +1,26 @@ +// Hint-count derivation, kept out of the .svelte component so it is unit-testable. +// +// The badge shows the per-game hint allowance remaining plus the player's global hint +// wallet. The server's hints_remaining folds the two together, but the wallet is global — +// shared across every game — so caching the combined number per game makes it go stale the +// moment a wallet hint is spent in another game. We therefore split it: the per-game +// allowance is hints_remaining - wallet_balance (both from the same fetch, so it is stable +// and cacheable), and the wallet is read live from the global profile, never the per-game +// snapshot. + +import type { StateView } from './model'; + +/** + * hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's + * hints_remaining minus the wallet snapshot baked into that same view) plus the live global + * wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count + * correct when a wallet hint was spent in another game since this view was fetched. + */ +export function hintsLeft( + view: Pick | null, + walletBalance: number, +): number { + if (!view) return 0; + const allowance = Math.max(0, view.hintsRemaining - view.walletBalance); + return allowance + Math.max(0, walletBalance); +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index af0bcea..a8dbf42 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -34,7 +34,7 @@ export const en = { 'lobby.noActive': 'No active games yet.', 'lobby.noFinished': 'No finished games yet.', 'lobby.limitReached': "You've reached the simultaneous games limit.", - 'lobby.new': 'New', + 'lobby.new': 'Play', 'lobby.stats': 'Stats', 'lobby.profile': 'Profile', 'lobby.settings': 'Settings', @@ -83,7 +83,6 @@ export const en = { 'game.passNoExchange': 'Pass without exchanging', 'game.confirmResign': 'Resign this game?', 'game.hintShown': 'Best move: {word} for {n}', - 'game.over': 'Game over', 'game.won': 'You won', 'game.lost': 'You lost', 'game.tied': 'Draw', @@ -118,7 +117,7 @@ export const en = { 'chat.nudge': 'Waiting for your move 🤭', 'chat.nudgeBy': '{name}: Waiting for your move 🤭', 'chat.nudgeAction': 'Nudge', - 'chat.awaitingReply': "Waiting for the opponent's reply", + 'chat.awaitingReply': "Let's be patient", 'chat.empty': 'No messages yet.', 'chat.nudged': '{name} nudged you', 'chat.sentThisTurn': 'You can write again next turn.', @@ -275,6 +274,8 @@ export const en = { 'stats.losses': 'Losses', 'stats.draws': 'Draws', 'stats.played': 'Games', + 'stats.moves': 'Moves', + 'stats.hintShare': 'Hint share', 'stats.winRate': 'Win rate', 'stats.maxGame': 'Best game', 'stats.maxWord': 'Best move', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 8cc19c4..8b3eff8 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -35,8 +35,8 @@ export const ru: Record = { 'lobby.noActive': 'Пока нет активных игр.', 'lobby.noFinished': 'Пока нет завершённых игр.', 'lobby.limitReached': 'Вы достигли лимита одновременных партий', - 'lobby.new': 'Новая', - 'lobby.stats': 'Статы', + 'lobby.new': 'Играть', + 'lobby.stats': 'Цифры', 'lobby.profile': 'Профиль', 'lobby.settings': 'Настройки', 'lobby.about': 'О программе', @@ -56,7 +56,7 @@ export const ru: Record = { 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15', 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.', 'new.searchHint': - 'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.', + 'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.', 'game.bag': '{n} в мешке', 'game.bagEmpty': 'Мешок пуст', @@ -84,7 +84,6 @@ export const ru: Record = { 'game.passNoExchange': 'Пас без обмена', 'game.confirmResign': 'Сдаться в этой игре?', 'game.hintShown': 'Лучший ход: {word} на {n}', - 'game.over': 'Игра окончена', 'game.won': 'Вы выиграли', 'game.lost': 'Вы проиграли', 'game.tied': 'Ничья', @@ -119,7 +118,7 @@ export const ru: Record = { 'chat.nudge': 'Жду Вашего хода 🤭', 'chat.nudgeBy': '{name}: Жду Вашего хода 🤭', 'chat.nudgeAction': 'Поторопить', - 'chat.awaitingReply': 'Ждём реакцию соперника', + 'chat.awaitingReply': 'Немного терпения', 'chat.empty': 'Сообщений пока нет.', 'chat.nudged': '{name} торопит вас', 'chat.sentThisTurn': 'Можно написать снова в следующем ходу.', @@ -275,7 +274,9 @@ export const ru: Record = { 'stats.wins': 'Победы', 'stats.losses': 'Поражения', 'stats.draws': 'Ничьи', - 'stats.played': 'Игр', + 'stats.played': 'Игры', + 'stats.moves': 'Ходы', + 'stats.hintShare': 'Доля подсказок', 'stats.winRate': 'Доля побед', 'stats.maxGame': 'Лучшая игра', 'stats.maxWord': 'Лучший ход', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 41c5a6d..78ecca8 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -295,7 +295,10 @@ export class MockGateway implements GatewayClient { seat: this.mySeat(g), rack: [...g.rack], bagLen: g.bagLen, - hintsRemaining: g.hintsRemaining, + // g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance. + // hints_remaining folds the two together (as the backend does), walletBalance is the wallet. + hintsRemaining: g.hintsRemaining + this.profile.hintBalance, + walletBalance: this.profile.hintBalance, }; } @@ -395,8 +398,11 @@ export class MockGateway implements GatewayClient { async hint(gameId: string): Promise { const g = this.game(gameId); - if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable'); - g.hintsRemaining -= 1; + if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable'); + // Spend the per-game allowance first, then the shared wallet — mirroring the backend, so a + // wallet hint in one game lowers the count shown in every other game (the bug this fixes). + if (g.hintsRemaining > 0) g.hintsRemaining -= 1; + else this.profile.hintBalance -= 1; const letter = g.rack.find((l) => l !== '?') ?? 'A'; return { move: { @@ -411,7 +417,8 @@ export class MockGateway implements GatewayClient { score: valueForLetter(g.view.variant, letter), total: 0, }, - hintsRemaining: g.hintsRemaining, + hintsRemaining: g.hintsRemaining + this.profile.hintBalance, + walletBalance: this.profile.hintBalance, }; } diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 3a3bac4..c4e7c20 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -50,7 +50,47 @@ export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Ka export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }]; -export const MOCK_STATS: Stats = { wins: 7, losses: 4, draws: 1, maxGamePoints: 421, maxWordPoints: 95 }; +export const MOCK_STATS: Stats = { + wins: 7, + losses: 4, + draws: 1, + maxGamePoints: 421, + maxWordPoints: 134, + moves: 248, // plays across all games + hintsUsed: 12, // -> hint share 12/248 = 4.8% + // Letters are lower-cased as the backend emits them; the tile renderer upper-cases for + // display. The 'd' in "wonderful" is a blank (value 0) to exercise wildcard rendering. + // Erudit is absent on purpose, so the screen demonstrates skipping a not-yet-played variant. + bestMoves: [ + { + variant: 'scrabble_en', + score: 134, + word: [ + { letter: 'w', value: 4, blank: false }, + { letter: 'o', value: 1, blank: false }, + { letter: 'n', value: 1, blank: false }, + { letter: 'd', value: 0, blank: true }, + { letter: 'e', value: 1, blank: false }, + { letter: 'r', value: 1, blank: false }, + { letter: 'f', value: 4, blank: false }, + { letter: 'u', value: 1, blank: false }, + { letter: 'l', value: 1, blank: false }, + ], + }, + { + variant: 'scrabble_ru', + score: 88, + word: [ + { letter: 'с', value: 1, blank: false }, + { letter: 'ъ', value: 10, blank: false }, + { letter: 'ё', value: 4, blank: false }, + { letter: 'м', value: 2, blank: false }, + { letter: 'к', value: 2, blank: false }, + { letter: 'а', value: 1, blank: false }, + ], + }, + ], +}; export function mockInvitations(): Invitation[] { return [ diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index ff7022a..5581fbf 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -67,13 +67,17 @@ export interface MoveRecord { total: number; } -/** A seated player's private view of a game. */ +/** A seated player's private view of a game. hintsRemaining folds the per-game allowance + * together with the global wallet; walletBalance is the wallet alone, so the client can + * derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live + * across games (see lib/hints). */ export interface StateView { game: GameView; seat: number; rack: string[]; bagLen: number; hintsRemaining: number; + walletBalance: number; } export interface MoveResult { @@ -87,6 +91,7 @@ export interface MoveResult { export interface HintResult { move: MoveRecord; hintsRemaining: number; + walletBalance: number; } export interface EvalResult { @@ -206,13 +211,37 @@ export interface FriendCode { expiresAtUnix: number; } -/** A durable account's lifetime statistics. */ +/** One letter cell of a best-move word: its display letter, its tile value (0 for a + * blank) and whether it is a blank — enough to render it as a game tile without the + * variant's alphabet table. */ +export interface BestMoveTile { + letter: string; + value: number; + blank: boolean; +} + +/** An account's highest-scoring single play within one variant: the variant, the play's + * total score and its main word as ordered tiles. */ +export interface BestMove { + variant: Variant; + score: number; + word: BestMoveTile[]; +} + +/** A durable account's lifetime statistics. bestMoves breaks the best move down per + * variant (with the word itself); it is empty for an account with no recorded play and + * lists only variants the account has played. */ export interface Stats { wins: number; losses: number; draws: number; maxGamePoints: number; maxWordPoints: number; + /** Lifetime count of the player's plays (tile placements). */ + moves: number; + /** Lifetime count of hints the player took (allowance + wallet). */ + hintsUsed: number; + bestMoves: BestMove[]; } /** Settings the inviter chooses for a friend game. */ diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index fbcf9e2..4aa53cc 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -34,7 +34,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView { } function stateView(id: string): StateView { - return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 }; + return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; } beforeEach(() => { diff --git a/ui/src/lib/stats.test.ts b/ui/src/lib/stats.test.ts index ccfdee2..bc60480 100644 --- a/ui/src/lib/stats.test.ts +++ b/ui/src/lib/stats.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { gamesPlayed, winRate } from './stats'; +import { gamesPlayed, hintSharePercent, winRate } from './stats'; import type { Stats } from './model'; const s = (wins: number, losses: number, draws: number): Stats => ({ @@ -8,8 +8,14 @@ const s = (wins: number, losses: number, draws: number): Stats => ({ draws, maxGamePoints: 0, maxWordPoints: 0, + moves: 0, + hintsUsed: 0, + bestMoves: [], }); +// withCounts overrides moves/hintsUsed on the zero fixture for the hint-share cases. +const withCounts = (moves: number, hintsUsed: number): Stats => ({ ...s(0, 0, 0), moves, hintsUsed }); + describe('stats', () => { it('sums games played', () => { expect(gamesPlayed(s(7, 4, 1))).toBe(12); @@ -23,4 +29,14 @@ describe('stats', () => { it('win rate is 0 with no games', () => { expect(winRate(s(0, 0, 0))).toBe(0); }); + + it('computes the hint share (hints / plays)', () => { + expect(hintSharePercent(withCounts(200, 10))).toBe(5); // 10/200 = 5% + expect(hintSharePercent(withCounts(248, 12))).toBeCloseTo(4.8387, 3); + }); + + it('hint share is 0 with no plays (no division by zero)', () => { + expect(hintSharePercent(withCounts(0, 0))).toBe(0); + expect(hintSharePercent(withCounts(0, 5))).toBe(0); + }); }); diff --git a/ui/src/lib/stats.ts b/ui/src/lib/stats.ts index 5c7e753..095ffeb 100644 --- a/ui/src/lib/stats.ts +++ b/ui/src/lib/stats.ts @@ -12,3 +12,10 @@ export function winRate(s: Stats): number { const n = gamesPlayed(s); return n > 0 ? Math.round((s.wins / n) * 100) : 0; } + +/** hintSharePercent is the share of the player's plays that drew on a hint + * (hints used / plays × 100), unrounded; 0 when no plays. The screen formats it to one + * decimal in the active locale. */ +export function hintSharePercent(s: Stats): number { + return s.moves > 0 ? (s.hintsUsed / s.moves) * 100 : 0; +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index c6c175c..9b5f985 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -306,7 +306,7 @@ 🎲{t('lobby.new')}
{/each} + {#if bestMoves.length > 0} +
+ {t('stats.maxWord')} +
+ {#each bestMoves as bm (bm.variant)} + {t(variantNameKey(bm.variant))} + + {bm.score} + {/each} +
+
+ {/if} {/if} @@ -79,4 +111,33 @@ color: var(--text-muted); font-size: 0.85rem; } + .bestmove { + margin-top: 12px; + gap: 12px; + } + /* One grid for all rows so columns align across them: variant on the left, the word + tiles right-aligned to a shared edge, the score right-aligned in its own column. */ + .rows { + display: grid; + /* minmax(0, 1fr) lets the word column shrink below its tiles' intrinsic width on a + narrow screen (the cell then scrolls) instead of overlapping the variant label. */ + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + row-gap: 12px; + column-gap: 8px; + } + .variant { + color: var(--text-muted); + font-size: 0.95rem; + } + .wordcell { + justify-self: end; + min-width: 0; + overflow-x: auto; + } + .score { + justify-self: end; + font-weight: 700; + font-variant-numeric: tabular-nums; + } -- 2.52.0 From 9d52885a6e346de10a4e64c2dbac56bcb51763b1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 18 Jun 2026 10:18:44 +0200 Subject: [PATCH 179/223] fix(matchmaking): re-enqueue opens a new game, not the caller's own A second "random opponent" enqueue with the same variant and per-turn rule, while the caller's first game was still open (awaiting an opponent), returned that same open game, so a player could never start a fresh random game while one was still searching. Drop the own-open short-circuit (step 1) in store.OpenOrJoin: a re-enqueue now joins another player's open game or opens a fresh one. Accumulation stays bounded by MaxActiveQuickGames, which counts open games. Update the matchmaker/service/store doc comments and ARCHITECTURE.md, and flip the pinning test to assert the new behavior. --- backend/internal/game/service.go | 9 +++--- backend/internal/game/store.go | 40 +++++++++----------------- backend/internal/inttest/lobby_test.go | 17 +++++++---- backend/internal/lobby/matchmaker.go | 7 +++-- docs/ARCHITECTURE.md | 6 ++-- 5 files changed, 39 insertions(+), 40 deletions(-) diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 310b882..522c3c0 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -266,10 +266,11 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro // OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in // params and returns the game they land in immediately: another waiting player's open -// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open -// game seating only the caller with an empty opponent seat that a human or the reaper's -// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly -// opened game (ignored when joining one). The bag seed defaults to random; params.Seed +// game (joined=true), or a fresh open game seating only the caller with an empty +// opponent seat that a human or the reaper's robot fills later. A re-enqueue while the +// caller is already waiting opens another game rather than returning their own. +// openDeadline is when the reaper substitutes a robot into a freshly opened game +// (ignored when joining one). The bag seed defaults to random; params.Seed // pins it. First-move fairness comes from seating the caller at seat 0 or seat 1 // (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the // caller just waits for the opponent. It backs the lobby auto-match enqueue. diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index d04eb8e..f726f27 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -188,37 +188,25 @@ func openMatchKey(variant string, multipleWords bool) int64 { } // OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it -// lands in: it re-uses the caller's own still-open game (joined=false, created=false, -// a re-enqueue is idempotent), joins another player's waiting open game and flips it -// active (joined=true), or opens a fresh game seating the caller with an empty -// opponent seat (created=true). ins supplies the new game's immutable fields and is -// used only when a game is created. A transaction-scoped advisory lock on the -// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather -// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil -// for the still-empty opponent, in the chosen order) used only when a game is created; -// callerName is the caller's display-name snapshot, stamped on their seat whether they -// open a fresh game or fill another player's open one. +// lands in: it joins another player's waiting open game and flips it active +// (joined=true), or opens a fresh game seating the caller with an empty opponent seat +// (created=true). A re-enqueue while the caller already has an open game in the bucket +// opens another fresh game (or joins a different player's) rather than returning the +// caller's own, so tapping "random opponent" again always starts a new search. ins +// supplies the new game's immutable fields and is used only when a game is created. A +// transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent +// enqueues so two callers pair rather than each opening a game. seats is the two-seat +// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen +// order) used only when a game is created; callerName is the caller's display-name +// snapshot, stamped on their seat whether they open a fresh game or fill another +// player's open one. func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) { err = withTx(ctx, s.db, func(tx *sql.Tx) error { if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil { return fmt.Errorf("open match lock: %w", e) } - // 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent. - var own uuid.UUID - switch e := tx.QueryRowContext(ctx, - `SELECT g.game_id FROM backend.games g - JOIN backend.game_players p ON p.game_id = g.game_id - WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3 - LIMIT 1`, - ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); { - case e == nil: - gameID = own - return nil - case !errors.Is(e, sql.ErrNoRows): - return fmt.Errorf("find own open game: %w", e) - } - // 2. Another player's open game waiting for an opponent — fill its seat and start it. + // 1. Another player's open game waiting for an opponent — fill its seat and start it. var other uuid.UUID switch e := tx.QueryRowContext(ctx, `SELECT g.game_id FROM backend.games g @@ -237,7 +225,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName case !errors.Is(e, sql.ErrNoRows): return fmt.Errorf("find open game: %w", e) } - // 3. None waiting — open a fresh game seating the caller (the other seat empty). + // 2. None waiting — open a fresh game seating the caller (the other seat empty). if e := insertGameTx(ctx, tx, ins, seats); e != nil { return e } diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index 5a2a2e5..4f237a4 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -74,9 +74,11 @@ func TestMatchmakingOpensThenJoins(t *testing.T) { } } -// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the -// caller gets their existing open game rather than a second one. -func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) { +// TestMatchmakingReEnqueueOpensNewGame checks a re-enqueue opens a fresh open game +// rather than returning the caller's existing one: tapping "random opponent" again while +// still waiting starts a new search instead of bouncing the player back into the pending +// game. Both games stay open until an opponent (a human or the reaper's robot) joins each. +func TestMatchmakingReEnqueueOpensNewGame(t *testing.T) { ctx := context.Background() clearOpenGames(t) mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second) @@ -90,8 +92,13 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) { if err != nil { t.Fatalf("re-enqueue: %v", err) } - if r2.Game.ID != r1.Game.ID || r2.Matched { - t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID) + if r2.Matched || r2.Game.ID == r1.Game.ID { + t.Fatalf("re-enqueue = (game %s, matched %v), want a new open game distinct from %s, unmatched", r2.Game.ID, r2.Matched, r1.Game.ID) + } + for _, g := range []uuid.UUID{r1.Game.ID, r2.Game.ID} { + if _, _, status, err := newGameService().Participants(ctx, g); err != nil || status != "open" { + t.Fatalf("game %s status = %q err %v, want open", g, status, err) + } } } diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 030c1b8..8bf1646 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -85,9 +85,10 @@ type EnqueueResult struct { // Enqueue resolves an auto-match request for accountID under variant and the per-turn // word rule (multipleWords) into the game they enter immediately — a freshly opened -// game awaiting an opponent, the caller's own still-open game (a re-enqueue is -// idempotent), or another player's open game they just joined. When the caller joins -// an existing game, opponent_joined is pushed to that game's waiting starter. +// game awaiting an opponent, or another player's open game they just joined. A +// re-enqueue while already waiting opens another game rather than returning the +// caller's own. When the caller joins an existing game, opponent_joined is pushed to +// that game's waiting starter. func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) { g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline()) if err != nil { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2f30438..489754c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -466,8 +466,10 @@ disguised robot stays indistinguishable from a person. caller with an **empty opponent seat** (status `open`, §9), or — when another player is already waiting for the same `variant` and per-turn rule — seats the caller into that open game and starts it; which seat the caller takes is randomised for - first-move fairness, and a re-enqueue returns the caller's own still-open game - (idempotent). Matchmaking state is therefore the **open games in the database** (not + first-move fairness, and a re-enqueue while already waiting opens **another** game + (or joins a different player's) rather than returning the caller's own, so choosing + "random opponent" again always starts a new search (bounded by the simultaneous + quick-game cap, §9). Matchmaking state is therefore the **open games in the database** (not an in-memory pool), so it survives a restart and stays anonymous (no block check); concurrent enqueues for one bucket are serialised by a transaction-scoped advisory lock so two callers pair rather than each opening a game. A background **reaper** -- 2.52.0 From 81b9e1529eedea9413c009c75374e38ac0a384d0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 18 Jun 2026 11:50:34 +0200 Subject: [PATCH 180/223] feat(social): asymmetric per-user block, in-game block control, admin lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make a per-user block one-directional and non-destructive: the blocker stops receiving everything from the blocked user (chat, nudge, friend requests, invitations) and the matchmaker never pairs them, while the blocked user notices nothing — their sends still persist by the normal rules but are never delivered or surfaced (born-read). A block no longer deletes the friendship (an unblock cleanly restores it) and instant-reads any unread the blocked user had left for the blocker. - backend: a directional blockExists guard across chat/nudge/friends/invitations (store-but-hide for the blocked->blocker direction, refuse blocker->blocked); the matchmaker excludes a block-related pair (both directions) from auto-match; user_blocked/user_unblocked notifications to the blocker only (in-app only). - ui: the opponent score card gains a block ✖️ control mirroring add-friend (red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat composer when blocked); optimistic apply + event confirm + rollback for both. - admin: the user card gains cross-linked blocks / blocked-by / friends lists. - docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE. --- PRERELEASE.md | 1 + backend/cmd/backend/main.go | 1 + backend/internal/adminconsole/render_test.go | 5 + .../templates/pages/user_detail.gohtml | 30 ++ backend/internal/adminconsole/views.go | 15 + backend/internal/game/service.go | 4 +- backend/internal/game/store.go | 9 +- backend/internal/inttest/block_test.go | 270 ++++++++++++++++++ backend/internal/inttest/game_limit_test.go | 2 +- backend/internal/inttest/lobby_test.go | 32 ++- backend/internal/inttest/open_match_test.go | 6 +- backend/internal/inttest/social_test.go | 71 ++++- backend/internal/lobby/invitations.go | 50 +++- backend/internal/lobby/lobby.go | 12 +- backend/internal/lobby/matchmaker.go | 33 ++- backend/internal/lobby/matchmaker_test.go | 31 +- backend/internal/notify/notify.go | 10 + .../internal/server/handlers_admin_console.go | 21 ++ backend/internal/server/handlers_blocks.go | 9 +- backend/internal/social/admin.go | 79 +++++ backend/internal/social/blocks.go | 123 +++++++- backend/internal/social/chat.go | 137 ++++++++- backend/internal/social/friends.go | 74 ++++- backend/internal/social/social.go | 4 + docs/ARCHITECTURE.md | 29 +- docs/FUNCTIONAL.md | 28 +- docs/FUNCTIONAL_ru.md | 29 +- docs/UI_DESIGN.md | 16 +- ui/e2e/social.spec.ts | 23 ++ ui/src/game/Chat.svelte | 7 + ui/src/game/ChatScreen.svelte | 27 ++ ui/src/game/Game.svelte | 108 ++++++- ui/src/lib/i18n/en.ts | 2 + ui/src/lib/i18n/ru.ts | 2 + 34 files changed, 1191 insertions(+), 109 deletions(-) create mode 100644 backend/internal/inttest/block_test.go create mode 100644 backend/internal/social/admin.go diff --git a/PRERELEASE.md b/PRERELEASE.md index 98890f4..3321c6c 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -35,6 +35,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) | | GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** | | CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** | +| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 05cd6c1..1663c5b 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -189,6 +189,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger) matchmaker.SetNotifier(hub) + matchmaker.SetBlocker(socialSvc) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) invitations.SetNotifier(hub) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 3d2952a..f827e3f 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -26,6 +26,11 @@ func TestRendererRendersEveryPage(t *testing.T) { {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"}, + {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", + Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}}, + Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}}, + BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}}, + }, `/_gm/users/c3`}, {"throttled", ThrottledView{ Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}}, Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}}, diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index b8ca25a..8b2b353 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -102,6 +102,36 @@
+

Friends

+ + + +{{range .Friends}} + +{{else}}{{end}} + +
AccountFriends since
{{.DisplayName}}{{.Date}}
no friends
+
+

Blocks

+ + + +{{range .Blocks}} + +{{else}}{{end}} + +
AccountBlocked at
{{.DisplayName}}{{.Date}}
blocks no one
+
+

Blocked by

+ + + +{{range .BlockedBy}} + +{{else}}{{end}} + +
AccountBlocked at
{{.DisplayName}}{{.Date}}
blocked by no one
+
{{if .TelegramID}}

Send Telegram message

{{if .ConnectorEnabled}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 11a3de4..59cb2f7 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -175,6 +175,21 @@ type UserDetailView struct { // grant form offers. The first role is the feedback ban (see internal/account). Roles []string KnownRoles []string + // Blocks, BlockedBy and Friends are the social graph on the card: who this account has + // blocked, who currently blocks it, and its mutual friendships — each cross-linked to the + // other account with the date it happened. They are the full truth; the asymmetric block + // suppression that hides relationships from players never applies to the console. + Blocks []RelationRow + BlockedBy []RelationRow + Friends []RelationRow +} + +// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends +// lists: the other account's id (the link target), its display name, and the pre-formatted date. +type RelationRow struct { + AccountID string + DisplayName string + Date string } // SuspensionView is an account's current manual-block state shown on the user card: whether it diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 522c3c0..5b2dac5 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -274,7 +274,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro // pins it. First-move fairness comes from seating the caller at seat 0 or seat 1 // (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the // caller just waits for the opponent. It backs the lobby auto-match enqueue. -func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) { +func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) { acc, err := svc.accounts.GetByID(ctx, accountID) if err != nil { if errors.Is(err, account.ErrNotFound) { @@ -319,7 +319,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params if seed&1 == 1 { seats = []seatInsert{{}, caller} } - gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats) + gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude) if err != nil { return Game{}, false, err } diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index f726f27..bcdec97 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -200,22 +200,27 @@ func openMatchKey(variant string, multipleWords bool) int64 { // order) used only when a game is created; callerName is the caller's display-name // snapshot, stamped on their seat whether they open a fresh game or fill another // player's open one. -func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) { +func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) { err = withTx(ctx, s.db, func(tx *sql.Tx) error { if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil { return fmt.Errorf("open match lock: %w", e) } // 1. Another player's open game waiting for an opponent — fill its seat and start it. + // A game whose waiting player is in exclude (the caller's per-user block set, either + // direction) is skipped, so a block keeps the pair out of the same anonymous game; + // an empty exclude (the "{}" literal) excludes nothing. var other uuid.UUID switch e := tx.QueryRowContext(ctx, `SELECT g.game_id FROM backend.games g WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND NOT EXISTS (SELECT 1 FROM backend.game_players p WHERE p.game_id = g.game_id AND p.account_id = $3) + AND NOT EXISTS (SELECT 1 FROM backend.game_players b + WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[])) ORDER BY g.created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, - ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); { + ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); { case e == nil: if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil { return er diff --git a/backend/internal/inttest/block_test.go b/backend/internal/inttest/block_test.go new file mode 100644 index 0000000..7993323 --- /dev/null +++ b/backend/internal/inttest/block_test.go @@ -0,0 +1,270 @@ +//go:build integration + +package inttest + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/notify" + "scrabble/backend/internal/social" +) + +// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked +// user had left unread for the blocker in their shared games, so no stale unread badge +// lingers for someone the blocker no longer sees. +func TestBlockInstantReadsExistingUnread(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) + sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat + + if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil { + t.Fatalf("post: %v", err) + } + if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread { + t.Fatal("viewer should have the message unread before blocking") + } + if err := svc.Block(ctx, viewer, sender); err != nil { + t.Fatalf("block: %v", err) + } + if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread { + t.Error("blocking should instant-read the blocked sender's prior unread message") + } +} + +// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a +// message the blocked user sends is stored and visible to themselves, but is born read for +// the blocker, never delivered to them, and hidden from their view. +func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + gameID, seats := newGameWithSeats(t, 2) + blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat + + if err := svc.Block(ctx, blocker, blocked); err != nil { + t.Fatalf("block: %v", err) + } + if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil { + t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err) + } + if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread { + t.Error("the blocked sender's message must be born read for the blocker") + } + if pub.delivered(blocker, notify.KindChatMessage) { + t.Error("the blocked sender's message must not be delivered to the blocker") + } + if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 { + t.Errorf("blocker still sees the blocked sender's message: %+v", msgs) + } + if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 { + t.Errorf("the blocked sender should see their own message, got %+v", msgs) + } +} + +// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their +// only opponent is someone they have blocked (the composer is hidden client-side too). +func TestChatToBlockedIsRejected(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) + blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat + + if err := svc.Block(ctx, blocker, blocked); err != nil { + t.Fatalf("block: %v", err) + } + if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) { + t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err) + } +} + +// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a +// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking +// ordinary to them) but is born read and never delivered to the blocker. +func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + gameID, seats := newGameWithSeats(t, 2) + blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it + + if err := svc.Block(ctx, blocker, blocked); err != nil { + t.Fatalf("block: %v", err) + } + if _, err := svc.Nudge(ctx, gameID, blocked); err != nil { + t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err) + } + if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread { + t.Error("the blocked sender's nudge must be born read for the blocker") + } + if pub.delivered(blocker, notify.KindNudge) { + t.Error("the blocked sender's nudge must not be delivered to the blocker") + } + // The nudge is still recorded, so the cooldown applies (the blocked user notices nothing). + if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok { + t.Error("the suppressed nudge should still be recorded") + } +} + +// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the +// awaited opponent when they have blocked them. +func TestNudgeToBlockedIsRejected(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) + blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it + + if err := svc.Block(ctx, blocker, blocked); err != nil { + t.Fatalf("block: %v", err) + } + if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) { + t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err) + } +} + +// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so +// their other sessions update in place) and never reach the blocked user. +func TestBlockPublishesToBlockerOnly(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + blocker, blocked := provisionAccount(t), provisionAccount(t) + + if err := svc.Block(ctx, blocker, blocked); err != nil { + t.Fatalf("block: %v", err) + } + if !pub.notified(blocker, notify.NotifyUserBlocked) { + t.Error("the blocker should receive a user_blocked event") + } + if pub.notified(blocked, notify.NotifyUserBlocked) { + t.Error("the blocked user must never receive a user_blocked event") + } + if err := svc.Unblock(ctx, blocker, blocked); err != nil { + t.Fatalf("unblock: %v", err) + } + if !pub.notified(blocker, notify.NotifyUserUnblocked) { + t.Error("the blocker should receive a user_unblocked event") + } + if pub.notified(blocked, notify.NotifyUserUnblocked) { + t.Error("the blocked user must never receive a user_unblocked event") + } +} + +// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a +// block between them (either direction), while an unblocked third player still joins. +func TestMatchmakingExcludesBlockedPlayers(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second) + soc := newSocialService() + mm.SetBlocker(soc) + a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t) + + if err := soc.Block(ctx, a, b); err != nil { + t.Fatalf("block: %v", err) + } + // The block is seen from both sides — the exclusion set unions both directions. + if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) { + t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked") + } + + // b opens a game awaiting an opponent. + r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("enqueue b: %v", err) + } + if r1.Matched { + t.Fatal("first enqueue must open a game, not match") + } + // a must not join b's game (a blocked b): it opens its own instead. + r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("enqueue a: %v", err) + } + if r2.Matched || r2.Game.ID == r1.Game.ID { + t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched) + } + // A third, unblocked player joins b's still-open game (the oldest), proving the exclusion + // is specific to the blocked pair, not a blanket refusal. + r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true) + if err != nil { + t.Fatalf("enqueue c: %v", err) + } + if !r3.Matched || r3.Game.ID != r1.Game.ID { + t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID) + } +} + +// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries +// return the full truth in both directions, including a friendship that a block overrides but +// does not delete. +func TestAdminSocialLists(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + _, seats := newGameWithSeats(t, 2) + a, b := seats[0], seats[1] + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("request: %v", err) + } + if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { + t.Fatalf("accept: %v", err) + } + if err := svc.Block(ctx, a, b); err != nil { + t.Fatalf("block: %v", err) + } + + blocks, err := svc.AdminBlocksBy(ctx, a) + if err != nil { + t.Fatalf("admin blocks by: %v", err) + } + if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() { + t.Errorf("a's blocks = %v, want [b] with a date", blocks) + } + blockedBy, err := svc.AdminBlockedBy(ctx, b) + if err != nil { + t.Fatalf("admin blocked by: %v", err) + } + if len(blockedBy) != 1 || blockedBy[0].AccountID != a { + t.Errorf("b's blocked-by = %v, want [a]", blockedBy) + } + // The friendship survives the block, so it still shows on both admin friend lists. + friendsA, err := svc.AdminFriends(ctx, a) + if err != nil { + t.Fatalf("admin friends: %v", err) + } + if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() { + t.Errorf("a's admin friends = %v, want [b] with a date", friendsA) + } + if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a { + t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err) + } +} + +// blockedWith reads the both-direction block set for id. +func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID { + t.Helper() + ids, err := soc.BlockedWith(context.Background(), id) + if err != nil { + t.Fatalf("blocked with: %v", err) + } + return ids +} + +// contains reports whether ids includes want. +func contains(ids []uuid.UUID, want uuid.UUID) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} diff --git a/backend/internal/inttest/game_limit_test.go b/backend/internal/inttest/game_limit_test.go index 63bce78..56b1574 100644 --- a/backend/internal/inttest/game_limit_test.go +++ b/backend/internal/inttest/game_limit_test.go @@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) { // An open (awaiting-opponent) quick game counts. if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{ Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour, - }, time.Now().Add(time.Minute)); err != nil { + }, time.Now().Add(time.Minute), nil); err != nil { t.Fatalf("open quick game: %v", err) } // An active quick game and an honest-AI quick game both count (neither has an invitation row). diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index 4f237a4..74d0485 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -198,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) { func TestInvitationBlockedInvitee(t *testing.T) { ctx := context.Background() svc := newInvitationService() - social := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + soc := newSocialService() + + // The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware). inviter := provisionAccount(t) - invitee := provisionAccount(t) - if err := social.Block(ctx, invitee, inviter); err != nil { + blockedInvitee := provisionAccount(t) + if err := soc.Block(ctx, inviter, blockedInvitee); err != nil { t.Fatalf("block: %v", err) } - if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) { - t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err) + if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) { + t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err) + } + + // An invitee who has blocked the inviter is instead suppressed: the invitation is created + // (no error — the inviter notices nothing), but that invitee is never notified and never + // sees it in their list. + inviter2 := provisionAccount(t) + suppressed := provisionAccount(t) + if err := soc.Block(ctx, suppressed, inviter2); err != nil { + t.Fatalf("block: %v", err) + } + if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil { + t.Fatalf("create with a suppressing invitee = %v, want nil", err) + } + if pub.notified(suppressed, notify.NotifyInvitation) { + t.Error("an invitee who blocked the inviter must not be notified of the invitation") + } + if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 { + t.Errorf("suppressed invitee's invitation list = %v, want empty", list) } } diff --git a/backend/internal/inttest/open_match_test.go b/backend/internal/inttest/open_match_test.go index e266e7f..f66a5f6 100644 --- a/backend/internal/inttest/open_match_test.go +++ b/backend/internal/inttest/open_match_test.go @@ -49,7 +49,7 @@ func openParams(seed int64) game.CreateParams { func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game { t.Helper() - g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute)) + g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil) if err != nil { t.Fatalf("open game: %v", err) } @@ -96,7 +96,7 @@ func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) { clearOpenGames(t) svc := newGameService() starter := provisionAccount(t) - g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute)) + g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil) if err != nil { t.Fatalf("open: %v", err) } @@ -128,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) { } joiner := provisionAccount(t) - g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute)) + g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil) if err != nil { t.Fatalf("join: %v", err) } diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 971b027..f6f9e84 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool { return false } +// delivered reports whether an intent of the given top-level kind (e.g. chat_message, +// nudge) was published to user — used to assert that a blocked sender's message or nudge +// never reaches the blocker. +func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for _, in := range c.intents { + if in.UserID == user && in.Kind == kind { + return true + } + } + return false +} + // 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. @@ -128,17 +142,53 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) { t.Fatalf("toggle send = %v, want ErrRequestBlocked", err) } - // Block: the addressee has blocked the requester. - c, d := provisionAccount(t), provisionAccount(t) - if err := svc.Block(ctx, d, c); err != nil { + // Block from the requester's own side: a requester who has blocked the addressee is + // refused outright (they are the blocker and aware of it). The reverse direction — the + // addressee having blocked the requester — is instead silently suppressed, covered by + // TestFriendRequestFromBlockedIsSuppressed. + _, seats := newGameWithSeats(t, 2) + c, d := seats[0], seats[1] + if err := svc.Block(ctx, c, d); err != nil { t.Fatalf("block: %v", err) } if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) { - t.Fatalf("blocked send = %v, want ErrRequestBlocked", err) + t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err) } } -func TestBlockSeversFriendship(t *testing.T) { +// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the +// addressee has blocked the requester, the requester's friend request succeeds silently +// (no error — they must not notice the block), is stored, but is never delivered to nor +// surfaced for the blocker. +func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + _, seats := newGameWithSeats(t, 2) + blocker, blocked := seats[0], seats[1] + if err := svc.Block(ctx, blocker, blocked); err != nil { + t.Fatalf("block: %v", err) + } + if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil { + t.Fatalf("suppressed request = %v, want nil", err) + } + if pub.notified(blocker, notify.NotifyFriendRequest) { + t.Error("blocker must not be notified of a suppressed request") + } + if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 { + t.Errorf("blocker incoming = %v, want none (suppressed)", got) + } + // The blocked user sees an ordinary outgoing request — no leak of the block. + if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker { + t.Errorf("blocked outgoing = %v, want [blocker]", got) + } +} + +// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a +// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is +// unchanged (so they never notice), and an unblock cleanly restores the friendship. +func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) { ctx := context.Background() svc := newSocialService() _, seats := newGameWithSeats(t, 2) @@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) { t.Fatalf("block: %v", err) } if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 { - t.Errorf("friendship must be severed by a block, got %v", friends) + t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends) + } + if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a { + t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends) + } + if err := svc.Unblock(ctx, a, b); err != nil { + t.Fatalf("unblock: %v", err) + } + if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b { + t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends) } } diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 8a91e34..30b661b 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi } intents := make([]notify.Intent, 0, len(recipients)) for _, id := range recipients { + // An invitee who has blocked the inviter never sees this invitation, so they must not + // receive its updates either (an update would otherwise re-add it to their lobby). The + // inviter always gets updates; on a lookup error suppress rather than risk a leak. + if id != inv.InviterID { + if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk { + continue + } + } intents = append(intents, notify.NotificationInvitationUpdate(id, summary)) } svc.pub.Publish(intents...) @@ -211,6 +219,11 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout) } seen := map[uuid.UUID]bool{inviterID: true} + // suppressed collects invitees who have blocked the inviter: the invitation is still + // created and persisted for them, but they are never notified and never see it (their + // friendship with the inviter survived the block, so they can still appear in the + // inviter's picker — they must not learn of the block). + suppressed := make(map[uuid.UUID]bool) for _, id := range inviteeIDs { if seen[id] { return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id) @@ -222,13 +235,22 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu } return Invitation{}, err } - blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id) + // A block the inviter placed refuses the invite (the inviter is aware; the picker hides + // the invitee anyway). A block the invitee placed on the inviter is instead suppressed. + iBlock, err := svc.blocker.Blocks(ctx, inviterID, id) if err != nil { return Invitation{}, err } - if blocked { + if iBlock { return Invitation{}, ErrInvitationBlocked } + theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID) + if err != nil { + return Invitation{}, err + } + if theyBlock { + suppressed[id] = true + } } id, err := uuid.NewV7() @@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu if err != nil { return Invitation{}, err } - svc.emitInvitation(ctx, inv, inviteeIDs) + // Notify every invitee except those who have blocked the inviter (their copy stays + // stored but unseen — the store-but-hide that keeps the block invisible). + notifyIDs := inviteeIDs + if len(suppressed) > 0 { + notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs)) + for _, id := range inviteeIDs { + if !suppressed[id] { + notifyIDs = append(notifyIDs, id) + } + } + } + svc.emitInvitation(ctx, inv, notifyIDs) return inv, nil } @@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui if err != nil { return nil, err } + // Hide an invitation whose inviter the viewer has blocked: it stays stored but must + // never surface to the blocker. The viewer's own invitations (as inviter) are unaffected. + if inv.InviterID != accountID { + blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID) + if err != nil { + return nil, err + } + if blocked { + continue + } + } out = append(out, inv) } return out, nil diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index b088691..1b2c1fe 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -38,11 +38,15 @@ type RobotProvider interface { PickNamed(variant engine.Variant) (uuid.UUID, string, error) } -// Blocker reports whether two accounts have a block between them (either -// direction). social.Service satisfies it; the lobby uses it to refuse -// invitations between blocked accounts. +// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it. +// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a +// block the invitee placed on the inviter is silently suppressed; auto-match consults +// BlockedWith so a block either way keeps the pair out of the same anonymous game. type Blocker interface { - IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) + // Blocks reports whether blocker has blocked blocked (exact direction). + Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) + // BlockedWith returns every account in a block with accountID in either direction. + BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) } // Auto-match defaults: a casual two-player game on the longest move clock with one diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go index 8bf1646..54e9b16 100644 --- a/backend/internal/lobby/matchmaker.go +++ b/backend/internal/lobby/matchmaker.go @@ -18,7 +18,10 @@ import ( // reading a player's view to enrich the opponent_joined event. game.Service satisfies // it. type GameMatcher interface { - OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error) + // OpenOrJoin joins the caller into another waiting player's open game or opens a fresh + // one; exclude lists account IDs whose open game must never be joined (the caller's + // per-user block set), so a block keeps the pair out of the same anonymous game. + OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error) ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) @@ -35,8 +38,9 @@ type GameMatcher interface { // Matchmaker holds only the wait policy and the live-event publisher, and is safe for // concurrent use. // -// Auto-match is anonymous, so it does not consult per-user blocks (those govern -// friends, chat and invitations between known players). +// Auto-match is anonymous, but it still consults per-user blocks: a player is never +// paired into a game whose waiting opponent they have blocked, or who has blocked them +// (either direction), so a block keeps two players apart everywhere. type Matchmaker struct { games GameMatcher robots RobotProvider @@ -44,6 +48,7 @@ type Matchmaker struct { jitter time.Duration clock func() time.Time pub notify.Publisher + blocker Blocker log *zap.Logger } @@ -75,6 +80,16 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) { } } +// SetBlocker installs the per-user block source used to keep auto-match from pairing a +// player with someone they have a block with (either direction). It must be called +// during startup wiring; the default is no blocker, in which case auto-match pairs +// anonymously (no block exclusion). +func (m *Matchmaker) SetBlocker(b Blocker) { + if b != nil { + m.blocker = b + } +} + // EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays // in, and whether it already had an opponent (they joined a waiting game) rather than // being freshly opened and still awaiting one. @@ -90,7 +105,17 @@ type EnqueueResult struct { // caller's own. When the caller joins an existing game, opponent_joined is pushed to // that game's waiting starter. func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) { - g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline()) + // Exclude every account the caller has a block with (either direction) from the + // candidate open games, so auto-match never pairs a blocked pair into one game. + var exclude []uuid.UUID + if m.blocker != nil { + var err error + exclude, err = m.blocker.BlockedWith(ctx, accountID) + if err != nil { + return EnqueueResult{}, err + } + } + g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude) if err != nil { return EnqueueResult{}, err } diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go index 60cbfd4..dd258c4 100644 --- a/backend/internal/lobby/matchmaker_test.go +++ b/backend/internal/lobby/matchmaker_test.go @@ -25,6 +25,7 @@ type stubMatcher struct { openErr error openCalls int lastDeadline time.Time + lastExclude []uuid.UUID expired []game.OpenGame @@ -39,9 +40,10 @@ type stubMatcher struct { createParams game.CreateParams } -func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) { +func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) { s.openCalls++ s.lastDeadline = deadline + s.lastExclude = exclude return s.openGame, s.openJoined, s.openErr } @@ -95,6 +97,33 @@ type capturePub struct{ intents []notify.Intent } func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) } +// fakeBlocker is a Blocker returning a canned both-direction block set. +type fakeBlocker struct { + with []uuid.UUID + err error +} + +func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil } +func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) { + return f.with, f.err +} + +// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set +// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game +// whose waiting player has a block with the caller. +func TestEnqueueExcludesBlockedStarters(t *testing.T) { + caller, blocked := uuid.New(), uuid.New() + m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)} + mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop()) + mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}}) + if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil { + t.Fatalf("enqueue: %v", err) + } + if !slices.Contains(m.lastExclude, blocked) { + t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked) + } +} + // twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1 // (uuid.Nil for a still-empty opponent seat). func twoSeatGame(starter, opponent uuid.UUID) game.Game { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 16b3ca0..9e74774 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -62,6 +62,16 @@ const ( // it re-fetches profile.get to show or hide the banner. It carries no payload (the // banner set rides the profile response). In-app only. NotifyBanner = "banner" + // NotifyUserBlocked confirms to the blocker that a per-user block took effect, + // carrying the blocked account, so every one of the blocker's sessions updates the + // in-game block/add-friend controls and the struck name in place. It is delivered + // only to the blocker — never to the blocked user, who must not learn of the block — + // and is in-app only (no out-of-app push: blocking is the viewer's own action). + NotifyUserBlocked = "user_blocked" + // NotifyUserUnblocked confirms an unblock to the (former) blocker, carrying the + // unblocked account, so their open game screens restore the controls and un-strike + // the name in place. Like NotifyUserBlocked it reaches only the actor and is in-app only. + NotifyUserUnblocked = "user_unblocked" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 80a3146..3dc929b 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -389,9 +389,30 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view.Roles = roles } view.KnownRoles = account.KnownRoles + if s.social != nil { + if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil { + view.Blocks = relationRows(rels) + } + if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil { + view.BlockedBy = relationRows(rels) + } + if rels, err := s.social.AdminFriends(ctx, id); err == nil { + view.Friends = relationRows(rels) + } + } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } +// relationRows maps the social graph entries to the cross-linked, date-formatted rows the +// user card renders. +func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow { + out := make([]adminconsole.RelationRow, 0, len(rels)) + for _, r := range rels { + out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)}) + } + return out +} + // consoleUserMessage sends an operator Telegram message to one user. func (s *Server) consoleUserMessage(c *gin.Context) { ctx := c.Request.Context() diff --git a/backend/internal/server/handlers_blocks.go b/backend/internal/server/handlers_blocks.go index a523f52..0f5b4a3 100644 --- a/backend/internal/server/handlers_blocks.go +++ b/backend/internal/server/handlers_blocks.go @@ -7,10 +7,11 @@ import ( "github.com/google/uuid" ) -// The /api/v1/user/blocks/* handlers wire the per-user block list. A block -// is mutual in effect (the social checks apply it both ways) and severs any -// friendship between the pair. They reuse the friend handlers' targetIDRequest and -// account-ref resolution. +// The /api/v1/user/blocks/* handlers wire the per-user block list. A block is asymmetric: +// the blocker stops seeing everything from the blocked user (chat, nudge, requests, +// invitations, matchmaking) while the blocked user notices nothing; it overrides — but does +// not delete — any friendship (an unblock restores it). They reuse the friend handlers' +// targetIDRequest and account-ref resolution. // blockListDTO is the accounts the caller has blocked. type blockListDTO struct { diff --git a/backend/internal/social/admin.go b/backend/internal/social/admin.go new file mode 100644 index 0000000..d6e6f81 --- /dev/null +++ b/backend/internal/social/admin.go @@ -0,0 +1,79 @@ +package social + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" +) + +// AdminRelation is one entry in the admin user card's blocks / blocked-by / friends lists: +// the other account, its display name, and when the relationship was recorded. It is the +// unfiltered truth — the asymmetric block suppression that hides relationships from players +// never applies to the operator console. +type AdminRelation struct { + AccountID uuid.UUID + DisplayName string + At time.Time +} + +// AdminBlocksBy returns the accounts blockerID has blocked, with display names and block +// times, newest first — the admin card's "blocks" list. +func (svc *Service) AdminBlocksBy(ctx context.Context, blockerID uuid.UUID) ([]AdminRelation, error) { + return svc.store.adminBlocks(ctx, blockerID, true) +} + +// AdminBlockedBy returns the accounts that have blocked blockedID, with names and times, +// newest first — the admin card's "blocked by" list. +func (svc *Service) AdminBlockedBy(ctx context.Context, blockedID uuid.UUID) ([]AdminRelation, error) { + return svc.store.adminBlocks(ctx, blockedID, false) +} + +// AdminFriends returns accountID's accepted friendships in either direction, with the other +// account's name and when the friendship was accepted, newest first — the admin card's +// "friends" list. +func (svc *Service) AdminFriends(ctx context.Context, accountID uuid.UUID) ([]AdminRelation, error) { + const q = `SELECT other_id, a.display_name, at FROM ( + SELECT CASE WHEN f.requester_id = $1 THEN f.addressee_id ELSE f.requester_id END AS other_id, + COALESCE(f.responded_at, f.created_at) AS at + FROM backend.friendships f + WHERE f.status = 'accepted' AND (f.requester_id = $1 OR f.addressee_id = $1) +) rel +JOIN backend.accounts a ON a.account_id = rel.other_id +ORDER BY at DESC` + return svc.store.adminRelations(ctx, q, accountID) +} + +// adminBlocks reads the blocks touching id: when asBlocker the rows where id is the blocker +// (the other side is who they blocked), otherwise the rows where id is the blocked (the +// other side is who blocked them). +func (s *Store) adminBlocks(ctx context.Context, id uuid.UUID, asBlocker bool) ([]AdminRelation, error) { + q := `SELECT b.blocked_id, a.display_name, b.created_at +FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocked_id +WHERE b.blocker_id = $1 ORDER BY b.created_at DESC` + if !asBlocker { + q = `SELECT b.blocker_id, a.display_name, b.created_at +FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocker_id +WHERE b.blocked_id = $1 ORDER BY b.created_at DESC` + } + return s.adminRelations(ctx, q, id) +} + +// adminRelations runs an admin list query of the (account_id, display_name, at) shape. +func (s *Store) adminRelations(ctx context.Context, query string, id uuid.UUID) ([]AdminRelation, error) { + rows, err := s.db.QueryContext(ctx, query, id) + if err != nil { + return nil, fmt.Errorf("social: admin relations: %w", err) + } + defer rows.Close() + var out []AdminRelation + for rows.Next() { + var r AdminRelation + if err := rows.Scan(&r.AccountID, &r.DisplayName, &r.At); err != nil { + return nil, fmt.Errorf("social: scan admin relation: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/backend/internal/social/blocks.go b/backend/internal/social/blocks.go index 4bd4417..b9d36bf 100644 --- a/backend/internal/social/blocks.go +++ b/backend/internal/social/blocks.go @@ -10,24 +10,41 @@ import ( "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" + "scrabble/backend/internal/notify" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) -// Block records that blockerID has blocked blockedID. Blocking severs any -// friendship or pending request between the two and, through the mutual block -// checks, suppresses chat visibility and new requests/invitations in both -// directions. It is idempotent. +// Block records that blockerID has blocked blockedID. The block is asymmetric and +// non-destructive: it suppresses everything the blocked user sends toward the blocker +// (chat, nudge, friend requests, invitations are persisted but never delivered or +// surfaced) and hides the blocked user from the blocker's lists, while the blocked +// user notices nothing. It does NOT delete the friendship — an unblock cleanly +// restores it — and keeps every stored row. As a courtesy it marks any of the blocked +// user's still-unread chat entries in their shared games read at once, so no stale +// unread badge lingers for someone the blocker no longer sees. A user_blocked event is +// delivered to the blocker (only) so their other sessions update in place. It is idempotent. func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error { if blockerID == blockedID { return ErrSelfRelation } - return svc.store.insertBlock(ctx, blockerID, blockedID) + if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil { + return err + } + svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserBlocked, svc.accountRef(ctx, blockedID))) + return nil } -// Unblock removes blockerID's block on blockedID. It is idempotent. +// Unblock removes blockerID's block on blockedID and confirms it to the (former) +// blocker with a user_unblocked event so their open game screens restore the controls +// and un-strike the name in place. Any friendship the block had been overriding becomes +// effective again. It is idempotent. func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error { - return svc.store.deleteBlock(ctx, blockerID, blockedID) + if err := svc.store.deleteBlock(ctx, blockerID, blockedID); err != nil { + return err + } + svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, blockedID))) + return nil } // ListBlocks returns the account IDs blockerID has blocked. @@ -35,11 +52,43 @@ func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid return svc.store.listBlocks(ctx, blockerID) } +// BlockedWith returns every account that has a block with accountID in either +// direction (those accountID blocked, plus those who blocked accountID), de-duplicated. +// The matchmaker excludes them so a block — either way — keeps the pair out of the same +// anonymous auto-match game. +func (svc *Service) BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { + mine, err := svc.store.listBlocks(ctx, accountID) + if err != nil { + return nil, err + } + theirs, err := svc.store.listBlockedBy(ctx, accountID) + if err != nil { + return nil, err + } + seen := make(map[uuid.UUID]struct{}, len(mine)+len(theirs)) + out := make([]uuid.UUID, 0, len(mine)+len(theirs)) + for _, id := range append(mine, theirs...) { + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out, nil +} + // IsBlocked reports whether a block stands between a and b in either direction. func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) { return svc.store.isBlocked(ctx, a, b) } +// Blocks reports whether blocker has blocked blocked — the exact direction, not the +// symmetric IsBlocked. It backs the directional guards (chat, friend requests, +// invitations) that must tell the blocker (refuse) from the blocked (silently suppress). +func (svc *Service) Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) { + return svc.store.blockExists(ctx, blocker, blocked) +} + // isBlocked reports whether a block row exists between a and b in either direction. func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) { stmt := postgres.SELECT(table.Blocks.BlockerID). @@ -58,14 +107,33 @@ func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) { return true, nil } -// insertBlock severs any friendship between the pair and inserts the block, in one -// transaction; a duplicate block is ignored. +// blockExists reports whether blocker has blocked blocked — the exact direction, not +// the symmetric isBlocked. The asymmetric block turns on this one-directional test: +// the blocker filters out and refuses the blocked user, while the blocked user's own +// actions are silently suppressed rather than refused, so they never notice. +func (s *Store) blockExists(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) { + stmt := postgres.SELECT(table.Blocks.BlockerID). + FROM(table.Blocks). + WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)). + AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))). + LIMIT(1) + var row model.Blocks + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("social: block exists: %w", err) + } + return true, nil +} + +// insertBlock inserts the block and, in the same transaction, marks read every chat +// entry the blocked user authored that the blocker had still left unread across their +// shared games — so no stale unread badge lingers for someone the blocker no longer +// sees. It deliberately leaves the friendship intact (the block overrides it; an +// unblock restores it). A duplicate block is ignored. func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error { return withTx(ctx, s.db, func(tx *sql.Tx) error { - del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked)) - if _, err := del.ExecContext(ctx, tx); err != nil { - return fmt.Errorf("clear friendship on block: %w", err) - } ins := table.Blocks. INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID). VALUES(blocker, blocked). @@ -73,6 +141,17 @@ func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) err if _, err := ins.ExecContext(ctx, tx); err != nil { return fmt.Errorf("insert block: %w", err) } + // Clear the blocker's unread bit on every entry the blocked user sent, in any game + // they share, resolving the blocker's seat through game_players. The bitwise terms + // are cast through int4 so the operators resolve unambiguously (as in markRead). + const clearUnread = `UPDATE backend.chat_messages m +SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint +FROM backend.game_players p +WHERE p.account_id = $1 AND p.game_id = m.game_id + AND m.sender_id = $2 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0` + if _, err := tx.ExecContext(ctx, clearUnread, blocker, blocked); err != nil { + return fmt.Errorf("clear unread on block: %w", err) + } return nil }) } @@ -104,3 +183,21 @@ func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID, } return out, nil } + +// listBlockedBy returns the accounts that have blocked blocked — the reverse of +// listBlocks. The matchmaker unions the two so a block in either direction keeps a +// pair out of the same auto-match game. +func (s *Store) listBlockedBy(ctx context.Context, blocked uuid.UUID) ([]uuid.UUID, error) { + stmt := postgres.SELECT(table.Blocks.BlockerID). + FROM(table.Blocks). + WHERE(table.Blocks.BlockedID.EQ(postgres.UUID(blocked))) + var rows []model.Blocks + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("social: list blocked by: %w", err) + } + out := make([]uuid.UUID, 0, len(rows)) + for _, r := range rows { + out = append(out, r.BlockerID) + } + return out, nil +} diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 9d07b88..27e8329 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -100,17 +100,32 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, if err := Clean(body); err != nil { return Message{}, err } - // A disguised robot opponent never reads chat, so its recipient bit is born clear. + // Blocker-side guard: a sender whose only opponents are people they have blocked cannot + // post (the composer is hidden client-side; this is the server-side counterpart). In a + // multi-player game with a non-blocked opponent the post is allowed and reaches them. + if guard, err := svc.senderBlocksEveryOpponent(ctx, seats, senderID); err != nil { + return Message{}, err + } else if guard { + return Message{}, ErrRecipientBlocked + } + // Recipients whose copy is born read so it never shows as unread: a disguised robot + // opponent (never opens the chat) and any recipient who has blocked the sender — the + // latter is also dropped from live delivery below, so the block stays invisible to the + // blocked sender while the blocker sees nothing. autoRead, err := svc.robotRecipients(ctx, seats, senderID) if err != nil { return Message{}, err } - msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead)) + suppressed, err := svc.blockedRecipients(ctx, seats, senderID) + if err != nil { + return Message{}, err + } + msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, mergeSeatSets(autoRead, suppressed))) if err != nil { return Message{}, err } svc.metrics.recordChat(ctx, kindMessage) - svc.emitChat(seats, senderID, msg) + svc.emitChat(seats, senderID, msg, suppressed) return msg, nil } @@ -138,6 +153,20 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess if idx == toMove { return Message{}, ErrNudgeOnOwnTurn } + // The awaited player is the nudge's sole recipient. + var target uuid.UUID + if toMove >= 0 && toMove < len(seats) { + target = seats[toMove] + } + // Blocker-side guard: a sender who has blocked the awaited player cannot nudge them + // (the control is hidden client-side; this is the server-side counterpart). + if target != uuid.Nil { + if guard, err := svc.store.blockExists(ctx, senderID, target); err != nil { + return Message{}, err + } else if guard { + return Message{}, ErrRecipientBlocked + } + } last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID) if err != nil { return Message{}, err @@ -153,16 +182,30 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess return Message{}, ErrNudgeTooSoon } } - msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<= 0 && toMove < len(seats) { + if !suppressed && target != uuid.Nil { // Name the sender by their per-game seat snapshot, so the toast and the out-of-app push // read ": …"; an unresolved name (best-effort) falls back to the plain phrase. senderName, _ := svc.games.SeatName(ctx, gameID, senderID) - nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName) + nudge := notify.Nudge(target, gameID, senderID, senderName) if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil { nudge.Language = lang // route by the game's bot, not the recipient's last-login one } @@ -187,12 +230,13 @@ func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, 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) { +// emitChat pushes a chat message to every seated player except the sender and any +// recipient in suppressed — a recipient who has blocked the sender, who must never see +// it (best-effort live delivery; the other recipients still read it via Messages). +func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message, suppressed map[uuid.UUID]bool) { intents := make([]notify.Intent, 0, len(seats)) for _, id := range seats { - if id == senderID { + if id == senderID || suppressed[id] { continue } intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt)) @@ -208,8 +252,9 @@ func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) } // Messages returns the per-game chat visible to viewerID: the viewer must be a -// seated player. Messages from a sender the viewer has a block with (either -// direction) are dropped, and if the viewer has disabled chat only nudges remain. +// seated player. Messages from a sender the viewer has blocked are dropped — one +// direction only, so a blocked player still sees the blocker's messages and never +// notices the block — and if the viewer has disabled chat only nudges remain. func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) { seats, _, _, err := svc.games.Participants(ctx, gameID) if err != nil { @@ -227,7 +272,7 @@ func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([ if seat == viewerID { continue } - yes, err := svc.store.isBlocked(ctx, viewerID, seat) + yes, err := svc.store.blockExists(ctx, viewerID, seat) if err != nil { return nil, err } @@ -303,6 +348,72 @@ func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, send return robots, nil } +// blockedRecipients returns the seated recipients (every non-empty seat but the sender) +// that have blocked the sender. Their copy of a message or nudge is born read and is not +// delivered — the store-but-hide that keeps a block invisible to the blocked sender while +// denying the blocker any sight of what they send. +func (svc *Service) blockedRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) { + var out map[uuid.UUID]bool + for _, id := range seats { + if id == uuid.Nil || id == senderID { + continue + } + yes, err := svc.store.blockExists(ctx, id, senderID) + if err != nil { + return nil, err + } + if yes { + if out == nil { + out = make(map[uuid.UUID]bool) + } + out[id] = true + } + } + return out, nil +} + +// senderBlocksEveryOpponent reports whether senderID has blocked every other seated +// player (and there is at least one). It is the blocker-side chat guard: a player whose +// only opponents are people they have blocked cannot post. In a multi-player game that +// still has a non-blocked opponent it is false, so the player can talk to the others. +func (svc *Service) senderBlocksEveryOpponent(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (bool, error) { + opponents := 0 + for _, id := range seats { + if id == uuid.Nil || id == senderID { + continue + } + opponents++ + blocked, err := svc.store.blockExists(ctx, senderID, id) + if err != nil { + return false, err + } + if !blocked { + return false, nil + } + } + return opponents > 0, nil +} + +// mergeSeatSets returns the union of two seat sets; either may be nil. It folds the +// born-read recipients (disguised robots and recipients who blocked the sender) into one +// mask input. +func mergeSeatSets(a, b map[uuid.UUID]bool) map[uuid.UUID]bool { + if len(b) == 0 { + return a + } + if len(a) == 0 { + return b + } + out := make(map[uuid.UUID]bool, len(a)+len(b)) + for id := range a { + out[id] = true + } + for id := range b { + out[id] = true + } + return out +} + // insertChatMessage stores one chat row, seeding its unread bitmask, and returns it. func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) { id, err := uuid.NewV7() diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index add0ec9..1f7a187 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -51,7 +51,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse if requesterID == addresseeID { return ErrSelfRelation } - blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID) + iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID) + if err != nil { + return err + } + theyBlockMe, err := svc.store.blockExists(ctx, addresseeID, requesterID) if err != nil { return err } @@ -62,7 +66,17 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse } return err } - if blocked || addressee.BlockFriendRequests { + if iBlockThem { + // The requester has blocked the addressee — they are the blocker and aware of it. + return ErrRequestBlocked + } + // When the addressee has blocked the requester, the request still proceeds and persists + // by the normal logic below, but it is never delivered and the blocker never sees it + // (ListIncomingRequests filters it out) — the blocked requester must not learn of the + // block. The flag gates only the notification; it also bypasses the addressee's + // block_friend_requests toggle so the suppressed request looks ordinary to the requester. + suppressed := theyBlockMe + if !suppressed && addressee.BlockFriendRequests { return ErrRequestBlocked } shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID) @@ -102,7 +116,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil { return err } - svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) + if !suppressed { + svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) + } return nil } } @@ -112,7 +128,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse } return err } - svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) + if !suppressed { + svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID))) + } return nil } @@ -164,15 +182,55 @@ func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID) return svc.store.deleteFriendship(ctx, accountID, otherID) } -// ListFriends returns the account IDs that are accepted friends of accountID. +// ListFriends returns the account IDs that are accepted friends of accountID, with any +// the caller has blocked filtered out: a block overrides — but does not delete — the +// friendship, so the blocker stops seeing the blocked friend while the blocked user's own +// list still shows the blocker (they never notice). func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { - return svc.store.listFriends(ctx, accountID) + ids, err := svc.store.listFriends(ctx, accountID) + if err != nil { + return nil, err + } + return svc.dropBlocked(ctx, accountID, ids) } // ListIncomingRequests returns the account IDs that have a live (not yet expired) -// pending friend request awaiting accountID's response. +// pending friend request awaiting accountID's response, with any from a requester the +// caller has blocked filtered out (their suppressed request stays stored but unseen). func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { - return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) + ids, err := svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) + if err != nil { + return nil, err + } + return svc.dropBlocked(ctx, accountID, ids) +} + +// dropBlocked removes from ids every account the viewer has blocked. It keeps the +// asymmetric block one-directional: the blocker stops seeing those they blocked in their +// friend / incoming-request lists, while the blocked user's lists are never filtered. +func (svc *Service) dropBlocked(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) { + if len(ids) == 0 { + return ids, nil + } + blockedIDs, err := svc.store.listBlocks(ctx, viewer) + if err != nil { + return nil, err + } + if len(blockedIDs) == 0 { + return ids, nil + } + blocked := make(map[uuid.UUID]struct{}, len(blockedIDs)) + for _, id := range blockedIDs { + blocked[id] = struct{}{} + } + out := make([]uuid.UUID, 0, len(ids)) + for _, id := range ids { + if _, b := blocked[id]; b { + continue + } + out = append(out, id) + } + return out, nil } // ListOutgoingRequests returns the account IDs the caller has already requested and diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index f571f30..ac86a1b 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -80,6 +80,10 @@ var ( ErrMessageNotFound = errors.New("social: message not found") // ErrChatBlocked is returned when the sender has disabled chat for themselves. ErrChatBlocked = errors.New("social: chat is disabled for this account") + // ErrRecipientBlocked is returned when a player tries to chat to, or nudge, someone + // they have blocked — the blocker-side guard behind the hidden composer (a blocked + // player's own sends are never refused, only silently suppressed, so they never notice). + ErrRecipientBlocked = errors.New("social: cannot message a blocked player") // ErrMessageTooLong is returned when a chat message exceeds the rune limit. ErrMessageTooLong = errors.New("social: message exceeds the length limit") // ErrEmptyMessage is returned when a chat message is blank after trimming. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 489754c..f28570c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -470,7 +470,9 @@ disguised robot stays indistinguishable from a person. (or joins a different player's) rather than returning the caller's own, so choosing "random opponent" again always starts a new search (bounded by the simultaneous quick-game cap, §9). Matchmaking state is therefore the **open games in the database** (not - an in-memory pool), so it survives a restart and stays anonymous (no block check); + an in-memory pool), so it survives a restart and stays anonymous beyond one filter — a +player is never paired into a game whose waiting opponent they have a per-user block with, +in either direction (the enqueue excludes the caller's `BlockedWith` set); concurrent enqueues for one bucket are serialised by a transaction-scoped advisory lock so two callers pair rather than each opening a game. A background **reaper** seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus @@ -506,12 +508,19 @@ disguised robot stays indistinguishable from a person. expires after **30 days** and may be re-sent), or **decline** — a decline is remembered (`status='declined'`) and blocks further requests from that sender, unless they hand them a code, which overrides it. The requester's own cancel still - deletes the row; blocking someone severs an existing friendship. (Discovery by - friend list or platform deep-link is future work.) + deletes the row. (Discovery by friend list or platform deep-link is future work.) - **Block**: two independent **global** account toggles (`block_chat`, `block_friend_requests`) **plus** a **per-user block list**. A per-user block is - applied mutually: it hides the pair's chat from each other and refuses friend - requests and game invitations between them. + **asymmetric and non-destructive**: the blocker stops receiving everything **from** the + blocked user — chat, nudges, friend requests, game invitations, and auto-match (§6) never + pairs them — while the blocked user notices nothing. Their sends still persist by the normal + rules but are never delivered or surfaced to the blocker (a directional `blockExists` check + drives this: the blocker filters/refuses, the blocked is silently suppressed and born-read), + and applying a block also marks read any unread the blocked user had left for the blocker. It + **overrides but does not delete** a friendship (an unblock cleanly restores it), so a pair may + be friends **and** blocked at once; the admin user card shows the full truth (blocks, + blocked-by, friends) regardless of this suppression. Block/unblock emit a `user_blocked` / + `user_unblocked` notification to the blocker only (§10). - **Friend games**: formed by **invitation → accept** (an `game_invitations` record with one row per invitee). The 2–4 player game starts once **every** invitee accepts; any decline cancels the invitation, and a pending invitation @@ -693,7 +702,10 @@ opponent card and the resign/chat controls update **in place**; **game-over** ca carry the changed account / invitation, so the client patches its lobby lists in place: **invitation** and **invitation-update** carry the full invitation, and the client upserts a still-pending one and drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel -too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` / +too, fresh from any screen without a refetch. The **user-blocked** / **user-unblocked** sub-kinds +confirm a per-user block change to the **blocker only** (never the blocked user), carrying the other +account, so the blocker's open game screens re-derive the block / add-friend controls and the struck +name in place across sessions. The move-commit **response** (`submit_play` / `pass` / `exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby **preloads** the player's ongoing games — each one's `game.state`, `game.history` and saved @@ -720,8 +732,9 @@ localized message with a Mini App deep-link button — only when the recipient h identity and has not confined notifications to the app, so the two channels never duplicate. The connector routes by that language to the matching bot and renders the message in it. The out-of-app set is your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds; -the connector renders the message and skips the rest — so the in-app-only **invitation-update** (a -response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts +the connector renders the message and skips the rest — so in-app-only sub-kinds like +**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a +block-state sync to the blocker) never become a platform push. Operator broadcasts (`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an **operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 01c160d..200a226 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -179,14 +179,26 @@ digits, valid for twelve hours), or send a **request to someone you have played with** — they accept, ignore it (a request lapses after thirty days and can then be re-sent), or decline (a decline blocks further requests from you until they hand you a code). Cancelling your own pending request withdraws it; unfriending removes the -friendship. In a game, each opponent's score card carries an **add-to-friends 🤝** control (while the -move history is open) that mirrors the live relationship: it confirms with a tap on a fading -✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is -pending or was declined, and **disappears** once you are friends — updating in place the -moment the opponent answers, and staying correct across reloads. Block globally — switch off incoming chat -and/or friend requests — and block individual players (a per-user block hides that -person's chat and stops requests and game invitations both ways; it also ends any -existing friendship). Per-game chat is for quick reactions: messages are short +friendship. In a game, each opponent's score card (while the move history is open) carries two controls: +an **add-to-friends 🤝** on the right and a **block ✖️** on the left. Each confirms with a tap +on a fading ✅ — the card reads *Add friend?* (or *Block?*, in red) in place of the score while +confirming — and while one is confirming the other is hidden so they never overlap. The 🤝 +goes **disabled** while a request is pending or was declined and **disappears** once you are +friends; both controls **disappear and the opponent's name is struck through** once you have +blocked them. They update in place the moment the relationship changes and stay correct across +reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a +live event; a transport failure rolls the control back to its prior state. + +Block globally — switch off incoming chat and/or friend requests — or block an **individual +player**. A per-user block is **one-directional and silent**: you stop receiving everything +**from** that person — chat, nudges, friend requests and game invitations — and the matchmaker +never pairs you with them, while they **notice nothing**. Their messages and nudges still send +by the normal rules but never reach you (anything that would show as unread for you is marked +read at once), and a friend request or invitation they send you is kept but never surfaces. A +block **overrides but does not delete** an existing friendship (so you may block a friend, and +they keep seeing you as one); active games are never interrupted — you can finish them, with +the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card +mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only. Per-game chat is for quick reactions: messages are short (up to 60 characters) and may not contain links, email addresses or phone numbers, even disguised. You may send **one message per turn, on your own turn**; once it is sent the field gives way to a short caption until your next turn. Nudge the player whose turn diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 18139ff..caeeec0 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -183,14 +183,27 @@ nudge) приходят от бота **этой партии** — по язы тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки -снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол -**в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается -тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится -**неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия — -обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие -чат и/или заявки — -и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки -и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат +снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника (при открытой +истории ходов) несёт два контрола: **в друзья 🤝** справа и **блокировка ✖️** слева. Каждый +подтверждается тапом по затухающей ✅ — на месте счёта карточка показывает *В друзья?* (или +*Блокируем?* красным) во время подтверждения, — и пока подтверждается один, второй скрыт, чтобы +они не пересекались. 🤝 становится **неактивным**, пока заявка висит или была отклонена, и +**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым** +после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после +перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым +событием; при сбое транспорта контрол возвращается в прежнее состояние. + +Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного +игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека +всё — чат, nudge, заявки в друзья и приглашения в игру, — и матчмейкер никогда не сводит вас с +ним, а он **ничего не замечает**. Его сообщения и nudge по-прежнему отправляются по обычным +правилам, но не доходят до вас (всё, что было бы для вас непрочитанным, сразу помечается +прочитанным), а присланные им заявка или приглашение сохраняются, но никогда не всплывают. Блок +**перекрывает, но не удаляет** имеющуюся дружбу (поэтому можно заблокировать друга, а он +продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у +заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в +партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей** +есть только там. Чат партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 8e428cc..e8369d7 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -131,10 +131,14 @@ except Login uses `Screen`. (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian game with "multiple words per turn" off) centres a **"One word per turn"** label between those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that - yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains a - 🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that - confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed - (see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge. + yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two + edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend, + disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap, + swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while + armed (see Controls); while one is armed the other hides so they never overlap. Once the + opponent is **blocked** both controls go and the **name is struck through** (and the comms-hub + chat composer is hidden — only the log remains). The name and score stay centred — the controls + are pinned to the card's edges. Unread chat is also badged on the score bar itself, so it shows with the history closed. - **Vertical fit & keyboard**: when the game does not fit the viewport, only the board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab @@ -180,8 +184,8 @@ except Login uses `Screen`. tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade under reduce-motion, but the window still holds); a tap on the ✅ within it confirms, otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing - the old press-and-hold popover) and the in-game **add-friend 🤝**. The - **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action). + the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card + controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action). - **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent button) beside the rack commits the move — no popover, and disabled while the pending word diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index db219a2..d07d1ec 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -193,6 +193,29 @@ test('game: the add-friend 🤝 confirms with a tap and then reads as sent', asy await expect(add).toBeDisabled(); }); +test('game: the block ✖️ confirms in red, then hides both controls and strikes the name', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann + await page.locator('.scoreboard').click(); // open the history -> 🤝 and ✖️ appear on Ann's card + const block = page.getByRole('button', { name: 'Block player' }); + await expect(block).toBeVisible(); + await block.click(); // arm: ✖️ -> a fading ✅, the score caption turns to a red "Block?" + await expect(page.locator('.sc.blockprompt')).toHaveText('Block?'); + // While confirming a block the add-friend control is hidden so the two never overlap. + await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0); + await block.click(); // confirm within the window + // The block applied (optimistically): both controls disappear and the name is struck. + await expect(page.getByRole('button', { name: 'Block player' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0); + await expect(page.locator('.nm.struck')).toBeVisible(); + + // The chat composer is hidden for a blocked opponent — only the log remains, as in a + // finished game. + await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub + await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); +}); + test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 963d94f..e58199d 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -13,6 +13,7 @@ waiting = false, nudgeOnCooldown = false, vsAi = false, + blocked = false, onsend, onnudge, }: { @@ -38,6 +39,10 @@ // vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is // nobody to message or hurry. The fields still show (turn-driven), but disabled. vsAi?: boolean; + // blocked hides the whole composer (message field, send and nudge), leaving only the chat + // log — as in a finished game — when the viewer has blocked the opponent: there is no one + // they will message (the backend rejects it too). + blocked?: boolean; onsend: (text: string) => void; onnudge: () => void; } = $props(); @@ -65,6 +70,7 @@ {/if} {/each} + {#if !blocked}
{#if canSend} 🛎️ {/if}
+ {/if} diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte new file mode 100644 index 0000000..acdf4a5 --- /dev/null +++ b/ui/src/components/WelcomeRedeemModal.svelte @@ -0,0 +1,58 @@ + + +{#if app.welcomeRedeem} + +

{parts[0]}{#if username}{/if}{parts[1] ?? ''}

+ +
+{/if} + + diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index febab72..11256b9 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -18,7 +18,6 @@ landscape, variant, labelMode, - lines, locale, focus, recenter, @@ -41,8 +40,6 @@ landscape: boolean; variant: Variant; labelMode: BoardLabelMode; - /** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */ - lines: boolean; locale: Locale; focus: { row: number; col: number } | null; /** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state @@ -234,7 +231,7 @@
-
+
{#each board as rowCells, r (r)} {#each rowCells as cell, c (c)} {@const p = pending.get(key(r, c))} @@ -311,18 +308,21 @@ width 0.25s ease, height 0.25s ease; } + /* A gapless checkerboard with no grid lines (the board has no lined variant): plain cells + alternate shades and tiles get rounded corners plus a soft right-side shadow so adjacent + gapless tiles still read as separate pieces. */ .grid { display: grid; grid-template-columns: repeat(15, 1fr); - gap: 1px; - background: var(--cell-line); - padding: 1px; + gap: 0; + background: var(--board-bg); + padding: 0; } .cell { position: relative; aspect-ratio: 1; border: none; - border-radius: 1px; + border-radius: 0; background: var(--cell-bg); color: var(--prem-text); /* No mobile tap flash on a cell tap (parity with the web click; the only intentional @@ -344,11 +344,20 @@ .cell.dl { background: var(--prem-dl); } + .cell.dark { + /* A gentle checkerboard tint: enough to read the alternation without competing with the + bonus cells, standing in for the grid lines that once separated the cells. Lightened + from a stronger mix that looked too contrasty in light theme. */ + background: color-mix(in srgb, var(--cell-bg) 94%, #000); + } .cell.filled, .cell.pending { background: var(--tile-bg); color: var(--tile-text); - box-shadow: inset 0 -2px 0 var(--tile-edge); + border-radius: 4px; + box-shadow: + inset 0 -2px 0 var(--tile-edge), + 2px 0 3px -1px rgba(0, 0, 0, 0.4); } .cell.pending { background: var(--tile-pending); @@ -356,30 +365,6 @@ board) instead of the touch starting a board pan. */ touch-action: none; } - /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they - reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles - get rounded corners and a soft right-side shadow so adjacent gapless tiles still read - as separate pieces. */ - .grid.gridless { - gap: 0; - padding: 0; - background: var(--board-bg); - } - .grid.gridless .cell { - border-radius: 0; - } - .grid.gridless .cell.dark { - /* A gentle checkerboard tint: enough to read the alternation without competing with the - bonus cells. Lightened from a stronger mix that looked too contrasty in light theme. */ - background: color-mix(in srgb, var(--cell-bg) 94%, #000); - } - .grid.gridless .cell.filled, - .grid.gridless .cell.pending { - border-radius: 4px; - box-shadow: - inset 0 -2px 0 var(--tile-edge), - 2px 0 3px -1px rgba(0, 0, 0, 0.4); - } .cell.droptarget { /* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the target reads clearly while dragging without obscuring the bonus label underneath. */ diff --git a/ui/src/game/CommsHub.svelte b/ui/src/game/CommsHub.svelte index 0c0fecc..6ee7f9e 100644 --- a/ui/src/game/CommsHub.svelte +++ b/ui/src/game/CommsHub.svelte @@ -39,11 +39,11 @@ {#snippet tabbar()} {#if active} {/if} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 5358a99..f22d153 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -813,6 +813,8 @@ // - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled // to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag // is the stage's own vertical scroll (the conflict that once retired this open gesture). + // On that same closed, zoom-out board an upward swipe with no staged tiles shuffles the + // rack (a convenience mirror of the shuffle control). // Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of // view, which left a stale-open state that made a follow-up score-bar tap "jump" the board). let stageEl = $state(); @@ -869,6 +871,14 @@ if (-dy > 32) closeHistoryByGesture(); // enough upward travel } else if (dy > 40 && dy > Math.abs(dx) * 1.4) { openHistoryByGesture(); // a clear, vertical-dominant downward pull + } else if (-dy > 40 && -dy > Math.abs(dx) * 1.4 && placement.pending.length === 0) { + // The same closed-board arm, opposite direction: a clear upward swipe on the zoom-out + // board with no staged tiles shuffles the rack (a convenience mirror of the shuffle + // control). Disarm and swallow the synthesised click so it cannot also place a tile. + shuffle(); + boardSwipe = null; + swallowClick = true; + setTimeout(() => (swallowClick = false), 120); } } function onBoardWrapUp(e: PointerEvent) { @@ -1156,7 +1166,6 @@ {landscape} {variant} labelMode={app.boardLabels} - lines={app.boardLines} locale={app.locale} {focus} {recenter} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 9f2154c..e820771 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -83,7 +83,12 @@ } .tile { position: relative; - flex: 0 0 auto; + /* Sit at the natural tile width, but shrink to fit when the row is crowded so a full + rack never overflows its wrapper onto the MakeMove control to its right (the confirm + button stays pinned at the right screen edge). flex-grow stays 0 so a sparse rack does + not stretch its tiles. */ + flex: 0 1 auto; + min-width: 0; width: min(12.5vw, 46px); aspect-ratio: 1; background: var(--tile-bg); diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index c1e529f..20e8418 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -56,8 +56,6 @@ export const app = $state<{ locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; - /** Draw grid lines between board cells; off (default) is a gapless checkerboard. */ - boardLines: boolean; localeLocked: boolean; /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ notifications: number; @@ -72,6 +70,10 @@ export const app = $state<{ * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to * the bot instead of a scary error on the Friends screen. */ staleInvite: boolean; + /** Whether to show the Telegram "welcome" window: set when a deep-link friend code is + * redeemed successfully inside Telegram, greeting the arriving player and pointing them at + * the bot for the most convenient way to play. */ + welcomeRedeem: boolean; /** Monotonic counter bumped when the app returns to the foreground without the live stream * having dropped. An open game watches it to refetch once, recovering an in-game event shed * from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */ @@ -88,12 +90,12 @@ export const app = $state<{ locale: 'en', reduceMotion: false, boardLabels: 'beginner', - boardLines: false, localeLocked: false, notifications: 0, chatUnread: {}, feedbackReplyUnread: false, staleInvite: false, + welcomeRedeem: false, resync: 0, }); @@ -143,7 +145,18 @@ function goForeground(): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void { app.toast = { kind, text, seq: ++toastSeq }; if (toastTimer) clearTimeout(toastTimer); - toastTimer = setTimeout(() => (app.toast = null), 4000); + // An info bubble lives exactly as long as its rise-and-fade animation (2s); an error + // dwells longer (4s) so it is not missed. + toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000); +} + +/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */ +export function dismissToast(): void { + if (toastTimer) { + clearTimeout(toastTimer); + toastTimer = null; + } + app.toast = null; } /** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */ @@ -151,6 +164,11 @@ export function dismissStaleInvite(): void { app.staleInvite = false; } +/** dismissWelcomeRedeem hides the Telegram welcome window (the user acknowledged it). */ +export function dismissWelcomeRedeem(): void { + app.welcomeRedeem = false; +} + /** * seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby * list, a game's state, or a move result). The live-event GameView omits the flag, so the live @@ -476,7 +494,6 @@ export async function bootstrap(): Promise { app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; app.boardLabels = prefs.boardLabels ?? 'beginner'; - app.boardLines = prefs.boardLines ?? false; applyTheme(app.theme); applyReduceMotion(app.reduceMotion); if (prefs.locale) { @@ -560,7 +577,13 @@ async function routeStartParam(param: string): Promise { try { const friend = await gateway.friendCodeRedeem(link.code); navigate('/friends'); - showToast(t('friends.added', { name: friend.displayName })); + // Inside Telegram a successful invite-link redeem welcomes the arriving player and + // points them at the bot (its native convenience); elsewhere a quiet toast suffices. + if (insideTelegram()) { + app.welcomeRedeem = true; + } else { + showToast(t('friends.added', { name: friend.displayName })); + } void refreshNotifications(); } catch (err) { const code = err instanceof GatewayError ? err.code : ''; @@ -633,7 +656,6 @@ function persistPrefs(): void { locale: app.locale, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels, - boardLines: app.boardLines, }); } @@ -686,11 +708,6 @@ export function setBoardLabels(mode: BoardLabelMode): void { persistPrefs(); } -export function setBoardLines(on: boolean): void { - app.boardLines = on; - persistPrefs(); -} - // Background/foreground lifecycle: silence the reconnect banner during a suspend and // reconnect quietly on return (and refresh the lobby badge for any push missed while // hidden, §10). Several signals cover the platforms: the page Visibility API, the diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 27da867..0b45a14 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -163,7 +163,6 @@ export const en = { 'settings.labelsBeginner': 'Beginner', 'settings.labelsClassic': 'Classic', 'settings.labelsNone': 'None', - 'settings.boardLines': 'Grid lines', 'settings.reduceMotion': 'Reduce motion', 'about.title': 'About', @@ -241,6 +240,8 @@ export const en = { 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", 'friends.staleInviteTitle': 'Link expired', 'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.', + 'friends.welcomeRedeemTitle': 'Welcome!', + 'friends.welcomeRedeem': 'Welcome, {name}!\n\nUse {bot} to join games the most convenient way.', 'friends.added': 'Added {name}.', 'friends.blockedList': 'Blocked players', 'friends.unblock': 'Unblock', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 2482f88..661bf7b 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -164,7 +164,6 @@ export const ru: Record = { 'settings.labelsBeginner': 'Новичок', 'settings.labelsClassic': 'Классика', 'settings.labelsNone': 'Без текста', - 'settings.boardLines': 'Линии сетки', 'settings.reduceMotion': 'Меньше анимаций', 'about.title': 'О программе', @@ -242,6 +241,8 @@ export const ru: Record = { 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', 'friends.staleInviteTitle': 'Ссылка устарела', 'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.', + 'friends.welcomeRedeemTitle': 'Добро пожаловать!', + 'friends.welcomeRedeem': 'Добро пожаловать, {name}!\n\nВоспользуйтесь {bot}, чтобы заходить в игру наиболее удобным способом.', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index 5c5a8dd..f4f703d 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -124,8 +124,6 @@ export interface Prefs { locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; - /** Draw the 1px grid lines between cells; off (default) shows a gapless checkerboard. */ - boardLines: boolean; } export async function loadPrefs(): Promise> { diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 9b5f985..7ee2060 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -380,8 +380,8 @@ align-items: center; justify-content: center; border: none; - background: var(--danger); - color: #fff; + background: var(--bg-elev); + color: var(--text); font-size: 1.1rem; } .row { @@ -404,7 +404,7 @@ gap: 12px; min-width: 0; text-align: left; - padding: 10px 6px; + padding: 5px 6px; border: none; background: none; color: var(--text); @@ -471,7 +471,7 @@ color: var(--text-muted); } .emoji { - font-size: 1.8rem; + font-size: 1.35rem; line-height: 1; flex: 0 0 auto; } diff --git a/ui/src/screens/Settings.svelte b/ui/src/screens/Settings.svelte index 520739c..6fd33e3 100644 --- a/ui/src/screens/Settings.svelte +++ b/ui/src/screens/Settings.svelte @@ -2,7 +2,6 @@ import { app, setBoardLabels, - setBoardLines, setLocalePref, setReduceMotion, setTheme, @@ -62,14 +61,6 @@ {/each}
-
@@ -124,7 +115,4 @@ align-items: center; justify-content: space-between; } - .gridlines { - margin-top: 12px; - } diff --git a/ui/src/screens/SettingsHub.svelte b/ui/src/screens/SettingsHub.svelte index bdc1d9c..b570fb2 100644 --- a/ui/src/screens/SettingsHub.svelte +++ b/ui/src/screens/SettingsHub.svelte @@ -46,18 +46,18 @@ {#snippet tabbar()} {#if !guest} {/if} {/snippet} -- 2.52.0 From b5688d4848656201a187ff631dc011ae277b5eb8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 09:17:39 +0200 Subject: [PATCH 183/223] fix(ui): right-align the confirm-move glyph under the preview caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ✅ box already ended at var(--pad) from the screen edge, but place-items: center centred the glyph inside the 56px box, so it sat ~14px left of the green preview caption (.scores, text-align:right at the same var(--pad)). Right-align the glyph (place-items: center end) so its right edge lands under the caption's. --- ui/src/game/Game.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f22d153..f25cc9b 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1471,14 +1471,16 @@ min-width: 0; } /* A borderless icon button (like the tab bar), not a filled accent button — and disabled - while the pending word is known to be illegal. */ + while the pending word is known to be illegal. The glyph is right-aligned within its box so + its right edge lands under the right edge of the preview caption above it (both sit at + var(--pad) from the screen edge — .status and .rack-row share that padding). */ .make { min-width: 56px; background: none; color: var(--text); border: none; display: grid; - place-items: center; + place-items: center end; font-size: 1.8rem; } .make:disabled { -- 2.52.0 From 4adb608ad18d8b3c8d8b5bcf2248ed0a44b64201 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 09:54:30 +0200 Subject: [PATCH 184/223] feat(ui): vs-AI comms trim, overlay confirm button, recall-to-slot drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI game has no comms at all, so its 💬 history-header entry is hidden too. - Confirm-move ✅: redone as an absolute overlay pinned to the right edge (its right edge sits under the preview caption), so the rack keeps a fixed tile size — this reverts the tile-shrink that resized the letters at 6 tiles. - Recall: dragging a placed tile back to the rack now drops it at the slot the pointer is over (drag-to-position, a gap opens), instead of snapping to its original slot; double-tap still recalls to the origin. New pure recallToSlot. Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e updated. Docs: UI_DESIGN + FUNCTIONAL (+ru). --- docs/FUNCTIONAL.md | 6 +++-- docs/FUNCTIONAL_ru.md | 6 +++-- docs/UI_DESIGN.md | 15 +++++++----- ui/e2e/game.spec.ts | 22 ++++++++++++++++++ ui/e2e/quickmatch.spec.ts | 24 ++++++++++--------- ui/src/game/CommsHub.svelte | 21 +++++++++++------ ui/src/game/Game.svelte | 45 +++++++++++++++++++++++++++--------- ui/src/game/Rack.svelte | 7 +----- ui/src/lib/placement.test.ts | 33 ++++++++++++++++++++++++++ ui/src/lib/placement.ts | 45 ++++++++++++++++++++++++++++++++++++ 10 files changed, 179 insertions(+), 45 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f541828..0a5130c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -93,7 +93,7 @@ invitation — so a player still sees and plays existing games of any language. **Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**. With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting, -chat and nudge are off, add-friend is never shown, and the word-check tool still works; instead of a +there is no chat or nudge — only the word-check tool — add-friend is never shown; instead of a per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely, and the AI itself never runs out of time). Choosing a **random player** is auto-match (always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you @@ -211,7 +211,9 @@ is awaited at most once per hour (the nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered via the platform) **name the nudger** (": waiting for your move"). Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** -tabs, reached from the 💬 in the move-history header (with a back to the game). An unread chat +tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no +chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the +dictionary closed) the 💬 entry disappears entirely. An unread chat entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 6e633ad..cc7ae87 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -96,7 +96,7 @@ nudge) приходят от бота **этой партии** — по язы **Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ** вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge -выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы +нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который сразу **помещает вас в игру, и вы ждёте соперника прямо @@ -217,7 +217,9 @@ nudge) приходят от бота **этой партии** — по язы и внеприложенческий push (доставляется через платформу) **называют отправителя** («<соперник>: жду вашего хода»). Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, -открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата — +открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её +экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход +💬 исчезает. Непрочитанная запись чата — сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в **лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 1443cc6..30ad8e7 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -32,7 +32,8 @@ except Login uses `Screen`. bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the back control always returns to the hub's parent (Settings → lobby, comms → game). Settings hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests); - comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The + comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active; an + honest-AI game has no Chat, so it shows the Dictionary alone). The routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The **Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so @@ -110,8 +111,9 @@ except Login uses `Screen`. 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. + the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A + **double-tap** returns the tile to its original rack slot; **dragging it back** drops it at the + rack slot the pointer is over (a gap opens there), the same drag-to-position as a rack reorder. - **Players plaque & history** (`Game.svelte`): 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 on the plaque toggles @@ -284,8 +286,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI** selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"** and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖** - everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's - send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working. + everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and there is + **no chat at all**: the comms hub drops the 💬 Chat tab and offers only the 🔎 dictionary + word-check (the 🛎️ nudge is likewise gone). - **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) — pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's @@ -318,7 +321,7 @@ enabled on the first, uncached load) and flip in place when an event refreshes t never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: it closes the history drawer (portrait) and zooms the board out. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header - offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab; and + offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input row is turn-driven: on your turn the message field shows until your one allowed message is diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 310be23..e49c028 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -284,6 +284,28 @@ test('a placed tile drags from one board cell to another (relocation)', async ({ expect(to).not.toBe(from); }); +test('a placed tile drags back to a chosen rack slot (recall-to-position)', async ({ page }) => { + await openGame(page); + // Place the first rack tile (slot 0) on the board, remembering its letter. + await page.locator('.rack .tile').first().click(); + const placed = (await page.locator('.rack .tile .letter').first().textContent()) ?? ''; + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + + // Drag it from the board back onto a later rack slot (the 4th visible tile), not its origin. + const fb = await pending.first().boundingBox(); + const slot = await page.locator('[data-rack] .tile').nth(3).boundingBox(); + await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2); + await page.mouse.down(); + await page.mouse.move(slot!.x + 2, slot!.y + slot!.height / 2, { steps: 10 }); + await page.mouse.up(); + + // Recalled (no pending) and reinserted at the drop slot — slot 3, not its origin slot 0. + await expect(pending).toHaveCount(0); + await expect(page.locator('[data-rack] .tile .letter').nth(3)).toHaveText(placed); +}); + test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 59d9a3a..3561745 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -50,18 +50,20 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible(); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); - // Chat is disabled (the message field), while the dictionary word-check stays usable. + // An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary + // alone, which stays usable. await page.locator('.scoreboard').click(); // open the history - await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub + await page.getByRole('button', { name: 'Chat' }).click(); // 💬 (the history-header entry) -> comms hub await expect(page).toHaveURL(/\/game\/.+\/chat$/); - await expect(page.locator('.chat input')).toBeDisabled(); - await page.getByRole('button', { name: 'Dictionary' }).click(); - await expect(page.locator('.check input')).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab + await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts + await expect(page.locator('.check input')).toBeEnabled(); // the Dictionary is the only surface }); -// GCG export is pointless for a practice AI game, so the finished AI game hides the 📤 export -// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header. -test('AI game: no GCG export offered after it ends', async ({ page }) => { +// GCG export is pointless for a practice AI game, and a finished AI game has no comms at all +// (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms +// entry are gone. Start an AI game, resign it, reopen the history and check the header. +test('AI game: after it ends, no GCG export and no comms entry', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /🎲/ }).click(); @@ -75,11 +77,11 @@ test('AI game: no GCG export offered after it ends', async ({ page }) => { await page.locator('button.danger').click(); await expect(page.locator('.status .over')).toBeVisible(); - // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export, - // while the comms hub stays reachable. + // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and + // no comms entry at all. await page.locator('.scoreboard').click(); await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); }); // The opponent_joined push is best-effort and never replayed, so a join that lands while the live diff --git a/ui/src/game/CommsHub.svelte b/ui/src/game/CommsHub.svelte index 6ee7f9e..0cac7fe 100644 --- a/ui/src/game/CommsHub.svelte +++ b/ui/src/game/CommsHub.svelte @@ -8,19 +8,24 @@ // The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word // Dictionary. Tabs switch in place, so the back control always returns to the game. The - // Dictionary tab is offered only while the game is active (mirrors the old "check word"). + // Dictionary tab is offered only while the game is active (mirrors the old "check word"); an + // honest-AI game has no Chat, so it shows the Dictionary alone. type CommsTab = 'chat' | 'dictionary'; let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props(); // The game is rendered (and cached) before its comms open, so the cache tells us whether // it is still active without another fetch; an unknown game keeps the Dictionary offered. const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); - // Seeded once from the entry route's tab and then owned locally; the effect below - // corrects a Dictionary deep-link into a finished game back to Chat. + // An honest-AI game has no chat at all, so its hub is Dictionary-only. + const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); + // Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid: + // an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary + // deep-link falls back to Chat). // svelte-ignore state_referenced_locally let tab = $state(initialTab); $effect(() => { - if (tab === 'dictionary' && !active) tab = 'chat'; + if (vsAi) tab = 'dictionary'; + else if (tab === 'dictionary' && !active) tab = 'chat'; }); @@ -38,9 +43,11 @@ {#snippet tabbar()} - + {#if !vsAi} + + {/if} {#if active} {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - + + {#if !(gameOver && view.game.vsAi)} + + {/if}
@@ -1456,9 +1473,9 @@ white-space: nowrap; } .rack-row { + position: relative; display: flex; flex: none; - gap: 8px; align-items: stretch; padding: 0 var(--pad) 6px; } @@ -1471,11 +1488,17 @@ min-width: 0; } /* A borderless icon button (like the tab bar), not a filled accent button — and disabled - while the pending word is known to be illegal. The glyph is right-aligned within its box so - its right edge lands under the right edge of the preview caption above it (both sit at - var(--pad) from the screen edge — .status and .rack-row share that padding). */ + while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the + rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize + when a tile is staged), and the button is absolutely pinned over the slots a staged tile + frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the + preview caption above (.status shares that padding). */ .make { - min-width: 56px; + position: absolute; + right: var(--pad); + top: 0; + bottom: 6px; + width: 56px; background: none; color: var(--text); border: none; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index e820771..9f2154c 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -83,12 +83,7 @@ } .tile { position: relative; - /* Sit at the natural tile width, but shrink to fit when the row is crowded so a full - rack never overflows its wrapper onto the MakeMove control to its right (the confirm - button stays pinned at the right screen edge). flex-grow stays 0 so a sparse rack does - not stretch its tiles. */ - flex: 0 1 auto; - min-width: 0; + flex: 0 0 auto; width: min(12.5vw, 46px); aspect-ratio: 1; background: var(--tile-bg); diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index 0f388d9..241b9e6 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -9,6 +9,7 @@ import { rackView, recallAt, recallIndex, + recallToSlot, reorderIndices, reset, toSubmit, @@ -112,3 +113,35 @@ describe('reorderIndices', () => { expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end }); }); + +describe('recallToSlot', () => { + it('reinserts the recalled tile at the chosen visible slot, reordering the rack', () => { + // Lift Q (slot 1) onto the board, then recall it to visible slot 4. + const p = place(newPlacement(rack), 1, 7, 7); + const { placement, order } = recallToSlot(p, 7, 7, 4); + expect(placement.pending).toHaveLength(0); + expect(placement.rack).toEqual(['A', BLANK, 'N', 'I', 'Q', 'W', 'E']); + expect(order).toEqual([0, 2, 3, 4, 1, 5, 6]); + }); + + it('counts only still-visible slots and remaps the remaining pending tiles', () => { + // A (slot 0) and N (slot 3) are on the board; recall A to visible slot 2 — the placed N is + // skipped, and N's rackIndex follows the rack permutation. + let p = place(newPlacement(rack), 0, 7, 7); + p = place(p, 3, 7, 8); + const { placement, order } = recallToSlot(p, 7, 7, 2); + expect(placement.rack).toEqual(['Q', BLANK, 'N', 'A', 'I', 'W', 'E']); + expect(order).toEqual([1, 2, 3, 0, 4, 5, 6]); + expect(placement.pending).toHaveLength(1); + expect(placement.pending[0].rackIndex).toBe(2); + expect(placement.rack[placement.pending[0].rackIndex]).toBe('N'); + }); + + it('clamps the slot to the end and is an identity no-op when no tile sits at the cell', () => { + const p = place(newPlacement(rack), 1, 7, 7); + expect(recallToSlot(p, 7, 7, 99).placement.rack).toEqual(['A', BLANK, 'N', 'I', 'W', 'E', 'Q']); + const none = recallToSlot(p, 0, 0, 0); + expect(none.placement).toBe(p); + expect(none.order).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); +}); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts index 28727e8..8c551d6 100644 --- a/ui/src/lib/placement.ts +++ b/ui/src/lib/placement.ts @@ -102,6 +102,51 @@ export function recallIndex(p: Placement, rackIndex: number): Placement { return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) }; } +/** + * recallToSlot recalls the pending tile at (row, col) and reinserts its rack letter at the + * given visible slot, reordering the rack the way a drag-to-reorder does (the drop position the + * player chose) instead of snapping the tile back to its original slot. It returns the new + * placement together with the index permutation applied to the rack, so the caller can permute + * its parallel stable ids in lockstep. toSlot counts the slots that stay visible after the + * recall (those still on the board are skipped); it is clamped to a valid position. The result + * is the unchanged placement and an identity permutation when no pending tile sits at (row, col). + */ +export function recallToSlot( + p: Placement, + row: number, + col: number, + toSlot: number, +): { placement: Placement; order: number[] } { + const identity = p.rack.map((_, i) => i); + const tile = p.pending.find((t) => t.row === row && t.col === col); + if (!tile) return { placement: p, order: identity }; + const ri = tile.rackIndex; + const rest = p.pending.filter((t) => t !== tile); + const usedByRest = new Set(rest.map((t) => t.rackIndex)); + // Reinsert ri just before the toSlot-th slot that stays visible after the recall. + const others = identity.filter((i) => i !== ri); + let visible = 0; + let insertPos = others.length; + for (let pos = 0; pos < others.length; pos++) { + if (usedByRest.has(others[pos])) continue; + if (visible === toSlot) { + insertPos = pos; + break; + } + visible++; + } + const order = [...others]; + order.splice(insertPos, 0, ri); + const newIndex = new Map(order.map((oldIdx, idx) => [oldIdx, idx])); + return { + placement: { + rack: order.map((i) => p.rack[i]), + pending: rest.map((t) => ({ ...t, rackIndex: newIndex.get(t.rackIndex)! })), + }, + order, + }; +} + export function reset(p: Placement): Placement { return { ...p, pending: [] }; } -- 2.52.0 From b156d3403c1624011a6ae51575a51a46e77e87b5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 10:12:48 +0200 Subject: [PATCH 185/223] =?UTF-8?q?fix(ui):=20polish=20board=E2=86=92rack?= =?UTF-8?q?=20recall=20drag=20and=20its=20gesture=20interplay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The rack now animates the opening drop gap for a board-tile recall drag too (the reorder transition was gated on a rack-source drag only). - While a board tile is dragged over the rack (a recall, gap shown), the ✅ confirm and its preview caption are hidden so they don't sit over the gap; they return if the tile is dragged back out over the board. - A recall drop lands off the boardwrap, so its pointerup never reached the boardwrap handler and left a stale id in the active-pointer set — the next board swipe then read as multi-touch and the shuffle / history pull went dead. Reconcile the pointer when a drag ends or cancels. Regression e2e added. --- ui/e2e/history.spec.ts | 23 +++++++++++++++++++++++ ui/src/game/Game.svelte | 14 ++++++++++++-- ui/src/game/Rack.svelte | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/ui/e2e/history.spec.ts b/ui/e2e/history.spec.ts index 53b19dc..662e75e 100644 --- a/ui/e2e/history.spec.ts +++ b/ui/e2e/history.spec.ts @@ -43,6 +43,29 @@ test('a swipe-down on the zoom-out board opens the history', async ({ page }) => await expect(page.locator('.boardwrap.slid')).toBeVisible(); }); +test('a recall drag onto the rack does not break the next board swipe', async ({ page }) => { + await openGame(page); + // Place a tile, then drag it back onto the rack — the recall's pointerup lands off the + // boardwrap, which once left a stale pointer there so the next swipe read as multi-touch. + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + const fb = (await pending.first().boundingBox())!; + const slot = (await page.locator('[data-rack] .tile').nth(2).boundingBox())!; + await page.mouse.move(fb.x + fb.width / 2, fb.y + fb.height / 2); + await page.mouse.down(); + await page.mouse.move(slot.x + 2, slot.y + slot.height / 2, { steps: 10 }); + await page.mouse.up(); + await expect(pending).toHaveCount(0); + + // The swipe-down still opens the history (the recall pointer was reconciled). + await page.locator('.stage').evaluate((el) => (el.scrollTop = 0)); + const box = (await page.locator('.boardwrap').boundingBox())!; + await touchSwipeDown(page, box.y + 20, box.y + 180); + await expect(page.locator('.history')).toBeVisible(); +}); + test('the history is a per-seat grid with move scores but no names or running total', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history (gesture is covered separately) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2875ae9..75e3558 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -378,6 +378,10 @@ // While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell — // hidden from the board (the ghost stands in) like a lifted rack tile. let draggingPend = $state<{ row: number; col: number } | null>(null); + // A board tile dragged over the rack (a recall in progress, the rack showing a drop gap): the + // move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap; + // they return the moment the tile is dragged back out over the board. + const recallOverRack = $derived(draggingPend != null && reorderTo != null); let dragPointerId = -1; function beginDrag(src: DragSrc, e: PointerEvent) { @@ -398,6 +402,7 @@ window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); + boardPointers.delete(dragPointerId); // see onWinUp: don't leave a stale boardwrap pointer clearHover(); clearReorder(); downInfo = null; @@ -516,6 +521,11 @@ window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); + // A board-tile drag also registered this pointer on the boardwrap (the tile sits inside it), + // but it can be released off the boardwrap (e.g. a recall dropped on the rack), where the + // boardwrap's own pointerup never fires. Reconcile it here so no stale id is left to make the + // next swipe read as multi-touch and kill the shuffle / history-pull gesture. + boardPointers.delete(e.pointerId); clearHover(); const di = downInfo; downInfo = null; @@ -1205,7 +1215,7 @@ {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} - {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} + {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if}
{/if} @@ -1227,7 +1237,7 @@ ondown={onRackDown} />
- {#if !gameOver && placement.pending.length > 0} + {#if !gameOver && placement.pending.length > 0 && !recallOverRack} {/if} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 9f2154c..efe7cee 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -56,7 +56,7 @@ } -
+
{#each shown as slot, i (slot.id)} @@ -267,7 +269,7 @@ {opponents(g) || '—'} - {#if app.chatUnread[g.id]}{/if} + {#if badge}{/if} {scoreline(g)} @@ -458,7 +460,8 @@ overflow: hidden; text-overflow: ellipsis; } - /* A small red dot beside the opponent name: this game has an unread chat entry. */ + /* A small dot beside the opponent name: this game has an unread chat entry. Red for an unread + message, a softer amber when only nudges are unread. */ .unread-dot { flex: 0 0 auto; width: 8px; @@ -466,6 +469,9 @@ border-radius: 50%; background: var(--danger); } + .unread-dot.nudge { + background: var(--warn); + } .sub { font-size: 0.85rem; color: var(--text-muted); -- 2.52.0 From c127bc9f0eb0b8c57debe519f70a85af112b63de Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 21:39:27 +0200 Subject: [PATCH 190/223] feat(social): per-game friend request to disguised robots + lobby/stats/tile cosmetics Functional: the in-game add-friend handshake aimed at an auto-match opponent who is secretly a pooled robot now records the request per (game, seat) in a new robot_friend_requests table -- never against the shared robot account -- mirroring the robot_blocks pattern. The shared account stays out of friendships, the "requested" state is pinned to the seat (not leaked across the player's other games), the robot ignores it, and a background reaper drops the row 7 days after its game finishes. The outgoing-requests list carries these per-game rows so the seat control stays disabled across reloads. No withdraw UI, per owner decision. Cosmetics: - Lobby: an in-progress game tints the viewer's own score number green when leading or tied, red when trailing (reusing --ok/--danger); scores now render in seat-number order, matching the over-the-board scoreboard. - Stats: the per-variant best move is laid out on two lines -- the variant label, then the score (right-aligned in its column) and the word tiles (left-aligned) below it. - Dark theme: the played-tile background is a touch darker so a player-placed tile reads with more contrast (light theme unchanged). Docs (ARCHITECTURE, FUNCTIONAL + _ru mirror, backend README, UI_DESIGN) updated in the same change. --- backend/README.md | 3 + backend/cmd/backend/main.go | 7 + backend/internal/inttest/robot_friend_test.go | 82 ++++++++ .../00012_robot_friend_requests.sql | 28 +++ backend/internal/server/handlers_friends.go | 45 ++++- backend/internal/social/robotfriends.go | 175 ++++++++++++++++++ docs/ARCHITECTURE.md | 8 +- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 6 +- docs/UI_DESIGN.md | 14 +- gateway/internal/backendclient/api_social.go | 23 ++- gateway/internal/transcode/encode_social.go | 24 +++ .../internal/transcode/transcode_social.go | 4 +- .../transcode/transcode_social_test.go | 12 +- pkg/fbs/scrabble.fbs | 15 +- pkg/fbs/scrabblefb/OutgoingRequestList.go | 28 ++- pkg/fbs/scrabblefb/RobotFriendRef.go | 97 ++++++++++ ui/src/app.css | 2 +- ui/src/game/Game.svelte | 24 ++- ui/src/gen/fbs/scrabblefb.ts | 1 + .../fbs/scrabblefb/outgoing-request-list.ts | 32 +++- ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts | 82 ++++++++ ui/src/lib/client.ts | 10 +- ui/src/lib/codec.test.ts | 17 +- ui/src/lib/codec.ts | 21 ++- ui/src/lib/lobbysort.test.ts | 55 +++++- ui/src/lib/lobbysort.ts | 23 +++ ui/src/lib/mock/client.ts | 8 +- ui/src/lib/model.ts | 17 ++ ui/src/lib/transport.ts | 4 +- ui/src/screens/Lobby.svelte | 24 ++- ui/src/screens/Stats.svelte | 23 ++- 32 files changed, 854 insertions(+), 65 deletions(-) create mode 100644 backend/internal/inttest/robot_friend_test.go create mode 100644 backend/internal/postgres/migrations/00012_robot_friend_requests.sql create mode 100644 backend/internal/social/robotfriends.go create mode 100644 pkg/fbs/scrabblefb/RobotFriendRef.go create mode 100644 ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts diff --git a/backend/README.md b/backend/README.md index ed6fc53..22fd1af 100644 --- a/backend/README.md +++ b/backend/README.md @@ -45,6 +45,9 @@ including obfuscated forms) and stored with the sender's IP. Each message carrie `unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead` clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer` clears a nudge when its recipient moves — both record the publish-to-read latency. +A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat +in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a +background reaper drops a robot friend request once its game has been finished for **7 days**. `internal/account` gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a development log mailer). The engine now also handles **multi-player drop-out**: in diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 1663c5b..0dce367 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -171,6 +171,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) // A nudge the recipient answered by moving is marked read on the move path. games.SetNudgeClearer(socialSvc.ClearNudges) + // Reap per-game disguised-robot friend requests once their game is long finished + // (the robot ignores them; the row only pins the in-game "request sent" state). + robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger) + go robotReqReaper.Run(ctx) + logger.Info("robot friend request reaper started", + zap.Duration("interval", robotReqReaper.Interval()), + zap.Duration("retention", robotReqReaper.Retention())) feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts) feedbackSvc.SetNotifier(hub) diff --git a/backend/internal/inttest/robot_friend_test.go b/backend/internal/inttest/robot_friend_test.go new file mode 100644 index 0000000..2adb19b --- /dev/null +++ b/backend/internal/inttest/robot_friend_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// TestRobotFriendRequestIsPerGameAndReaped checks that sending a friend request to a +// disguised-robot opponent is recorded per-game in robot_friend_requests — never in the +// friendships table or against the shared robot account — that a re-send is idempotent, and +// that the reaper deletes the row once its game has been finished past the retention window +// while keeping the row for an active game. +func TestRobotFriendRequestIsPerGameAndReaped(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) + } + g, err := newGameService().Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID}, + TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + + if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil { + t.Fatalf("request robot: %v", err) + } + rfr, err := svc.ListRobotFriendRequests(ctx, human) + if err != nil { + t.Fatalf("list robot friend requests: %v", err) + } + if len(rfr) != 1 || rfr[0].GameID != g.ID || rfr[0].Seat != 1 { + t.Fatalf("robot friend requests = %v, want one for game %s seat 1", rfr, g.ID) + } + // The friendships table must stay empty: no outgoing request against the shared robot account. + if out, _ := svc.ListOutgoingRequests(ctx, human); len(out) != 0 { + t.Errorf("friendships outgoing must be empty for a robot request, got %v", out) + } + + // A re-send to the same game+seat is idempotent (the UNIQUE constraint collapses it). + if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil { + t.Fatalf("re-request robot: %v", err) + } + if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 { + t.Fatalf("re-request must not duplicate, got %v", rfr) + } + + // While the game is active the reaper keeps the row. + if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 0 { + t.Fatalf("reap on active game = (%d, %v), want (0, nil)", n, err) + } + if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 { + t.Fatalf("active-game request must survive the reaper, got %v", rfr) + } + + // Finish the game well past the retention window: the reaper now deletes the row. + finished := time.Now().UTC().Add(-8 * 24 * time.Hour) + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.games SET status='finished', finished_at=$1 WHERE game_id=$2`, finished, g.ID); err != nil { + t.Fatalf("finish game: %v", err) + } + if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 1 { + t.Fatalf("reap on long-finished game = (%d, %v), want (1, nil)", n, err) + } + if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 0 { + t.Errorf("long-finished request not reaped: %v", rfr) + } +} diff --git a/backend/internal/postgres/migrations/00012_robot_friend_requests.sql b/backend/internal/postgres/migrations/00012_robot_friend_requests.sql new file mode 100644 index 0000000..4ec1c39 --- /dev/null +++ b/backend/internal/postgres/migrations/00012_robot_friend_requests.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- Per-game friend requests sent to a disguised-robot opponent. A disguised robot is a +-- shared pool account reused across games under different per-game names, so such a +-- request must never go into `friendships` (that would befriend/await the shared robot +-- account, leak that it is a bot across the requester's other games under other names, +-- and pin the in-game "request sent" state to the shared account instead of this seat). +-- Each request is recorded here against the specific game + seat, snapshotting the name +-- the player saw, so the in-game scoreboard can re-mark that one seat as already +-- requested. The robot ignores it (it never becomes a friendship); a background reaper +-- deletes a row once its game has been finished for more than the retention window. +SET search_path = backend, pg_catalog; + +CREATE TABLE robot_friend_requests ( + id uuid PRIMARY KEY, + requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + seat smallint NOT NULL, + robot_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + display_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (requester_id, game_id, seat) +); + +CREATE INDEX robot_friend_requests_requester_idx ON robot_friend_requests (requester_id); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE robot_friend_requests; diff --git a/backend/internal/server/handlers_friends.go b/backend/internal/server/handlers_friends.go index c300694..1df393b 100644 --- a/backend/internal/server/handlers_friends.go +++ b/backend/internal/server/handlers_friends.go @@ -32,9 +32,22 @@ type incomingListDTO struct { } // outgoingListDTO is the addressees the caller has already requested (a live pending -// request or one the addressee declined) and therefore cannot re-request. +// request or one the addressee declined) and therefore cannot re-request, plus the +// per-game disguised-robot requests (not real accounts), which keep the in-game +// "request sent" state pinned to a seat. type outgoingListDTO struct { - Requests []accountRefDTO `json:"requests"` + Requests []accountRefDTO `json:"requests"` + Robots []robotFriendRequestDTO `json:"robots"` +} + +// robotFriendRequestDTO is one per-game disguised-robot friend request: the row id, the +// game name the player saw, and the game + seat it was sent in (so the in-game card can +// re-mark that seat as already requested). +type robotFriendRequestDTO struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + GameID string `json:"game_id"` + Seat int `json:"seat"` } // friendCodeDTO is a freshly issued one-time friend code (returned once). @@ -123,7 +136,19 @@ func (s *Server) handleFriendRequest(c *gin.Context) { abortBadRequest(c, "invalid account id") return } - if err := s.social.SendFriendRequest(c.Request.Context(), uid, target); err != nil { + // An in-game request carries the game id, so a disguised-robot opponent is recorded as a + // per-game request (RequestInGame); a request without a game (or to a human) goes through + // SendFriendRequest directly. + var err error + if strings.TrimSpace(req.GameID) == "" { + err = s.social.SendFriendRequest(c.Request.Context(), uid, target) + } else if gameID, ok := parseUUIDField(req.GameID); !ok { + abortBadRequest(c, "invalid game id") + return + } else { + err = s.social.RequestInGame(c.Request.Context(), uid, target, gameID) + } + if err != nil { s.abortErr(c, err) return } @@ -235,12 +260,22 @@ func (s *Server) handleOutgoingRequests(c *gin.Context) { abortBadRequest(c, "missing identity") return } - ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid) + ctx := c.Request.Context() + ids, err := s.social.ListOutgoingRequests(ctx, uid) if err != nil { s.abortErr(c, err) return } - c.JSON(http.StatusOK, outgoingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)}) + robots, err := s.social.ListRobotFriendRequests(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + dto := outgoingListDTO{Requests: s.accountRefs(ctx, ids)} + for _, r := range robots { + dto.Robots = append(dto.Robots, robotFriendRequestDTO{ID: r.ID.String(), DisplayName: r.DisplayName, GameID: r.GameID.String(), Seat: r.Seat}) + } + c.JSON(http.StatusOK, dto) } // handleIssueFriendCode issues a one-time add-a-friend code for the caller. diff --git a/backend/internal/social/robotfriends.go b/backend/internal/social/robotfriends.go new file mode 100644 index 0000000..99e58f9 --- /dev/null +++ b/backend/internal/social/robotfriends.go @@ -0,0 +1,175 @@ +package social + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// robotFriendRequestTTL is how long a per-game disguised-robot friend request is kept +// after its game has finished before the reaper deletes it. It is a sibling of +// friendRequestTTL; the request is housekeeping for the in-game "request sent" state, +// so it is purged once the finished game is well past its lobby lifetime. +const robotFriendRequestTTL = 7 * 24 * time.Hour + +// robotFriendRequestReapInterval is how often the reaper sweeps expired rows. +const robotFriendRequestReapInterval = time.Hour + +// RobotFriendRequest is one per-game friend request to a disguised-robot opponent: the +// row id, the game name the player saw, and the game + seat it was sent in (so the +// in-game scoreboard can re-mark that seat as already requested). +type RobotFriendRequest struct { + ID uuid.UUID + DisplayName string + GameID uuid.UUID + Seat int +} + +// RequestInGame sends a friend request to addresseeID for requesterID from within gameID. +// A human is requested normally (the friendships table, via SendFriendRequest). A +// disguised-robot opponent is instead recorded per-game in robot_friend_requests — never +// the shared robot account — so the matchmaker keeps robots free, the same robot stays +// un-requested in the requester's other games (under its other names), and the in-game +// "request sent" state is pinned to this seat rather than the shared account. The robot +// ignores the request (it never becomes a friendship); the reaper deletes the row once +// the game is long finished. It is the entry point for the in-game add-friend control; +// the friend-code path goes elsewhere. +func (svc *Service) RequestInGame(ctx context.Context, requesterID, addresseeID, gameID uuid.UUID) error { + if requesterID == addresseeID { + return ErrSelfRelation + } + isRobot, err := svc.accounts.IsRobot(ctx, addresseeID) + if err != nil { + return err + } + if !isRobot { + return svc.SendFriendRequest(ctx, requesterID, addresseeID) + } + if gameID == uuid.Nil { + return ErrNotParticipant + } + seats, _, _, err := svc.games.Participants(ctx, gameID) + if err != nil { + return err + } + seat := slices.Index(seats, addresseeID) + if seat < 0 { + return ErrNotParticipant + } + name, _ := svc.games.SeatName(ctx, gameID, addresseeID) + return svc.store.insertRobotFriendRequest(ctx, requesterID, gameID, seat, addresseeID, name) +} + +// ListRobotFriendRequests returns requesterID's per-game disguised-robot friend +// requests, newest first. +func (svc *Service) ListRobotFriendRequests(ctx context.Context, requesterID uuid.UUID) ([]RobotFriendRequest, error) { + return svc.store.listRobotFriendRequests(ctx, requesterID) +} + +// insertRobotFriendRequest records a per-game robot friend request; a duplicate (same +// requester, game, seat) is ignored. +func (s *Store) insertRobotFriendRequest(ctx context.Context, requester, gameID uuid.UUID, seat int, robotID uuid.UUID, name string) error { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("social: new robot friend request id: %w", err) + } + const q = `INSERT INTO backend.robot_friend_requests (id, requester_id, game_id, seat, robot_id, display_name) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (requester_id, game_id, seat) DO NOTHING` + if _, err := s.db.ExecContext(ctx, q, id, requester, gameID, int16(seat), robotID, name); err != nil { + return fmt.Errorf("social: insert robot friend request: %w", err) + } + return nil +} + +// listRobotFriendRequests returns requester's robot friend requests, newest first. +func (s *Store) listRobotFriendRequests(ctx context.Context, requester uuid.UUID) ([]RobotFriendRequest, error) { + const q = `SELECT id, display_name, game_id, seat FROM backend.robot_friend_requests +WHERE requester_id = $1 ORDER BY created_at DESC` + rows, err := s.db.QueryContext(ctx, q, requester) + if err != nil { + return nil, fmt.Errorf("social: list robot friend requests: %w", err) + } + defer rows.Close() + var out []RobotFriendRequest + for rows.Next() { + var r RobotFriendRequest + var seat int16 + if err := rows.Scan(&r.ID, &r.DisplayName, &r.GameID, &seat); err != nil { + return nil, fmt.Errorf("social: scan robot friend request: %w", err) + } + r.Seat = int(seat) + out = append(out, r) + } + return out, rows.Err() +} + +// ReapExpiredRobotFriendRequests deletes every per-game robot friend request whose game has +// been finished for longer than robotFriendRequestTTL, reporting how many rows were removed. +// Rows for active (not-yet-finished) games are kept so the in-game "request sent" state +// survives. It backs the RobotFriendRequestReaper and is directly callable in tests. +func (svc *Service) ReapExpiredRobotFriendRequests(ctx context.Context) (int64, error) { + return svc.store.deleteExpiredRobotFriendRequests(ctx, svc.now().Add(-robotFriendRequestTTL)) +} + +// deleteExpiredRobotFriendRequests removes robot friend requests whose game has been +// finished since before cutoff, reporting how many rows were deleted. +func (s *Store) deleteExpiredRobotFriendRequests(ctx context.Context, cutoff time.Time) (int64, error) { + const q = `DELETE FROM backend.robot_friend_requests r +USING backend.games g +WHERE r.game_id = g.game_id AND g.status = 'finished' AND g.finished_at < $1` + res, err := s.db.ExecContext(ctx, q, cutoff) + if err != nil { + return 0, fmt.Errorf("social: delete expired robot friend requests: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("social: delete expired robot friend requests rows: %w", err) + } + return n, nil +} + +// RobotFriendRequestReaper periodically reaps expired per-game disguised-robot friend +// requests via Service.ReapExpiredRobotFriendRequests. It mirrors the account.GuestReaper: +// one background goroutine, started once from main. +type RobotFriendRequestReaper struct { + svc *Service + log *zap.Logger +} + +// NewRobotFriendRequestReaper constructs a reaper over svc. log may be nil. +func NewRobotFriendRequestReaper(svc *Service, log *zap.Logger) *RobotFriendRequestReaper { + if log == nil { + log = zap.NewNop() + } + return &RobotFriendRequestReaper{svc: svc, log: log} +} + +// Interval is the reaper's sweep cadence, for the startup log line. +func (r *RobotFriendRequestReaper) Interval() time.Duration { return robotFriendRequestReapInterval } + +// Retention is the reaper's post-finish retention window, for the startup log line. +func (r *RobotFriendRequestReaper) Retention() time.Duration { return robotFriendRequestTTL } + +// Run reaps expired robot friend requests on each tick until ctx is cancelled. +func (r *RobotFriendRequestReaper) Run(ctx context.Context) { + ticker := time.NewTicker(robotFriendRequestReapInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + n, err := r.svc.ReapExpiredRobotFriendRequests(ctx) + if err != nil { + r.log.Warn("robot friend request reap failed", zap.Error(err)) + } else if n > 0 { + r.log.Info("reaped expired robot friend requests", zap.Int64("count", n)) + } + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa461fc..e5229a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -519,7 +519,13 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); expires after **30 days** and may be re-sent), or **decline** — a decline is remembered (`status='declined'`) and blocks further requests from that sender, unless they hand them a code, which overrides it. The requester's own cancel still - deletes the row. (Discovery by friend list or platform deep-link is future work.) + deletes the row. A request sent in-game to an **auto-match opponent who is secretly a + pooled robot** is recorded instead in a separate **`robot_friend_requests`** table, keyed on + the requester + game + seat with the seen name snapshotted (`RequestInGame`): the shared robot + account is never put in `friendships` (so it is not befriended/awaited under its other per-game + names and the in-game 🤝 stays pinned to that seat as *sent*), the robot never accepts, the row + is not surfaced in Settings → Friends, and a background reaper deletes it once its game has been + finished for **7 days**. (Discovery by friend list or platform deep-link is future work.) - **Block**: two independent **global** account toggles (`block_chat`, `block_friend_requests`) **plus** a **per-user block list**. A per-user block is **asymmetric and non-destructive**: the blocker stops receiving everything **from** the diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f76a17c..7a78adb 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -207,7 +207,10 @@ Blocking an **auto-match opponent who is secretly a robot** behaves the same in (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out -of opponents). Per-game chat is for quick reactions: messages are short +of opponents). Sending the 🤝 to such a **disguised robot** likewise records the request only +against that game (pinned to the seat, so it stays *sent* there without touching that robot in +your other games); the robot **ignores it**, the request never appears in **Settings → Friends**, +and it drops automatically about a week after the game ends. Per-game chat is for quick reactions: messages are short (up to 60 characters) and may not contain links, email addresses or phone numbers, even disguised. You may send **one message per turn, on your own turn**; once it is sent the field gives way to a short caption until your next turn. Nudge the player whose turn diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1124539..9e22b63 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -212,7 +212,11 @@ nudge) приходят от бота **этой партии** — по язы (зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что -заблокировать себя без соперников нельзя). Чат +заблокировать себя без соперников нельзя). Отправка 🤝 такому **замаскированному роботу** так же +записывает заявку только для этой партии (привязанной к месту, поэтому она читается как +*отправленная* здесь, не затрагивая этого робота в твоих других играх); робот её **игнорирует**, +заявка не показывается в **Настройки → Друзья** и автоматически удаляется примерно через неделю +после завершения партии. Чат партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 30ad8e7..b7258ce 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -237,7 +237,11 @@ and a long message does not scroll. Lobby rows show two lines (opponents, then result + score) with a large place-based emoji on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 / -IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. When a listed +IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. The score line +lists seats in **seat-number order** (matching the over-the-board scoreboard); on an +**in-progress** game the viewer's **own** number is tinted `--ok` when leading or tied and +`--danger` when trailing (other numbers stay muted; no bold), a quick "am I ahead" read that +finished games leave to the place emoji. When a listed game **becomes your turn or finishes** while the lobby is open, that status emoji **blinks twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping @@ -294,11 +298,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's notation (e.g. "4.8%" / "4,8%") — followed by a **full-width "best move" card** that breaks the best move - down per variant: one row per played variant (catalogue order, empty variants - omitted) with the variant name on the left, the **word drawn as game tiles** + down per variant: two lines per played variant (catalogue order, empty variants + omitted) — the variant name on its own line, then below it the score (right-aligned + in a shared column so the scores align) and the **word drawn as game tiles** (`components/WordTiles.svelte`, the board's placed-tile look at a small fixed size; - a wildcard shows its letter but no value) right-aligned to a shared edge, and the - score right-aligned in its own column. + a wildcard shows its letter but no value) left-aligned, so long words get the full width. - **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a **UTC-offset** timezone dropdown (defaulting to the browser's offset), the away window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index f9b9fb4..7d774a4 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -26,9 +26,20 @@ type IncomingListResp struct { } // OutgoingListResp is the addressees the caller has already requested (a live pending -// request or one the addressee declined) and cannot re-request. +// request or one the addressee declined) and cannot re-request, plus the per-game +// disguised-robot requests (not real accounts). type OutgoingListResp struct { - Requests []AccountRefResp `json:"requests"` + Requests []AccountRefResp `json:"requests"` + Robots []RobotFriendReqResp `json:"robots"` +} + +// RobotFriendReqResp is one per-game disguised-robot friend request: the row id, the game +// name the player saw, and the game + seat it was sent in. +type RobotFriendReqResp struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + GameID string `json:"game_id"` + Seat int `json:"seat"` } // FriendCodeResp is a freshly issued one-time friend code. @@ -137,10 +148,12 @@ type InvitationParams struct { // --- friends --- -// SendFriendRequest sends a friend request to a played opponent. -func (c *Client) SendFriendRequest(ctx context.Context, userID, targetID string) error { +// SendFriendRequest sends a friend request to a played opponent. A non-empty gameID +// marks an in-game request, so a disguised-robot opponent is recorded as a per-game +// request; it is empty for any non-game path. +func (c *Client) SendFriendRequest(ctx context.Context, userID, targetID, gameID string) error { return c.do(ctx, http.MethodPost, "/api/v1/user/friends/request", userID, "", - map[string]string{"account_id": targetID}, nil) + map[string]string{"account_id": targetID, "game_id": gameID}, nil) } // RespondFriendRequest accepts or declines an incoming request. diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index ca4cad3..9a136fb 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -50,12 +50,36 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte { return b.FinishedBytes() } +// buildRobotFriendVector builds the OutgoingRequestList robots vector (per-game +// disguised-robot friend requests). +func buildRobotFriendVector(b *flatbuffers.Builder, robots []backendclient.RobotFriendReqResp) flatbuffers.UOffsetT { + offs := make([]flatbuffers.UOffsetT, len(robots)) + for i, r := range robots { + id := b.CreateString(r.ID) + name := b.CreateString(r.DisplayName) + gid := b.CreateString(r.GameID) + fb.RobotFriendRefStart(b) + fb.RobotFriendRefAddId(b, id) + fb.RobotFriendRefAddDisplayName(b, name) + fb.RobotFriendRefAddGameId(b, gid) + fb.RobotFriendRefAddSeat(b, int32(r.Seat)) + offs[i] = fb.RobotFriendRefEnd(b) + } + fb.OutgoingRequestListStartRobotsVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + return b.EndVector(len(offs)) +} + // encodeOutgoingList builds an OutgoingRequestList payload. func encodeOutgoingList(r backendclient.OutgoingListResp) []byte { b := flatbuffers.NewBuilder(256) v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector) + rv := buildRobotFriendVector(b, r.Robots) fb.OutgoingRequestListStart(b) fb.OutgoingRequestListAddRequests(b, v) + fb.OutgoingRequestListAddRobots(b, rv) b.Finish(fb.OutgoingRequestListEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index c3a900c..72d1473 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -93,7 +93,9 @@ func friendsOutgoingHandler(backend *backendclient.Client) Handler { func friendRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTargetRequest(req.Payload, 0) - if err := backend.SendFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil { + // game_id is set only by an in-game request, so a disguised-robot opponent is recorded + // per-game; it is empty for any non-game path. + if err := backend.SendFriendRequest(ctx, req.UserID, string(in.AccountId()), string(in.GameId())); err != nil { return nil, err } return encodeAck(true), nil diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index b0892e1..96206a1 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -59,7 +59,8 @@ func TestFriendsOutgoingRoundTrip(t *testing.T) { if r.URL.Path != "/api/v1/user/friends/outgoing" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}]}`)) + _, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}],` + + `"robots":[{"id":"r-1","display_name":"Robbie","game_id":"g-1","seat":1}]}`)) }) defer cleanup() @@ -81,6 +82,15 @@ func TestFriendsOutgoingRoundTrip(t *testing.T) { if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" { t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName()) } + // The per-game disguised-robot requests round-trip in their own vector. + if ol.RobotsLength() != 1 { + t.Fatalf("robots length = %d, want 1", ol.RobotsLength()) + } + var rr fb.RobotFriendRef + ol.Robots(&rr, 0) + if string(rr.Id()) != "r-1" || string(rr.DisplayName()) != "Robbie" || string(rr.GameId()) != "g-1" || rr.Seat() != 1 { + t.Fatalf("robot[0] = (%q, %q, %q, %d), want (r-1, Robbie, g-1, 1)", rr.Id(), rr.DisplayName(), rr.GameId(), rr.Seat()) + } } func TestFriendRequestForwardsTarget(t *testing.T) { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 9fe46cc..c396b82 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -532,11 +532,24 @@ table IncomingRequestList { requests:[AccountRef]; } +// RobotFriendRef is one per-game friend request to a disguised-robot opponent: a +// per-game record (not a real account) carrying the game name the player saw and the +// game/seat it was sent in, so the in-game card can re-mark that seat as already +// requested. Its id is the robot_friend_requests row. +table RobotFriendRef { + id:string; + display_name:string; + game_id:string; + seat:int; +} + // OutgoingRequestList is the accounts the caller has already requested and cannot // (re-)request: a live pending request or one the addressee declined. The game's -// "add to friends" item reads it to stay disabled across reloads. +// "add to friends" item reads it to stay disabled across reloads. robots are the +// per-game disguised-robot requests (not real accounts), added trailing. table OutgoingRequestList { requests:[AccountRef]; + robots:[RobotFriendRef]; } // FriendCode is a freshly issued one-time add-a-friend code (returned once). diff --git a/pkg/fbs/scrabblefb/OutgoingRequestList.go b/pkg/fbs/scrabblefb/OutgoingRequestList.go index 5ad4d29..818335e 100644 --- a/pkg/fbs/scrabblefb/OutgoingRequestList.go +++ b/pkg/fbs/scrabblefb/OutgoingRequestList.go @@ -61,8 +61,28 @@ func (rcv *OutgoingRequestList) RequestsLength() int { return 0 } +func (rcv *OutgoingRequestList) Robots(obj *RobotFriendRef, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *OutgoingRequestList) RobotsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + func OutgoingRequestListStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0) @@ -70,6 +90,12 @@ func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatb func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func OutgoingRequestListAddRobots(builder *flatbuffers.Builder, robots flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(robots), 0) +} +func OutgoingRequestListStartRobotsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} func OutgoingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/RobotFriendRef.go b/pkg/fbs/scrabblefb/RobotFriendRef.go new file mode 100644 index 0000000..466efb2 --- /dev/null +++ b/pkg/fbs/scrabblefb/RobotFriendRef.go @@ -0,0 +1,97 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type RobotFriendRef struct { + _tab flatbuffers.Table +} + +func GetRootAsRobotFriendRef(buf []byte, offset flatbuffers.UOffsetT) *RobotFriendRef { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &RobotFriendRef{} + x.Init(buf, n+offset) + return x +} + +func FinishRobotFriendRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsRobotFriendRef(buf []byte, offset flatbuffers.UOffsetT) *RobotFriendRef { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &RobotFriendRef{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedRobotFriendRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *RobotFriendRef) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *RobotFriendRef) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *RobotFriendRef) Id() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *RobotFriendRef) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *RobotFriendRef) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *RobotFriendRef) Seat() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *RobotFriendRef) MutateSeat(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func RobotFriendRefStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func RobotFriendRefAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) +} +func RobotFriendRefAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0) +} +func RobotFriendRefAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(gameId), 0) +} +func RobotFriendRefAddSeat(builder *flatbuffers.Builder, seat int32) { + builder.PrependInt32Slot(3, seat, 0) +} +func RobotFriendRefEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/src/app.css b/ui/src/app.css index 9729f05..c2fb4ba 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -73,7 +73,7 @@ --board-bg: #2a3330; --cell-bg: #222a27; --cell-line: #56655c; - --tile-bg: #d9c79a; + --tile-bg: #cdba88; --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #d8b75e; diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 6894761..d05ffd9 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -932,6 +932,10 @@ // re-send and disable the 🤝). let friends = $state(new Set()); let requested = $state(new Set()); + // `requestedRobotSeats` are the seat indices of disguised-robot opponents already requested in + // THIS game: a robot request is recorded per game+seat (never the shared robot account), so the + // 🤝 disables for just this seat and never leaks across the requester's other games. + let requestedRobotSeats = $state(new Set()); // `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and // their name is struck. Derived from the server so it is correct across reloads and live-updates // on a user_blocked / user_unblocked event. `blockedRobotSeats` are the seat indices of blocked @@ -952,7 +956,8 @@ try { const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]); friends = new Set(fl.map((f) => f.accountId)); - requested = new Set(out.map((f) => f.accountId)); + requested = new Set(out.requests.map((f) => f.accountId)); + requestedRobotSeats = new Set(out.robots.filter((r) => r.gameId === id).map((r) => r.seat)); } catch { /* best-effort */ } @@ -981,14 +986,17 @@ // caption update the instant the confirm fires) and roll back to the prior state if the command // fails to reach the server. The confirming user_blocked / user_added event then just reconciles // the same state in place (no flicker). - async function addFriend(accountId: string) { - const had = requested.has(accountId); - requested = new Set([...requested, accountId]); + async function addFriend(s: { accountId: string; seat: number }) { + // Optimistically mark this seat requested (disables the 🤝 for both a human and a disguised + // robot until the server confirms). The request carries the game id so a robot opponent is + // recorded as a per-game request pinned to this seat, not against the shared robot account. + const hadSeat = requestedRobotSeats.has(s.seat); + requestedRobotSeats = new Set([...requestedRobotSeats, s.seat]); try { - await gateway.friendRequest(accountId); + await gateway.friendRequest(s.accountId, id); showToast(t('friends.requestSent')); } catch (e) { - if (!had) requested = new Set([...requested].filter((id) => id !== accountId)); + if (!hadSeat) requestedRobotSeats = new Set([...requestedRobotSeats].filter((x) => x !== s.seat)); handleError(e); } } @@ -1118,9 +1126,9 @@ (addConfirm[s.seat] = v)} - onconfirm={() => addFriend(s.accountId)} + onconfirm={() => addFriend(s)} > 🤝 diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 741bab3..8f70548 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -60,6 +60,7 @@ export { Profile } from './scrabblefb/profile.js'; export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; export { RedeemResult } from './scrabblefb/redeem-result.js'; export { RobotBlockRef } from './scrabblefb/robot-block-ref.js'; +export { RobotFriendRef } from './scrabblefb/robot-friend-ref.js'; export { SeatView } from './scrabblefb/seat-view.js'; export { Session } from './scrabblefb/session.js'; export { StateRequest } from './scrabblefb/state-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts b/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts index ded6fa1..6bc5c7c 100644 --- a/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts +++ b/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts @@ -3,6 +3,7 @@ import * as flatbuffers from 'flatbuffers'; import { AccountRef } from '../scrabblefb/account-ref.js'; +import { RobotFriendRef } from '../scrabblefb/robot-friend-ref.js'; export class OutgoingRequestList { @@ -33,8 +34,18 @@ requestsLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +robots(index: number, obj?:RobotFriendRef):RobotFriendRef|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? (obj || new RobotFriendRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +robotsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + static startOutgoingRequestList(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) { @@ -53,14 +64,31 @@ static startRequestsVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addRobots(builder:flatbuffers.Builder, robotsOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, robotsOffset, 0); +} + +static createRobotsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startRobotsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + static endOutgoingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset { +static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset, robotsOffset:flatbuffers.Offset):flatbuffers.Offset { OutgoingRequestList.startOutgoingRequestList(builder); OutgoingRequestList.addRequests(builder, requestsOffset); + OutgoingRequestList.addRobots(builder, robotsOffset); return OutgoingRequestList.endOutgoingRequestList(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts b/ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts new file mode 100644 index 0000000..7f54b27 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts @@ -0,0 +1,82 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class RobotFriendRef { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):RobotFriendRef { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsRobotFriendRef(bb:flatbuffers.ByteBuffer, obj?:RobotFriendRef):RobotFriendRef { + return (obj || new RobotFriendRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsRobotFriendRef(bb:flatbuffers.ByteBuffer, obj?:RobotFriendRef):RobotFriendRef { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new RobotFriendRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +id():string|null +id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +id(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; +} + +displayName():string|null +displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +displayName(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; +} + +gameId():string|null +gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +gameId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +seat():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +static startRobotFriendRef(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, idOffset, 0); +} + +static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, displayNameOffset, 0); +} + +static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, gameIdOffset, 0); +} + +static addSeat(builder:flatbuffers.Builder, seat:number) { + builder.addFieldInt32(3, seat, 0); +} + +static endRobotFriendRef(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createRobotFriendRef(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, gameIdOffset:flatbuffers.Offset, seat:number):flatbuffers.Offset { + RobotFriendRef.startRobotFriendRef(builder); + RobotFriendRef.addId(builder, idOffset); + RobotFriendRef.addDisplayName(builder, displayNameOffset); + RobotFriendRef.addGameId(builder, gameIdOffset); + RobotFriendRef.addSeat(builder, seat); + return RobotFriendRef.endRobotFriendRef(builder); +} +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index bb55630..4b45b90 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -22,6 +22,7 @@ import type { LinkResult, MatchResult, MoveResult, + OutgoingList, Profile, ProfileUpdate, PushEvent, @@ -118,9 +119,12 @@ export interface GatewayClient { // --- friends --- friendsList(): Promise; friendsIncoming(): Promise; - /** Addressees the caller has already requested (pending or declined); cannot re-request. */ - friendsOutgoing(): Promise; - friendRequest(accountId: string): Promise; + /** Addressees the caller has already requested (pending or declined; cannot re-request), + * plus the per-game disguised-robot requests that keep the in-game 🤝 disabled by seat. */ + friendsOutgoing(): Promise; + /** Send a friend request. A non-empty gameId marks an in-game request, so a disguised-robot + * opponent is recorded as a per-game request rather than against the shared robot account. */ + friendRequest(accountId: string, gameId?: string): Promise; friendRespond(requesterId: string, accept: boolean): Promise; friendCancel(accountId: string): Promise; unfriend(accountId: string): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 089a465..9bfe3fd 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -256,7 +256,7 @@ describe('codec', () => { expect(gl.atGameLimit).toBe(true); }); - it('decodes an OutgoingRequestList of account refs', () => { + it('decodes an OutgoingRequestList of account refs plus per-game robot requests', () => { const b = new Builder(128); const id = b.createString('o-1'); const dn = b.createString('Pat'); @@ -264,11 +264,20 @@ describe('codec', () => { fb.AccountRef.addAccountId(b, id); fb.AccountRef.addDisplayName(b, dn); const ref = fb.AccountRef.endAccountRef(b); - const vec = fb.OutgoingRequestList.createRequestsVector(b, [ref]); + const reqVec = fb.OutgoingRequestList.createRequestsVector(b, [ref]); + const rid = b.createString('r-1'); + const rdn = b.createString('Robbie'); + const rgid = b.createString('g-1'); + const robot = fb.RobotFriendRef.createRobotFriendRef(b, rid, rdn, rgid, 1); + const robVec = fb.OutgoingRequestList.createRobotsVector(b, [robot]); fb.OutgoingRequestList.startOutgoingRequestList(b); - fb.OutgoingRequestList.addRequests(b, vec); + fb.OutgoingRequestList.addRequests(b, reqVec); + fb.OutgoingRequestList.addRobots(b, robVec); b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b)); - expect(decodeOutgoingList(b.asUint8Array())).toEqual([{ accountId: 'o-1', displayName: 'Pat' }]); + expect(decodeOutgoingList(b.asUint8Array())).toEqual({ + requests: [{ accountId: 'o-1', displayName: 'Pat' }], + robots: [{ id: 'r-1', displayName: 'Robbie', gameId: 'g-1', seat: 1 }], + }); }); it('encodes a TargetRequest', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index aeb995e..2708a16 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -10,7 +10,9 @@ import type { PlacedTile } from './client'; import type { AccountRef, BlockList, + OutgoingList, RobotBlockEntry, + RobotFriendRequestEntry, Banner, BannerCampaign, BestMove, @@ -719,14 +721,25 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] { return out; } -export function decodeOutgoingList(buf: Uint8Array): AccountRef[] { +export function decodeOutgoingList(buf: Uint8Array): OutgoingList { const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf)); - const out: AccountRef[] = []; + const requests: AccountRef[] = []; for (let i = 0; i < l.requestsLength(); i++) { const r = l.requests(i); - if (r) out.push(decodeAccountRef(r)); + if (r) requests.push(decodeAccountRef(r)); } - return out; + const robots: RobotFriendRequestEntry[] = []; + for (let i = 0; i < l.robotsLength(); i++) { + const r = l.robots(i); + if (r) + robots.push({ + id: r.id() ?? '', + displayName: r.displayName() ?? '', + gameId: r.gameId() ?? '', + seat: r.seat(), + }); + } + return { requests, robots }; } export function decodeBlockList(buf: Uint8Array): BlockList { diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index f4e3007..3e65ee8 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { gamePhase, groupGames, isMyTurn, shouldBlink } from './lobbysort'; +import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBlink } from './lobbysort'; import type { GameView, Seat } from './model'; const ME = 'me'; @@ -118,6 +118,59 @@ describe('gamePhase', () => { }); }); +describe('scoreStanding', () => { + const withScores = (status: GameView['status'], myScore: number, oppScore: number): GameView => ({ + ...game('g', status, 0, 0), + seats: [ + { ...seat(0, ME), score: myScore }, + { ...seat(1, 'opp'), score: oppScore }, + ], + }); + + it('greens a leading or tied active game, reds a losing one', () => { + expect(scoreStanding(withScores('active', 30, 10), ME)).toBe('win'); + expect(scoreStanding(withScores('active', 10, 10), ME)).toBe('win'); // a tie counts as green + expect(scoreStanding(withScores('active', 5, 10), ME)).toBe('lose'); + }); + + it('is null for any non-active game (open or finished)', () => { + expect(scoreStanding(withScores('finished', 30, 10), ME)).toBeNull(); + expect(scoreStanding(withScores('open', 0, 0), ME)).toBeNull(); + }); + + it('is null when the viewer is not seated or has no opponent', () => { + expect(scoreStanding(withScores('active', 30, 10), 'stranger')).toBeNull(); + const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] }; + expect(scoreStanding(solo, ME)).toBeNull(); + }); + + it('compares against the strongest opponent in a multiplayer game', () => { + const g3: GameView = { + ...game('g', 'active', 0, 0), + players: 3, + seats: [ + { ...seat(0, ME), score: 40 }, + { ...seat(1, 'a'), score: 35 }, + { ...seat(2, 'b'), score: 50 }, // b is ahead -> losing + ], + }; + expect(scoreStanding(g3, ME)).toBe('lose'); + }); +}); + +describe('orderedSeats', () => { + it('returns seats in seat-number order without mutating the source', () => { + const g: GameView = { + ...game('g', 'active', 0, 0), + seats: [seat(2, 'c'), seat(0, ME), seat(1, 'b')], + }; + const src = g.seats; + expect(orderedSeats(g).map((s) => s.seat)).toEqual([0, 1, 2]); + expect(g.seats).toBe(src); // the source array is left untouched + expect(g.seats.map((s) => s.seat)).toEqual([2, 0, 1]); + }); +}); + describe('shouldBlink', () => { it('blinks on a transition into mine or finished', () => { expect(shouldBlink('theirs', 'mine')).toBe(true); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts index 6cc4906..7292da0 100644 --- a/ui/src/lib/lobbysort.ts +++ b/ui/src/lib/lobbysort.ts @@ -12,6 +12,29 @@ export function isMyTurn(game: GameView, myId: string): boolean { return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat; } +/** + * scoreStanding colours the viewer's own score on an in-progress game: 'win' when leading or + * tied (green), 'lose' when trailing (red). It is null for any game that is not active (open or + * finished — finished games show the result emoji instead) or where myId is not seated against + * an opponent, so the lobby leaves those numbers in the muted default. + */ +export function scoreStanding(game: GameView, myId: string): 'win' | 'lose' | null { + if (game.status !== 'active') return null; + const me = game.seats.find((s) => s.accountId === myId); + if (!me) return null; + const others = game.seats.filter((s) => s.accountId && s.accountId !== myId).map((s) => s.score); + if (!others.length) return null; + return me.score >= Math.max(...others) ? 'win' : 'lose'; +} + +/** + * orderedSeats returns a game's seats in seat-number order (matching the over-the-board + * scoreboard), without mutating the source array. + */ +export function orderedSeats(game: GameView): GameView['seats'] { + return [...game.seats].sort((a, b) => a.seat - b.seat); +} + /** LobbyPhase is a game's lobby bucket — which of the three card sections it currently sits in. */ export type LobbyPhase = 'mine' | 'theirs' | 'finished'; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index ee2fd8e..941ea33 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -29,6 +29,7 @@ import type { LinkResult, MatchResult, MoveResult, + OutgoingList, Profile, ProfileUpdate, PushEvent, @@ -527,10 +528,11 @@ export class MockGateway implements GatewayClient { async friendsIncoming(): Promise { return this.incoming.map((f) => ({ ...f })); } - async friendsOutgoing(): Promise { - return this.outgoing.map((f) => ({ ...f })); + async friendsOutgoing(): Promise { + // The mock models human outgoing requests only (no disguised robots); robots stays empty. + return { requests: this.outgoing.map((f) => ({ ...f })), robots: [] }; } - async friendRequest(accountId: string): Promise { + async friendRequest(accountId: string, _gameId?: string): Promise { // The real backend requires a shared game; the mock records the outgoing request so // the game's "add to friends" item reads as sent across reloads. if (!this.outgoing.some((o) => o.accountId === accountId)) { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index b5483ec..f2be57e 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -225,6 +225,23 @@ export interface BlockList { robots: RobotBlockEntry[]; } +// RobotFriendRequestEntry is one per-game friend request to a disguised-robot opponent: a +// per-game record (not a real account) carrying the game name the player saw and the +// game/seat it was sent in, so the in-game card can re-mark that seat as already requested. +export interface RobotFriendRequestEntry { + id: string; + displayName: string; + gameId: string; + seat: number; +} + +// OutgoingList is the caller's outgoing friend requests: human addressees already requested +// (pending or declined) plus the per-game disguised-robot requests. +export interface OutgoingList { + requests: AccountRef[]; + robots: RobotFriendRequestEntry[]; +} + /** A freshly issued one-time friend code (the plaintext is returned once). */ export interface FriendCode { code: string; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 4219024..3979d7f 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -165,8 +165,8 @@ export function createTransport(baseUrl: string): GatewayClient { async friendsOutgoing() { return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty())); }, - async friendRequest(accountId) { - await exec('friends.request', codec.encodeTarget(accountId)); + async friendRequest(accountId, gameId) { + await exec('friends.request', codec.encodeTarget(accountId, gameId)); }, async friendRespond(requesterId, accept) { await exec('friends.respond', codec.encodeFriendRespond(requesterId, accept)); diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 212cd70..bc1956b 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -12,7 +12,7 @@ import { badgeKind } from '../lib/unread'; import { getLobby, setLobby } from '../lib/lobbycache'; import { preloadGames } from '../lib/preload'; - import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; + import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import type { AccountRef, GameView, Invitation } from '../lib/model'; let games = $state([]); @@ -130,11 +130,6 @@ .map((s) => s.displayName) .join(', '); } - function scoreline(g: GameView): string { - const me = g.seats.find((s) => s.accountId === myId); - const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score); - return `${me?.score ?? 0} : ${opp.join(', ')}`; - } // Hiding a finished game. The delete action sits behind each finished row and is // revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is @@ -271,7 +266,14 @@ {opponents(g) || '—'} {#if badge}{/if} - {scoreline(g)} + {#each orderedSeats(g) as s, i (s.seat)}{#if i > 0} : {/if}{s.score}{/each} {#key blinkNonce.get(g.id) ?? 0} @@ -476,6 +478,14 @@ font-size: 0.85rem; color: var(--text-muted); } + /* The viewer's own number on an in-progress game: green when leading or tied, red when + losing. Other numbers and the separators keep the muted .sub colour; no bold. */ + .num.win { + color: var(--ok); + } + .num.lose { + color: var(--danger); + } .emoji { font-size: 1.35rem; line-height: 1; diff --git a/ui/src/screens/Stats.svelte b/ui/src/screens/Stats.svelte index 5cc6805..74c48f1 100644 --- a/ui/src/screens/Stats.svelte +++ b/ui/src/screens/Stats.svelte @@ -72,8 +72,8 @@
{#each bestMoves as bm (bm.variant)} {t(variantNameKey(bm.variant))} - {bm.score} + {/each}
@@ -115,23 +115,30 @@ margin-top: 12px; gap: 12px; } - /* One grid for all rows so columns align across them: variant on the left, the word - tiles right-aligned to a shared edge, the score right-aligned in its own column. */ + /* Two lines per variant: the variant label on its own line, then the score and the word + tiles below it. One shared grid so the score column aligns across all rows — the score + right-aligned in its column, the word left-aligned. */ .rows { display: grid; - /* minmax(0, 1fr) lets the word column shrink below its tiles' intrinsic width on a - narrow screen (the cell then scrolls) instead of overlapping the variant label. */ - grid-template-columns: auto minmax(0, 1fr) auto; + /* score column (sized to the widest score) then the word; minmax(0, 1fr) lets the word + shrink below its tiles' intrinsic width on a narrow screen (the cell then scrolls). */ + grid-template-columns: auto minmax(0, 1fr); align-items: center; - row-gap: 12px; + row-gap: 4px; column-gap: 8px; } .variant { + grid-column: 1 / -1; color: var(--text-muted); font-size: 0.95rem; } + /* Extra space above each variant after the first, separating the variant blocks while the + variant-to-score gap stays tight (row-gap). */ + .variant:not(:first-child) { + margin-top: 10px; + } .wordcell { - justify-self: end; + justify-self: start; min-width: 0; overflow-x: auto; } -- 2.52.0 From 9644bd6e5e05e09ed22d35d439c56ba372396b79 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 21:51:50 +0200 Subject: [PATCH 191/223] style(lobby): bold the score line, a touch smaller Per owner follow-up: the whole lobby score line is now bold (font-weight 700, matching the over-the-board score plaques) and a hair smaller (0.8rem) to offset the heavier weight. The viewer's own number keeps its green/red standing tint. --- docs/UI_DESIGN.md | 8 ++++---- ui/src/screens/Lobby.svelte | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b7258ce..45cc441 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -238,10 +238,10 @@ and a long message does not scroll. Lobby rows show two lines (opponents, then result + score) with a large place-based emoji on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 / IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. The score line -lists seats in **seat-number order** (matching the over-the-board scoreboard); on an -**in-progress** game the viewer's **own** number is tinted `--ok` when leading or tied and -`--danger` when trailing (other numbers stay muted; no bold), a quick "am I ahead" read that -finished games leave to the place emoji. When a listed +lists seats in **seat-number order** (matching the over-the-board scoreboard) in a **bold**, +slightly smaller line; on an **in-progress** game the viewer's **own** number is tinted `--ok` +when leading or tied and `--danger` when trailing (other numbers stay muted), a quick "am I +ahead" read that finished games leave to the place emoji. When a listed game **becomes your turn or finishes** while the lobby is open, that status emoji **blinks twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index bc1956b..0beff5c 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -478,8 +478,13 @@ font-size: 0.85rem; color: var(--text-muted); } + /* The score line is bold and a touch smaller than the surrounding muted sub-text. */ + .scoreline { + font-weight: 700; + font-size: 0.8rem; + } /* The viewer's own number on an in-progress game: green when leading or tied, red when - losing. Other numbers and the separators keep the muted .sub colour; no bold. */ + losing; other numbers and the separators keep the muted .sub colour. */ .num.win { color: var(--ok); } -- 2.52.0 From c9a5ca3ed79da283732c6123395ecc908ce5ba2d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 21:56:05 +0200 Subject: [PATCH 192/223] fix(lobby): keep the spaces around the score separator Svelte trims leading/trailing whitespace inside an element, so the literal ` : ` rendered as a bare ":" ("123:123"). Emit the separator as a string expression `{' : '}`, which Svelte preserves, restoring "123 : 123". --- ui/src/screens/Lobby.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 0beff5c..28fb22f 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -267,7 +267,7 @@ {#if badge}{/if} {#each orderedSeats(g) as s, i (s.seat)}{#if i > 0} : {#each orderedSeats(g) as s, i (s.seat)}{#if i > 0}{' : '}{/if} Date: Sat, 20 Jun 2026 08:47:18 +0200 Subject: [PATCH 193/223] feat(game): official first-move tile draw + admin step-by-step replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decide who moves first by the official rule: each seated player draws one tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader remains. Each draw uses honest per-draw crypto/rand entropy (not the deterministic bag seed), so the recorded draw — not a seed — is the only account of the outcome. The leader takes seat 0, so the engine and journal replay are unchanged. The draw is recorded with the game (game_setup_draws, migration 00013) for future tournaments, designed as a discrete "player N draws a tile" step. Friend/AI games draw at creation. Auto-match draws when the game opens, against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are back-filled to the real opponent on join — so the opener's seat is fixed up front and the existing open-game pre-move is preserved with no reseating. Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with A-O/1-15 headers and highlighted premium squares, placed letters with their tile value as a subscript, rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) with the current player highlighted, and a per-move log with the tiles drawn and the bag remainder. Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec. --- PRERELEASE.md | 1 + .../internal/adminconsole/assets/console.css | 56 ++++++ .../templates/pages/game_detail.gohtml | 82 +++++++++ backend/internal/adminconsole/views.go | 21 +++ backend/internal/engine/setup.go | 52 ++++++ backend/internal/engine/setup_test.go | 47 +++++ backend/internal/game/replay_timeline.go | 144 +++++++++++++++ backend/internal/game/replay_timeline_test.go | 50 ++++++ backend/internal/game/seating.go | 156 ++++++++++++++++ backend/internal/game/seating_test.go | 130 ++++++++++++++ backend/internal/game/service.go | 111 +++++++++--- backend/internal/game/store.go | 98 ++++++++-- backend/internal/inttest/admin_test.go | 6 + .../inttest/dictionary_update_test.go | 4 +- .../internal/inttest/first_move_draw_test.go | 154 ++++++++++++++++ backend/internal/inttest/helpers.go | 38 +++- backend/internal/inttest/open_match_test.go | 25 +-- .../jet/backend/model/game_setup_draws.go | 24 +++ .../jet/backend/table/game_setup_draws.go | 99 +++++++++++ .../jet/backend/table/table_use_schema.go | 1 + .../migrations/00013_game_setup_draws.sql | 31 ++++ .../internal/server/handlers_admin_console.go | 28 +++ .../internal/server/handlers_admin_replay.go | 168 ++++++++++++++++++ docs/ARCHITECTURE.md | 24 ++- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 4 +- .../2026-06-19-first-move-draw-design.md | 161 +++++++++++++++++ 27 files changed, 1661 insertions(+), 59 deletions(-) create mode 100644 backend/internal/engine/setup.go create mode 100644 backend/internal/engine/setup_test.go create mode 100644 backend/internal/game/replay_timeline.go create mode 100644 backend/internal/game/replay_timeline_test.go create mode 100644 backend/internal/game/seating.go create mode 100644 backend/internal/game/seating_test.go create mode 100644 backend/internal/inttest/first_move_draw_test.go create mode 100644 backend/internal/postgres/jet/backend/model/game_setup_draws.go create mode 100644 backend/internal/postgres/jet/backend/table/game_setup_draws.go create mode 100644 backend/internal/postgres/migrations/00013_game_setup_draws.sql create mode 100644 backend/internal/server/handlers_admin_replay.go create mode 100644 docs/superpowers/specs/2026-06-19-first-move-draw-design.md diff --git a/PRERELEASE.md b/PRERELEASE.md index c1ed1a1..83c1ff7 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -36,6 +36,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** | | CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** | | BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** | +| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 2f53bcc..3cda4a0 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -137,3 +137,59 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } an image attachment is previewed inline, bounded so it cannot dominate the page. */ .msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; } .attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; } + +/* Game replay (admin): a script-stepped board with rack panels around it, a move log and the + first-move draw. A placed tile shows its value as a subscript; a 0 value (a blank) shows none. */ +.replay-stage { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: ". top ." "left board right" ". bottom ."; + gap: 0.5rem; + align-items: center; + justify-items: center; + margin-bottom: 0.6rem; +} +.rack-top { grid-area: top; } +.rack-bottom { grid-area: bottom; } +.rack-left { grid-area: left; } +.rack-right { grid-area: right; } +.replay-board { grid-area: board; overflow: auto; } +.board-grid { + display: grid; + grid-template-columns: 1.4rem repeat(15, 1.7rem); + grid-auto-rows: 1.7rem; + gap: 1px; + background: var(--line); + border: 1px solid var(--line); + width: max-content; +} +.board-grid .bh { display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: var(--ink-dim); background: var(--panel); } +.board-grid .cell { position: relative; display: flex; align-items: center; justify-content: center; background: var(--panel-hi); } +.cell .prem { font-size: 0.55rem; color: var(--ink); opacity: 0.8; } +.cell.tw { background: #7a2230; } +.cell.dw { background: #a8506a; } +.cell.tl { background: #235a7a; } +.cell.dl { background: #3f87a8; } +.cell.centre .prem { font-size: 0.95rem; color: var(--warn); opacity: 1; } +.tile { + display: inline-flex; align-items: baseline; justify-content: center; + min-width: 1.35rem; height: 1.35rem; padding: 0 0.12rem; + background: #e8d9a0; color: #1b1408; border-radius: 3px; + font-weight: 700; font-size: 0.8rem; line-height: 1.35rem; +} +.tile sub { font-size: 0.5rem; font-weight: 600; line-height: 1; align-self: flex-end; margin-left: 1px; } +.tile.blank { background: #cdbfe0; } +.cell.filled { background: var(--panel-hi); } +.cell.filled .tile { width: 100%; height: 100%; border-radius: 2px; } +.rack-slot { padding: 0.25rem; border-radius: 6px; } +.rack-slot.active { outline: 2px solid var(--accent); background: var(--panel-hi); } +.rack-name { font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 0.2rem; text-align: center; } +.rack-tiles { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; } +.rack-left .rack-tiles, .rack-right .rack-tiles { flex-direction: column; } +.replay-controls { display: flex; align-items: center; gap: 0.8rem; justify-content: center; margin: 0.6rem 0; } +.replay-controls button { background: var(--panel-hi); color: var(--ink); border: 1px solid var(--line); font-weight: 600; } +.replay-controls button:disabled { opacity: 0.4; cursor: default; } +.replay-pos { color: var(--ink-dim); font-variant-numeric: tabular-nums; } +.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; } +.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; } +.replay-log li.cur { color: var(--ink); font-weight: 600; } diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 08316b1..0c33a48 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -27,5 +27,87 @@ {{if .HasRobot}}

Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.

{{end}}
+{{if .SetupDraws}} +

First-move draw

+

Each player draws a tile; the one closest to “A” moves first (a blank beats every letter), ties re-drawing until a single leader remains.{{if .FirstMover}} {{.FirstMover}} leads.{{end}}

+ + + +{{range .SetupDraws}} + +{{end}} + +
RoundPlayerTileRank
{{.Round}}{{if .AccountID}}{{.Name}}{{else}}{{.Name}}{{end}}{{.Letter}}{{if .Blank}} (blank){{end}}{{.Rank}}
+
+{{end}} +{{if .HasReplay}} +

Replay

+
+
+
+
+
+
+
+
+ + + +
+
    + +
    +{{end}} {{end}} {{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 59cb2f7..7142021 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -268,6 +268,27 @@ type GameDetailView struct { // RobotTargetPct is the configured global play-to-win rate, in percent. HasRobot bool RobotTargetPct int + // ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the + // game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section. + ReplayJSON template.JS + HasReplay bool + // SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) — + // and FirstMover is the resolved name of the seat-0 player the draw elected. + SetupDraws []SetupDrawRow + FirstMover string +} + +// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the +// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an +// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a +// blank) and its draw rank. +type SetupDrawRow struct { + Round int + Name string + AccountID string + Letter string + Blank bool + Rank int } // SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's diff --git a/backend/internal/engine/setup.go b/backend/internal/engine/setup.go new file mode 100644 index 0000000..e0a8ee2 --- /dev/null +++ b/backend/internal/engine/setup.go @@ -0,0 +1,52 @@ +package engine + +import "fmt" + +// SetupTile is one tile of a variant's full bag, decoded for the first-move draw +// (docs/ARCHITECTURE.md §6): its concrete letter (or the blank marker), a blank +// flag, and its draw rank. Lower rank wins the draw — a blank ranks above every +// letter, and letters rank by alphabet index, so the tile closest to the start of +// the alphabet ("A") wins. It is dictionary-independent, built from the variant's +// solver ruleset alone. +type SetupTile struct { + // Letter is the concrete character (the case the solver ruleset emits), or + // the blank marker "?" for a blank. + Letter string + // Blank reports whether the tile is a blank. + Blank bool + // Rank orders the draw: BlankRank for a blank (best), else the letter's + // alphabet index (0 = closest to "A"). + Rank int +} + +// BlankRank is the first-move draw rank of a blank: below every letter index, so a +// blank always beats a lettered tile, matching the official rule that a blank +// supersedes all letters. +const BlankRank = -1 + +// SetupBag returns variant's full tile bag — every lettered tile expanded by its +// count, plus one entry per blank — decoded for the first-move seeding draw. The +// order is deterministic (alphabet order, blanks last); callers shuffle it with +// their own entropy. It needs no dictionary, so it is built from the variant's +// ruleset alone and reports ErrUnknownVariant for an unrecognised variant. +func SetupBag(v Variant) ([]SetupTile, error) { + rs, ok := v.ruleset() + if !ok { + return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + bag := make([]SetupTile, 0, 128) + for i, n := range rs.Counts { + ch, err := rs.Alphabet.Character(byte(i)) + if err != nil { + // An offered variant's alphabet never yields a bad index; skip defensively. + continue + } + for range n { + bag = append(bag, SetupTile{Letter: ch, Rank: i}) + } + } + for range rs.Blanks { + bag = append(bag, SetupTile{Letter: blankLetter, Blank: true, Rank: BlankRank}) + } + return bag, nil +} diff --git a/backend/internal/engine/setup_test.go b/backend/internal/engine/setup_test.go new file mode 100644 index 0000000..f986133 --- /dev/null +++ b/backend/internal/engine/setup_test.go @@ -0,0 +1,47 @@ +package engine + +import ( + "errors" + "testing" +) + +func TestSetupBagEnglish(t *testing.T) { + bag, err := SetupBag(VariantEnglish) + if err != nil { + t.Fatalf("SetupBag: %v", err) + } + // English Scrabble: 98 lettered tiles + 2 blanks = 100. + if len(bag) != 100 { + t.Fatalf("bag size = %d, want 100", len(bag)) + } + blanks, aCount := 0, 0 + for _, tl := range bag { + switch { + case tl.Blank: + blanks++ + if tl.Rank != BlankRank { + t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank) + } + if tl.Letter != blankLetter { + t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter) + } + case tl.Letter == "a": + aCount++ + if tl.Rank != 0 { + t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank) + } + } + } + if blanks != 2 { + t.Errorf("blanks = %d, want 2", blanks) + } + if aCount != 9 { + t.Errorf("'a' count = %d, want 9", aCount) + } +} + +func TestSetupBagUnknownVariant(t *testing.T) { + if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) { + t.Fatalf("err = %v, want ErrUnknownVariant", err) + } +} diff --git a/backend/internal/game/replay_timeline.go b/backend/internal/game/replay_timeline.go new file mode 100644 index 0000000..a7f2da8 --- /dev/null +++ b/backend/internal/game/replay_timeline.go @@ -0,0 +1,144 @@ +package game + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" +) + +// ReplayStep is one step of an admin game replay: the move that produced it (nil for the +// initial dealt state, step 0) and the resulting position — every seat's rack, the running +// scores, whose turn it is and the bag remainder. Step k's board is the union of every +// play's placements through step k, which the renderer accumulates onto an empty grid +// (docs/ARCHITECTURE.md §9.1 visual replay). +type ReplayStep struct { + // Move is the journalled move that produced this state, or nil for the initial deal. + Move *HistoryMove + // Drawn lists the tiles the mover drew from the bag after this move ("?" for a blank); + // empty for the initial deal, a pass or a resignation. + Drawn []string + // Racks holds every seat's rack at this step, indexed by seat ("?" for a blank). + Racks [][]string + // Scores holds every seat's running score, indexed by seat. + Scores []int + // ToMove is the seat to move at this step. + ToMove int + // BagLen is the number of tiles left in the bag at this step. + BagLen int +} + +// ReplayTimelineView is the admin replay of a game: the persisted game plus the ordered +// replay steps (the initial deal followed by one step per journalled move). +type ReplayTimelineView struct { + Game Game + Steps []ReplayStep +} + +// ReplayTimeline rebuilds a game from its pinned seed and journal and returns the ordered +// replay steps for the admin console: the initial deal (step 0) then one step per +// journalled move, each carrying the resulting racks, scores, turn cursor, bag size and the +// tiles the mover drew. The deterministic bag makes the reconstruction exact. It needs no +// dictionary beyond the engine the seed deals, and — like the live replay — stops early if a +// committed move became illegal under tightened rules rather than failing. +func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (ReplayTimelineView, error) { + pre, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return ReplayTimelineView{}, err + } + seed, err := svc.store.GameSeed(ctx, gameID) + if err != nil { + return ReplayTimelineView{}, err + } + g, err := engine.New(svc.registry, engine.Options{ + Variant: pre.Variant, + Version: pre.DictVersion, + Players: pre.Players, + Seed: seed, + DropoutTiles: pre.DropoutTiles, + MultipleWordsPerTurn: pre.MultipleWordsPerTurn, + }) + if err != nil { + return ReplayTimelineView{}, err + } + moves, err := svc.store.GetJournal(ctx, gameID) + if err != nil { + return ReplayTimelineView{}, err + } + steps := make([]ReplayStep, 0, len(moves)+1) + steps = append(steps, snapshotStep(g, nil, nil)) + for i := range moves { + mv := moves[i] + before := g.Hand(mv.Seat) + if err := replayMove(g, mv); err != nil { + if errors.Is(err, engine.ErrIllegalPlay) { + g.Abort() + break + } + return ReplayTimelineView{}, fmt.Errorf("game: replay-timeline %s move %d: %w", gameID, mv.Seq, err) + } + moveCopy := mv + steps = append(steps, snapshotStep(g, &moveCopy, drawnTiles(before, g.Hand(mv.Seat), usedTiles(mv)))) + } + return ReplayTimelineView{Game: pre, Steps: steps}, nil +} + +// snapshotStep captures the position after applying move (nil for the initial deal): every +// seat's rack and score, the turn cursor and the bag size, with the supplied drawn tiles. +func snapshotStep(g *engine.Game, move *HistoryMove, drawn []string) ReplayStep { + n := g.Players() + racks := make([][]string, n) + scores := make([]int, n) + for i := 0; i < n; i++ { + racks[i] = g.Hand(i) + scores[i] = g.Score(i) + } + return ReplayStep{Move: move, Drawn: drawn, Racks: racks, Scores: scores, ToMove: g.ToMove(), BagLen: g.BagLen()} +} + +// usedTiles returns the rack tiles a move consumed ("?" for a blank): the placed tiles of a +// play or the swapped tiles of an exchange; a pass or resignation consumes none. +func usedTiles(mv HistoryMove) []string { + switch mv.Action { + case "play": + used := make([]string, len(mv.Tiles)) + for i, t := range mv.Tiles { + if t.Blank { + used[i] = "?" // a placed blank leaves the rack as the blank marker + } else { + used[i] = t.Letter + } + } + return used + case "exchange": + return mv.Exchanged + } + return nil +} + +// drawnTiles returns the tiles the mover drew from the bag: the post-move rack (after) minus +// the tiles kept (before minus used). It compares the racks as multisets, so duplicate +// letters are counted correctly. +func drawnTiles(before, after, used []string) []string { + kept := make(map[string]int, len(before)) + for _, t := range before { + kept[t]++ + } + for _, t := range used { + if kept[t] > 0 { + kept[t]-- + } + } + var drawn []string + for _, t := range after { + if kept[t] > 0 { + kept[t]-- + continue + } + drawn = append(drawn, t) + } + return drawn +} diff --git a/backend/internal/game/replay_timeline_test.go b/backend/internal/game/replay_timeline_test.go new file mode 100644 index 0000000..f6b126c --- /dev/null +++ b/backend/internal/game/replay_timeline_test.go @@ -0,0 +1,50 @@ +package game + +import ( + "reflect" + "testing" + + "scrabble/backend/internal/engine" +) + +func TestUsedTiles(t *testing.T) { + tests := []struct { + name string + mv HistoryMove + want []string + }{ + {"pass", HistoryMove{Action: "pass"}, nil}, + {"resign", HistoryMove{Action: "resign"}, nil}, + {"play with blank", HistoryMove{Action: "play", Tiles: []engine.TileRecord{{Letter: "a"}, {Letter: "b", Blank: true}}}, []string{"a", "?"}}, + {"exchange", HistoryMove{Action: "exchange", Exchanged: []string{"a", "?"}}, []string{"a", "?"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := usedTiles(tt.mv); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("usedTiles = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDrawnTiles(t *testing.T) { + tests := []struct { + name string + before, used []string + after []string + want []string + }{ + {"play refill", []string{"a", "b", "c", "d"}, []string{"a", "b"}, []string{"c", "d", "e", "f"}, []string{"e", "f"}}, + {"blank played", []string{"?", "a"}, []string{"?"}, []string{"a", "x"}, []string{"x"}}, + {"pass keeps rack", []string{"a", "b"}, nil, []string{"a", "b"}, nil}, + {"duplicate letters", []string{"e", "e", "e"}, []string{"e"}, []string{"e", "e", "q"}, []string{"q"}}, + {"empty bag no refill", []string{"a", "b"}, []string{"a"}, []string{"b"}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := drawnTiles(tt.before, tt.after, tt.used); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("drawnTiles = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/internal/game/seating.go b/backend/internal/game/seating.go new file mode 100644 index 0000000..8c2f7dd --- /dev/null +++ b/backend/internal/game/seating.go @@ -0,0 +1,156 @@ +package game + +import ( + "crypto/rand" + "fmt" + "math/big" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" +) + +// maxSeedingRounds caps the first-move draw's tie re-draws. With a real bag a tie +// breaks with positive probability each round, so this is reached only by a +// degenerate (e.g. test) entropy source that ties forever; the cap turns that into +// an error instead of an infinite loop. +const maxSeedingRounds = 1000 + +// SetupDraw is one recorded tile draw of the first-move seeding — one row of +// game_setup_draws (docs/ARCHITECTURE.md §6, §9): the round, the draw order within +// it, the seated account that drew, and the decoded tile with its rank. It is +// dictionary-independent: the letter, the blank flag and the numeric rank describe +// the draw without any alphabet table. +type SetupDraw struct { + Round int + PickNo int + Account uuid.UUID + Letter string + Blank bool + Rank int +} + +// seedingResult is the outcome of the first-move seeding: the winning account, the +// seated accounts rotated so the winner leads (seat 0), and the full draw log to +// persist. +type seedingResult struct { + winner uuid.UUID + order []uuid.UUID + draws []SetupDraw +} + +// drawIntn returns a uniformly random integer in [0, n) for n > 0. It is the +// entropy seam of the first-move seeding: production uses crypto/rand (cryptoIntn), +// so every draw is honestly random with no single seed; tests inject a +// deterministic source. +type drawIntn func(n int) (int, error) + +// cryptoIntn draws a uniform integer in [0, n) from crypto/rand — the honest, +// seedless entropy the first-move draw requires. +func cryptoIntn(n int) (int, error) { + v, err := rand.Int(rand.Reader, big.NewInt(int64(n))) + if err != nil { + return 0, fmt.Errorf("game: first-move draw entropy: %w", err) + } + return int(v.Int64()), nil +} + +// seedFirstMove runs the official first-move draw over accounts for variant v, +// drawing tiles with the entropy source intn. Each round every contender draws one +// tile (without replacement) from a fresh full bag; the tile closest to "A" wins, a +// blank beating every letter; contenders tied for the best tile re-draw in the next +// round until a single leader remains. It returns the leader, the rotation that +// seats the leader first (preserving the others' seating order), and every draw for +// the record. It performs no I/O beyond calling intn. +func seedFirstMove(v engine.Variant, accounts []uuid.UUID, intn drawIntn) (seedingResult, error) { + if len(accounts) < 2 { + return seedingResult{}, fmt.Errorf("game: first-move seeding needs at least 2 accounts, got %d", len(accounts)) + } + full, err := engine.SetupBag(v) + if err != nil { + return seedingResult{}, err + } + contenders := append([]uuid.UUID(nil), accounts...) + var draws []SetupDraw + for round := 1; ; round++ { + if round > maxSeedingRounds { + return seedingResult{}, fmt.Errorf("game: first-move seeding unresolved after %d rounds", maxSeedingRounds) + } + bag := append([]engine.SetupTile(nil), full...) + picks := make([]engine.SetupTile, len(contenders)) + for i, acc := range contenders { + tile, rest, err := drawSetupTile(bag, intn) + if err != nil { + return seedingResult{}, err + } + bag = rest + picks[i] = tile + draws = append(draws, SetupDraw{ + Round: round, PickNo: i, Account: acc, + Letter: tile.Letter, Blank: tile.Blank, Rank: tile.Rank, + }) + } + winners := bestContenders(contenders, picks) + if len(winners) == 1 { + return seedingResult{winner: winners[0], order: rotateToFirst(accounts, winners[0]), draws: draws}, nil + } + contenders = winners + } +} + +// drawSetupTile removes one uniformly random tile from bag using the entropy source +// intn and returns it with the shrunk bag (a fresh slice, leaving bag untouched). +// It is the per-tile draw primitive — the seam a future manual "player N draws a +// tile" tournament API will drive, one call per external request. +func drawSetupTile(bag []engine.SetupTile, intn drawIntn) (engine.SetupTile, []engine.SetupTile, error) { + if len(bag) == 0 { + return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw from an empty bag") + } + i, err := intn(len(bag)) + if err != nil { + return engine.SetupTile{}, nil, err + } + if i < 0 || i >= len(bag) { + return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw index %d out of range %d", i, len(bag)) + } + tile := bag[i] + rest := make([]engine.SetupTile, 0, len(bag)-1) + rest = append(rest, bag[:i]...) + rest = append(rest, bag[i+1:]...) + return tile, rest, nil +} + +// bestContenders returns the contenders whose drawn tile has the lowest (best) +// rank — the sole winner if one, else the tied set that re-draws. picks is aligned +// with contenders by index. +func bestContenders(contenders []uuid.UUID, picks []engine.SetupTile) []uuid.UUID { + best := picks[0].Rank + for _, p := range picks[1:] { + if p.Rank < best { + best = p.Rank + } + } + var winners []uuid.UUID + for i, p := range picks { + if p.Rank == best { + winners = append(winners, contenders[i]) + } + } + return winners +} + +// rotateToFirst returns accounts rotated cyclically so winner sits first (seat 0), +// preserving the seating order of the rest (docs/ARCHITECTURE.md §6). winner must be +// present in accounts. +func rotateToFirst(accounts []uuid.UUID, winner uuid.UUID) []uuid.UUID { + i := 0 + for ; i < len(accounts); i++ { + if accounts[i] == winner { + break + } + } + out := make([]uuid.UUID, 0, len(accounts)) + out = append(out, accounts[i:]...) + out = append(out, accounts[:i]...) + return out +} diff --git a/backend/internal/game/seating_test.go b/backend/internal/game/seating_test.go new file mode 100644 index 0000000..b73abee --- /dev/null +++ b/backend/internal/game/seating_test.go @@ -0,0 +1,130 @@ +package game + +import ( + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" +) + +// scriptIntn returns a drawIntn that yields the scripted indices in order, failing +// the test if the script is exhausted or an index is out of range. It lets a test +// drive the first-move draw deterministically (English SetupBag order: 9 'a' at +// 0..8, then 'b' …, blanks last). +func scriptIntn(t *testing.T, seq ...int) drawIntn { + t.Helper() + i := 0 + return func(n int) (int, error) { + if i >= len(seq) { + t.Fatalf("intn script exhausted (asked for [0,%d))", n) + } + v := seq[i] + i++ + if v < 0 || v >= n { + t.Fatalf("intn script value %d out of range [0,%d)", v, n) + } + return v, nil + } +} + +func TestSeedFirstMoveDirectWinner(t *testing.T) { + a, b := uuid.New(), uuid.New() + // a draws bag[0]='a' (rank 0); b draws bag[8]='b' (rank 1) → a wins, no tie. + res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 8)) + if err != nil { + t.Fatalf("seedFirstMove: %v", err) + } + if res.winner != a { + t.Fatalf("winner = %v, want %v", res.winner, a) + } + if got := res.order; len(got) != 2 || got[0] != a || got[1] != b { + t.Fatalf("order = %v, want [a b]", got) + } + if len(res.draws) != 2 { + t.Fatalf("draws = %d, want 2", len(res.draws)) + } +} + +func TestSeedFirstMoveBlankSupersedes(t *testing.T) { + a, b := uuid.New(), uuid.New() + // a draws 'a' (rank 0); after the draw the two blanks sit at 97,98 — b draws + // bag[97], a blank, which beats every letter. + res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 97)) + if err != nil { + t.Fatalf("seedFirstMove: %v", err) + } + if res.winner != b { + t.Fatalf("winner = %v, want %v (blank supersedes)", res.winner, b) + } + last := res.draws[len(res.draws)-1] + if !last.Blank || last.Rank != engine.BlankRank || last.Letter != "?" { + t.Fatalf("winning draw = %+v, want a blank (rank %d, '?')", last, engine.BlankRank) + } +} + +func TestSeedFirstMoveTieRedraw(t *testing.T) { + a, b, c := uuid.New(), uuid.New(), uuid.New() + // Round 1: a→bag[0]='a'(0), b→bag[0]='a'(0), c→bag[7]='b'(1) → a,b tie best. + // Round 2 (a,b only): a→bag[0]='a'(0), b→bag[8]='b'(1) → a wins. + res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b, c}, scriptIntn(t, 0, 0, 7, 0, 8)) + if err != nil { + t.Fatalf("seedFirstMove: %v", err) + } + if res.winner != a { + t.Fatalf("winner = %v, want %v", res.winner, a) + } + if len(res.draws) != 5 { + t.Fatalf("draws = %d, want 5 (3 in round 1, 2 in round 2)", len(res.draws)) + } + if res.draws[2].Round != 1 || res.draws[3].Round != 2 { + t.Fatalf("round boundaries wrong: %+v", res.draws) + } + // The full table keeps every account's seating order, winner first. + if got := res.order; got[0] != a || got[1] != b || got[2] != c { + t.Fatalf("order = %v, want [a b c]", got) + } +} + +func TestSeedFirstMoveTooFewAccounts(t *testing.T) { + if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{uuid.New()}, scriptIntn(t)); err == nil { + t.Fatal("want error for a single account") + } +} + +func TestSeedFirstMovePerpetualTieCapped(t *testing.T) { + a, b := uuid.New(), uuid.New() + // Always drawing bag[0] gives both players an 'a' every round — a tie that never + // resolves; the round cap must turn it into an error, not an infinite loop. + alwaysZero := drawIntn(func(int) (int, error) { return 0, nil }) + if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, alwaysZero); err == nil { + t.Fatal("want error when ties never resolve") + } +} + +func TestRotateToFirst(t *testing.T) { + a, b, c, d := uuid.New(), uuid.New(), uuid.New(), uuid.New() + tests := []struct { + name string + accounts []uuid.UUID + winner uuid.UUID + want []uuid.UUID + }{ + {"two winner second", []uuid.UUID{a, b}, b, []uuid.UUID{b, a}}, + {"three winner first", []uuid.UUID{a, b, c}, a, []uuid.UUID{a, b, c}}, + {"four winner third", []uuid.UUID{a, b, c, d}, c, []uuid.UUID{c, d, a, b}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rotateToFirst(tt.accounts, tt.winner) + if len(got) != len(tt.want) { + t.Fatalf("len = %d, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("rotate = %v, want %v", got, tt.want) + } + } + }) + } +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 5b2dac5..3da994c 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -38,7 +38,12 @@ type Service struct { version string clock func() time.Time rng func() int64 - pub notify.Publisher + // firstMoveEntropy returns the entropy source for one game's first-move draw + // (docs/ARCHITECTURE.md §6). The default is crypto/rand — an honest, seedless draw; + // tests override it via SetFirstMoveEntropy for a deterministic turn order. It is a + // factory so a stateful test source restarts cleanly per game. + firstMoveEntropy func() drawIntn + pub notify.Publisher // aiTrigger, when set, is called after an honest-AI game is created or advanced and // is still on a robot's potential turn, so the robot replies at once instead of // waiting for the next driver scan. It is fire-and-forget (the robot package wires @@ -59,17 +64,18 @@ type Service struct { func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service { clock := func() time.Time { return time.Now().UTC() } return &Service{ - store: store, - accounts: accounts, - registry: registry, - cache: newGameCache(cfg.CacheTTL, clock), - locks: newKeyedMutex(), - version: cfg.DictVersion, - clock: clock, - rng: randomSeed, - pub: notify.Nop{}, - metrics: defaultGameMetrics(), - log: log, + store: store, + accounts: accounts, + registry: registry, + cache: newGameCache(cfg.CacheTTL, clock), + locks: newKeyedMutex(), + version: cfg.DictVersion, + clock: clock, + rng: randomSeed, + firstMoveEntropy: func() drawIntn { return cryptoIntn }, + pub: notify.Nop{}, + metrics: defaultGameMetrics(), + log: log, } } @@ -101,6 +107,18 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account svc.clearNudges = fn } +// SetFirstMoveEntropy overrides the entropy source for the first-move draw +// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any +// game is created; the production default is crypto/rand and is never overridden. +// factory returns a fresh draw function per game, so a stateful deterministic test +// source restarts cleanly for each game. It exists for deterministic tests. +func (svc *Service) SetFirstMoveEntropy(factory func() func(n int) (int, error)) { + if factory == nil { + return + } + svc.firstMoveEntropy = func() drawIntn { return drawIntn(factory()) } +} + // triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort, // fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is // installed, so callers can invoke it unconditionally after a create or commit. @@ -191,15 +209,32 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro } } seen := make(map[uuid.UUID]bool, len(params.Seats)) - seats := make([]seatInsert, len(params.Seats)) - for i, id := range params.Seats { + for _, id := range params.Seats { if seen[id] { return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id) } seen[id] = true - // Snapshot each seat's display name at creation. For a vs-AI game this stamps the - // robot's seeded account name (unchanged behaviour); a disguised auto-match robot - // instead gets a fresh per-game name when the reaper attaches it (AttachRobot). + } + + // Decide who moves first by the official draw (docs/ARCHITECTURE.md §6): each seated + // account draws a tile, the one closest to "A" leads (a blank supersedes all letters), + // ties re-drawing until a single leader remains. Each draw uses fresh entropy, not the + // game seed, so the recorded draws — persisted with the game — are the only account of + // the outcome. The winner takes seat 0; the rest keep their order. + seeding, err := seedFirstMove(params.Variant, params.Seats, svc.firstMoveEntropy()) + if err != nil { + if errors.Is(err, engine.ErrUnknownVariant) { + return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + return Game{}, err + } + + // Build the seats in the drawn turn order, snapshotting each seat's display name. For a + // vs-AI game this stamps the robot's seeded account name (unchanged behaviour); a + // disguised auto-match robot instead gets a fresh per-game name when the reaper attaches + // it (AttachRobot). + seats := make([]seatInsert, len(seeding.order)) + for i, id := range seeding.order { acc, err := svc.accounts.GetByID(ctx, id) if err != nil { if errors.Is(err, account.ErrNotFound) { @@ -249,7 +284,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro multipleWordsPerTurn: params.MultipleWordsPerTurn, vsAI: params.VsAI, } - if err := svc.store.CreateGame(ctx, ins, seats); err != nil { + if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil { return Game{}, err } svc.cache.put(id, g, params.Variant.String()) @@ -312,14 +347,27 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params status: StatusOpen, openDeadline: &deadline, } - // Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their - // display name; the other seat is left empty (a zero seatInsert) for the opponent. - caller := seatInsert{accountID: accountID, displayName: acc.DisplayName} - seats := []seatInsert{caller, {}} - if seed&1 == 1 { - seats = []seatInsert{{}, caller} + // Decide the first move now by the official draw, with the not-yet-arrived opponent as a + // synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves + // first — before either player acts (docs/ARCHITECTURE.md §6). The caller takes their + // drawn seat; the opponent seat is left empty. The opponent's draw rows are recorded with + // a NULL account and back-filled when a real opponent joins. Each draw uses fresh entropy, + // not the game seed. seats/draws are used only when a fresh game is opened. + seeding, err := seedFirstMove(params.Variant, []uuid.UUID{accountID, uuid.Nil}, svc.firstMoveEntropy()) + if err != nil { + if errors.Is(err, engine.ErrUnknownVariant) { + return Game{}, false, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + return Game{}, false, err } - gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude) + caller := seatInsert{accountID: accountID, displayName: acc.DisplayName} + seats := make([]seatInsert, len(seeding.order)) + for seat, who := range seeding.order { + if who == accountID { + seats[seat] = caller // the empty opponent seat stays a zero seatInsert + } + } + gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude, seeding.draws) if err != nil { return Game{}, false, err } @@ -547,8 +595,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, return MoveResult{}, ErrNotAPlayer } // A move is allowed while the game is active or still open (the starter may move on - // their turn before an opponent joins); only a finished game rejects it. The turn - // check below keeps the starter off the still-empty opponent seat. + // their turn before an opponent joins; the first-move draw ran when the game opened, so + // the seats are already fixed); only a finished game rejects it. The turn check below + // keeps the starter off the still-empty opponent seat. if pre.Status == StatusFinished { return MoveResult{}, ErrFinished } @@ -1287,6 +1336,14 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, return HistoryView{Game: g, Moves: moves}, nil } +// SetupDraws returns a game's recorded first-move draws (docs/ARCHITECTURE.md §6), ordered by +// round then pick. It backs the admin console's first-move section. An auto-match opponent's +// draws carry uuid.Nil until a real opponent joins and back-fills them; an empty slice means +// the game predates the draw record. +func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDraw, error) { + return svc.store.SetupDraws(ctx, gameID) +} + // ExportGCG renders a game as GCG text from the journal alone (no dictionary). It // is allowed only on a finished game: exporting an in-progress game would leak the // full move journal mid-play, so an active game yields ErrGameActive. diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 90ab3a2..30add2e 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -127,11 +127,14 @@ type seatInsert struct { displayName string } -// CreateGame inserts the games row and one game_players row per seat (seat 0 -// first) inside a single transaction. -func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error { +// CreateGame inserts the games row, one game_players row per seat (seat 0 first) and +// the first-move seeding draws inside a single transaction. +func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error { return withTx(ctx, s.db, func(tx *sql.Tx) error { - return insertGameTx(ctx, tx, ins, seats) + if err := insertGameTx(ctx, tx, ins, seats); err != nil { + return err + } + return insertSetupDrawsTx(ctx, tx, ins.id, draws) }) } @@ -173,6 +176,30 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI return nil } +// insertSetupDrawsTx appends the first-move seeding draws for game gameID on tx — +// the dictionary-independent record of how the first player was chosen +// (docs/ARCHITECTURE.md §6). The games row must already exist on tx (the foreign +// key), so it runs after insertGameTx. An empty draws slice is a no-op. +func insertSetupDrawsTx(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, draws []SetupDraw) error { + for _, d := range draws { + // A uuid.Nil account marks the synthetic opponent of an auto-match draw, persisted as + // a NULL account_id and back-filled when a real opponent joins. + var acc any = d.Account + if d.Account == uuid.Nil { + acc = postgres.NULL + } + di := table.GameSetupDraws.INSERT( + table.GameSetupDraws.GameID, table.GameSetupDraws.Round, table.GameSetupDraws.PickNo, + table.GameSetupDraws.AccountID, table.GameSetupDraws.Letter, table.GameSetupDraws.IsBlank, + table.GameSetupDraws.DrawRank, + ).VALUES(gameID, d.Round, d.PickNo, acc, d.Letter, d.Blank, d.Rank) + if _, err := di.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("insert setup draw round %d pick %d: %w", d.Round, d.PickNo, err) + } + } + return nil +} + // openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the // advisory-lock key that serialises concurrent enqueues for that bucket, so two // players never both open a game instead of pairing. @@ -199,8 +226,9 @@ func openMatchKey(variant string, multipleWords bool) int64 { // arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen // order) used only when a game is created; callerName is the caller's display-name // snapshot, stamped on their seat whether they open a fresh game or fill another -// player's open one. -func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) { +// player's open one. seats and draws (the first-move draw recorded against the synthetic +// opponent, docs/ARCHITECTURE.md §6) are used only when a game is created. +func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID, draws []SetupDraw) (gameID uuid.UUID, joined, created bool, err error) { err = withTx(ctx, s.db, func(tx *sql.Tx) error { if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil { @@ -222,7 +250,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName LIMIT 1 FOR UPDATE SKIP LOCKED`, ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); { case e == nil: - if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil { + if er := fillAndActivate(ctx, tx, other, accountID, callerName); er != nil { return er } gameID, joined = other, true @@ -230,10 +258,15 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName case !errors.Is(e, sql.ErrNoRows): return fmt.Errorf("find open game: %w", e) } - // 2. None waiting — open a fresh game seating the caller (the other seat empty). + // 2. None waiting — open a fresh game seating the caller (the other seat empty) and + // recording the first-move draw; the synthetic opponent's rows carry a NULL account + // until a real opponent joins and back-fills them. if e := insertGameTx(ctx, tx, ins, seats); e != nil { return e } + if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); e != nil { + return e + } gameID, created = ins.id, true return nil }) @@ -258,7 +291,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp if status != StatusOpen { return nil } - if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil { + if e := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil { return e } attached = true @@ -267,15 +300,23 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp return attached, err } -// fillOpenSeat seats accountID in an open game's empty opponent seat — stamping -// displayName as the seat's display-name snapshot — and flips the game to active with a -// fresh turn clock. The caller holds the game row. -func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error { +// fillAndActivate seats accountID in an open game's empty opponent seat — stamping +// displayName as the seat's snapshot — back-fills the first-move draw rows the open game +// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to +// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at +// open time, so the journal (any opening move the starter made while waiting) is never +// disturbed. The caller holds the game row. +func fillAndActivate(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error { if _, err := tx.ExecContext(ctx, `UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`, gameID, accountID, displayName); err != nil { return fmt.Errorf("fill opponent seat: %w", err) } + if _, err := tx.ExecContext(ctx, + `UPDATE backend.game_setup_draws SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`, + gameID, accountID); err != nil { + return fmt.Errorf("back-fill opponent draws: %w", err) + } if _, err := tx.ExecContext(ctx, `UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now() WHERE game_id = $1`, gameID); err != nil { @@ -587,6 +628,37 @@ func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, er return out, nil } +// SetupDraws loads the ordered first-move seeding draws for a game (round then pick +// order), or an empty slice for a game created before the draw was recorded. It +// backs the admin console's first-move section. +func (s *Store) SetupDraws(ctx context.Context, id uuid.UUID) ([]SetupDraw, error) { + stmt := postgres.SELECT(table.GameSetupDraws.AllColumns). + FROM(table.GameSetupDraws). + WHERE(table.GameSetupDraws.GameID.EQ(postgres.UUID(id))). + ORDER_BY(table.GameSetupDraws.Round.ASC(), table.GameSetupDraws.PickNo.ASC()) + var rows []model.GameSetupDraws + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("game: get setup draws %s: %w", id, err) + } + out := make([]SetupDraw, len(rows)) + for i, r := range rows { + // A NULL account is the synthetic opponent of an auto-match draw not yet back-filled. + acc := uuid.Nil + if r.AccountID != nil { + acc = *r.AccountID + } + out[i] = SetupDraw{ + Round: int(r.Round), + PickNo: int(r.PickNo), + Account: acc, + Letter: r.Letter, + Blank: r.IsBlank, + Rank: int(r.DrawRank), + } + } + return out, nil +} + // CommitMove appends the move and applies the post-move game state — the turn // cursor and per-seat scores, plus the finish stamp and statistics when the move // ended the game — in one transaction. diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 75354cf..86b1c46 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -206,6 +206,12 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) { if !strings.Contains(body, "~40%") { t.Error("robot play-to-win target caption missing") } + if !strings.Contains(body, "First-move draw") { + t.Error("first-move draw section missing from the game detail") + } + if !strings.Contains(body, `id="replay-board"`) { + t.Error("replay board container missing from the game detail") + } } // TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to diff --git a/backend/internal/inttest/dictionary_update_test.go b/backend/internal/inttest/dictionary_update_test.go index a829514..c1c359b 100644 --- a/backend/internal/inttest/dictionary_update_test.go +++ b/backend/internal/inttest/dictionary_update_test.go @@ -134,11 +134,13 @@ func TestDictionaryUpdateFlow(t *testing.T) { // newGameServiceOn builds a game service over the shared pool but a caller-supplied // dictionary directory, version and registry, for the isolated dictionary tests. func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service { - return game.NewService( + svc := game.NewService( game.NewStore(testDB), account.NewStore(testDB), reg, game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour}, zap.NewNop(), ) + svc.SetFirstMoveEntropy(seatZeroFirstMove) + return svc } // seedDawgs copies the committed DAWG of every variant from src into dst (flat). diff --git a/backend/internal/inttest/first_move_draw_test.go b/backend/internal/inttest/first_move_draw_test.go new file mode 100644 index 0000000..d67fa3e --- /dev/null +++ b/backend/internal/inttest/first_move_draw_test.go @@ -0,0 +1,154 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// The first-move-draw suite covers the official seeding (docs/ARCHITECTURE.md §6): the draw is +// recorded with the game, decides seat 0 (the first mover), and — in auto-match — runs against +// a synthetic opponent at open time whose draw rows are back-filled when a real opponent joins. + +// TestCreateRecordsFirstMoveDraws checks a directly-seated game records the draw against the +// real seated accounts and seats the drawn leader (the suite's deterministic draw elects the +// first-listed account) at seat 0. +func TestCreateRecordsFirstMoveDraws(t *testing.T) { + ctx := context.Background() + svc := newGameService() + a := provisionAccount(t) + b := provisionAccount(t) + g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 1}) + if err != nil { + t.Fatalf("create: %v", err) + } + if g.Seats[0].AccountID != a || g.Seats[1].AccountID != b { + t.Fatalf("seats = [%s %s], want [a b]", g.Seats[0].AccountID, g.Seats[1].AccountID) + } + draws, err := svc.SetupDraws(ctx, g.ID) + if err != nil { + t.Fatalf("setup draws: %v", err) + } + // seatZeroFirstMove resolves in one round: the first contender (a) draws a blank and wins. + if len(draws) != 2 { + t.Fatalf("draws = %d, want 2 (one round, two players)", len(draws)) + } + if draws[0].Account != a || !draws[0].Blank { + t.Fatalf("draw[0] = %+v, want a having drawn a blank", draws[0]) + } + if draws[1].Account != b { + t.Fatalf("draw[1] account = %s, want b", draws[1].Account) + } +} + +// TestAutoMatchDrawBackfilledOnJoin checks an open auto-match game records the draw at open +// time against the synthetic opponent (a NULL account) and back-fills those rows to the real +// opponent when they join. +func TestAutoMatchDrawBackfilledOnJoin(t *testing.T) { + ctx := context.Background() + clearOpenGames(t) + svc := newGameService() + starter := provisionAccount(t) + g := openGame(t, svc, starter, evenOpeningSeed(t)) + + draws, err := svc.SetupDraws(ctx, g.ID) + if err != nil { + t.Fatalf("setup draws: %v", err) + } + if len(draws) != 2 { + t.Fatalf("open draws = %d, want 2", len(draws)) + } + var nullSeen, starterSeen bool + for _, d := range draws { + switch d.Account { + case uuid.Nil: + nullSeen = true + case starter: + starterSeen = true + } + } + if !nullSeen || !starterSeen { + t.Fatalf("open draws = %+v, want the starter and a NULL synthetic opponent", draws) + } + + joiner := provisionAccount(t) + if _, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil); err != nil || !joined { + t.Fatalf("join = (joined %v, err %v), want joined", joined, err) + } + after, err := svc.SetupDraws(ctx, g.ID) + if err != nil { + t.Fatalf("setup draws after join: %v", err) + } + for _, d := range after { + if d.Account == uuid.Nil { + t.Fatalf("draw still NULL after join: %+v", d) + } + if d.Account != starter && d.Account != joiner { + t.Fatalf("draw account %s is neither player", d.Account) + } + } +} + +// TestReplayTimeline checks the admin replay timeline reconstructs the dealt racks and one +// played move: the step count, the drawn tiles, the bag remainder, the running score and the +// turn cursor. +func TestReplayTimeline(t *testing.T) { + ctx := context.Background() + svc := newGameService() + a := provisionAccount(t) + b := provisionAccount(t) + seed := evenOpeningSeed(t) + g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: seed}) + if err != nil { + t.Fatalf("create: %v", err) + } + hint, ok := newMirror(t, seed, 2).HintView() + if !ok || len(hint.Tiles) == 0 { + t.Fatal("no opening move for the seed") + } + if _, err := svc.SubmitPlay(ctx, g.ID, a, hint.Tiles); err != nil { + t.Fatalf("play: %v", err) + } + + tl, err := svc.ReplayTimeline(ctx, g.ID) + if err != nil { + t.Fatalf("replay timeline: %v", err) + } + if len(tl.Steps) != 2 { + t.Fatalf("steps = %d, want 2 (deal + one move)", len(tl.Steps)) + } + deal := tl.Steps[0] + if deal.Move != nil { + t.Fatalf("step 0 must be the deal, got move %+v", deal.Move) + } + rackSize := len(deal.Racks[0]) + if rackSize == 0 || len(deal.Racks) != 2 || len(deal.Racks[1]) != rackSize { + t.Fatalf("deal racks = %v, want two equal full racks", deal.Racks) + } + move := tl.Steps[1] + if move.Move == nil || move.Move.Action != "play" { + t.Fatalf("step 1 move = %+v, want a play", move.Move) + } + if len(move.Drawn) != len(hint.Tiles) { + t.Fatalf("drawn = %v, want %d refilled tiles", move.Drawn, len(hint.Tiles)) + } + if move.BagLen != deal.BagLen-len(hint.Tiles) { + t.Fatalf("bag after play = %d, want %d", move.BagLen, deal.BagLen-len(hint.Tiles)) + } + if len(move.Racks[0]) != rackSize { + t.Fatalf("mover rack after refill = %d, want %d", len(move.Racks[0]), rackSize) + } + if move.Scores[0] <= 0 { + t.Fatalf("seat 0 score after play = %d, want > 0", move.Scores[0]) + } + if move.ToMove != 1 { + t.Fatalf("to_move after seat 0 play = %d, want 1", move.ToMove) + } +} diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go index d574dab..f8ab433 100644 --- a/backend/internal/inttest/helpers.go +++ b/backend/internal/inttest/helpers.go @@ -26,9 +26,10 @@ import ( // assembly, and the stats reader. Helpers used by a single test file stay in // that file; everything reused across files lives here. -// newGameService builds a game service over the shared pool and registry. +// newGameService builds a game service over the shared pool and registry, with a +// deterministic first-move draw so the suite keeps a stable turn order. func newGameService() *game.Service { - return game.NewService( + svc := game.NewService( game.NewStore(testDB), account.NewStore(testDB), testRegistry, @@ -40,6 +41,39 @@ func newGameService() *game.Service { }, zap.NewNop(), ) + svc.SetFirstMoveEntropy(seatZeroFirstMove) + return svc +} + +// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first +// listed account as the leader, keeping the integration suite's turn order stable +// despite the real draw's randomness: the first contender draws a blank — the best +// possible tile — and wins outright, the rest draw the first remaining tile. It is a +// factory so each game restarts the one-shot "blank" pick. +func seatZeroFirstMove() func(n int) (int, error) { + first := true + return func(n int) (int, error) { + if first { + first = false + return n - 1, nil // a blank sits last in the bag → best rank + } + return 0, nil + } +} + +// seatOneFirstMove is a first-move-draw entropy factory that elects the second contender (in +// auto-match, the synthetic opponent) as the leader, so the caller is seated at seat 1: the +// first contender draws the first remaining tile and the second draws a blank, winning. +func seatOneFirstMove() func(n int) (int, error) { + pick := 0 + return func(n int) (int, error) { + i := 0 + if pick == 1 { + i = n - 1 // the second contender draws a blank → best rank + } + pick++ + return i, nil + } } // newSocialService builds a social service over the shared pool, reading game diff --git a/backend/internal/inttest/open_match_test.go b/backend/internal/inttest/open_match_test.go index f66a5f6..dc27626 100644 --- a/backend/internal/inttest/open_match_test.go +++ b/backend/internal/inttest/open_match_test.go @@ -18,8 +18,9 @@ import ( // The open-game suite covers an auto-match game that a player enters immediately and // waits inside (status 'open', the opponent seat empty) until a human or a robot joins. -// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which -// moves first) whose fresh two-player English opening rack has a legal move. +// evenOpeningSeed returns a seed whose fresh two-player English opening rack (seat 0) has a +// legal move, for tests that drive or attempt an opening play. It scans even seeds; seat +// order no longer depends on the seed — the first-move draw decides it (docs/ARCHITECTURE.md §6). func evenOpeningSeed(t *testing.T) int64 { t.Helper() for seed := int64(2); seed <= 400; seed += 2 { @@ -59,9 +60,9 @@ func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) ga return g } -// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn -// while the game is open, after which it is the empty opponent seat's turn and the -// starter just waits. +// TestOpenGameStarterMovesThenWaits checks the starter may move on their turn while the game +// is open (the first-move draw, run when the game opened, put them at seat 0), after which it +// is the empty opponent seat's turn and the starter just waits. func TestOpenGameStarterMovesThenWaits(t *testing.T) { ctx := context.Background() clearOpenGames(t) @@ -89,28 +90,30 @@ func TestOpenGameStarterMovesThenWaits(t *testing.T) { } } -// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated -// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act. +// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the first-move draw seats +// the starter at seat 1 (the synthetic opponent won the open draw), the still-empty seat 0 is +// to move, so the starter cannot act until an opponent fills it. func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) { ctx := context.Background() clearOpenGames(t) svc := newGameService() + svc.SetFirstMoveEntropy(seatOneFirstMove) // the synthetic opponent wins → the caller sits at seat 1 starter := provisionAccount(t) g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil) if err != nil { t.Fatalf("open: %v", err) } if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil { - t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID) + t.Fatalf("opponent-won open draw: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID) } if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) { t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err) } } -// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after -// the starter has already made their first move, landing on the board mid-opening and -// able to act on their turn. +// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after the +// starter has already made their first move: the seats were fixed by the draw at open, so the +// joiner takes the empty seat without disturbing the journal, and can act on their turn. func TestOpenGameJoinAfterStarterMoved(t *testing.T) { ctx := context.Background() clearOpenGames(t) diff --git a/backend/internal/postgres/jet/backend/model/game_setup_draws.go b/backend/internal/postgres/jet/backend/model/game_setup_draws.go new file mode 100644 index 0000000..ec4f1b4 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/game_setup_draws.go @@ -0,0 +1,24 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type GameSetupDraws struct { + GameID uuid.UUID `sql:"primary_key"` + Round int16 `sql:"primary_key"` + PickNo int16 `sql:"primary_key"` + AccountID *uuid.UUID + Letter string + IsBlank bool + DrawRank int16 + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/game_setup_draws.go b/backend/internal/postgres/jet/backend/table/game_setup_draws.go new file mode 100644 index 0000000..2a5f5b8 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/game_setup_draws.go @@ -0,0 +1,99 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var GameSetupDraws = newGameSetupDrawsTable("backend", "game_setup_draws", "") + +type gameSetupDrawsTable struct { + postgres.Table + + // Columns + GameID postgres.ColumnString + Round postgres.ColumnInteger + PickNo postgres.ColumnInteger + AccountID postgres.ColumnString + Letter postgres.ColumnString + IsBlank postgres.ColumnBool + DrawRank postgres.ColumnInteger + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type GameSetupDrawsTable struct { + gameSetupDrawsTable + + EXCLUDED gameSetupDrawsTable +} + +// AS creates new GameSetupDrawsTable with assigned alias +func (a GameSetupDrawsTable) AS(alias string) *GameSetupDrawsTable { + return newGameSetupDrawsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new GameSetupDrawsTable with assigned schema name +func (a GameSetupDrawsTable) FromSchema(schemaName string) *GameSetupDrawsTable { + return newGameSetupDrawsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new GameSetupDrawsTable with assigned table prefix +func (a GameSetupDrawsTable) WithPrefix(prefix string) *GameSetupDrawsTable { + return newGameSetupDrawsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new GameSetupDrawsTable with assigned table suffix +func (a GameSetupDrawsTable) WithSuffix(suffix string) *GameSetupDrawsTable { + return newGameSetupDrawsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newGameSetupDrawsTable(schemaName, tableName, alias string) *GameSetupDrawsTable { + return &GameSetupDrawsTable{ + gameSetupDrawsTable: newGameSetupDrawsTableImpl(schemaName, tableName, alias), + EXCLUDED: newGameSetupDrawsTableImpl("", "excluded", ""), + } +} + +func newGameSetupDrawsTableImpl(schemaName, tableName, alias string) gameSetupDrawsTable { + var ( + GameIDColumn = postgres.StringColumn("game_id") + RoundColumn = postgres.IntegerColumn("round") + PickNoColumn = postgres.IntegerColumn("pick_no") + AccountIDColumn = postgres.StringColumn("account_id") + LetterColumn = postgres.StringColumn("letter") + IsBlankColumn = postgres.BoolColumn("is_blank") + DrawRankColumn = postgres.IntegerColumn("draw_rank") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{GameIDColumn, RoundColumn, PickNoColumn, AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{IsBlankColumn, CreatedAtColumn} + ) + + return gameSetupDrawsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + GameID: GameIDColumn, + Round: RoundColumn, + PickNo: PickNoColumn, + AccountID: AccountIDColumn, + Letter: LetterColumn, + IsBlank: IsBlankColumn, + DrawRank: DrawRankColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 46921db..19113ed 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -31,6 +31,7 @@ func UseSchema(schema string) { GameInvitations = GameInvitations.FromSchema(schema) GameMoves = GameMoves.FromSchema(schema) GamePlayers = GamePlayers.FromSchema(schema) + GameSetupDraws = GameSetupDraws.FromSchema(schema) Games = Games.FromSchema(schema) Identities = Identities.FromSchema(schema) Sessions = Sessions.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00013_game_setup_draws.sql b/backend/internal/postgres/migrations/00013_game_setup_draws.sql new file mode 100644 index 0000000..5e66004 --- /dev/null +++ b/backend/internal/postgres/migrations/00013_game_setup_draws.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- The first-move draw (docs/ARCHITECTURE.md §6): before a game starts, each seated +-- player draws one tile from the bag and the tile closest to "A" decides who moves +-- first (a blank supersedes all letters); players tied for the best tile re-draw +-- until a single leader remains. Each draw uses fresh entropy (not the game's +-- deterministic bag seed), so this record — not a seed — is the only account of how +-- the order was chosen. It is kept for tournaments, where the draw becomes a manual +-- per-tile call. The record is dictionary-independent: the decoded letter, the blank +-- flag and the numeric draw rank describe each draw without any alphabet table. The +-- winner is reflected as seat 0 in game_players, so no order column is duplicated +-- here. In auto-match the opponent is unknown at draw time (the draw runs against a +-- synthetic placeholder when the game opens), so their draw rows carry a NULL account_id, +-- back-filled when a real opponent joins. Hidden from players for now; surfaced only in the +-- admin console. +SET search_path = backend, pg_catalog; + +CREATE TABLE game_setup_draws ( + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + round smallint NOT NULL, + pick_no smallint NOT NULL, + account_id uuid REFERENCES accounts (account_id) ON DELETE CASCADE, + letter text NOT NULL, + is_blank boolean NOT NULL DEFAULT false, + draw_rank smallint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (game_id, round, pick_no) +); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE game_setup_draws; diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index f8f63df..c7a1013 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -523,6 +523,34 @@ func (s *Server) consoleGameDetail(c *gin.Context) { } } } + // First-move draw (docs/ARCHITECTURE.md §6) and the step-by-step replay timeline. Both are + // best-effort: a game that predates the draw or cannot be replayed still renders its + // summary and seats. + names := make(map[string]string, len(view.Seats)) + for _, st := range view.Seats { + names[st.AccountID] = st.DisplayName + } + if draws, derr := s.games.SetupDraws(ctx, g.ID); derr == nil { + for _, d := range draws { + row := adminconsole.SetupDrawRow{Round: d.Round, Letter: strings.ToUpper(d.Letter), Blank: d.Blank, Rank: d.Rank} + if d.Account == uuid.Nil { + row.Name = "(opponent)" + } else { + row.Name = names[d.Account.String()] + row.AccountID = d.Account.String() + } + view.SetupDraws = append(view.SetupDraws, row) + } + } + if len(view.Seats) > 0 { + view.FirstMover = view.Seats[0].DisplayName + } + if tl, terr := s.games.ReplayTimeline(ctx, g.ID); terr == nil { + if rj, jerr := buildReplayJSON(g.Variant, tl, view.Seats); jerr == nil { + view.ReplayJSON = rj + view.HasReplay = len(tl.Steps) > 0 + } + } s.renderConsole(c, "game_detail", "games", "Game", view) } diff --git a/backend/internal/server/handlers_admin_replay.go b/backend/internal/server/handlers_admin_replay.go new file mode 100644 index 0000000..2add380 --- /dev/null +++ b/backend/internal/server/handlers_admin_replay.go @@ -0,0 +1,168 @@ +package server + +import ( + "encoding/json" + "html/template" + "strings" + + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// The admin game-replay payload embedded in the game_detail page for its vanilla-JS stepper: +// the premium board layout, the seats and one step per replay frame (the dealt racks, then +// one per move) carrying the resulting racks, scores, turn cursor and bag size. Letters are +// upper-cased for display and carry their tile value (0 renders without a subscript). + +type replayTileJSON struct { + L string `json:"l"` + V int `json:"v"` + B bool `json:"b,omitempty"` +} + +type replayPlaceJSON struct { + R int `json:"r"` + C int `json:"c"` + L string `json:"l"` + V int `json:"v"` + B bool `json:"b,omitempty"` +} + +type replayMoveJSON struct { + Seat int `json:"seat"` + Action string `json:"action"` + Words []string `json:"words,omitempty"` + Score int `json:"score"` + Placements []replayPlaceJSON `json:"placements,omitempty"` + Exchanged []replayTileJSON `json:"exchanged,omitempty"` +} + +type replayStepJSON struct { + Move *replayMoveJSON `json:"move"` + Drawn []replayTileJSON `json:"drawn,omitempty"` + Racks [][]replayTileJSON `json:"racks"` + Scores []int `json:"scores"` + ToMove int `json:"toMove"` + BagLen int `json:"bagLen"` +} + +type replaySeatJSON struct { + Seat int `json:"seat"` + Name string `json:"name"` + AccountID string `json:"accountId"` +} + +type replayDataJSON struct { + Centre [2]int `json:"centre"` + Premium [][]string `json:"premium"` + Seats []replaySeatJSON `json:"seats"` + Steps []replayStepJSON `json:"steps"` +} + +// buildReplayJSON renders a game's replay timeline as the JSON the stepper consumes, resolving +// each tile's value from the variant's alphabet. The result is safe to embed in a {#if !app.ready} -
    {t('common.loading')}
    + + {#if !routeIsLobby} +
    {t('common.loading')}
    + {/if} {:else if app.blocked} {:else} @@ -113,6 +123,10 @@ +{#if routeIsLobby && !app.splashDone && !app.blocked} + +{/if} + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 40ec7e8..3f1a818 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -41,6 +41,12 @@ export interface Toast { export const app = $state<{ ready: boolean; + /** Whether the lobby's first cold load has settled (success or error). The loading splash + * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ + lobbyReady: boolean; + /** Whether the loading splash has finished and removed itself. Gates the App-level overlay + * so a warm intra-session return to the lobby shows no splash again. */ + splashDone: boolean; /** Whether the live-event stream is connected. The event hub is best-effort and never replays a * missed event, so an open game waiting for an opponent uses this to recover: it polls the game * state while the stream is down and refetches once on reconnect (see game/Game.svelte). */ @@ -84,6 +90,8 @@ export const app = $state<{ resync: number; }>({ ready: false, + lobbyReady: false, + splashDone: false, streamAlive: false, session: null, profile: null, @@ -658,6 +666,9 @@ export async function logout(): Promise { resetConnection(); clearGameCache(); clearLobby(); + // Replay the loading splash on the next cold lobby load after a re-login. + app.lobbyReady = false; + app.splashDone = false; gateway.setToken(null); await clearSession(); app.session = null; diff --git a/ui/src/lib/splash.test.ts b/ui/src/lib/splash.test.ts new file mode 100644 index 0000000..e76f6ed --- /dev/null +++ b/ui/src/lib/splash.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + ERUDIT_KEYS, + ERUDIT_VALUES, + PAUSE_MS, + SPLASH_TILES, + WORD_MS, + splashSchedule, + type SplashStep, +} from './splash'; + +const at = (word: 'erudit' | 'zagruzka' | 'ozhidanie') => + SPLASH_TILES.filter((t) => t.word === word); +const text = (word: 'erudit' | 'zagruzka' | 'ozhidanie') => at(word).map((t) => t.letter).join(''); +const reveals = (steps: SplashStep[]) => steps.filter((s) => s.kind === 'reveal'); + +describe('splash layout', () => { + it('uses the Эрудит point values for every splash letter', () => { + // Spot-check the distinctive weights against scrabble-solver rules.Erudit. + expect(ERUDIT_VALUES['Э']).toBe(10); + expect(ERUDIT_VALUES['Ж']).toBe(5); + expect(ERUDIT_VALUES['З']).toBe(5); + expect(ERUDIT_VALUES['У']).toBe(3); + expect(ERUDIT_VALUES['Г']).toBe(3); + expect(ERUDIT_VALUES['Р']).toBe(2); + expect(ERUDIT_VALUES['И']).toBe(1); + }); + + it('places ЭРУДИТ horizontally on row 3, left to right, with its values', () => { + const e = at('erudit'); + expect(text('erudit')).toBe('ЭРУДИТ'); + expect(e.map((t) => t.col)).toEqual([0, 1, 2, 3, 4, 5]); + expect(e.every((t) => t.row === 3)).toBe(true); + expect(e.map((t) => t.value)).toEqual([10, 2, 3, 2, 1, 2]); + }); + + it('drops the shared crossing tiles (Р, Д) from the vertical words', () => { + // ЗАГРУЗКА in column 1 minus the shared Р at row 3; ОЖИДАНИЕ in column 3 minus Д. + expect(at('zagruzka').map((t) => t.row)).toEqual([0, 1, 2, 4, 5, 6, 7]); + expect(text('zagruzka')).toBe('ЗАГУЗКА'); // ЗАГ(Р)УЗКА without the shared Р + expect(at('zagruzka').every((t) => t.col === 1)).toBe(true); + expect(at('ozhidanie').map((t) => t.row)).toEqual([0, 1, 2, 4, 5, 6, 7]); + expect(text('ozhidanie')).toBe('ОЖИАНИЕ'); // ОЖИ(_)АНИЕ without Д + expect(at('ozhidanie').every((t) => t.col === 3)).toBe(true); + }); + + it('keeps only the ЭРУДИТ tiles as the persistent set', () => { + expect(ERUDIT_KEYS.size).toBe(6); + expect(ERUDIT_KEYS.has('3-1')).toBe(true); // Р crossing + expect(ERUDIT_KEYS.has('3-3')).toBe(true); // Д crossing + expect(SPLASH_TILES).toHaveLength(20); // 6 + 7 + 7 unique cells + }); +}); + +describe('splash schedule', () => { + const s = splashSchedule(); + + it('lays ЭРУДИТ first, completing by WORD_MS, then checks', () => { + const r = reveals(s.prefix); + expect(r).toHaveLength(6); + expect(r.map((x) => x.atMs)).toEqual([...r.map((x) => x.atMs)].sort((a, b) => a - b)); + expect(r[0].atMs).toBe(0); + expect(r[r.length - 1].atMs).toBeLessThan(WORD_MS); + expect(s.prefix.at(-1)).toEqual({ atMs: WORD_MS, kind: 'check' }); + expect(s.prefixMs).toBe(WORD_MS); + }); + + it('reveals both vertical words per cycle and never re-lays a shared tile', () => { + const r = reveals(s.cycle); + expect(r).toHaveLength(14); // 7 + 7 + expect(r.some((x) => x.key === '3-1' || x.key === '3-3')).toBe(false); + // The first vertical reveal waits out the leading pause. + expect(r[0].atMs).toBe(PAUSE_MS); + }); + + it('checks at each word boundary and clears at the end of the cycle', () => { + const checks = s.cycle.filter((x) => x.kind === 'check').map((x) => x.atMs); + expect(checks).toEqual([PAUSE_MS + WORD_MS, PAUSE_MS + 2 * WORD_MS + PAUSE_MS]); + const last = s.cycle.at(-1)!; + expect(last.kind).toBe('clear'); + expect(last.atMs).toBe(s.cycleMs); + expect(s.cycleMs).toBe(2 * WORD_MS + 3 * PAUSE_MS); + }); + + it('honours overridden timings', () => { + const fast = splashSchedule({ wordMs: 700, pauseMs: 100 }); + expect(fast.prefixMs).toBe(700); + expect(fast.cycleMs).toBe(2 * 700 + 3 * 100); + }); +}); diff --git a/ui/src/lib/splash.ts b/ui/src/lib/splash.ts new file mode 100644 index 0000000..bb5e56b --- /dev/null +++ b/ui/src/lib/splash.ts @@ -0,0 +1,138 @@ +// Pure model for the cold-start loading splash: a small Scrabble crossword that lays the +// words ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ out of tiles, letter by letter, until the lobby is +// ready. Kept free of any DOM or reactive state so it unit-tests in the node environment; +// components/Splash.svelte wraps it with the reactive reveal set and the timer driver. +// +// The tile point values are hardcoded from the Эрудит ruleset (scrabble-solver +// rules.Erudit). They cannot come from alphabet.valueForLetter() here: the splash renders on +// a cold start, before the server has sent the variant's alphabet table that lookup reads. + +/** + * ERUDIT_VALUES is the per-letter point value (вес) for every letter the splash words use, + * taken from the Эрудит ruleset. Letters are upper-cased, matching the rest of the UI. + */ +export const ERUDIT_VALUES: Record = { + А: 1, + Г: 3, + Д: 2, + Е: 1, + Ж: 5, + З: 5, + И: 1, + К: 2, + Н: 1, + О: 1, + Р: 2, + Т: 2, + У: 3, + Э: 10, +}; + +/** SplashWord names the three crossword words, used to group tiles for the reveal sequence. */ +export type SplashWord = 'erudit' | 'zagruzka' | 'ozhidanie'; + +/** SplashTile is one placed tile on the 6×8 splash grid. */ +export interface SplashTile { + /** row is the grid row, 0..7 (ЭРУДИТ sits on row 3). */ + row: number; + /** col is the grid column, 0..5. */ + col: number; + /** letter is the upper-cased Cyrillic glyph shown on the tile. */ + letter: string; + /** value is the tile's Эрудит point value. */ + value: number; + /** word groups the tile by its crossword word. */ + word: SplashWord; + /** key is the stable `"row-col"` identifier used by the renderer's reveal set. */ + key: string; +} + +/** COLS and ROWS are the splash grid dimensions (6 wide, 8 tall). */ +export const COLS = 6; +export const ROWS = 8; + +function tile(row: number, col: number, letter: string, word: SplashWord): SplashTile { + return { row, col, letter, value: ERUDIT_VALUES[letter] ?? 0, word, key: `${row}-${col}` }; +} + +// ЭРУДИТ — horizontal on row 3, columns 0..5 (laid left → right). +const ERUDIT_TILES: SplashTile[] = [...'ЭРУДИТ'].map((l, i) => tile(3, i, l, 'erudit')); + +// ЗАГРУЗКА — vertical in column 1, rows 0..7 (laid top → bottom). Its row-3 letter (Р) is the +// tile already placed by ЭРУДИТ, so it is dropped here ("через имеющиеся буквы"). +const ZAGRUZKA_TILES: SplashTile[] = [...'ЗАГРУЗКА'] + .map((l, r) => tile(r, 1, l, 'zagruzka')) + .filter((t) => t.row !== 3); + +// ОЖИДАНИЕ — vertical in column 3, rows 0..7. Its row-3 letter (Д) is shared with ЭРУДИТ and +// likewise dropped. +const OZHIDANIE_TILES: SplashTile[] = [...'ОЖИДАНИЕ'] + .map((l, r) => tile(r, 3, l, 'ozhidanie')) + .filter((t) => t.row !== 3); + +/** SPLASH_TILES is every unique tile on the grid (ЭРУДИТ owns the two shared crossings). */ +export const SPLASH_TILES: SplashTile[] = [...ERUDIT_TILES, ...ZAGRUZKA_TILES, ...OZHIDANIE_TILES]; + +/** ERUDIT_KEYS is the set of ЭРУДИТ tile keys — the tiles kept on screen across a clear. */ +export const ERUDIT_KEYS: ReadonlySet = new Set(ERUDIT_TILES.map((t) => t.key)); + +/** WORD_MS is the time to lay one whole word; PAUSE_MS the gap between words. */ +export const WORD_MS = 1000; +export const PAUSE_MS = 250; + +/** SplashStep is one scheduled action: reveal a tile, check whether the lobby is ready (and + * if so dismiss), or clear every non-ЭРУДИТ tile before the cycle repeats. atMs is relative + * to the start of its segment (prefix or cycle). */ +export interface SplashStep { + atMs: number; + kind: 'reveal' | 'check' | 'clear'; + /** key is the tile to reveal, set only on a 'reveal' step. */ + key?: string; +} + +/** SplashSchedule is the timed plan the renderer drives: the one-time prefix (ЭРУДИТ) and the + * repeating cycle (ЗАГРУЗКА → ОЖИДАНИЕ → clear). The driver plays the prefix once, then loops + * the cycle every cycleMs until the splash is dismissed. */ +export interface SplashSchedule { + prefix: SplashStep[]; + cycle: SplashStep[]; + prefixMs: number; + cycleMs: number; +} + +function revealSteps(tiles: SplashTile[], base: number, perTile: number): SplashStep[] { + return tiles.map((t, i) => ({ atMs: Math.round(base + i * perTile), kind: 'reveal', key: t.key })); +} + +/** + * splashSchedule builds the reveal plan. Each word is laid over wordMs (so its per-tile + * stagger is wordMs divided by the word's tile count), a readiness check lands at every word + * boundary, and pauseMs separates the words; after ОЖИДАНИЕ the cycle clears the two vertical + * words and restarts (ЭРУДИТ persists). wordMs and pauseMs are overridable for tuning/tests. + */ +export function splashSchedule(opts: { wordMs?: number; pauseMs?: number } = {}): SplashSchedule { + const wordMs = opts.wordMs ?? WORD_MS; + const pauseMs = opts.pauseMs ?? PAUSE_MS; + + // Prefix: ЭРУДИТ left → right, the word completing at wordMs, then a readiness check. + const prefix: SplashStep[] = [ + ...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length), + { atMs: wordMs, kind: 'check' }, + ]; + + // Cycle: pause, ЗАГРУЗКА, check, pause, ОЖИДАНИЕ, check, pause, clear. The leading pause is + // the gap after ЭРУДИТ (and, on repeat, after the previous cycle's clear). + const sV = wordMs / ZAGRUZKA_TILES.length; + const zBase = pauseMs; + const oBase = pauseMs + wordMs + pauseMs; + const clearAt = oBase + wordMs + pauseMs; + const cycle: SplashStep[] = [ + ...revealSteps(ZAGRUZKA_TILES, zBase, sV), + { atMs: zBase + wordMs, kind: 'check' }, + ...revealSteps(OZHIDANIE_TILES, oBase, sV), + { atMs: oBase + wordMs, kind: 'check' }, + { atMs: clearAt, kind: 'clear' }, + ]; + + return { prefix, cycle, prefixMs: wordMs, cycleMs: clearAt }; +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index dd5f9f7..c8c5f73 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -52,6 +52,10 @@ if (connection.online) void preloadGames(games); } catch (e) { handleError(e); + } finally { + // The first cold load has settled (success or error): release the loading splash. Set + // unconditionally on every refresh — it is an idempotent latch, reset only on logout. + app.lobbyReady = true; } } -- 2.52.0 From 9642cafc1f38a34dc857fdccc2bb3616c6122d02 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 10:42:31 +0200 Subject: [PATCH 207/223] fix(ui): hold each splash word for the pause before the readiness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dismiss check fired at the instant a word finished laying, so a word was never held: ЭРУДИТ fell straight into the lobby (too fast) and ЗАГРУЗКА got no readable pause. The pause was a *leading* gap before the next word, not a hold after the current one. Move the hold to after each word and run the check after it: every word (ЭРУДИТ included) now stays up for PAUSE_MS before the splash either dismisses or lays the next word. prefixMs = WORD_MS + PAUSE_MS, cycleMs = 2*(WORD_MS + PAUSE_MS). --- docs/UI_DESIGN.md | 9 +++++---- ui/src/lib/splash.test.ts | 24 ++++++++++++++---------- ui/src/lib/splash.ts | 38 ++++++++++++++++++++------------------ 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 37604d6..a488475 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -34,10 +34,11 @@ point values** (hardcoded in `lib/splash.ts`, since the alphabet table the board It is an **App-level overlay** shown while `routeIsLobby && !app.splashDone` (so it also covers the session bootstrap; a deep-link to another screen is not covered). The lobby sets -`app.lobbyReady` when its first load settles (success **or** error), and the splash dismisses -on the next **word boundary**. ЭРУДИТ lays over ~1 s, then the splash loops -ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a fast load -shows it for ~1 s. Each tile **drops in** with a brief scale + fade. Under **reduced motion** +`app.lobbyReady` when its first load settles (success **or** error). Each word is laid, then +**held ~0.25 s** so it stays readable, and only after that hold does the readiness check fire — +so a word never blinks away the instant it finishes. ЭРУДИТ lays + holds over ~1.25 s, then the +splash loops ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a +fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade. Under **reduced motion** (or the mock build, to keep the Playwright smoke unblocked) it shows a static ЭРУДИТ and dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts` (unit-tested); `Splash.svelte` is the renderer. diff --git a/ui/src/lib/splash.test.ts b/ui/src/lib/splash.test.ts index e76f6ed..578e48e 100644 --- a/ui/src/lib/splash.test.ts +++ b/ui/src/lib/splash.test.ts @@ -54,37 +54,41 @@ describe('splash layout', () => { describe('splash schedule', () => { const s = splashSchedule(); + const HOLD = WORD_MS + PAUSE_MS; - it('lays ЭРУДИТ first, completing by WORD_MS, then checks', () => { + it('lays ЭРУДИТ first, then holds it for PAUSE_MS before the check', () => { const r = reveals(s.prefix); expect(r).toHaveLength(6); expect(r.map((x) => x.atMs)).toEqual([...r.map((x) => x.atMs)].sort((a, b) => a - b)); expect(r[0].atMs).toBe(0); expect(r[r.length - 1].atMs).toBeLessThan(WORD_MS); - expect(s.prefix.at(-1)).toEqual({ atMs: WORD_MS, kind: 'check' }); - expect(s.prefixMs).toBe(WORD_MS); + // The check waits out the whole word plus the readability hold — it never fires the instant + // the last tile lands. + expect(s.prefix.at(-1)).toEqual({ atMs: HOLD, kind: 'check' }); + expect(HOLD - WORD_MS).toBe(PAUSE_MS); + expect(s.prefixMs).toBe(HOLD); }); it('reveals both vertical words per cycle and never re-lays a shared tile', () => { const r = reveals(s.cycle); expect(r).toHaveLength(14); // 7 + 7 expect(r.some((x) => x.key === '3-1' || x.key === '3-3')).toBe(false); - // The first vertical reveal waits out the leading pause. - expect(r[0].atMs).toBe(PAUSE_MS); + // ЗАГРУЗКА starts at the cycle start (no leading gap); the hold lives after the word. + expect(r[0].atMs).toBe(0); }); - it('checks at each word boundary and clears at the end of the cycle', () => { + it('checks one hold after each word and clears at the end of the cycle', () => { const checks = s.cycle.filter((x) => x.kind === 'check').map((x) => x.atMs); - expect(checks).toEqual([PAUSE_MS + WORD_MS, PAUSE_MS + 2 * WORD_MS + PAUSE_MS]); + expect(checks).toEqual([HOLD, 2 * HOLD]); const last = s.cycle.at(-1)!; expect(last.kind).toBe('clear'); expect(last.atMs).toBe(s.cycleMs); - expect(s.cycleMs).toBe(2 * WORD_MS + 3 * PAUSE_MS); + expect(s.cycleMs).toBe(2 * HOLD); }); it('honours overridden timings', () => { const fast = splashSchedule({ wordMs: 700, pauseMs: 100 }); - expect(fast.prefixMs).toBe(700); - expect(fast.cycleMs).toBe(2 * 700 + 3 * 100); + expect(fast.prefixMs).toBe(700 + 100); + expect(fast.cycleMs).toBe(2 * (700 + 100)); }); }); diff --git a/ui/src/lib/splash.ts b/ui/src/lib/splash.ts index bb5e56b..38f3aa8 100644 --- a/ui/src/lib/splash.ts +++ b/ui/src/lib/splash.ts @@ -76,7 +76,8 @@ export const SPLASH_TILES: SplashTile[] = [...ERUDIT_TILES, ...ZAGRUZKA_TILES, . /** ERUDIT_KEYS is the set of ЭРУДИТ tile keys — the tiles kept on screen across a clear. */ export const ERUDIT_KEYS: ReadonlySet = new Set(ERUDIT_TILES.map((t) => t.key)); -/** WORD_MS is the time to lay one whole word; PAUSE_MS the gap between words. */ +/** WORD_MS is the time to lay one whole word; PAUSE_MS the hold after each word — so the word + * stays readable before the next word starts or the splash dismisses into the lobby. */ export const WORD_MS = 1000; export const PAUSE_MS = 250; @@ -106,33 +107,34 @@ function revealSteps(tiles: SplashTile[], base: number, perTile: number): Splash /** * splashSchedule builds the reveal plan. Each word is laid over wordMs (so its per-tile - * stagger is wordMs divided by the word's tile count), a readiness check lands at every word - * boundary, and pauseMs separates the words; after ОЖИДАНИЕ the cycle clears the two vertical - * words and restarts (ЭРУДИТ persists). wordMs and pauseMs are overridable for tuning/tests. + * stagger is wordMs divided by the word's tile count), then **held for pauseMs** so it stays + * readable; only after that hold does a readiness check land — so the splash never dismisses + * (nor starts the next word) the instant a word finishes. The next word begins right at the + * check. After ОЖИДАНИЕ the cycle clears the two vertical words and restarts (ЭРУДИТ persists). + * wordMs and pauseMs are overridable for tuning/tests. */ export function splashSchedule(opts: { wordMs?: number; pauseMs?: number } = {}): SplashSchedule { const wordMs = opts.wordMs ?? WORD_MS; const pauseMs = opts.pauseMs ?? PAUSE_MS; + const sV = wordMs / ZAGRUZKA_TILES.length; + const hold = wordMs + pauseMs; // lay the word, then hold it readable before the check - // Prefix: ЭРУДИТ left → right, the word completing at wordMs, then a readiness check. + // Prefix: ЭРУДИТ left → right, held, then a readiness check. const prefix: SplashStep[] = [ ...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length), - { atMs: wordMs, kind: 'check' }, + { atMs: hold, kind: 'check' }, ]; - // Cycle: pause, ЗАГРУЗКА, check, pause, ОЖИДАНИЕ, check, pause, clear. The leading pause is - // the gap after ЭРУДИТ (and, on repeat, after the previous cycle's clear). - const sV = wordMs / ZAGRUZKA_TILES.length; - const zBase = pauseMs; - const oBase = pauseMs + wordMs + pauseMs; - const clearAt = oBase + wordMs + pauseMs; + // Cycle: ЗАГРУЗКА, hold, check, ОЖИДАНИЕ, hold, check, clear. Each word starts at the previous + // word's check (no leading gap); the hold lives at the end of every word. + const endAt = 2 * hold; const cycle: SplashStep[] = [ - ...revealSteps(ZAGRUZKA_TILES, zBase, sV), - { atMs: zBase + wordMs, kind: 'check' }, - ...revealSteps(OZHIDANIE_TILES, oBase, sV), - { atMs: oBase + wordMs, kind: 'check' }, - { atMs: clearAt, kind: 'clear' }, + ...revealSteps(ZAGRUZKA_TILES, 0, sV), + { atMs: hold, kind: 'check' }, + ...revealSteps(OZHIDANIE_TILES, hold, sV), + { atMs: endAt, kind: 'check' }, + { atMs: endAt, kind: 'clear' }, ]; - return { prefix, cycle, prefixMs: wordMs, cycleMs: clearAt }; + return { prefix, cycle, prefixMs: hold, cycleMs: endAt }; } -- 2.52.0 From e71e40eef5da3fe97841acc788bd4a3639258b9b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 14:46:51 +0200 Subject: [PATCH 208/223] feat(telegram): promo bot + channel-chat moderation gate Add a second standalone promo bot to the bot container (answers /start with a localized message + a URL button into the main bot's Mini App) and gate write access in a channel's linked discussion chat: grant on join when the Telegram user is registered and neither admin-suspended nor holding a new chat_muted role, and revoke/grant on the matching moderation change for a member currently in the chat. Eligibility (registered AND NOT suspended AND NOT chat_muted; the game suspension dominates) is resolved once in the backend and reached two ways: the bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link, and a backend chat_access_changed event -> gateway -> ChatGate command (idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the block/unblock path with getChatMember, since bots cannot list members. A web_app button cannot open another bot's Mini App (it signs initData with the sending bot's token), so the promo button is a t.me ?startapp URL reusing the UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members right and chat_member in its allowed updates. No schema change: chat_muted reuses the data-driven account_roles table. --- .gitea/workflows/ci.yaml | 5 + PRERELEASE.md | 32 +++ backend/README.md | 7 + backend/cmd/backend/main.go | 10 + backend/internal/account/account.go | 8 + backend/internal/account/roles.go | 10 +- backend/internal/account/suspension.go | 25 ++ .../internal/account/suspension_sweeper.go | 84 ++++++ .../account/suspension_sweeper_test.go | 80 ++++++ backend/internal/inttest/chat_access_test.go | 272 ++++++++++++++++++ backend/internal/notify/events.go | 10 + backend/internal/notify/notify.go | 7 + backend/internal/server/chataccess.go | 131 +++++++++ backend/internal/server/handlers.go | 7 + .../internal/server/handlers_admin_console.go | 6 + .../server/handlers_admin_feedback.go | 6 + deploy/.env.example | 4 + deploy/docker-compose.yml | 10 + docs/ARCHITECTURE.md | 25 +- docs/FUNCTIONAL.md | 13 +- docs/FUNCTIONAL_ru.md | 13 +- gateway/cmd/gateway/main.go | 36 ++- gateway/internal/backendclient/api.go | 28 ++ gateway/internal/botlink/command.go | 12 + gateway/internal/botlink/hub.go | 39 ++- gateway/internal/botlink/hub_test.go | 70 ++++- gateway/internal/botlink/mtls_test.go | 2 +- pkg/proto/botlink/v1/botlink.pb.go | 241 ++++++++++++++-- pkg/proto/botlink/v1/botlink.proto | 34 +++ pkg/proto/botlink/v1/botlink_grpc.pb.go | 53 +++- platform/telegram/README.md | 25 +- platform/telegram/cmd/bot/main.go | 39 ++- platform/telegram/internal/bot/bot.go | 158 +++++++++- platform/telegram/internal/bot/chat_test.go | 165 +++++++++++ platform/telegram/internal/botlink/client.go | 53 ++-- .../telegram/internal/botlink/client_test.go | 6 +- .../telegram/internal/botlink/executor.go | 23 ++ .../internal/botlink/executor_test.go | 57 +++- platform/telegram/internal/config/config.go | 27 ++ .../telegram/internal/config/config_test.go | 48 ++++ .../telegram/internal/promobot/promobot.go | 164 +++++++++++ .../internal/promobot/promobot_test.go | 105 +++++++ 42 files changed, 2082 insertions(+), 68 deletions(-) create mode 100644 backend/internal/account/suspension_sweeper.go create mode 100644 backend/internal/account/suspension_sweeper_test.go create mode 100644 backend/internal/inttest/chat_access_test.go create mode 100644 backend/internal/server/chataccess.go create mode 100644 platform/telegram/internal/bot/chat_test.go create mode 100644 platform/telegram/internal/promobot/promobot.go create mode 100644 platform/telegram/internal/promobot/promobot_test.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 2af44b9..1299e62 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -260,11 +260,16 @@ jobs: GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }} + TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }} 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: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }} + TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }} + TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }} + # The promo button reuses the UI's Mini App link variable. + TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} # The test contour always uses Telegram's test environment — pinned here, # not an operator variable. The prod workflow leaves it false. TELEGRAM_TEST_ENV: "true" diff --git a/PRERELEASE.md b/PRERELEASE.md index 718f609..db9dcba 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -41,6 +41,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) | | AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) | +| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat — granting on join when the Telegram user is registered and neither admin-suspended nor holding a new **`chat_muted`** role, and revoking/granting on the matching moderation change for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's join-time unary `ResolveChatEligibility` over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (idempotent, so a temporary-block-expiry sweeper may over-emit). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) @@ -579,3 +580,34 @@ Then Stage 18. (`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md` (+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`. + +- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list): + - **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no + bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the + bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a + temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new + **`chat_muted` role** is a chat-only mute with the **game suspension dominating** + (`eligible = registered AND NOT suspended AND NOT chat_muted`). + - **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the + main bot's `t.me/?startapp` link — a `web_app` button signs initData with the *sending* bot's token, + which the main validator rejects — so the promo button reuses the UI's `VITE_TELEGRAM_LINK`. `chat_member` + updates arrive **only** when the bot is a chat **admin** with the "Ban users" right (the client label for the + Bot API `can_restrict_members`) and `chat_member` is in `allowed_updates`; bots cannot list members but can + `getChatMember` a single user, which is the membership guard on the block/unblock path. + - **Wire:** `pkg/proto/botlink/v1` gains a `ChatGateCommand` in the `Command` oneof and a unary + `ResolveChatEligibility`; the backend gains `notify.KindChatAccessChanged` (no payload, infra-only — never an + out-of-app message) and an internal `POST /api/v1/internal/chat-access` resolver; the gateway resolves the + join (by external_id) and the event (by user_id) through it and pushes the chat-gate command fire-and-forget + (at-most-once, recovered by the next moderation action or a re-join). + - **No schema change → no contour DB wipe:** `chat_muted` is a new `account.KnownRoles` entry (the + `account_roles` table is data-driven). The suspension-expiry sweeper is a new `account.SuspensionSweeper` + (a 1-minute window, idempotent) started in `cmd/backend`, alongside the guest reaper. + - **Deploy:** new `TEST_`/`PROD_` `TELEGRAM_PROMO_BOT_TOKEN` (secret), `TELEGRAM_BOT_USERNAME` and + `TELEGRAM_CHAT_ID` (variables); the promo link reuses the existing `*_VITE_TELEGRAM_LINK` variable as + `TELEGRAM_BOT_LINK`. The bot must be promoted to admin in the real discussion group, and the group default + set to no-send, as part of the Stage 18 prod cutover (the test contour exercises the code path). + - **Bake-back:** `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `platform/telegram/README.md`, + `backend/README.md`, Go Doc comments. Tests: backend resolver truth table + publish on block/unblock/role + + the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot + `chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config + parsing. diff --git a/backend/README.md b/backend/README.md index bde9d55..a833500 100644 --- a/backend/README.md +++ b/backend/README.md @@ -106,6 +106,13 @@ Telegram `external_id`, language and `notifications_in_app_only` flag) lets the route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login seeds a new account's language and display name from the launch fields, and the `accounts.notifications_in_app_only` flag (default true). +The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the +bot's join-time query) or an account id (a `chat_access_changed` event) to its +**moderated-chat write eligibility** — `registered AND NOT suspended AND NOT chat_muted`. +That event is emitted on an admin block/unblock, a `chat_muted` role grant/revoke, or — via +the `account.SuspensionSweeper` started in `cmd/backend` — a temporary block lapsing; +`chat_muted` is an `account.KnownRoles` entry, a chat-only mute distinct from the game +suspension (which dominates it). `accounts.is_guest` marks an ephemeral guest — a durable row with no identity, excluded from statistics. The server-rendered **admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`; diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index c4c17a4..69ee34a 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -15,6 +15,7 @@ import ( "syscall" "time" + "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" @@ -160,6 +161,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("retention", cfg.GuestRetention)) + // Re-evaluate moderated-chat write access when a temporary block self-expires: + // no operator action fires then, so the sweeper emits the chat-access-changed + // event for lapsed blocks and the gateway re-pushes the chat-gate command. + chatSweeper := account.NewSuspensionSweeper(accounts, func(id uuid.UUID) { + hub.Publish(notify.ChatAccessChanged(id)) + }, logger) + go chatSweeper.Run(ctx) + logger.Info("suspension expiry sweeper started", zap.Duration("interval", chatSweeper.Interval())) + // Lobby & social domains. Their REST and stream surface lives in the gateway, // so they are handed to the server (like the route groups) for the handlers. mailer := newMailer(cfg.SMTP, logger) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 14e143a..1cdacbc 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -303,6 +303,14 @@ func (s *Store) CountAccounts(ctx context.Context) (int, error) { return int(dest.Count), nil } +// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound +// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access +// resolver uses it to tell a registered Telegram user (eligible to be granted chat +// write access) from an unregistered one (left muted). +func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) { + return s.findByIdentity(ctx, kind, externalID) +} + // findByIdentity joins identities to accounts and returns the matching account, // or ErrNotFound. func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) { diff --git a/backend/internal/account/roles.go b/backend/internal/account/roles.go index 5aa2725..acc1d6d 100644 --- a/backend/internal/account/roles.go +++ b/backend/internal/account/roles.go @@ -24,11 +24,19 @@ const ( // unconditionally, overriding the usual eligibility (a free account with an // empty hint wallet otherwise sees it). See internal/ads. RoleNoBanner = "no_banner" + + // RoleChatMuted forbids the account from writing in the moderated Telegram + // discussion chat, without otherwise restricting the game (the chat-only + // counterpart to a full account suspension). It is one input to the chat-access + // gate; an active admin suspension mutes the player regardless, so this role only + // matters for an account that is not suspended. Granting or revoking it re-pushes + // the chat-gate command for a member currently in the chat. + RoleChatMuted = "chat_muted" ) // KnownRoles is the set of roles the console may grant or revoke; an operator // cannot assign an unrecognised role. -var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner} +var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner, RoleChatMuted} // IsKnownRole reports whether role is a recognised account role. func IsKnownRole(role string) bool { diff --git a/backend/internal/account/suspension.go b/backend/internal/account/suspension.go index 155f289..fe55962 100644 --- a/backend/internal/account/suspension.go +++ b/backend/internal/account/suspension.go @@ -161,6 +161,31 @@ func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, return modelToSuspension(row), true, nil } +// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the +// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The +// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires, +// since no operator action fires then. An account that still has another active block may be +// included; the eligibility resolver returns the true state, so emitting for it is harmless. +func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT DISTINCT account_id FROM backend.account_suspensions + WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`, + since.UTC(), until.UTC()) + if err != nil { + return nil, fmt.Errorf("account: suspensions expired between: %w", err) + } + defer rows.Close() + var out []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("account: scan expired suspension: %w", err) + } + out = append(out, id) + } + return out, rows.Err() +} + // invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it. // Called after Suspend and LiftSuspension. func (s *Store) invalidateSuspension(accountID uuid.UUID) { diff --git a/backend/internal/account/suspension_sweeper.go b/backend/internal/account/suspension_sweeper.go new file mode 100644 index 0000000..f838398 --- /dev/null +++ b/backend/internal/account/suspension_sweeper.go @@ -0,0 +1,84 @@ +package account + +import ( + "context" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// suspensionSweepInterval is how often the sweeper re-checks for temporary blocks +// that lapsed. A minute is well under the coarsest block grain (operators pick day +// presets) while keeping the query trivial. +const suspensionSweepInterval = time.Minute + +// suspensionExpiryQuerier is the slice of the account store the sweeper depends on: +// the accounts whose temporary block lapsed in a window. *Store satisfies it; a fake +// drives the sweeper's unit tests. +type suspensionExpiryQuerier interface { + SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) +} + +// SuspensionSweeper re-evaluates chat write access when a temporary block self- +// expires. No operator action fires on expiry — the suspension gate just re-reads +// the wall clock — so without this a temporarily blocked player would stay muted in +// the moderated discussion chat after their block lapsed. Each tick it finds blocks +// that expired since the previous tick and calls onExpire for the affected accounts; +// onExpire is wired to publish the chat-access-changed event, after which the gateway +// re-resolves the true eligibility. A liberal call (an account that still has another +// active block) is therefore harmless. The window is in-memory, so a block that +// expires while the process is down is not re-granted until the next operator action +// or the player rejoins — an accepted best-effort gap. +type SuspensionSweeper struct { + store suspensionExpiryQuerier + onExpire func(accountID uuid.UUID) + log *zap.Logger + // since is the upper bound of the previous swept window; the next sweep covers + // (since, now]. It advances only on a successful query, so a failed tick retries + // the same window rather than dropping expiries. + since time.Time +} + +// NewSuspensionSweeper builds the sweeper over the account store, the per-account +// expiry callback (publishing the chat-access-changed event) and a logger. The first +// window opens at construction time, so blocks that lapsed earlier are not re-emitted. +func NewSuspensionSweeper(store *Store, onExpire func(accountID uuid.UUID), log *zap.Logger) *SuspensionSweeper { + if log == nil { + log = zap.NewNop() + } + return &SuspensionSweeper{store: store, onExpire: onExpire, log: log, since: time.Now().UTC()} +} + +// Interval reports the sweep cadence, for the startup log line. +func (w *SuspensionSweeper) Interval() time.Duration { return suspensionSweepInterval } + +// Run sweeps every Interval until ctx is cancelled. +func (w *SuspensionSweeper) Run(ctx context.Context) { + ticker := time.NewTicker(suspensionSweepInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.sweep(ctx) + } + } +} + +// sweep emits a chat-access-changed signal for every account whose temporary block +// lapsed in (since, now], then advances the window. On a query error it keeps the +// window so the next tick retries it. +func (w *SuspensionSweeper) sweep(ctx context.Context) { + now := time.Now().UTC() + ids, err := w.store.SuspensionsExpiredBetween(ctx, w.since, now) + if err != nil { + w.log.Warn("suspension expiry sweep failed", zap.Error(err)) + return + } + w.since = now + for _, id := range ids { + w.onExpire(id) + } +} diff --git a/backend/internal/account/suspension_sweeper_test.go b/backend/internal/account/suspension_sweeper_test.go new file mode 100644 index 0000000..55c5d45 --- /dev/null +++ b/backend/internal/account/suspension_sweeper_test.go @@ -0,0 +1,80 @@ +package account + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// fakeExpiryQuerier records the `since` bound of each call and replays a scripted +// result/error per call, so the sweeper's window and dispatch logic is testable +// without a database. +type fakeExpiryQuerier struct { + results [][]uuid.UUID + errs []error + sinces []time.Time + idx int +} + +func (f *fakeExpiryQuerier) SuspensionsExpiredBetween(_ context.Context, since, _ time.Time) ([]uuid.UUID, error) { + f.sinces = append(f.sinces, since) + i := f.idx + f.idx++ + if i < len(f.errs) && f.errs[i] != nil { + return nil, f.errs[i] + } + if i < len(f.results) { + return f.results[i], nil + } + return nil, nil +} + +func newSweeper(store suspensionExpiryQuerier, onExpire func(uuid.UUID)) *SuspensionSweeper { + return &SuspensionSweeper{ + store: store, + onExpire: onExpire, + log: zap.NewNop(), + since: time.Now().Add(-time.Minute).UTC(), + } +} + +func TestSuspensionSweeperDispatchesAndAdvances(t *testing.T) { + id1, id2 := uuid.New(), uuid.New() + fake := &fakeExpiryQuerier{results: [][]uuid.UUID{{id1, id2}, nil}} + var got []uuid.UUID + w := newSweeper(fake, func(id uuid.UUID) { got = append(got, id) }) + + first := w.since + w.sweep(context.Background()) + assert.Equal(t, []uuid.UUID{id1, id2}, got, "every expired account is dispatched") + assert.True(t, w.since.After(first), "the window advances on success") + + // A second sweep opens the next window at the previous upper bound. + prev := w.since + w.sweep(context.Background()) + require.Len(t, fake.sinces, 2) + assert.True(t, fake.sinces[1].After(fake.sinces[0]), "consecutive windows are contiguous and forward") + assert.True(t, fake.sinces[1].Equal(prev), "the next window starts at the previous upper bound") +} + +func TestSuspensionSweeperKeepsWindowOnError(t *testing.T) { + fake := &fakeExpiryQuerier{errs: []error{errors.New("db down")}} + w := newSweeper(fake, func(uuid.UUID) { t.Fatal("onExpire must not run when the query fails") }) + + before := w.since + w.sweep(context.Background()) + assert.True(t, w.since.Equal(before), "the window is retained on error so the next tick retries it") +} + +func TestNewSuspensionSweeperDefaults(t *testing.T) { + w := NewSuspensionSweeper(nil, func(uuid.UUID) {}, nil) + assert.Equal(t, time.Minute, w.Interval()) + assert.NotNil(t, w.log, "a nil logger is tolerated") + assert.WithinDuration(t, time.Now().UTC(), w.since, time.Second, "the first window opens at construction time") +} diff --git a/backend/internal/inttest/chat_access_test.go b/backend/internal/inttest/chat_access_test.go new file mode 100644 index 0000000..065af0d --- /dev/null +++ b/backend/internal/inttest/chat_access_test.go @@ -0,0 +1,272 @@ +//go:build integration + +package inttest + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "go.uber.org/zap/zaptest" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/notify" + "scrabble/backend/internal/server" +) + +// chatAccessBody mirrors the backend's /internal/chat-access JSON for the test. +type chatAccessBody struct { + ExternalID string `json:"external_id"` + Registered bool `json:"registered"` + Eligible bool `json:"eligible"` +} + +// chatAccess issues the gateway-internal chat-access query and asserts a 200. +func chatAccess(t *testing.T, srv *server.Server, body string) chatAccessBody { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("chat-access %s = %d: %s", body, rec.Code, rec.Body.String()) + } + var b chatAccessBody + if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil { + t.Fatalf("decode chat-access: %v", err) + } + return b +} + +// TestChatAccessResolver drives the gateway-internal eligibility resolver over HTTP: +// the registered/suspended/chat_muted truth table by Telegram identity and by account +// id, the suspension dominating the chat_muted role, an unknown identity reported +// unregistered, and an account with no Telegram identity carrying an empty external_id. +func TestChatAccessResolver(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) + + ext := "tg-" + uuid.NewString() + acc, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") + if err != nil { + t.Fatalf("provision: %v", err) + } + id := acc.ID + + byExt := func() chatAccessBody { return chatAccess(t, srv, `{"external_id":"`+ext+`"}`) } + byUser := func() chatAccessBody { return chatAccess(t, srv, `{"user_id":"`+id.String()+`"}`) } + + // A registered, unsuspended, unmuted account is eligible by either address, and the + // account-id query resolves back to its Telegram identity. + if b := byExt(); !b.Registered || !b.Eligible || b.ExternalID != ext { + t.Fatalf("fresh by external_id = %+v, want registered+eligible+ext", b) + } + if b := byUser(); !b.Registered || !b.Eligible || b.ExternalID != ext { + t.Fatalf("fresh by user_id = %+v, want registered+eligible+ext", b) + } + + // A suspension mutes; a lift restores. + if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil { + t.Fatalf("suspend: %v", err) + } + if b := byExt(); !b.Registered || b.Eligible { + t.Fatalf("suspended = %+v, want registered but not eligible", b) + } + if err := accounts.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift: %v", err) + } + if b := byExt(); !b.Eligible { + t.Fatalf("after lift = %+v, want eligible", b) + } + + // The chat_muted role mutes independently; a revoke restores. + if err := accounts.GrantRole(ctx, id, account.RoleChatMuted); err != nil { + t.Fatalf("grant chat_muted: %v", err) + } + if b := byExt(); !b.Registered || b.Eligible { + t.Fatalf("chat_muted = %+v, want registered but not eligible", b) + } + + // Suspension dominates: while chat_muted is set, lifting a concurrent suspension + // must not re-grant chat (the role still mutes). + if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil { + t.Fatalf("suspend over mute: %v", err) + } + if b := byExt(); b.Eligible { + t.Fatalf("suspended+muted = %+v, want not eligible", b) + } + if err := accounts.LiftSuspension(ctx, id); err != nil { + t.Fatalf("lift over mute: %v", err) + } + if b := byExt(); b.Eligible { + t.Fatalf("lifted but still muted = %+v, want not eligible", b) + } + if err := accounts.RevokeRole(ctx, id, account.RoleChatMuted); err != nil { + t.Fatalf("revoke chat_muted: %v", err) + } + if b := byExt(); !b.Eligible { + t.Fatalf("after revoke = %+v, want eligible", b) + } + + // An unknown Telegram identity is unregistered (and thus left muted). + if b := chatAccess(t, srv, `{"external_id":"tg-missing-`+uuid.NewString()+`"}`); b.Registered || b.Eligible { + t.Fatalf("unknown identity = %+v, want neither registered nor eligible", b) + } + + // An account with no Telegram identity (a guest) carries an empty external_id, so + // the gateway has nothing to gate. + guest := provisionGuest(t) + if b := chatAccess(t, srv, `{"user_id":"`+guest.String()+`"}`); b.ExternalID != "" || b.Registered { + t.Fatalf("guest by user_id = %+v, want empty external_id and not registered", b) + } + + // A request naming neither address is a bad request. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("empty query = %d, want 400", rec.Code) + } +} + +// captureNotifier records every published intent so a test can assert which live +// events a console action emitted. +type captureNotifier struct { + mu sync.Mutex + intents []notify.Intent +} + +func (c *captureNotifier) Publish(in ...notify.Intent) { + c.mu.Lock() + defer c.mu.Unlock() + c.intents = append(c.intents, in...) +} + +// count returns how many intents of kind addressed to user were captured. +func (c *captureNotifier) count(user uuid.UUID, kind string) int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, in := range c.intents { + if in.UserID == user && in.Kind == kind { + n++ + } + } + return n +} + +// TestChatAccessPublishedOnModeration drives the admin console and asserts each +// moderation action that can change chat eligibility — block, unblock, and the +// chat_muted role grant/revoke — emits the chat_access_changed signal the gateway +// turns into a chat-gate command. +func TestChatAccessPublishedOnModeration(t *testing.T) { + notifier := &captureNotifier{} + srv := server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: account.NewStore(testDB), + Games: newGameService(), + Registry: testRegistry, + DictDir: dictDir(), + Notifier: notifier, + }) + h := srv.Handler() + id := provisionAccount(t) + base := "http://admin.test/_gm/users/" + id.String() + const origin = "http://admin.test" + + steps := []struct { + name, path, body string + want string + }{ + {"block", "/block", "duration=permanent", "Blocked"}, + {"unblock", "/unblock", "", "Unblocked"}, + {"grant chat_muted", "/grant-role", "role=chat_muted", "Role granted"}, + {"revoke chat_muted", "/revoke-role", "role=chat_muted", "Role revoked"}, + } + for i, s := range steps { + code, body := consoleDo(h, http.MethodPost, base+s.path, s.body, origin) + if code != http.StatusOK || !strings.Contains(body, s.want) { + t.Fatalf("%s = %d, has %q = %v", s.name, code, s.want, strings.Contains(body, s.want)) + } + if got := notifier.count(id, notify.KindChatAccessChanged); got != i+1 { + t.Fatalf("after %s: chat_access_changed count = %d, want %d", s.name, got, i+1) + } + } +} + +// TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted +// temporary block whose expiry falls in the window is returned, while one outside the +// window, a permanent block, and a lifted block are not. +func TestSuspensionsExpiredBetween(t *testing.T) { + ctx := context.Background() + accounts := account.NewStore(testDB) + + // A temporary block whose expiry already lapsed at a known instant. + tempID := provisionAccount(t) + expiry := time.Now().Add(-time.Hour).Truncate(time.Second) + if _, err := accounts.Suspend(ctx, tempID, &expiry, "", "", nil); err != nil { + t.Fatalf("suspend temp: %v", err) + } + + contains := func(ids []uuid.UUID, want uuid.UUID) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false + } + + // A window straddling the expiry returns the account. + got, err := accounts.SuspensionsExpiredBetween(ctx, expiry.Add(-time.Minute), expiry.Add(time.Minute)) + if err != nil { + t.Fatalf("expired between: %v", err) + } + if !contains(got, tempID) { + t.Fatalf("window over expiry missing the lapsed block %s", tempID) + } + // A window entirely after the expiry does not. + got, err = accounts.SuspensionsExpiredBetween(ctx, expiry.Add(time.Minute), expiry.Add(2*time.Minute)) + if err != nil { + t.Fatalf("expired between (after): %v", err) + } + if contains(got, tempID) { + t.Fatalf("window after expiry should not return %s", tempID) + } + + // A permanent block never appears, even in a wide window. + permID := provisionAccount(t) + if _, err := accounts.Suspend(ctx, permID, nil, "", "", nil); err != nil { + t.Fatalf("suspend perm: %v", err) + } + // A lifted block does not appear either. The block must still be in force when lifted + // (LiftSuspension only lifts in-force blocks), so its expiry is in the future and the + // wide window below still covers it — yet lifted_at excludes it. + liftID := provisionAccount(t) + liftExpiry := time.Now().Add(30 * time.Minute).Truncate(time.Second) + if _, err := accounts.Suspend(ctx, liftID, &liftExpiry, "", "", nil); err != nil { + t.Fatalf("suspend lift: %v", err) + } + if err := accounts.LiftSuspension(ctx, liftID); err != nil { + t.Fatalf("lift: %v", err) + } + wide, err := accounts.SuspensionsExpiredBetween(ctx, time.Now().Add(-2*time.Hour), time.Now().Add(time.Hour)) + if err != nil { + t.Fatalf("expired between (wide): %v", err) + } + if contains(wide, permID) { + t.Fatalf("permanent block %s must not be reported as expired", permID) + } + if contains(wide, liftID) { + t.Fatalf("lifted block %s must not be reported as expired", liftID) + } +} diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index bc9769c..795f287 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -216,6 +216,16 @@ func BannerChanged(userID uuid.UUID) Intent { return Notification(userID, NotifyBanner) } +// ChatAccessChanged signals that userID's eligibility to write in the moderated +// Telegram discussion chat may have changed (an admin block/unblock, a chat_muted +// grant/revoke, or a temporary block lapsing). It carries no payload: the gateway +// resolves the user's Telegram identity and current eligibility and pushes the +// resulting chat-gate command to the bot. Unlike the lobby notifications it is an +// infra signal — a distinct top-level kind, never an out-of-app rendered message. +func ChatAccessChanged(userID uuid.UUID) Intent { + return Intent{UserID: userID, Kind: KindChatAccessChanged, EventID: eventID()} +} + // eventID returns a best-effort correlation id for one emitted event. func eventID() string { if id, err := uuid.NewV7(); err == nil { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 583987a..4b2204a 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -35,6 +35,13 @@ const ( // KindGameOver announces a finished game to each seated player, driving the // out-of-app "game over" push. KindGameOver = "game_over" + // KindChatAccessChanged signals that a player's eligibility to write in the + // moderated Telegram discussion chat may have changed (an admin block or unblock, + // a chat_muted grant or revoke, or a temporary block lapsing). It carries no + // payload and is never fanned out to in-app clients: the gateway consumes it to + // resolve the player's Telegram identity and current eligibility and push the + // resulting chat-gate command to the bot. + KindChatAccessChanged = "chat_access_changed" ) // Notification sub-kinds carried in a KindNotification event payload; the client diff --git a/backend/internal/server/chataccess.go b/backend/internal/server/chataccess.go new file mode 100644 index 0000000..141b6f3 --- /dev/null +++ b/backend/internal/server/chataccess.go @@ -0,0 +1,131 @@ +package server + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/notify" +) + +// chatAccessRequest is the gateway's chat write-eligibility query, addressed either +// by Telegram identity (ExternalID — the join path, when the bot sees a user enter +// the chat) or by account id (UserID — the change path, resolving an emitted +// chat-access-changed event). Exactly one field is set. +type chatAccessRequest struct { + ExternalID string `json:"external_id"` + UserID string `json:"user_id"` +} + +// chatAccessResponse is the resolved eligibility. ExternalID echoes the account's +// Telegram identity (empty when it has none — the gateway then has nothing to gate); +// Registered reports whether the lookup found an account at all; Eligible is the +// final gate the bot applies (registered and neither admin-suspended nor chat-muted). +type chatAccessResponse struct { + ExternalID string `json:"external_id"` + Registered bool `json:"registered"` + Eligible bool `json:"eligible"` +} + +// handleChatAccess resolves whether a Telegram user may write in the moderated +// discussion chat. It is gateway-internal: the gateway's bot-link serves the bot's +// join-time query (by external_id) and resolves an emitted chat-access-changed event +// (by user_id) through it. +func (s *Server) handleChatAccess(c *gin.Context) { + var req chatAccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid body") + return + } + switch { + case req.ExternalID != "": + s.respondChatAccessByExternalID(c, req.ExternalID) + case req.UserID != "": + s.respondChatAccessByUserID(c, req.UserID) + default: + abortBadRequest(c, "external_id or user_id required") + } +} + +// respondChatAccessByExternalID answers the join-path query: an unknown identity is +// reported unregistered (and left muted); a known one carries its current eligibility. +func (s *Server) respondChatAccessByExternalID(c *gin.Context, externalID string) { + ctx := c.Request.Context() + resp := chatAccessResponse{ExternalID: externalID} + acc, err := s.accounts.AccountByIdentity(ctx, account.KindTelegram, externalID) + if errors.Is(err, account.ErrNotFound) { + c.JSON(http.StatusOK, resp) + return + } + if err != nil { + s.abortErr(c, err) + return + } + resp.Registered = true + eligible, err := s.chatEligible(ctx, acc.ID) + if err != nil { + s.abortErr(c, err) + return + } + resp.Eligible = eligible + c.JSON(http.StatusOK, resp) +} + +// respondChatAccessByUserID answers the change-path query: an account with no +// Telegram identity carries an empty external_id (nothing for the gateway to gate); +// otherwise it carries the identity and the current eligibility. +func (s *Server) respondChatAccessByUserID(c *gin.Context, raw string) { + ctx := c.Request.Context() + uid, err := uuid.Parse(raw) + if err != nil { + abortBadRequest(c, "invalid user_id") + return + } + var resp chatAccessResponse + ext, err := s.accounts.IdentityExternalID(ctx, uid, account.KindTelegram) + if errors.Is(err, account.ErrNotFound) { + c.JSON(http.StatusOK, resp) + return + } + if err != nil { + s.abortErr(c, err) + return + } + resp.ExternalID = ext + resp.Registered = true + eligible, err := s.chatEligible(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + resp.Eligible = eligible + c.JSON(http.StatusOK, resp) +} + +// chatEligible reports whether the account may write in the moderated discussion +// chat: not currently admin-suspended and not holding the chat_muted role. A +// suspension dominates — it mutes regardless of the role. Registration is established +// by the caller's identity lookup. +func (s *Server) chatEligible(ctx context.Context, accountID uuid.UUID) (bool, error) { + if _, blocked, err := s.accounts.CurrentSuspension(ctx, accountID); err != nil { + return false, err + } else if blocked { + return false, nil + } + muted, err := s.accounts.HasRole(ctx, accountID, account.RoleChatMuted) + if err != nil { + return false, err + } + return !muted, nil +} + +// publishChatAccessChange emits the chat-access-changed signal for the account, so +// the gateway re-resolves the player's chat eligibility and pushes the chat-gate +// command to the bot. Best-effort (notify.Nop when no notifier is wired). +func (s *Server) publishChatAccessChange(id uuid.UUID) { + s.notifier.Publish(notify.ChatAccessChanged(id)) +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 118fdbf..764ed4e 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -37,6 +37,13 @@ func (s *Server) registerRoutes() { // before delivering an out-of-app notification. in.POST("/push-target", s.handlePushTarget) } + if s.accounts != nil { + // Moderated-chat write eligibility for the Telegram bot: resolve a Telegram + // identity (the bot's join-time query) or an account id (a chat-access-changed + // event) to whether the user may write in the discussion chat. It needs only the + // account store, not the session service, so it registers independently. + s.internal.POST("/chat-access", s.handleChatAccess) + } if s.ratewatch != nil { // The gateway's periodic rate-limiter rejection summary: feeds the // admin console's throttled view and the high-rate auto-flag. diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index cb6ed2f..4e7c1f8 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -987,6 +987,9 @@ func (s *Server) consoleBlockUser(c *gin.Context) { s.consoleError(c, err) return } + // Re-evaluate the player's moderated-chat write access: a block mutes them in + // the discussion chat if they are currently in it. + s.publishChatAccessChange(id) s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back) } @@ -1001,6 +1004,9 @@ func (s *Server) consoleUnblockUser(c *gin.Context) { s.consoleError(c, err) return } + // Re-evaluate the player's moderated-chat write access: an unblock restores it + // (unless they are still chat-muted) for a member currently in the chat. + s.publishChatAccessChange(id) s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String()) } diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go index 582e6fe..664bf10 100644 --- a/backend/internal/server/handlers_admin_feedback.go +++ b/backend/internal/server/handlers_admin_feedback.go @@ -248,6 +248,9 @@ func (s *Server) consoleGrantRole(c *gin.Context) { if role == account.RoleNoBanner { s.publishBannerChange(id) } + if role == account.RoleChatMuted { + s.publishChatAccessChange(id) + } s.renderConsoleMessage(c, "Role granted", "granted "+role, back) } @@ -270,6 +273,9 @@ func (s *Server) consoleRevokeRole(c *gin.Context) { if role == account.RoleNoBanner { s.publishBannerChange(id) } + if role == account.RoleChatMuted { + s.publishChatAccessChange(id) + } s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back) } diff --git a/deploy/.env.example b/deploy/.env.example index 9dd4eea..584b9af 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -46,6 +46,10 @@ GRAFANA_ADMIN_PASSWORD=admin AWG_CONF= # required; AmneziaWG sidecar config (the bot's Telegram egress) TELEGRAM_BOT_TOKEN= # required TELEGRAM_GAME_CHANNEL_ID= +TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating +TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it +TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set +TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set TELEGRAM_MINIAPP_URL= # required TELEGRAM_TEST_ENV=false TELEGRAM_API_BASE_URL= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 664efa8..e18fd1f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -268,6 +268,16 @@ services: # at boot; an empty value leaves the bot down while the rest of the contour comes up. TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} + # The moderated discussion chat (a channel's linked group) the bot gates write + # access in. Empty disables gating; the bot must be an admin there with the + # "Ban users" right and receives chat_member updates only as an admin. + TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + # The optional standalone promo bot (its own token) answering /start with a button + # into the main bot's app. Empty disables it; when set it needs the main bot's + # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK). + TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} + TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} + TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false} TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 278bf19..b27b7d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -824,7 +824,13 @@ the bot renders the message and skips the rest — so in-app-only sub-kinds like block-state sync to the blocker) never become a platform push. Operator broadcasts (`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in the console; the backend calls them on the **gateway's bot-link relay**, which forwards them -to the bot and **awaits its delivery ack** (so the console still reports delivered/not). +to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond +messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's +write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a +joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional +**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot +answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app` +button would sign initData with the promo token); it is self-contained — no bot-link, no gateway. Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP). A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md), @@ -987,9 +993,24 @@ revoked token would fail session resolution at the gateway *before* the gate, se login instead of the blocked screen). A block instantly **forfeits** every active game the player is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels their open matchmaking games; a temporary block lapses automatically once its expiry passes (no -sweeper — the gate recomputes against `now`). No operator identity is recorded (shared +sweeper for the gate — it recomputes against `now`). No operator identity is recorded (shared Basic-Auth). +**Moderated discussion chat.** A channel's linked discussion group is gated by the Telegram bot +(`TELEGRAM_CHAT_ID`): the group defaults to no-send, and a user may write only while they are +**registered and neither admin-suspended nor holding the chat-only `chat_muted` role** +(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates). A +single backend resolver behind `POST /api/v1/internal/chat-access` answers both directions: the +bot's join-time `ResolveChatEligibility` (over the mTLS bot-link) grants write access to an +eligible joiner, and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` +grant/revoke, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no +request fires then) — drives a `ChatGate` command the gateway pushes to the bot. The bot applies it +only to a member currently in the chat (a per-user `getChatMember` probe, since bots cannot list +members); the signal is idempotent and is never an in-app or out-of-app message. `chat_muted` is an +`account_roles` entry (an operator toggle in the console), so it needs no schema change. The bot +must be an administrator in the group with the **restrict-members** right and `chat_member` in its +allowed updates. + **Short numeric codes** (email confirm-codes and friend codes) are stored only as SHA-256 hashes and are short-lived and single-use. The unauthenticated email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem** diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 49646a7..07a63e6 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -33,7 +33,10 @@ App** launch authenticates from the platform's signed `initData`, themes the UI the Telegram colours, and — on first contact — seeds the new account's interface language from the Telegram client. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the -player's own **interface language** (en/ru). Guests are session-only with restricted features +player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the +main one — its only job is to answer `/start` with a short message and a button that opens the +**main** bot's app, where the player picks their game variant; it is an onboarding entry point that +touches nothing else. Guests are session-only with restricted features (auto-match only; no friends, stats or history); an abandoned guest that never joined a game and has been idle past the retention window is garbage-collected. While the app is open the client keeps a live stream and receives in-app updates in real time — the opponent's move, @@ -320,6 +323,14 @@ plus the reason when one was given, and the app stops all background traffic wit temporary block lifts itself when it expires; the operator can also **unblock** from the user card at any time (games already lost stay lost). +Where the bot manages a channel's **linked discussion chat**, only a **registered** player who is +**not blocked** may write there: the bot grants the right to write when such a player joins, while an +unregistered or blocked one stays muted (the promo bot points newcomers at the game so they register). +An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card — +without a full account block; an account block mutes them in the chat regardless. Muting/unmuting and +blocking/unblocking take effect for a player already in the chat; one who is not in it is unaffected +until they next join. + From the user card the operator can also **top up a player's hint wallet**: an additive grant (1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** — the console can never lower a wallet (a player only loses hints by spending them in a game), so an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 58bae72..a43119a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -34,7 +34,10 @@ Mini App** авторизует по подписанным `initData` плат в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке -интерфейса** самого игрока (en/ru). Гость — только сессия, с урезанными функциями (только +интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный +**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, +открывающей приложение **основного** бота, где игрок выбирает нужный вариант игры; это точка входа +для онбординга, не затрагивающая больше ничего. Гость — только сессия, с урезанными функциями (только авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, @@ -329,6 +332,14 @@ high-rate флага. С карточки пользователя операт истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент (уже проигранные партии не возвращаются). +Там, где бот ведёт **привязанный к каналу чат-обсуждение**, писать в нём может только +**зарегистрированный** игрок, который **не заблокирован**: при входе такого игрока бот выдаёт право +писать, а незарегистрированному или заблокированному — оставляет немым (промо-бот направляет +новичков в игру, чтобы они зарегистрировались). Оператор также может **замьютить игрока только в +чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка +аккаунта всё равно мьютит его в чате. Мьют/размьют и блокировка/разблокировка срабатывают для +игрока, уже находящегося в чате; того, кого в чате нет, это не затрагивает до его следующего входа. + С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления **только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 35e1f65..e06f443 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -147,7 +147,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // fire-and-forget; the backend admin relay (plaintext, internal) awaits the Ack. var botHub *botlink.Hub if cfg.BotLinkEnabled() { - botHub = botlink.NewHub(logger, tel.MeterProvider().Meter("scrabble/gateway/botlink")) + botHub = botlink.NewHub(logger, tel.MeterProvider().Meter("scrabble/gateway/botlink"), + func(ctx context.Context, externalID string) (bool, bool, error) { + r, rerr := backend.ChatEligibility(ctx, externalID) + return r.Registered, r.Eligible, rerr + }) tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile) if terr != nil { return terr @@ -361,6 +365,15 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H } break } + // A chat-access-changed event is an infra signal, not an in-app event: + // resolve the recipient's Telegram identity and current eligibility and + // push the chat-gate command to the bot, without fanning it out to clients. + if ev.GetKind() == chatAccessChangedKind { + if bot != nil { + go deliverChatGate(ctx, backend, bot, ev.GetUserId(), logger) + } + continue + } hub.Publish(push.Event{ UserID: ev.GetUserId(), Kind: ev.GetKind(), @@ -398,6 +411,27 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, bot *bo bot.Send(botlink.NotifyCommand(target.ExternalID, kind, payload, target.Language)) } +// chatAccessChangedKind is the backend event signalling that a player's moderated-chat +// write eligibility may have changed; the gateway turns it into a bot-link chat-gate +// command rather than an in-app event (it mirrors notify.KindChatAccessChanged). +const chatAccessChangedKind = "chat_access_changed" + +// deliverChatGate resolves a chat-access-changed event to the recipient's Telegram +// identity and current eligibility and pushes the chat-gate command to the bot. It is +// best-effort: a recipient with no Telegram identity is skipped, and a resolve failure +// is logged and dropped (the next moderation action, or a re-join, re-applies the gate). +func deliverChatGate(ctx context.Context, backend *backendclient.Client, bot *botlink.Hub, userID string, logger *zap.Logger) { + res, err := backend.ChatAccessByUser(ctx, userID) + if err != nil { + logger.Warn("chat-gate resolve failed", zap.String("user_id", userID), zap.Error(err)) + return + } + if res.ExternalID == "" { + return // no Telegram identity, nothing to gate + } + bot.Send(botlink.ChatGateCommand(res.ExternalID, res.Eligible)) +} + // sleep waits for d or until ctx is cancelled, reporting whether it waited the // full duration. func sleep(ctx context.Context, d time.Duration) bool { diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 3a5e656..cb039f4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -215,6 +215,34 @@ func (c *Client) PushTarget(ctx context.Context, userID string) (PushTargetResp, return out, err } +// ChatAccessResp is a user's moderated-chat write eligibility: ExternalID is their +// Telegram identity (empty when they have none, so the gateway has nothing to gate), +// Registered whether an account was found, and Eligible the final gate the bot applies +// (registered and neither admin-suspended nor chat-muted). +type ChatAccessResp struct { + ExternalID string `json:"external_id"` + Registered bool `json:"registered"` + Eligible bool `json:"eligible"` +} + +// ChatEligibility resolves a Telegram identity to its moderated-chat write +// eligibility — the join path, when the bot sees a user enter the chat. +func (c *Client) ChatEligibility(ctx context.Context, externalID string) (ChatAccessResp, error) { + var out ChatAccessResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "", + map[string]string{"external_id": externalID}, &out) + return out, err +} + +// ChatAccessByUser resolves an account id to its Telegram identity and current +// moderated-chat write eligibility — the change path, for a chat-access-changed event. +func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAccessResp, error) { + var out ChatAccessResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "", + map[string]string{"user_id": userID}, &out) + return out, err +} + // GuestAuth provisions a guest account and mints a session. func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) { var out SessionResp diff --git a/gateway/internal/botlink/command.go b/gateway/internal/botlink/command.go index 1173725..b2c01b1 100644 --- a/gateway/internal/botlink/command.go +++ b/gateway/internal/botlink/command.go @@ -36,3 +36,15 @@ func SendToGameChannelCommand(text string) *botlinkv1.Command { }}, } } + +// ChatGateCommand builds a chat-gate command that sets whether the Telegram user +// identified by externalID may write in the moderated discussion chat. The bot +// applies it only to a member currently in the chat (guarded on getChatMember). +func ChatGateCommand(externalID string, allow bool) *botlinkv1.Command { + return &botlinkv1.Command{ + Payload: &botlinkv1.Command_ChatGate{ChatGate: &botlinkv1.ChatGateCommand{ + ExternalId: externalID, + Allow: allow, + }}, + } +} diff --git a/gateway/internal/botlink/hub.go b/gateway/internal/botlink/hub.go index fa0a773..fc45b05 100644 --- a/gateway/internal/botlink/hub.go +++ b/gateway/internal/botlink/hub.go @@ -31,13 +31,21 @@ var ErrNoBot = errors.New("botlink: no bot connected") // (at-most-once under backpressure). const outboundBuffer = 64 +// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility +// for the bot's join-time ResolveChatEligibility query: registered reports whether the +// identity maps to an account, eligible is the final gate the bot acts on (registered +// and neither admin-suspended nor chat-muted). The gateway backs it with the backend +// chat-access endpoint. +type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error) + // Hub registers connected bots and routes send commands to them. A single bot is // expected today; the registry already holds a set so adding more later needs no // rewrite. type Hub struct { botlinkv1.UnimplementedBotLinkServer - log *zap.Logger + log *zap.Logger + eligibility EligibilityResolver mu sync.Mutex links map[*link]struct{} @@ -56,15 +64,18 @@ type link struct { out chan *botlinkv1.ToBot } -// NewHub builds a Hub. A nil meter disables metrics; a nil logger is tolerated. -func NewHub(log *zap.Logger, meter metric.Meter) *Hub { +// NewHub builds a Hub. resolve answers the bot's join-time chat-eligibility query +// (nil rejects it as unavailable). A nil meter disables metrics; a nil logger is +// tolerated. +func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *Hub { if log == nil { log = zap.NewNop() } h := &Hub{ - log: log, - links: make(map[*link]struct{}), - pending: make(map[string]chan *botlinkv1.Ack), + log: log, + eligibility: resolve, + links: make(map[*link]struct{}), + pending: make(map[string]chan *botlinkv1.Ack), } if meter != nil { h.connected, _ = meter.Int64UpDownCounter("botlink_connected_bots", @@ -120,6 +131,22 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1. } } +// ResolveChatEligibility serves the bot's join-time query: whether the Telegram user +// identified in the request may write in the moderated discussion chat. It delegates +// to the configured resolver (the backend chat-access endpoint), unlike the streamed +// Commands it is a plain request/response over the same mTLS channel. +func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEligibilityRequest) (*botlinkv1.ChatEligibilityResponse, error) { + if h.eligibility == nil { + return nil, status.Error(codes.Unavailable, "chat eligibility resolver not configured") + } + registered, eligible, err := h.eligibility(ctx, req.GetExternalId()) + if err != nil { + h.log.Warn("resolve chat eligibility failed", zap.String("external_id", req.GetExternalId()), zap.Error(err)) + return nil, status.Error(codes.Internal, "resolve chat eligibility") + } + return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil +} + // register adds a connected bot. func (h *Hub) register(l *link) { h.mu.Lock() diff --git a/gateway/internal/botlink/hub_test.go b/gateway/internal/botlink/hub_test.go index f2d1716..eae28fc 100644 --- a/gateway/internal/botlink/hub_test.go +++ b/gateway/internal/botlink/hub_test.go @@ -8,7 +8,9 @@ import ( "time" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" botlinkv1 "scrabble/pkg/proto/botlink/v1" @@ -23,12 +25,18 @@ type fakeBot struct { received chan *botlinkv1.Command } -// startHub registers a Hub on an in-memory gRPC server and returns the hub plus a -// dialer for fake bots. +// startHub registers a Hub (no chat-eligibility resolver) on an in-memory gRPC +// server and returns the hub plus a dialer for fake bots. func startHub(t *testing.T) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) { + return startHubWith(t, nil) +} + +// startHubWith is startHub with an explicit chat-eligibility resolver, for the +// ResolveChatEligibility tests. +func startHubWith(t *testing.T, resolve EligibilityResolver) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) { t.Helper() lis := bufconn.Listen(1 << 20) - hub := NewHub(nil, nil) + hub := NewHub(nil, nil, resolve) srv := grpc.NewServer() botlinkv1.RegisterBotLinkServer(srv, hub) go func() { _ = srv.Serve(lis) }() @@ -162,6 +170,62 @@ func TestHubSendAsync(t *testing.T) { } } +func TestHubSendChatGate(t *testing.T) { + hub, dial := startHub(t) + ctx := t.Context() + bot := &fakeBot{ack: false} + bot.connect(t, ctx, hub, dial(t)) + + hub.Send(ChatGateCommand("42", true)) + select { + case cmd := <-bot.received: + cg := cmd.GetChatGate() + if cg.GetExternalId() != "42" || !cg.GetAllow() { + t.Errorf("chat_gate = %+v, want external_id=42 allow=true", cg) + } + case <-time.After(time.Second): + t.Fatal("bot received no command") + } +} + +func TestHubResolveChatEligibility(t *testing.T) { + var gotExt string + _, dial := startHubWith(t, func(_ context.Context, ext string) (bool, bool, error) { + gotExt = ext + return true, ext == "good", nil + }) + client := dial(t) + ctx := t.Context() + + resp, err := client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "good"}) + if err != nil { + t.Fatalf("ResolveChatEligibility: %v", err) + } + if gotExt != "good" { + t.Errorf("resolver external_id = %q, want good", gotExt) + } + if !resp.GetRegistered() || !resp.GetEligible() { + t.Errorf("resp = %+v, want registered+eligible", resp) + } + + resp, err = client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "muted"}) + if err != nil { + t.Fatalf("ResolveChatEligibility(muted): %v", err) + } + if !resp.GetRegistered() || resp.GetEligible() { + t.Errorf("resp = %+v, want registered but not eligible", resp) + } +} + +func TestHubResolveChatEligibilityUnconfigured(t *testing.T) { + _, dial := startHub(t) // nil resolver + client := dial(t) + _, err := client.ResolveChatEligibility(t.Context(), &botlinkv1.ChatEligibilityRequest{ExternalId: "x"}) + if status.Code(err) != codes.Unavailable { + t.Fatalf("err = %v, want Unavailable", err) + } +} + func TestRelayServerNoBot(t *testing.T) { hub, _ := startHub(t) relay := NewRelayServer(hub, 200*time.Millisecond) diff --git a/gateway/internal/botlink/mtls_test.go b/gateway/internal/botlink/mtls_test.go index c7a6367..ff97e73 100644 --- a/gateway/internal/botlink/mtls_test.go +++ b/gateway/internal/botlink/mtls_test.go @@ -94,7 +94,7 @@ func startMTLSHub(t *testing.T) (hub *Hub, addr, caFile, cliCert, cliKey string) if err != nil { t.Fatalf("listen: %v", err) } - hub = NewHub(nil, nil) + hub = NewHub(nil, nil, nil) srv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg))) botlinkv1.RegisterBotLinkServer(srv, hub) go func() { _ = srv.Serve(lis) }() diff --git a/pkg/proto/botlink/v1/botlink.pb.go b/pkg/proto/botlink/v1/botlink.pb.go index 07cdf3d..46bd55d 100644 --- a/pkg/proto/botlink/v1/botlink.pb.go +++ b/pkg/proto/botlink/v1/botlink.pb.go @@ -224,6 +224,7 @@ type Command struct { // *Command_Notify // *Command_SendToUser // *Command_SendToChannel + // *Command_ChatGate Payload isCommand_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -300,6 +301,15 @@ func (x *Command) GetSendToChannel() *v1.SendToGameChannelRequest { return nil } +func (x *Command) GetChatGate() *ChatGateCommand { + if x != nil { + if x, ok := x.Payload.(*Command_ChatGate); ok { + return x.ChatGate + } + } + return nil +} + type isCommand_Payload interface { isCommand_Payload() } @@ -316,12 +326,18 @@ type Command_SendToChannel struct { SendToChannel *v1.SendToGameChannelRequest `protobuf:"bytes,4,opt,name=send_to_channel,json=sendToChannel,proto3,oneof"` } +type Command_ChatGate struct { + ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"` +} + func (*Command_Notify) isCommand_Payload() {} func (*Command_SendToUser) isCommand_Payload() {} func (*Command_SendToChannel) isCommand_Payload() {} +func (*Command_ChatGate) isCommand_Payload() {} + // Ack reports the outcome of the Command with command_id. delivered mirrors the // connector delivery semantics (false when the kind is not rendered out-of-app, the // user never started the bot, or no channel is configured); error carries an @@ -386,6 +402,166 @@ func (x *Ack) GetError() string { return "" } +// ChatGateCommand sets a Telegram user's write access in the moderated discussion +// chat. external_id is the user's Telegram identity (as in the backend identities +// table); allow grants the right to write when true and revokes it when false. The +// bot applies it only to a user currently in the chat — it guards on getChatMember, +// so a command for an absent user is a no-op. The gateway emits one whenever the +// user's eligibility may have changed: an admin block or unblock, a chat_muted +// grant or revoke, or a temporary block lapsing. +type ChatGateCommand struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + Allow bool `protobuf:"varint,2,opt,name=allow,proto3" json:"allow,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatGateCommand) Reset() { + *x = ChatGateCommand{} + mi := &file_botlink_v1_botlink_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatGateCommand) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatGateCommand) ProtoMessage() {} + +func (x *ChatGateCommand) ProtoReflect() protoreflect.Message { + mi := &file_botlink_v1_botlink_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead. +func (*ChatGateCommand) Descriptor() ([]byte, []int) { + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5} +} + +func (x *ChatGateCommand) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + +func (x *ChatGateCommand) GetAllow() bool { + if x != nil { + return x.Allow + } + return false +} + +// ChatEligibilityRequest asks whether the Telegram user identified by external_id +// may write in the moderated discussion chat. +type ChatEligibilityRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatEligibilityRequest) Reset() { + *x = ChatEligibilityRequest{} + mi := &file_botlink_v1_botlink_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatEligibilityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatEligibilityRequest) ProtoMessage() {} + +func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message { + mi := &file_botlink_v1_botlink_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead. +func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) { + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6} +} + +func (x *ChatEligibilityRequest) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + +// ChatEligibilityResponse is the eligibility answer. registered reports whether the +// external_id maps to an account at all; eligible is the final gate the bot acts on +// (registered and neither admin-suspended nor chat-muted). +type ChatEligibilityResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Registered bool `protobuf:"varint,1,opt,name=registered,proto3" json:"registered,omitempty"` + Eligible bool `protobuf:"varint,2,opt,name=eligible,proto3" json:"eligible,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatEligibilityResponse) Reset() { + *x = ChatEligibilityResponse{} + mi := &file_botlink_v1_botlink_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatEligibilityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatEligibilityResponse) ProtoMessage() {} + +func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message { + mi := &file_botlink_v1_botlink_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead. +func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) { + return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7} +} + +func (x *ChatEligibilityResponse) GetRegistered() bool { + if x != nil { + return x.Registered + } + return false +} + +func (x *ChatEligibilityResponse) GetEligible() bool { + if x != nil { + return x.Eligible + } + return false +} + var File_botlink_v1_botlink_proto protoreflect.FileDescriptor const file_botlink_v1_botlink_proto_rawDesc = "" + @@ -400,22 +576,36 @@ const file_botlink_v1_botlink_proto_rawDesc = "" + "\x05Hello\x12\x1f\n" + "\vinstance_id\x18\x01 \x01(\tR\n" + "instanceId\x12!\n" + - "\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\x99\x02\n" + + "\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xde\x02\n" + "\aCommand\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" + "\x06notify\x18\x02 \x01(\v2#.scrabble.telegram.v1.NotifyRequestH\x00R\x06notify\x12K\n" + "\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" + "sendToUser\x12X\n" + - "\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannelB\t\n" + + "\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" + + "\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGateB\t\n" + "\apayload\"X\n" + "\x03Ack\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" + "\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error2O\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"H\n" + + "\x0fChatGateCommand\x12\x1f\n" + + "\vexternal_id\x18\x01 \x01(\tR\n" + + "externalId\x12\x14\n" + + "\x05allow\x18\x02 \x01(\bR\x05allow\"9\n" + + "\x16ChatEligibilityRequest\x12\x1f\n" + + "\vexternal_id\x18\x01 \x01(\tR\n" + + "externalId\"U\n" + + "\x17ChatEligibilityResponse\x12\x1e\n" + + "\n" + + "registered\x18\x01 \x01(\bR\n" + + "registered\x12\x1a\n" + + "\beligible\x18\x02 \x01(\bR\beligible2\xc4\x01\n" + "\aBotLink\x12D\n" + - "\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01B)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3" + "\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" + + "\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3" var ( file_botlink_v1_botlink_proto_rawDescOnce sync.Once @@ -429,31 +619,37 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte { return file_botlink_v1_botlink_proto_rawDescData } -var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_botlink_v1_botlink_proto_goTypes = []any{ (*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot (*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot (*Hello)(nil), // 2: scrabble.botlink.v1.Hello (*Command)(nil), // 3: scrabble.botlink.v1.Command (*Ack)(nil), // 4: scrabble.botlink.v1.Ack - (*v1.NotifyRequest)(nil), // 5: scrabble.telegram.v1.NotifyRequest - (*v1.SendToUserRequest)(nil), // 6: scrabble.telegram.v1.SendToUserRequest - (*v1.SendToGameChannelRequest)(nil), // 7: scrabble.telegram.v1.SendToGameChannelRequest + (*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand + (*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest + (*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse + (*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest + (*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest + (*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest } var file_botlink_v1_botlink_proto_depIdxs = []int32{ - 2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello - 4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack - 3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command - 5, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest - 6, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest - 7, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest - 0, // 6: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot - 1, // 7: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot - 7, // [7:8] is the sub-list for method output_type - 6, // [6:7] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello + 4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack + 3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command + 8, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest + 9, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest + 10, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest + 5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand + 0, // 7: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot + 6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest + 1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot + 7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse + 9, // [9:11] is the sub-list for method output_type + 7, // [7:9] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_botlink_v1_botlink_proto_init() } @@ -469,6 +665,7 @@ func file_botlink_v1_botlink_proto_init() { (*Command_Notify)(nil), (*Command_SendToUser)(nil), (*Command_SendToChannel)(nil), + (*Command_ChatGate)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -476,7 +673,7 @@ func file_botlink_v1_botlink_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)), NumEnums: 0, - NumMessages: 5, + NumMessages: 8, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/proto/botlink/v1/botlink.proto b/pkg/proto/botlink/v1/botlink.proto index 15e96e9..9c4f868 100644 --- a/pkg/proto/botlink/v1/botlink.proto +++ b/pkg/proto/botlink/v1/botlink.proto @@ -20,6 +20,13 @@ service BotLink { // Link opens the single bot <-> gateway stream. The first client message is // Hello; thereafter the client sends one Ack per received Command. rpc Link(stream FromBot) returns (stream ToBot); + + // ResolveChatEligibility answers whether the Telegram user identified by + // external_id may write in the moderated discussion chat: registered with an + // account and neither admin-suspended nor chat-muted. The bot calls it over the + // same mTLS channel when a user joins the chat, to decide whether to grant the + // write permission. Delivery of the answer is request/response (not best-effort). + rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse); } // FromBot is a message the bot sends to the gateway: the opening Hello, then one @@ -53,6 +60,7 @@ message Command { scrabble.telegram.v1.NotifyRequest notify = 2; scrabble.telegram.v1.SendToUserRequest send_to_user = 3; scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4; + ChatGateCommand chat_gate = 5; } } @@ -65,3 +73,29 @@ message Ack { bool delivered = 2; string error = 3; } + +// ChatGateCommand sets a Telegram user's write access in the moderated discussion +// chat. external_id is the user's Telegram identity (as in the backend identities +// table); allow grants the right to write when true and revokes it when false. The +// bot applies it only to a user currently in the chat — it guards on getChatMember, +// so a command for an absent user is a no-op. The gateway emits one whenever the +// user's eligibility may have changed: an admin block or unblock, a chat_muted +// grant or revoke, or a temporary block lapsing. +message ChatGateCommand { + string external_id = 1; + bool allow = 2; +} + +// ChatEligibilityRequest asks whether the Telegram user identified by external_id +// may write in the moderated discussion chat. +message ChatEligibilityRequest { + string external_id = 1; +} + +// ChatEligibilityResponse is the eligibility answer. registered reports whether the +// external_id maps to an account at all; eligible is the final gate the bot acts on +// (registered and neither admin-suspended nor chat-muted). +message ChatEligibilityResponse { + bool registered = 1; + bool eligible = 2; +} diff --git a/pkg/proto/botlink/v1/botlink_grpc.pb.go b/pkg/proto/botlink/v1/botlink_grpc.pb.go index 93e4782..80f26ca 100644 --- a/pkg/proto/botlink/v1/botlink_grpc.pb.go +++ b/pkg/proto/botlink/v1/botlink_grpc.pb.go @@ -26,7 +26,8 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link" + BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link" + BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility" ) // BotLinkClient is the client API for BotLink service. @@ -41,6 +42,12 @@ type BotLinkClient interface { // Link opens the single bot <-> gateway stream. The first client message is // Hello; thereafter the client sends one Ack per received Command. Link(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FromBot, ToBot], error) + // ResolveChatEligibility answers whether the Telegram user identified by + // external_id may write in the moderated discussion chat: registered with an + // account and neither admin-suspended nor chat-muted. The bot calls it over the + // same mTLS channel when a user joins the chat, to decide whether to grant the + // write permission. Delivery of the answer is request/response (not best-effort). + ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error) } type botLinkClient struct { @@ -64,6 +71,16 @@ func (c *botLinkClient) Link(ctx context.Context, opts ...grpc.CallOption) (grpc // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type BotLink_LinkClient = grpc.BidiStreamingClient[FromBot, ToBot] +func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ChatEligibilityResponse) + err := c.cc.Invoke(ctx, BotLink_ResolveChatEligibility_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // BotLinkServer is the server API for BotLink service. // All implementations must embed UnimplementedBotLinkServer // for forward compatibility. @@ -76,6 +93,12 @@ type BotLinkServer interface { // Link opens the single bot <-> gateway stream. The first client message is // Hello; thereafter the client sends one Ack per received Command. Link(grpc.BidiStreamingServer[FromBot, ToBot]) error + // ResolveChatEligibility answers whether the Telegram user identified by + // external_id may write in the moderated discussion chat: registered with an + // account and neither admin-suspended nor chat-muted. The bot calls it over the + // same mTLS channel when a user joins the chat, to decide whether to grant the + // write permission. Delivery of the answer is request/response (not best-effort). + ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) mustEmbedUnimplementedBotLinkServer() } @@ -89,6 +112,9 @@ type UnimplementedBotLinkServer struct{} func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot]) error { return status.Errorf(codes.Unimplemented, "method Link not implemented") } +func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented") +} func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {} func (UnimplementedBotLinkServer) testEmbeddedByValue() {} @@ -117,13 +143,36 @@ func _BotLink_Link_Handler(srv interface{}, stream grpc.ServerStream) error { // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type BotLink_LinkServer = grpc.BidiStreamingServer[FromBot, ToBot] +func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ChatEligibilityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BotLinkServer).ResolveChatEligibility(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: BotLink_ResolveChatEligibility_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BotLinkServer).ResolveChatEligibility(ctx, req.(*ChatEligibilityRequest)) + } + return interceptor(ctx, in, info, handler) +} + // BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var BotLink_ServiceDesc = grpc.ServiceDesc{ ServiceName: "scrabble.botlink.v1.BotLink", HandlerType: (*BotLinkServer)(nil), - Methods: []grpc.MethodDesc{}, + Methods: []grpc.MethodDesc{ + { + MethodName: "ResolveChatEligibility", + Handler: _BotLink_ResolveChatEligibility_Handler, + }, + }, Streams: []grpc.StreamDesc{ { StreamName: "Link", diff --git a/platform/telegram/README.md b/platform/telegram/README.md index fda04f1..147f5e4 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -42,6 +42,23 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC launch button; a deep-link payload routes the launch to a game / invitation / friend code. This is **self-contained** — the bot never calls back into the game, so `/start` onboarding works even when the game is down. +- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion + group, the bot gates who may write there. The group is configured **default no-send** + (a human setting); the bot grants write access to a user who **joins** when they are + registered and neither admin-suspended nor `chat_muted`, asking the gateway over the + bot-link (`ResolveChatEligibility`). When an operator blocks/unblocks an account or + toggles its `chat_muted` role, the gateway pushes a `ChatGate` command and the bot + applies it — but only to a member currently in the chat (it probes one user with + `getChatMember`, since bots cannot list members). The bot must be an **administrator** + there with the **"Ban users"** right (the Bot API `can_restrict_members`), and it + subscribes to `chat_member` updates, which Telegram delivers only to a chat admin. +- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also + runs a **second, standalone** bot whose only job is to answer `/start` with a localized + message and a button that opens the **main** bot's Mini App. The button is a **URL** to + the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with + `?startapp` — a `web_app` button would launch under the promo bot's identity (its token + would sign the initData), which the main bot's validator rejects. It is fully + self-contained: no bot-link, no gateway, no game. - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`, default 25) to respect the Bot API flood limits. @@ -57,7 +74,9 @@ parsing is Telegram-specific. gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin relay. - `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials - on the gateway (`Hello` / `Command` / `Ack`). Generated Go is committed under `pkg`. + on the gateway (`Hello` / `Command` / `Ack`), now also carrying a `ChatGateCommand` (set + a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time + query) over the same mTLS channel. Generated Go is committed under `pkg`. ## Deep-link scheme @@ -101,6 +120,10 @@ Bot (`cmd/bot`): | `TELEGRAM_BOTLINK_SERVER_NAME` | — (required) | the gateway certificate's expected SNI / CN | | `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert | | `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` | +| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating | +| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it | +| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs | +| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs | | `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) | | `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) | | `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway | diff --git a/platform/telegram/cmd/bot/main.go b/platform/telegram/cmd/bot/main.go index 777807a..bca70a9 100644 --- a/platform/telegram/cmd/bot/main.go +++ b/platform/telegram/cmd/bot/main.go @@ -22,6 +22,7 @@ import ( "scrabble/platform/telegram/internal/bot" "scrabble/platform/telegram/internal/botlink" "scrabble/platform/telegram/internal/config" + "scrabble/platform/telegram/internal/promobot" ) // telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. @@ -70,6 +71,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { TestEnv: cfg.TestEnv, MiniAppURL: cfg.MiniAppURL, SendRatePerSecond: cfg.SendRatePerSecond, + ChatID: cfg.ChatID, }, logger) if err != nil { return err @@ -80,19 +82,47 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { return err } exec := botlink.NewExecutor(b, cfg.GameChannelID, logger) - client := botlink.NewClient(botlink.ClientConfig{ + client, err := botlink.NewClient(botlink.ClientConfig{ GatewayAddr: cfg.BotLink.GatewayAddr, InstanceID: cfg.BotLink.InstanceID, OwnsUpdates: cfg.OwnsUpdates, Creds: credentials.NewTLS(tlsCfg), ReconnectDelay: cfg.BotLink.ReconnectDelay, }, exec, logger) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + // The chat-join eligibility query rides the same bot-link connection; wire it into + // the bot after the client is built — the late binding that breaks the bot <-> + // client construction cycle. + b.SetEligibilityResolver(client.ResolveChatEligibility) + + // The optional standalone promo bot: a second bot (its own token) that only answers + // /start with a button opening the main bot's Mini App. It is self-contained — no + // bot-link, no gateway — so onboarding works even when the game is down. + var promo *promobot.Bot + if cfg.PromoBotToken != "" { + promo, err = promobot.New(promobot.Config{ + Token: cfg.PromoBotToken, + APIBaseURL: cfg.APIBaseURL, + TestEnv: cfg.TestEnv, + BotUsername: cfg.BotUsername, + BotLinkURL: cfg.BotLinkURL, + SendRatePerSecond: cfg.SendRatePerSecond, + }, logger) + if err != nil { + return err + } + } logger.Info("telegram bot starting", zap.String("gateway", cfg.BotLink.GatewayAddr), zap.String("miniapp_url", cfg.MiniAppURL), zap.Bool("owns_updates", cfg.OwnsUpdates), - zap.Bool("test_env", cfg.TestEnv)) + zap.Bool("test_env", cfg.TestEnv), + zap.Bool("chat_gating", cfg.ChatID != 0), + zap.Bool("promo_bot", promo != nil)) var wg sync.WaitGroup // The long-poll holds the exclusive getUpdates lease (one bot per token); a bot @@ -105,6 +135,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { logger.Error("bot-link client stopped", zap.Error(err)) } }) + // The promo bot runs its own getUpdates long-poll on its own token (no 409 with + // the main bot's lease). + if promo != nil { + wg.Go(func() { promo.Run(ctx) }) + } <-ctx.Done() wg.Wait() diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index 8c3aae9..c88fd06 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -8,6 +8,7 @@ package bot import ( "context" "net/url" + "strconv" "strings" tgbot "github.com/go-telegram/bot" @@ -30,8 +31,19 @@ type Config struct { // Telegram Bot API flood limits; 0 disables the limiter. The burst equals the // per-second rate. SendRatePerSecond int + // ChatID is the moderated discussion chat the bot gates write access in; 0 + // disables chat gating (and the chat_member long-poll subscription). Gating needs + // the bot to be an administrator there with the restrict-members right. + ChatID int64 } +// EligibilityResolver answers whether the Telegram user identified by externalID +// (the decimal user id) may write in the moderated chat: registered and neither +// admin-suspended nor chat-muted. The bot calls it when a user joins the chat. It is +// late-bound (SetEligibilityResolver) because it is backed by the bot-link client, +// which is built after the bot. +type EligibilityResolver func(ctx context.Context, externalID string) (eligible bool, err error) + // Bot wraps a Telegram Bot API client and the Mini App launch URL. type Bot struct { api *tgbot.Bot @@ -40,6 +52,11 @@ type Bot struct { // limiter throttles outbound sends to stay under the Bot API flood limits; nil // disables throttling. limiter *rate.Limiter + // chatID is the moderated discussion chat (0 disables gating). + chatID int64 + // eligibility resolves a joining user's chat write eligibility; nil leaves a + // joiner muted (fail-closed) until it is wired. + eligibility EligibilityResolver } // New builds the bot wrapper, registering the /start handler and a default handler @@ -49,15 +66,25 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) { if log == nil { log = zap.NewNop() } - t := &Bot{miniAppURL: cfg.MiniAppURL, log: log} + t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID} if cfg.SendRatePerSecond > 0 { t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) } opts := []tgbot.Option{ - tgbot.WithDefaultHandler(t.handleStart), + tgbot.WithDefaultHandler(t.handleUpdate), tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), } + if cfg.ChatID != 0 { + // chat_member updates are off by default; subscribe explicitly (alongside + // messages) so the bot sees joins in the moderated chat. The bot must also be an + // administrator there for Telegram to deliver them. + opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{ + models.AllowedUpdateMessage, + models.AllowedUpdateMyChatMember, + models.AllowedUpdateChatMember, + })) + } if cfg.TestEnv { // Route to the Bot API test environment (.../bot/test/METHOD). opts = append(opts, tgbot.UseTestEnvironment()) @@ -176,3 +203,130 @@ func startPayload(text string) string { } return strings.TrimSpace(strings.TrimPrefix(text, cmd)) } + +// handleUpdate is the default-handler dispatcher: a chat-member change in the +// moderated chat drives the write-access gate; anything else is treated as a message +// and gets the Mini App launch reply. +func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.Update) { + if update.ChatMember != nil { + t.handleChatMember(ctx, update.ChatMember) + return + } + t.handleStart(ctx, api, update) +} + +// SetEligibilityResolver wires the chat-eligibility resolver after construction (the +// bot-link client backing it is built after the bot). +func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) { + t.eligibility = resolve +} + +// handleChatMember grants write access to a user who joins the moderated chat when +// they are registered and not blocked. A non-eligible joiner is left muted (the chat +// defaults to no-send), and a resolve failure fails closed (also left muted). Other +// status changes (leaves, restrictions, admin edits) are ignored. +func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) { + if t.chatID == 0 || cm.Chat.ID != t.chatID { + return + } + // Only a transition into plain membership is a join to evaluate. + if cm.NewChatMember.Type != models.ChatMemberTypeMember || cm.OldChatMember.Type == models.ChatMemberTypeMember { + return + } + user := chatMemberUser(cm.NewChatMember) + if user == nil || user.IsBot { + return + } + if t.eligibility == nil { + return // resolver not wired: leave muted (fail closed) + } + eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10)) + if err != nil { + t.log.Warn("chat join eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) + return + } + if !eligible { + return // not registered or blocked: leave muted + } + if err := t.setChatWrite(ctx, user.ID, true); err != nil { + t.log.Warn("grant chat write failed", zap.Int64("user_id", user.ID), zap.Error(err)) + } +} + +// ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted +// change relayed by the gateway): it sets the user's write access, but only when they +// are currently in the chat. Bots cannot list members, so it probes the single user +// with getChatMember and is a no-op when they are absent (left/kicked) or an +// administrator (who cannot be restricted). It reports whether a restriction was +// applied. +func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) { + if t.chatID == 0 { + return false, nil + } + member, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: userID}) + if err != nil { + return false, err + } + switch member.Type { + case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted: + if err := t.setChatWrite(ctx, userID, allow); err != nil { + return false, err + } + return true, nil + default: + return false, nil // absent, or an admin/owner who cannot be restricted + } +} + +// setChatWrite restricts the user in the moderated chat to either the full send +// permission set (allow) or none (mute); the non-send permissions stay at their +// default-deny either way. +func (t *Bot) setChatWrite(ctx context.Context, userID int64, allow bool) error { + perms := models.ChatPermissions{} + if allow { + perms = chatWritePerms() + } + _, err := t.api.RestrictChatMember(ctx, &tgbot.RestrictChatMemberParams{ + ChatID: t.chatID, + UserID: userID, + Permissions: &perms, + }) + return err +} + +// chatWritePerms grants a member the ability to send every kind of message; the +// non-send permissions stay denied. +func chatWritePerms() models.ChatPermissions { + return models.ChatPermissions{ + CanSendMessages: true, + CanSendAudios: true, + CanSendDocuments: true, + CanSendPhotos: true, + CanSendVideos: true, + CanSendVideoNotes: true, + CanSendVoiceNotes: true, + CanSendPolls: true, + CanSendOtherMessages: true, + CanAddWebPagePreviews: true, + } +} + +// chatMemberUser returns the user a ChatMember refers to across the union variants, +// or nil for an unrecognised type. +func chatMemberUser(m models.ChatMember) *models.User { + switch m.Type { + case models.ChatMemberTypeOwner: + return m.Owner.User + case models.ChatMemberTypeAdministrator: + return &m.Administrator.User + case models.ChatMemberTypeMember: + return m.Member.User + case models.ChatMemberTypeRestricted: + return m.Restricted.User + case models.ChatMemberTypeLeft: + return m.Left.User + case models.ChatMemberTypeBanned: + return m.Banned.User + } + return nil +} diff --git a/platform/telegram/internal/bot/chat_test.go b/platform/telegram/internal/bot/chat_test.go new file mode 100644 index 0000000..1fefb5e --- /dev/null +++ b/platform/telegram/internal/bot/chat_test.go @@ -0,0 +1,165 @@ +package bot + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-telegram/bot/models" + "go.uber.org/zap" +) + +const testChatID = 555 + +// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a +// scripted getChatMember status, and records restrictChatMember calls. +type chatAPI struct { + memberStatus string // the status getChatMember reports (default "left") + restricts []restrictCall +} + +type restrictCall struct { + userID string + canSend bool // can_send_messages in the applied permissions +} + +func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/getMe"): + io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"tb"}}`) + case strings.HasSuffix(r.URL.Path, "/getChatMember"): + status := a.memberStatus + if status == "" { + status = "left" + } + io.WriteString(w, `{"ok":true,"result":{"status":"`+status+`","user":{"id":`+r.FormValue("user_id")+`,"is_bot":false,"first_name":"u"}}}`) + case strings.HasSuffix(r.URL.Path, "/restrictChatMember"): + var perms struct { + CanSendMessages bool `json:"can_send_messages"` + } + _ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms) + a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages}) + io.WriteString(w, `{"ok":true,"result":true}`) + default: + io.WriteString(w, `{"ok":true,"result":true}`) + } +} + +// newChatBot builds a gating bot (ChatID set) over the fake API, without an +// eligibility resolver — each test wires the one it needs. +func newChatBot(t *testing.T, api *chatAPI) *Bot { + t.Helper() + srv := httptest.NewServer(api) + t.Cleanup(srv.Close) + b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/", ChatID: testChatID}, zap.NewNop()) + if err != nil { + t.Fatalf("new bot: %v", err) + } + return b +} + +// chatMemberUpdate builds a status transition oldType -> member for userID in chatID. +func chatMemberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.ChatMemberUpdated { + return &models.ChatMemberUpdated{ + Chat: models.Chat{ID: chatID}, + OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}}, + NewChatMember: models.ChatMember{Type: models.ChatMemberTypeMember, Member: &models.ChatMemberMember{User: &models.User{ID: userID}}}, + } +} + +func TestHandleChatMemberGrantsEligibleJoin(t *testing.T) { + api := &chatAPI{} + b := newChatBot(t, api) + b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil }) + + b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) + + if len(api.restricts) != 1 || api.restricts[0].userID != "777" || !api.restricts[0].canSend { + t.Fatalf("restricts = %+v, want one grant (can_send=true) for 777", api.restricts) + } +} + +func TestHandleChatMemberSkipsIneligibleJoin(t *testing.T) { + api := &chatAPI{} + b := newChatBot(t, api) + b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return false, nil }) + + b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) + + if len(api.restricts) != 0 { + t.Fatalf("an ineligible joiner was restricted: %+v (want left muted, no call)", api.restricts) + } +} + +func TestHandleChatMemberFailsClosedOnResolveError(t *testing.T) { + api := &chatAPI{} + b := newChatBot(t, api) + b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, context.DeadlineExceeded }) + + b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) + + if len(api.restricts) != 0 { + t.Fatalf("a resolve error still granted write: %+v (want fail-closed)", api.restricts) + } +} + +func TestHandleChatMemberIgnoresOtherChatAndNonJoins(t *testing.T) { + api := &chatAPI{} + b := newChatBot(t, api) + b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil }) + ctx := context.Background() + + // A different chat is ignored. + b.handleChatMember(ctx, chatMemberUpdate(999, 777, models.ChatMemberTypeLeft)) + // A non-join transition (already a member) is ignored. + b.handleChatMember(ctx, chatMemberUpdate(testChatID, 777, models.ChatMemberTypeMember)) + + if len(api.restricts) != 0 { + t.Fatalf("restricts = %+v, want none for a foreign chat / non-join", api.restricts) + } +} + +func TestApplyChatGatePresentMember(t *testing.T) { + for _, tc := range []struct { + name string + allow bool + }{{"grant", true}, {"mute", false}} { + t.Run(tc.name, func(t *testing.T) { + api := &chatAPI{memberStatus: "member"} + b := newChatBot(t, api) + applied, err := b.ApplyChatGate(context.Background(), 777, tc.allow) + if err != nil { + t.Fatalf("apply: %v", err) + } + if !applied { + t.Fatal("applied = false, want true for a present member") + } + if len(api.restricts) != 1 || api.restricts[0].canSend != tc.allow { + t.Fatalf("restricts = %+v, want one with can_send=%v", api.restricts, tc.allow) + } + }) + } +} + +func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) { + for _, status := range []string{"left", "kicked", "administrator", "creator"} { + t.Run(status, func(t *testing.T) { + api := &chatAPI{memberStatus: status} + b := newChatBot(t, api) + applied, err := b.ApplyChatGate(context.Background(), 777, false) + if err != nil { + t.Fatalf("apply: %v", err) + } + if applied { + t.Errorf("applied = true for status %q, want a no-op", status) + } + if len(api.restricts) != 0 { + t.Errorf("restricts = %+v for status %q, want none", api.restricts, status) + } + }) + } +} diff --git a/platform/telegram/internal/botlink/client.go b/platform/telegram/internal/botlink/client.go index f415abb..8de04f6 100644 --- a/platform/telegram/internal/botlink/client.go +++ b/platform/telegram/internal/botlink/client.go @@ -37,27 +37,25 @@ type ClientConfig struct { } // Client maintains the long-lived bot-link to the gateway, executing the commands -// it receives and re-dialing after any break. +// it receives and re-dialing after any break. The same mTLS connection also serves +// the unary chat-eligibility query the bot makes on a chat join. type Client struct { - cfg ClientConfig - exec *Executor - log *zap.Logger + cfg ClientConfig + exec *Executor + log *zap.Logger + conn *grpc.ClientConn + client botlinkv1.BotLinkClient } -// NewClient builds the bot-link client over the executor. -func NewClient(cfg ClientConfig, exec *Executor, log *zap.Logger) *Client { +// NewClient builds the bot-link client over the executor, dialing the gateway. The +// gRPC connection is lazy, so the dial does not block on the gateway being up; the +// caller must Close it. The bot-link command stream is opened by Run. +func NewClient(cfg ClientConfig, exec *Executor, log *zap.Logger) (*Client, error) { if log == nil { log = zap.NewNop() } - return &Client{cfg: cfg, exec: exec, log: log} -} - -// Run dials the gateway and keeps the bot-link open, re-dialing after each break, -// until ctx is cancelled. The gRPC connection auto-reconnects the transport; this -// loop re-opens the Link stream on top of it. -func (c *Client) Run(ctx context.Context) error { - conn, err := grpc.NewClient(c.cfg.GatewayAddr, - grpc.WithTransportCredentials(c.cfg.Creds), + conn, err := grpc.NewClient(cfg.GatewayAddr, + grpc.WithTransportCredentials(cfg.Creds), grpc.WithStatsHandler(otelgrpc.NewClientHandler()), grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: clientKeepaliveTime, @@ -66,13 +64,30 @@ func (c *Client) Run(ctx context.Context) error { }), ) if err != nil { - return err + return nil, err } - defer func() { _ = conn.Close() }() - client := botlinkv1.NewBotLinkClient(conn) + return &Client{cfg: cfg, exec: exec, log: log, conn: conn, client: botlinkv1.NewBotLinkClient(conn)}, nil +} +// Close releases the bot-link connection. +func (c *Client) Close() error { return c.conn.Close() } + +// ResolveChatEligibility asks the gateway whether the Telegram user identified by +// externalID may write in the moderated chat. The bot calls it on a chat join, over +// the same mTLS connection as the command stream. +func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string) (bool, error) { + resp, err := c.client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: externalID}) + if err != nil { + return false, err + } + return resp.GetEligible(), nil +} + +// Run keeps the bot-link command stream open, re-opening it after each break, until +// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath. +func (c *Client) Run(ctx context.Context) error { for ctx.Err() == nil { - if err := c.serve(ctx, client); err != nil && ctx.Err() == nil { + if err := c.serve(ctx, c.client); err != nil && ctx.Err() == nil { c.log.Warn("bot-link stream ended", zap.Error(err)) } if !sleep(ctx, c.cfg.ReconnectDelay) { diff --git a/platform/telegram/internal/botlink/client_test.go b/platform/telegram/internal/botlink/client_test.go index 09c4690..2fff57d 100644 --- a/platform/telegram/internal/botlink/client_test.go +++ b/platform/telegram/internal/botlink/client_test.go @@ -63,12 +63,16 @@ func TestClientServesCommands(t *testing.T) { t.Cleanup(srv.Stop) sender := &fakeSender{} - client := NewClient(ClientConfig{ + client, err := NewClient(ClientConfig{ GatewayAddr: lis.Addr().String(), InstanceID: "test", Creds: insecure.NewCredentials(), ReconnectDelay: 50 * time.Millisecond, }, NewExecutor(sender, 0, nil), nil) + if err != nil { + t.Fatalf("new client: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) go func() { _ = client.Run(t.Context()) }() diff --git a/platform/telegram/internal/botlink/executor.go b/platform/telegram/internal/botlink/executor.go index 0dae3cf..1175622 100644 --- a/platform/telegram/internal/botlink/executor.go +++ b/platform/telegram/internal/botlink/executor.go @@ -23,6 +23,10 @@ type Sender interface { Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error // SendText sends a plain text message to chatID. SendText(ctx context.Context, chatID int64, text string) error + // ApplyChatGate sets the Telegram user's write access in the moderated discussion + // chat, but only when they are currently in it; it reports whether a restriction + // was applied. + ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) } // Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors @@ -53,11 +57,30 @@ func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, er return e.sendToUser(ctx, p.SendToUser) case *botlinkv1.Command_SendToChannel: return e.sendToChannel(ctx, p.SendToChannel) + case *botlinkv1.Command_ChatGate: + return e.chatGate(ctx, p.ChatGate) default: return false, fmt.Errorf("botlink: empty command") } } +// chatGate applies a chat-gate command: it parses the target Telegram user id and +// sets their write access in the moderated chat (a no-op when they are not in it). A +// Bot API failure is logged and reported as not-delivered, not a hard error. +func (e *Executor) chatGate(ctx context.Context, req *botlinkv1.ChatGateCommand) (bool, error) { + userID, err := parseChatID(req.GetExternalId()) + if err != nil { + return false, err + } + applied, err := e.sender.ApplyChatGate(ctx, userID, req.GetAllow()) + if err != nil { + e.log.Warn("chat gate apply failed", + zap.String("external_id", req.GetExternalId()), zap.Bool("allow", req.GetAllow()), zap.Error(err)) + return false, nil + } + return applied, nil +} + // notify renders an out-of-app push and sends it with a Mini App launch button. func (e *Executor) notify(ctx context.Context, req *telegramv1.NotifyRequest) (bool, error) { msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage()) diff --git a/platform/telegram/internal/botlink/executor_test.go b/platform/telegram/internal/botlink/executor_test.go index 5e039cc..502eb84 100644 --- a/platform/telegram/internal/botlink/executor_test.go +++ b/platform/telegram/internal/botlink/executor_test.go @@ -13,9 +13,11 @@ import ( // fakeSender records the delivery calls the executor makes. type fakeSender struct { - notify []notifyCall - text []textCall - err error + notify []notifyCall + text []textCall + gate []gateCall + applied bool // ApplyChatGate's reported result + err error } type notifyCall struct { @@ -26,6 +28,10 @@ type textCall struct { chatID int64 text string } +type gateCall struct { + userID int64 + allow bool +} func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error { f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam}) @@ -37,6 +43,11 @@ func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) erro return f.err } +func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool) (bool, error) { + f.gate = append(f.gate, gateCall{userID, allow}) + return f.applied, f.err +} + func yourTurnPayload(gameID string) []byte { b := flatbuffers.NewBuilder(0) gid := b.CreateString(gameID) @@ -106,6 +117,46 @@ func TestExecutorSendToUser(t *testing.T) { } } +func chatGateCmd(externalID string, allow bool) *botlinkv1.Command { + return &botlinkv1.Command{Payload: &botlinkv1.Command_ChatGate{ChatGate: &botlinkv1.ChatGateCommand{ + ExternalId: externalID, Allow: allow, + }}} +} + +func TestExecutorChatGateApplied(t *testing.T) { + sender := &fakeSender{applied: true} + exec := NewExecutor(sender, 0, nil) + delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true)) + if err != nil { + t.Fatalf("handle: %v", err) + } + if !delivered || len(sender.gate) != 1 || sender.gate[0].userID != 777 || !sender.gate[0].allow { + t.Errorf("chat gate = %v / calls %+v", delivered, sender.gate) + } +} + +func TestExecutorChatGateNotInChat(t *testing.T) { + sender := &fakeSender{applied: false} // user not in the chat + exec := NewExecutor(sender, 0, nil) + delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false)) + if err != nil { + t.Fatalf("handle: %v", err) + } + if delivered { + t.Error("expected delivered=false when the user is not in the chat") + } + if len(sender.gate) != 1 { + t.Errorf("gate calls = %d, want 1", len(sender.gate)) + } +} + +func TestExecutorChatGateInvalidExternalID(t *testing.T) { + exec := NewExecutor(&fakeSender{}, 0, nil) + if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil { + t.Error("expected an error for a non-numeric external_id") + } +} + func TestExecutorSendToChannel(t *testing.T) { channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}} diff --git a/platform/telegram/internal/config/config.go b/platform/telegram/internal/config/config.go index b5ea2fa..cbda8a1 100644 --- a/platform/telegram/internal/config/config.go +++ b/platform/telegram/internal/config/config.go @@ -37,6 +37,24 @@ type BotConfig struct { // GameChannelID is the chat id of the bot's game channel for the admin channel // post (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts). GameChannelID int64 + // ChatID is the chat id of the moderated discussion supergroup (a channel's linked + // chat) whose write access the bot gates by registration and moderation + // (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin + // there with the "Ban users" right, and "chat_member" in its allowed updates. + ChatID int64 + // PromoBotToken is the API token of the optional standalone promo bot run in this + // container — a second bot whose only job is to answer /start with a button that + // opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables + // the promo bot). + PromoBotToken string + // BotUsername is the main bot's @username without the leading @, used in the promo + // bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs). + BotUsername string + // BotLinkURL is the main bot's Mini App direct link — the same value the UI builds + // share links from (VITE_TELEGRAM_LINK), e.g. https://t.me//. The promo + // button appends ?startapp= to it (TELEGRAM_BOT_LINK; required when the + // promo bot runs). It is distinct from the BotLink mTLS dial config below. + BotLinkURL string // MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is // the base of every launch button (TELEGRAM_MINIAPP_URL, required). MiniAppURL string @@ -114,6 +132,9 @@ func LoadBot() (BotConfig, error) { TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true", OwnsUpdates: os.Getenv("TELEGRAM_OWNS_UPDATES") != "false", SendRatePerSecond: defaultSendRatePerSecond, + PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"), + BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"), + BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), BotLink: BotLinkClientConfig{ GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"), @@ -128,6 +149,9 @@ func LoadBot() (BotConfig, error) { if cfg.GameChannelID, err = envInt64("TELEGRAM_GAME_CHANNEL_ID", 0); err != nil { return BotConfig{}, err } + if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil { + return BotConfig{}, err + } if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil { return BotConfig{}, err } @@ -155,6 +179,9 @@ func LoadBot() (BotConfig, error) { if cfg.BotLink.CertFile == "" || cfg.BotLink.KeyFile == "" || cfg.BotLink.CAFile == "" { return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOTLINK_TLS_CERT, _KEY and _CA are required") } + if cfg.PromoBotToken != "" && (cfg.BotUsername == "" || cfg.BotLinkURL == "") { + return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOT_USERNAME and TELEGRAM_BOT_LINK are required when TELEGRAM_PROMO_BOT_TOKEN is set") + } return cfg, nil } diff --git a/platform/telegram/internal/config/config_test.go b/platform/telegram/internal/config/config_test.go index df6230f..2aa5483 100644 --- a/platform/telegram/internal/config/config_test.go +++ b/platform/telegram/internal/config/config_test.go @@ -103,6 +103,54 @@ func TestLoadBotRequired(t *testing.T) { } } +// TestLoadBotChatAndPromo verifies the moderated-chat id and the promo-bot +// configuration parse, the @-prefix is stripped from the username, and a promo token +// without a username/link is rejected. +func TestLoadBotChatAndPromo(t *testing.T) { + t.Run("parsed", func(t *testing.T) { + setBotRequired(t) + t.Setenv("TELEGRAM_CHAT_ID", "-100222") + t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token") + t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot") + t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app") + c, err := LoadBot() + if err != nil { + t.Fatalf("LoadBot: %v", err) + } + if c.ChatID != -100222 { + t.Errorf("ChatID = %d, want -100222", c.ChatID) + } + if c.PromoBotToken != "promo-token" { + t.Errorf("PromoBotToken = %q", c.PromoBotToken) + } + if c.BotUsername != "ScrabbleBot" { + t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername) + } + if c.BotLinkURL != "https://t.me/ScrabbleBot/app" { + t.Errorf("BotLinkURL = %q", c.BotLinkURL) + } + }) + + t.Run("promo token requires username and link", func(t *testing.T) { + setBotRequired(t) + t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token") + if _, err := LoadBot(); err == nil { + t.Fatal("LoadBot: expected an error for a promo token without username/link") + } + }) + + t.Run("disabled by default", func(t *testing.T) { + setBotRequired(t) + c, err := LoadBot() + if err != nil { + t.Fatalf("LoadBot: %v", err) + } + if c.PromoBotToken != "" || c.ChatID != 0 { + t.Errorf("defaults: promo=%q chat=%d, want empty/0", c.PromoBotToken, c.ChatID) + } + }) +} + // TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set // fails validation (the validator path). func TestLoadRejectsUnsupportedExporter(t *testing.T) { diff --git a/platform/telegram/internal/promobot/promobot.go b/platform/telegram/internal/promobot/promobot.go new file mode 100644 index 0000000..f4911ff --- /dev/null +++ b/platform/telegram/internal/promobot/promobot.go @@ -0,0 +1,164 @@ +// Package promobot is the standalone promo bot: a second Telegram bot in the bot +// container whose only job is to answer /start with a localized message and a button +// that opens the MAIN bot's Mini App. It is self-contained — it never calls the +// gateway or the game — so onboarding works even when the game is down. The button is +// a URL to the main bot's direct Mini App link: a web_app button would launch the Mini +// App under the promo bot's identity (its token would sign the initData), which the +// main bot's validator would reject, so the cross-bot launch must be a t.me link. It +// reuses the same link the UI builds invitation links from (VITE_TELEGRAM_LINK). +package promobot + +import ( + "context" + "net/url" + "strings" + + tgbot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + "go.uber.org/zap" + "golang.org/x/time/rate" +) + +// Config configures the promo bot. +type Config struct { + // Token is the promo bot's Bot API token. + Token string + // APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org). + APIBaseURL string + // TestEnv routes requests to the Bot API test environment. + TestEnv bool + // BotUsername is the main bot's @username without the leading @, named in the + // message text. + BotUsername string + // BotLinkURL is the main bot's Mini App direct link; the button appends + // ?startapp= to it. + BotLinkURL string + // SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0 + // disables the limiter. The burst equals the per-second rate. + SendRatePerSecond int +} + +// Bot is the promo bot wrapper around a Telegram Bot API client. +type Bot struct { + api *tgbot.Bot + username string + linkURL string + log *zap.Logger + limiter *rate.Limiter +} + +// New builds the promo bot, registering a /start (and default) handler that replies +// with the launch button. It does not start polling; call Run for that. +func New(cfg Config, log *zap.Logger) (*Bot, error) { + if log == nil { + log = zap.NewNop() + } + t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log} + if cfg.SendRatePerSecond > 0 { + t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) + } + opts := []tgbot.Option{ + tgbot.WithDefaultHandler(t.handleStart), + tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), + } + if cfg.TestEnv { + opts = append(opts, tgbot.UseTestEnvironment()) + } + if cfg.APIBaseURL != "" { + opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL)) + } + api, err := tgbot.New(cfg.Token, opts...) + if err != nil { + return nil, err + } + t.api = api + return t, nil +} + +// Run sets the bot command, then blocks on the long-poll update loop until ctx is +// cancelled. +func (t *Bot) Run(ctx context.Context) { + if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{ + Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}}, + }); err != nil { + t.log.Warn("promo: set commands failed", zap.Error(err)) + } + t.api.Start(ctx) +} + +// handleStart replies to any message (typically /start) with the localized promo text +// and a button that opens the main bot's Mini App, forwarding any /start payload. +func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) { + if update.Message == nil { + return + } + if err := t.throttle(ctx); err != nil { + return + } + lang := "" + if update.Message.From != nil { + lang = update.Message.From.LanguageCode + } + text, button := promoText(lang, t.username) + if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: text, + ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)), + }); err != nil { + t.log.Warn("promo: reply to start failed", zap.Error(err)) + } +} + +// launchMarkup builds the single URL button that opens the main bot's Mini App at the +// optional startapp payload. +func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup { + return &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{{ + {Text: buttonText, URL: t.launchURL(startParam)}, + }}, + } +} + +// launchURL appends the startapp payload to the main bot's Mini App link; an empty +// payload returns the base link unchanged. +func (t *Bot) launchURL(startParam string) string { + if startParam == "" { + return t.linkURL + } + u, err := url.Parse(t.linkURL) + if err != nil { + return t.linkURL + } + q := u.Query() + q.Set("startapp", startParam) + u.RawQuery = q.Encode() + return u.String() +} + +// throttle blocks until the rate limiter admits one send, or ctx is cancelled. It is +// a no-op when no limiter is configured. +func (t *Bot) throttle(ctx context.Context) error { + if t.limiter == nil { + return nil + } + return t.limiter.Wait(ctx) +} + +// startPayload extracts the deep-link payload from a "/start " command; any +// other text yields an empty payload (open the lobby). +func startPayload(text string) string { + const cmd = "/start" + if !strings.HasPrefix(text, cmd) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(text, cmd)) +} + +// promoText returns the localized message body and button label, naming the main bot +// (Russian for a "ru" language code, English otherwise). +func promoText(lang, username string) (text, button string) { + if strings.HasPrefix(strings.ToLower(lang), "ru") { + return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!" + } + return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!" +} diff --git a/platform/telegram/internal/promobot/promobot_test.go b/platform/telegram/internal/promobot/promobot_test.go new file mode 100644 index 0000000..459d650 --- /dev/null +++ b/platform/telegram/internal/promobot/promobot_test.go @@ -0,0 +1,105 @@ +package promobot + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-telegram/bot/models" + "go.uber.org/zap" +) + +func TestPromoTextLocalization(t *testing.T) { + en, enBtn := promoText("en", "ScrabbleBot") + if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") { + t.Errorf("en text = %q", en) + } + if enBtn != "🤩 I want to play!" { + t.Errorf("en button = %q", enBtn) + } + + ru, ruBtn := promoText("ru-RU", "ScrabbleBot") + if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") { + t.Errorf("ru text = %q", ru) + } + if ruBtn != "🤩 Хочу играть!" { + t.Errorf("ru button = %q", ruBtn) + } + + // An unknown language falls back to English. + if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") { + t.Errorf("fallback text = %q, want English", got) + } +} + +func TestLaunchURLAppendsStartapp(t *testing.T) { + b := &Bot{linkURL: "https://t.me/bot/app"} + if got := b.launchURL(""); got != "https://t.me/bot/app" { + t.Errorf("empty payload = %q, want the base link unchanged", got) + } + if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" { + t.Errorf("launchURL = %q, want startapp=g123 appended", got) + } +} + +func TestLaunchMarkupIsURLButton(t *testing.T) { + b := &Bot{linkURL: "https://t.me/bot/app"} + btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0] + if btn.WebApp != nil { + t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)") + } + if !strings.Contains(btn.URL, "startapp=f99") { + t.Errorf("button URL = %q, want startapp=f99", btn.URL) + } +} + +// fakeAPI answers getMe (so New succeeds offline) and records the last sendMessage. +type fakeAPI struct { + chatID, text, replyMarkup string +} + +func (f *fakeAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/getMe"): + io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"promo"}}`) + case strings.HasSuffix(r.URL.Path, "/sendMessage"): + f.chatID = r.FormValue("chat_id") + f.text = r.FormValue("text") + f.replyMarkup = r.FormValue("reply_markup") + io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) + default: + io.WriteString(w, `{"ok":true,"result":true}`) + } +} + +func TestHandleStartReplies(t *testing.T) { + api := &fakeAPI{} + srv := httptest.NewServer(api) + t.Cleanup(srv.Close) + b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop()) + if err != nil { + t.Fatalf("new: %v", err) + } + + b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: 42}, + From: &models.User{LanguageCode: "ru"}, + Text: "/start f99", + }}) + + if api.chatID != "42" { + t.Errorf("chat_id = %q, want 42", api.chatID) + } + if !strings.Contains(api.text, "@ScrabbleBot") { + t.Errorf("text = %q, want the @mention", api.text) + } + if strings.Contains(api.replyMarkup, "web_app") { + t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup) + } + if !strings.Contains(api.replyMarkup, "startapp=f99") { + t.Errorf("reply_markup = %q, want startapp=f99 (the /start payload forwarded)", api.replyMarkup) + } +} -- 2.52.0 From b22b624d28c6cdddbaa464ff736a8961c42f66f7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 14:50:01 +0200 Subject: [PATCH 209/223] fix(telegram): keep a failed promo-bot construction non-fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tgbot.New validates the token with getMe, so a bad or unreachable promo token would otherwise return an error from run() and crash-loop the whole bot process — taking the main game bot down with it, since they share the container. Log it and skip the promo bot instead; the main bot and bot-link are unaffected. --- platform/telegram/cmd/bot/main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/platform/telegram/cmd/bot/main.go b/platform/telegram/cmd/bot/main.go index bca70a9..17db610 100644 --- a/platform/telegram/cmd/bot/main.go +++ b/platform/telegram/cmd/bot/main.go @@ -103,6 +103,10 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { // bot-link, no gateway — so onboarding works even when the game is down. var promo *promobot.Bot if cfg.PromoBotToken != "" { + // The promo bot is auxiliary and shares this process with the main bot, so its + // construction failure (a bad or unreachable promo token — tgbot.New validates it + // with getMe) is logged and the promo bot is skipped, never fatal: it must not + // take the main game bot down with it. promo, err = promobot.New(promobot.Config{ Token: cfg.PromoBotToken, APIBaseURL: cfg.APIBaseURL, @@ -112,7 +116,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error { SendRatePerSecond: cfg.SendRatePerSecond, }, logger) if err != nil { - return err + logger.Error("promo bot disabled: construction failed (the main bot is unaffected)", zap.Error(err)) + promo = nil } } -- 2.52.0 From a4045130376873fc47fbd83ee83939ddb87b848b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 15:19:21 +0200 Subject: [PATCH 210/223] feat(telegram): chat-gate observability + grant on first registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from a contour test where a user joined the chat, then registered, and got no write access — with silent logs. Observability: log every chat_member update (chat id, configured id, user, old->new status), the eligibility result and the grant outcome; plus a startup self-check that warns loudly when the bot is not an administrator in the chat with the restrict-members ("Ban users") right — the common misconfiguration, previously invisible in the logs. Grant on first registration: a user who joins the moderated chat BEFORE registering is covered by no chat_member event, so the join-time grant never fires for them. ProvisionTelegram now reports first contact, and the Telegram auth handler emits chat_access_changed on it, so the gateway re-evaluates and grants write access if the user is already in the chat. --- backend/internal/account/account.go | 28 +++++++--- backend/internal/inttest/account_test.go | 18 ++++--- backend/internal/inttest/admin_test.go | 2 +- backend/internal/inttest/chat_access_test.go | 45 +++++++++++++++- .../internal/inttest/suspension_gate_test.go | 2 +- backend/internal/inttest/userlist_test.go | 2 +- backend/internal/server/handlers_auth.go | 8 ++- platform/telegram/internal/bot/bot.go | 52 ++++++++++++++++++- 8 files changed, 136 insertions(+), 21 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 1cdacbc..816ba4b 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -151,14 +151,26 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri 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 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)) +// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity, +// reporting whether this call created it (first contact). 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 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. The created flag lets the auth handler re-evaluate moderated- +// chat write access on first registration — the path of a user who joined the chat +// before registering, whom no chat_member event covers. +func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) { + // Pre-check whether the identity already exists so the caller can act on first + // contact. A race with a concurrent create only over- or under-reports created for + // that one call, which the idempotent chat-access re-evaluation tolerates. + _, err := s.findByIdentity(ctx, KindTelegram, externalID) + created := errors.Is(err, ErrNotFound) + if err != nil && !created { + return Account{}, false, err + } + acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) + return acc, created, err } // provision finds the account for (kind, externalID) or creates it with seed, diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index b9327a7..67e002c 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -118,10 +118,13 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { store := account.NewStore(testDB) ext := "tg-" + uuid.NewString() - acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван") + acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван") if err != nil { t.Fatalf("provision telegram: %v", err) } + if !created { + t.Error("created = false on first contact, want true") + } if acc.PreferredLanguage != "ru" { t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage) } @@ -133,10 +136,13 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { } // A later login with different fields returns the same account, unchanged. - again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other") + again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other") if err != nil { t.Fatalf("re-provision telegram: %v", err) } + if created { + t.Error("created = true on a repeat login, want false") + } if again.ID != acc.ID { t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) } @@ -150,7 +156,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { // language CHECK. func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { ctx := context.Background() - acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "") + acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -166,7 +172,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { func TestHighRateFlagRoundTrip(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) - acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -222,7 +228,7 @@ func TestIdentityExternalID(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) ext := "tg-" + uuid.NewString() - acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User") + acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -247,7 +253,7 @@ func TestIdentityExternalID(t *testing.T) { func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) - acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") if err != nil { t.Fatalf("provision telegram: %v", err) } diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 86b1c46..46f3689 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) { func TestConsoleThrottledViewAndFlagClear(t *testing.T) { ctx := context.Background() accounts := account.NewStore(testDB) - acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player") + acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/chat_access_test.go b/backend/internal/inttest/chat_access_test.go index 065af0d..b36599b 100644 --- a/backend/internal/inttest/chat_access_test.go +++ b/backend/internal/inttest/chat_access_test.go @@ -18,6 +18,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/notify" "scrabble/backend/internal/server" + "scrabble/backend/internal/session" ) // chatAccessBody mirrors the backend's /internal/chat-access JSON for the test. @@ -54,7 +55,7 @@ func TestChatAccessResolver(t *testing.T) { srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) ext := "tg-" + uuid.NewString() - acc, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") + acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") if err != nil { t.Fatalf("provision: %v", err) } @@ -203,6 +204,48 @@ func TestChatAccessPublishedOnModeration(t *testing.T) { } } +// TestChatAccessPublishedOnFirstRegistration checks that a Telegram first contact +// (the sessions/telegram endpoint creating the account) emits chat_access_changed — +// the re-grant for a user who joined the moderated chat before registering — and that +// a repeat login does not re-emit. +func TestChatAccessPublishedOnFirstRegistration(t *testing.T) { + notifier := &captureNotifier{} + srv := server.New(":0", server.Deps{ + Logger: zaptest.NewLogger(t), + DB: testDB, + Accounts: account.NewStore(testDB), + Sessions: session.NewService(session.NewStore(testDB), session.NewCache()), + Notifier: notifier, + }) + h := srv.Handler() + ext := "tg-" + uuid.NewString() + + post := func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", + strings.NewReader(`{"external_id":"`+ext+`","language_code":"en","first_name":"Reg"}`)) + req.Header.Set("Content-Type", "application/json") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String()) + } + } + + post() + acc, err := account.NewStore(testDB).AccountByIdentity(context.Background(), account.KindTelegram, ext) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 { + t.Fatalf("first registration: chat_access_changed count = %d, want 1", got) + } + // A repeat login (the account already exists) must not re-emit. + post() + if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 { + t.Fatalf("repeat login: chat_access_changed count = %d, want still 1", got) + } +} + // TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted // temporary block whose expiry falls in the window is returned, while one outside the // window, a permanent block, and a lifted block are not. diff --git a/backend/internal/inttest/suspension_gate_test.go b/backend/internal/inttest/suspension_gate_test.go index 27555f8..3d123af 100644 --- a/backend/internal/inttest/suspension_gate_test.go +++ b/backend/internal/inttest/suspension_gate_test.go @@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) { Accounts: accounts, }) - acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") + acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/userlist_test.go b/backend/internal/inttest/userlist_test.go index 8e47e5f..78a4e2c 100644 --- a/backend/internal/inttest/userlist_test.go +++ b/backend/internal/inttest/userlist_test.go @@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) { st := account.NewStore(testDB) uniq := uuid.NewString() - human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") + human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") if err != nil { t.Fatalf("provision human: %v", err) } diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 59bc100..06cdf72 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -35,11 +35,17 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { abortBadRequest(c, "external_id is required") return } - acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName) + acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName) if err != nil { s.abortErr(c, err) return } + if created { + // First registration: re-evaluate moderated-chat write access, so a user who + // joined the chat before registering is granted on the spot (no chat_member + // event fires on registration). + s.publishChatAccessChange(acc.ID) + } s.mintSession(c, acc) } diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index c88fd06..6cb3b45 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -117,9 +117,38 @@ func (t *Bot) Run(ctx context.Context) { }); err != nil { t.log.Warn("set menu button failed", zap.Error(err)) } + if t.chatID != 0 { + t.logChatAdminStatus(ctx) + } t.api.Start(ctx) } +// logChatAdminStatus checks, at startup, whether the bot can actually gate the +// moderated chat — it must be an administrator there with the restrict-members +// ("Ban users") right, or Telegram delivers no chat_member updates and restricts +// fail. It logs a prominent warning when the prerequisite is missing (the common +// misconfiguration), so the cause is visible without reproducing a join. +func (t *Bot) logChatAdminStatus(ctx context.Context) { + me, err := t.api.GetMe(ctx) + if err != nil { + t.log.Warn("chat self-check: getMe failed", zap.Error(err)) + return + } + m, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: me.ID}) + if err != nil { + t.log.Warn("chat gating self-check failed: the bot cannot read the chat — is it added and is TELEGRAM_CHAT_ID the discussion group id?", + zap.Int64("chat_id", t.chatID), zap.Error(err)) + return + } + canRestrict := m.Type == models.ChatMemberTypeAdministrator && m.Administrator.CanRestrictMembers + if !canRestrict { + t.log.Warn(`chat gating WILL NOT WORK: the bot must be an administrator with the restrict-members ("Ban users") right`, + zap.Int64("chat_id", t.chatID), zap.String("bot_status", string(m.Type))) + return + } + t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID)) +} + // Notify sends a notification message with a Mini App launch button that opens the // app at startParam (empty opens the lobby). func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error { @@ -226,6 +255,20 @@ func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) { // defaults to no-send), and a resolve failure fails closed (also left muted). Other // status changes (leaves, restrictions, admin edits) are ignored. func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) { + user := chatMemberUser(cm.NewChatMember) + var uid int64 + if user != nil { + uid = user.ID + } + // Log every chat_member update the bot receives: this is the one place to see + // whether Telegram is delivering joins at all, for which chat, and the transition. + t.log.Info("chat_member update", + zap.Int64("chat_id", cm.Chat.ID), + zap.Int64("configured_chat_id", t.chatID), + zap.Int64("user_id", uid), + zap.String("old_status", string(cm.OldChatMember.Type)), + zap.String("new_status", string(cm.NewChatMember.Type))) + if t.chatID == 0 || cm.Chat.ID != t.chatID { return } @@ -233,24 +276,27 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated if cm.NewChatMember.Type != models.ChatMemberTypeMember || cm.OldChatMember.Type == models.ChatMemberTypeMember { return } - user := chatMemberUser(cm.NewChatMember) if user == nil || user.IsBot { return } if t.eligibility == nil { - return // resolver not wired: leave muted (fail closed) + t.log.Warn("chat join: eligibility resolver not wired", zap.Int64("user_id", uid)) + return } eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10)) if err != nil { t.log.Warn("chat join eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) return } + t.log.Info("chat join evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible)) if !eligible { return // not registered or blocked: leave muted } if err := t.setChatWrite(ctx, user.ID, true); err != nil { t.log.Warn("grant chat write failed", zap.Int64("user_id", user.ID), zap.Error(err)) + return } + t.log.Info("chat write granted", zap.Int64("user_id", user.ID)) } // ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted @@ -272,8 +318,10 @@ func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool if err := t.setChatWrite(ctx, userID, allow); err != nil { return false, err } + t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow)) return true, nil default: + t.log.Info("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type))) return false, nil // absent, or an admin/owner who cannot be restricted } } -- 2.52.0 From 380f82438ce0cda1fd2fd32a7f1106f276e9afe2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 16:12:58 +0200 Subject: [PATCH 211/223] fix(telegram): grant write to restricted members in default-deny chats A default-deny discussion group reports a present or freshly joined member as `restricted` (no send right), not `member`. The join filter required `member`, so the real case never matched and a registered user stayed muted. Grant any eligible in-chat member (member or restricted) that still lacks the send right, with a loop guard (skip when send is already allowed) so the bot's own grant does not re-fire. Revoking a now-ineligible user stays the chat-gate path's job, so this never fights a chat_muted/block. --- PRERELEASE.md | 10 +++ platform/telegram/internal/bot/bot.go | 27 ++++-- platform/telegram/internal/bot/chat_test.go | 99 +++++++++++++++------ 3 files changed, 100 insertions(+), 36 deletions(-) diff --git a/PRERELEASE.md b/PRERELEASE.md index db9dcba..e7f3b80 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -611,3 +611,13 @@ Then Stage 18. the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot `chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config parsing. + - **Post-contour-test fixes (same PR):** a live test surfaced gaps. (1) **Join detection** — a + default-deny discussion group reports a present member as `restricted`, not `member`, so the grant + trigger now fires for any eligible in-chat member (`member` or `restricted`) still lacking the send + right, with a loop guard (skip when send is already allowed, so the bot's own grant does not + re-fire); revoking a now-ineligible user stays the chat-gate path's job, so this never fights a + `chat_muted`/block. (2) **Join-before-register** — a user who joins before registering is covered by + no `chat_member` event, so `ProvisionTelegram` now reports first contact and the Telegram auth + handler emits `chat_access_changed` on it. (3) **Observability** — a startup self-check logs whether + the bot is an admin-with-restrict in the chat (it caught a misconfigured `TELEGRAM_CHAT_ID` set to a + channel id instead of the discussion-group id), plus per-event grant-path logging. diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index 6cb3b45..a9a7b0b 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -272,23 +272,36 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated if t.chatID == 0 || cm.Chat.ID != t.chatID { return } - // Only a transition into plain membership is a join to evaluate. - if cm.NewChatMember.Type != models.ChatMemberTypeMember || cm.OldChatMember.Type == models.ChatMemberTypeMember { - return - } if user == nil || user.IsBot { return } + // (Re)grant write to an eligible user who is in the chat but cannot yet send: a join + // into the default-deny chat (where a joined member appears `restricted`, sometimes + // `member`) or a restricted member still lacking send rights. A user who can already + // send is skipped — which also breaks the feedback loop from the bot's own grant, + // since restricting re-fires a chat_member event. Revoking a now-ineligible user is + // the chat-gate path's job (an admin block or chat_muted change), not this one. + switch cm.NewChatMember.Type { + case models.ChatMemberTypeMember: + // A plain member follows the chat default; in the gated (default-deny) chat that + // denies sending, so (re)grant. + case models.ChatMemberTypeRestricted: + if cm.NewChatMember.Restricted.CanSendMessages { + return // already allowed to send + } + default: + return // left / kicked / administrator / owner — nothing to grant here + } if t.eligibility == nil { - t.log.Warn("chat join: eligibility resolver not wired", zap.Int64("user_id", uid)) + t.log.Warn("chat access: eligibility resolver not wired", zap.Int64("user_id", uid)) return } eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10)) if err != nil { - t.log.Warn("chat join eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) + t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) return } - t.log.Info("chat join evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible)) + t.log.Info("chat access evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible)) if !eligible { return // not registered or blocked: leave muted } diff --git a/platform/telegram/internal/bot/chat_test.go b/platform/telegram/internal/bot/chat_test.go index 1fefb5e..acf0330 100644 --- a/platform/telegram/internal/bot/chat_test.go +++ b/platform/telegram/internal/bot/chat_test.go @@ -62,8 +62,8 @@ func newChatBot(t *testing.T, api *chatAPI) *Bot { return b } -// chatMemberUpdate builds a status transition oldType -> member for userID in chatID. -func chatMemberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.ChatMemberUpdated { +// memberUpdate builds an oldType -> member transition for userID in chatID. +func memberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.ChatMemberUpdated { return &models.ChatMemberUpdated{ Chat: models.Chat{ID: chatID}, OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}}, @@ -71,55 +71,96 @@ func chatMemberUpdate(chatID, userID int64, oldType models.ChatMemberType) *mode } } -func TestHandleChatMemberGrantsEligibleJoin(t *testing.T) { - api := &chatAPI{} +// restrictedUpdate builds an oldType -> restricted transition for userID, the new +// member's text-send permission set to canSend. A default-deny group reports a present +// member as restricted with canSend=false. +func restrictedUpdate(chatID, userID int64, oldType models.ChatMemberType, canSend bool) *models.ChatMemberUpdated { + return &models.ChatMemberUpdated{ + Chat: models.Chat{ID: chatID}, + OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}}, + NewChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}, CanSendMessages: canSend}}, + } +} + +// leftUpdate builds a transition to left (a leave) for userID. +func leftUpdate(chatID, userID int64) *models.ChatMemberUpdated { + return &models.ChatMemberUpdated{ + Chat: models.Chat{ID: chatID}, + OldChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}}}, + NewChatMember: models.ChatMember{Type: models.ChatMemberTypeLeft, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}}, + } +} + +// eligibleBot builds a gating bot whose resolver returns (eligible, err). +func eligibleBot(t *testing.T, api *chatAPI, eligible bool, err error) *Bot { + t.Helper() b := newChatBot(t, api) - b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil }) - - b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) + b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return eligible, err }) + return b +} +func TestHandleChatMemberGrantsEligibleMemberJoin(t *testing.T) { + api := &chatAPI{} + b := eligibleBot(t, api, true, nil) + b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) if len(api.restricts) != 1 || api.restricts[0].userID != "777" || !api.restricts[0].canSend { t.Fatalf("restricts = %+v, want one grant (can_send=true) for 777", api.restricts) } } -func TestHandleChatMemberSkipsIneligibleJoin(t *testing.T) { +func TestHandleChatMemberGrantsRestrictedMember(t *testing.T) { + // A default-deny group reports a present member as restricted with no send right — + // the real-world case. Both a fresh join (left->restricted) and an already-present + // restricted member (restricted->restricted) must be granted when eligible. + for _, oldType := range []models.ChatMemberType{models.ChatMemberTypeLeft, models.ChatMemberTypeRestricted} { + t.Run(string(oldType), func(t *testing.T) { + api := &chatAPI{} + b := eligibleBot(t, api, true, nil) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, oldType, false)) + if len(api.restricts) != 1 || !api.restricts[0].canSend { + t.Fatalf("restricts = %+v, want one grant for a restricted member", api.restricts) + } + }) + } +} + +func TestHandleChatMemberSkipsAlreadyAllowed(t *testing.T) { + // The bot's own grant re-fires a chat_member event (restricted, can_send=true); it + // must not loop into another grant. api := &chatAPI{} - b := newChatBot(t, api) - b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return false, nil }) - - b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) - + b := eligibleBot(t, api, true, nil) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true)) if len(api.restricts) != 0 { - t.Fatalf("an ineligible joiner was restricted: %+v (want left muted, no call)", api.restricts) + t.Fatalf("restricts = %+v, want none for an already-allowed member (no loop)", api.restricts) + } +} + +func TestHandleChatMemberSkipsIneligible(t *testing.T) { + api := &chatAPI{} + b := eligibleBot(t, api, false, nil) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false)) + if len(api.restricts) != 0 { + t.Fatalf("an ineligible member was granted: %+v (want left muted)", api.restricts) } } func TestHandleChatMemberFailsClosedOnResolveError(t *testing.T) { api := &chatAPI{} - b := newChatBot(t, api) - b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, context.DeadlineExceeded }) - - b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) - + b := eligibleBot(t, api, true, context.DeadlineExceeded) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false)) if len(api.restricts) != 0 { t.Fatalf("a resolve error still granted write: %+v (want fail-closed)", api.restricts) } } -func TestHandleChatMemberIgnoresOtherChatAndNonJoins(t *testing.T) { +func TestHandleChatMemberIgnoresOtherChatAndLeaves(t *testing.T) { api := &chatAPI{} - b := newChatBot(t, api) - b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil }) + b := eligibleBot(t, api, true, nil) ctx := context.Background() - - // A different chat is ignored. - b.handleChatMember(ctx, chatMemberUpdate(999, 777, models.ChatMemberTypeLeft)) - // A non-join transition (already a member) is ignored. - b.handleChatMember(ctx, chatMemberUpdate(testChatID, 777, models.ChatMemberTypeMember)) - + b.handleChatMember(ctx, restrictedUpdate(999, 777, models.ChatMemberTypeLeft, false)) // foreign chat + b.handleChatMember(ctx, leftUpdate(testChatID, 777)) // a leave if len(api.restricts) != 0 { - t.Fatalf("restricts = %+v, want none for a foreign chat / non-join", api.restricts) + t.Fatalf("restricts = %+v, want none for a foreign chat / a leave", api.restricts) } } -- 2.52.0 From 0ab1719ee9ce336c74e193389260a29aa64b1efc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 16:25:57 +0200 Subject: [PATCH 212/223] fix(telegram): grant in-chat members regardless of reported can_send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CanSendMessages loop-guard skipped exactly the stuck case — a restricted member whose chat_member event reports can_send=true yet who cannot actually write. Replace it with a precise loop guard (skip only the bot's own restrict action, i.e. the update whose performer is the bot) and grant any eligible in-chat member (member or restricted) otherwise. Also log the new member's can_send, is_member and the actor id for full visibility. --- platform/telegram/internal/bot/bot.go | 50 ++++++++++++++------- platform/telegram/internal/bot/chat_test.go | 39 +++++++++++----- 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index a9a7b0b..fbb4ce1 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -54,6 +54,9 @@ type Bot struct { limiter *rate.Limiter // chatID is the moderated discussion chat (0 disables gating). chatID int64 + // botID is the bot's own Telegram user id (resolved at startup); it skips the + // chat_member updates the bot's own restrict actions generate — the grant loop guard. + botID int64 // eligibility resolves a joining user's chat write eligibility; nil leaves a // joiner muted (fail-closed) until it is wired. eligibility EligibilityResolver @@ -134,6 +137,7 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) { t.log.Warn("chat self-check: getMe failed", zap.Error(err)) return } + t.botID = me.ID m, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: me.ID}) if err != nil { t.log.Warn("chat gating self-check failed: the bot cannot read the chat — is it added and is TELEGRAM_CHAT_ID the discussion group id?", @@ -260,14 +264,19 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated if user != nil { uid = user.ID } - // Log every chat_member update the bot receives: this is the one place to see - // whether Telegram is delivering joins at all, for which chat, and the transition. + // Log every chat_member update the bot receives: the one place to see whether + // Telegram delivers joins, for which chat, the transition, who performed it, and the + // new member's send/membership state. + canSend, isMember := restrictedSendState(cm.NewChatMember) t.log.Info("chat_member update", zap.Int64("chat_id", cm.Chat.ID), zap.Int64("configured_chat_id", t.chatID), zap.Int64("user_id", uid), + zap.Int64("actor_id", cm.From.ID), zap.String("old_status", string(cm.OldChatMember.Type)), - zap.String("new_status", string(cm.NewChatMember.Type))) + zap.String("new_status", string(cm.NewChatMember.Type)), + zap.Bool("new_can_send", canSend), + zap.Bool("new_is_member", isMember)) if t.chatID == 0 || cm.Chat.ID != t.chatID { return @@ -275,22 +284,19 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated if user == nil || user.IsBot { return } - // (Re)grant write to an eligible user who is in the chat but cannot yet send: a join - // into the default-deny chat (where a joined member appears `restricted`, sometimes - // `member`) or a restricted member still lacking send rights. A user who can already - // send is skipped — which also breaks the feedback loop from the bot's own grant, - // since restricting re-fires a chat_member event. Revoking a now-ineligible user is - // the chat-gate path's job (an admin block or chat_muted change), not this one. + // Loop guard: the bot's own restrict re-fires a chat_member update whose performer is + // the bot; skip those so a grant never re-triggers itself. + if t.botID != 0 && cm.From.ID == t.botID { + return + } + // Act only on a user who is in the chat (member or restricted) — a join into the + // default-deny chat appears as `restricted`, not `member`. Left/kicked and admins + // have nothing to grant here. An eligible user is (re)granted write; revoking a + // now-ineligible user is the chat-gate path's job (an admin block or chat_muted). switch cm.NewChatMember.Type { - case models.ChatMemberTypeMember: - // A plain member follows the chat default; in the gated (default-deny) chat that - // denies sending, so (re)grant. - case models.ChatMemberTypeRestricted: - if cm.NewChatMember.Restricted.CanSendMessages { - return // already allowed to send - } + case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted: default: - return // left / kicked / administrator / owner — nothing to grant here + return } if t.eligibility == nil { t.log.Warn("chat access: eligibility resolver not wired", zap.Int64("user_id", uid)) @@ -372,6 +378,16 @@ func chatWritePerms() models.ChatPermissions { } } +// restrictedSendState returns a restricted member's text-send permission and whether +// they are currently a member of the chat; (false, false) for any non-restricted +// status (the fields exist only on the restricted variant). +func restrictedSendState(m models.ChatMember) (canSend, isMember bool) { + if m.Type == models.ChatMemberTypeRestricted && m.Restricted != nil { + return m.Restricted.CanSendMessages, m.Restricted.IsMember + } + return false, false +} + // chatMemberUser returns the user a ChatMember refers to across the union variants, // or nil for an unrecognised type. func chatMemberUser(m models.ChatMember) *models.User { diff --git a/platform/telegram/internal/bot/chat_test.go b/platform/telegram/internal/bot/chat_test.go index acf0330..4641e36 100644 --- a/platform/telegram/internal/bot/chat_test.go +++ b/platform/telegram/internal/bot/chat_test.go @@ -3,6 +3,7 @@ package bot import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -13,7 +14,10 @@ import ( "go.uber.org/zap" ) -const testChatID = 555 +const ( + testChatID = 555 + botSelfID = 111111 // the bot's own id in tests (for the loop guard) +) // chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a // scripted getChatMember status, and records restrictChatMember calls. @@ -59,6 +63,7 @@ func newChatBot(t *testing.T, api *chatAPI) *Bot { if err != nil { t.Fatalf("new bot: %v", err) } + b.botID = botSelfID // normally set at startup; the chat_member updates default actor 0 != this return b } @@ -109,14 +114,22 @@ func TestHandleChatMemberGrantsEligibleMemberJoin(t *testing.T) { } func TestHandleChatMemberGrantsRestrictedMember(t *testing.T) { - // A default-deny group reports a present member as restricted with no send right — - // the real-world case. Both a fresh join (left->restricted) and an already-present - // restricted member (restricted->restricted) must be granted when eligible. - for _, oldType := range []models.ChatMemberType{models.ChatMemberTypeLeft, models.ChatMemberTypeRestricted} { - t.Run(string(oldType), func(t *testing.T) { + // A default-deny group reports a present member as restricted — the real-world case. + // A fresh join (left->restricted) or an already-present restricted member + // (restricted->restricted), and regardless of the reported can_send (which can be + // misleading), an eligible member is granted. + for _, tc := range []struct { + old models.ChatMemberType + canSend bool + }{ + {models.ChatMemberTypeLeft, false}, + {models.ChatMemberTypeRestricted, false}, + {models.ChatMemberTypeRestricted, true}, + } { + t.Run(fmt.Sprintf("%s_cansend=%v", tc.old, tc.canSend), func(t *testing.T) { api := &chatAPI{} b := eligibleBot(t, api, true, nil) - b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, oldType, false)) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, tc.old, tc.canSend)) if len(api.restricts) != 1 || !api.restricts[0].canSend { t.Fatalf("restricts = %+v, want one grant for a restricted member", api.restricts) } @@ -124,14 +137,16 @@ func TestHandleChatMemberGrantsRestrictedMember(t *testing.T) { } } -func TestHandleChatMemberSkipsAlreadyAllowed(t *testing.T) { - // The bot's own grant re-fires a chat_member event (restricted, can_send=true); it - // must not loop into another grant. +func TestHandleChatMemberSkipsBotsOwnAction(t *testing.T) { + // The bot's own grant re-fires a chat_member update performed by the bot; it must be + // skipped so a grant never loops into another grant. api := &chatAPI{} b := eligibleBot(t, api, true, nil) - b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true)) + upd := restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, false) + upd.From = models.User{ID: botSelfID} + b.handleChatMember(context.Background(), upd) if len(api.restricts) != 0 { - t.Fatalf("restricts = %+v, want none for an already-allowed member (no loop)", api.restricts) + t.Fatalf("restricts = %+v, want none for the bot's own action (no loop)", api.restricts) } } -- 2.52.0 From bdd1cc7d85ecfa53c65cd20953a073566e1f8b18 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 16:50:44 +0200 Subject: [PATCH 213/223] =?UTF-8?q?fix(telegram):=20invert=20the=20chat=20?= =?UTF-8?q?gate=20=E2=80=94=20mute=20the=20ineligible=20(default-allow)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram intersects the chat default with each user's permissions, so a per-user grant can never exceed a deny-by-default group: the original default-deny + grant design could not let any user write (can_send=true was AND-ed with the denying default). Invert it — the chat allows sending by default and the bot MUTES an ineligible member (unregistered, admin-suspended, or chat_muted) and restores an eligible one it had muted, acting only when the current state differs (idempotent, no self-loop). The block/unblock/chat_muted/registration path already sets can_send to the eligibility, so it is unchanged. --- platform/telegram/internal/bot/bot.go | 48 +++++--- platform/telegram/internal/bot/chat_test.go | 118 +++++++++++--------- 2 files changed, 100 insertions(+), 66 deletions(-) diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index fbb4ce1..cb1dc85 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -254,10 +254,12 @@ func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) { t.eligibility = resolve } -// handleChatMember grants write access to a user who joins the moderated chat when -// they are registered and not blocked. A non-eligible joiner is left muted (the chat -// defaults to no-send), and a resolve failure fails closed (also left muted). Other -// status changes (leaves, restrictions, admin edits) are ignored. +// handleChatMember keeps a chat member's write access in sync with their eligibility. +// The chat allows sending by default, so the bot mutes an ineligible member (not +// registered, or admin-suspended, or chat_muted) and restores an eligible one it had +// muted; an eligible member that can already send is left untouched. It acts only when +// the current state differs from the desired one, so it is idempotent and does not +// re-act on its own change; a resolve failure makes no change. func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) { user := chatMemberUser(cm.NewChatMember) var uid int64 @@ -289,13 +291,22 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated if t.botID != 0 && cm.From.ID == t.botID { return } - // Act only on a user who is in the chat (member or restricted) — a join into the - // default-deny chat appears as `restricted`, not `member`. Left/kicked and admins - // have nothing to grant here. An eligible user is (re)granted write; revoking a - // now-ineligible user is the chat-gate path's job (an admin block or chat_muted). + // The chat allows sending by default and the bot only restricts: Telegram intersects + // the chat default with the per-user permission, so a per-user grant cannot exceed a + // deny-by-default — the gate must mute the ineligible, not grant the eligible. + // Determine whether the user is in the chat and can currently send: a plain member + // follows the permissive default; a restricted member can send only with + // CanSendMessages, and only while a member. + var inChat, currentlyCanSend bool switch cm.NewChatMember.Type { - case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted: + case models.ChatMemberTypeMember: + inChat, currentlyCanSend = true, true + case models.ChatMemberTypeRestricted: + inChat, currentlyCanSend = isMember, canSend default: + return // left / kicked / administrator / owner — not a member to gate + } + if !inChat { return } if t.eligibility == nil { @@ -307,15 +318,20 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) return } - t.log.Info("chat access evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible)) - if !eligible { - return // not registered or blocked: leave muted - } - if err := t.setChatWrite(ctx, user.ID, true); err != nil { - t.log.Warn("grant chat write failed", zap.Int64("user_id", user.ID), zap.Error(err)) + t.log.Info("chat access evaluated", + zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend)) + // Desired: an eligible user may send, an ineligible one may not. Act only when the + // current state differs — idempotent, a no-op for the common eligible member, and it + // keeps the bot from re-acting on its own change. + if eligible == currentlyCanSend { return } - t.log.Info("chat write granted", zap.Int64("user_id", user.ID)) + if err := t.setChatWrite(ctx, user.ID, eligible); err != nil { + t.log.Warn("set chat write failed", + zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible), zap.Error(err)) + return + } + t.log.Info("chat access applied", zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible)) } // ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted diff --git a/platform/telegram/internal/bot/chat_test.go b/platform/telegram/internal/bot/chat_test.go index 4641e36..6fb09af 100644 --- a/platform/telegram/internal/bot/chat_test.go +++ b/platform/telegram/internal/bot/chat_test.go @@ -3,7 +3,6 @@ package bot import ( "context" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -77,13 +76,13 @@ func memberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.C } // restrictedUpdate builds an oldType -> restricted transition for userID, the new -// member's text-send permission set to canSend. A default-deny group reports a present -// member as restricted with canSend=false. -func restrictedUpdate(chatID, userID int64, oldType models.ChatMemberType, canSend bool) *models.ChatMemberUpdated { +// member's text-send permission set to canSend and membership to isMember. A muted +// member is restricted with canSend=false; an un-muted one with canSend=true. +func restrictedUpdate(chatID, userID int64, oldType models.ChatMemberType, canSend, isMember bool) *models.ChatMemberUpdated { return &models.ChatMemberUpdated{ Chat: models.Chat{ID: chatID}, OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}}, - NewChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}, CanSendMessages: canSend}}, + NewChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}, CanSendMessages: canSend, IsMember: isMember}}, } } @@ -104,45 +103,73 @@ func eligibleBot(t *testing.T, api *chatAPI, eligible bool, err error) *Bot { return b } -func TestHandleChatMemberGrantsEligibleMemberJoin(t *testing.T) { +func TestHandleChatMemberMutesIneligibleMember(t *testing.T) { + // An unregistered/blocked member can send by the permissive default, so the bot mutes. api := &chatAPI{} - b := eligibleBot(t, api, true, nil) + b := eligibleBot(t, api, false, nil) b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) - if len(api.restricts) != 1 || api.restricts[0].userID != "777" || !api.restricts[0].canSend { - t.Fatalf("restricts = %+v, want one grant (can_send=true) for 777", api.restricts) + if len(api.restricts) != 1 || api.restricts[0].userID != "777" || api.restricts[0].canSend { + t.Fatalf("restricts = %+v, want one mute (can_send=false) for 777", api.restricts) } } -func TestHandleChatMemberGrantsRestrictedMember(t *testing.T) { - // A default-deny group reports a present member as restricted — the real-world case. - // A fresh join (left->restricted) or an already-present restricted member - // (restricted->restricted), and regardless of the reported can_send (which can be - // misleading), an eligible member is granted. - for _, tc := range []struct { - old models.ChatMemberType - canSend bool - }{ - {models.ChatMemberTypeLeft, false}, - {models.ChatMemberTypeRestricted, false}, - {models.ChatMemberTypeRestricted, true}, - } { - t.Run(fmt.Sprintf("%s_cansend=%v", tc.old, tc.canSend), func(t *testing.T) { - api := &chatAPI{} - b := eligibleBot(t, api, true, nil) - b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, tc.old, tc.canSend)) - if len(api.restricts) != 1 || !api.restricts[0].canSend { - t.Fatalf("restricts = %+v, want one grant for a restricted member", api.restricts) - } - }) +func TestHandleChatMemberLeavesEligibleMemberAlone(t *testing.T) { + // An eligible plain member already sends (the permissive default); no action needed. + api := &chatAPI{} + b := eligibleBot(t, api, true, nil) + b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) + if len(api.restricts) != 0 { + t.Fatalf("restricts = %+v, want none for an eligible member (already allowed)", api.restricts) + } +} + +func TestHandleChatMemberUnmutesEligibleRestricted(t *testing.T) { + // An eligible member the bot had muted (restricted, can_send=false) is restored. + api := &chatAPI{} + b := eligibleBot(t, api, true, nil) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, false, true)) + if len(api.restricts) != 1 || !api.restricts[0].canSend { + t.Fatalf("restricts = %+v, want one un-mute (can_send=true)", api.restricts) + } +} + +func TestHandleChatMemberLeavesEligibleAllowedRestrictedAlone(t *testing.T) { + // An eligible restricted member who can already send needs no change — the real case + // from the contour (restricted, can_send=true, eligible). + api := &chatAPI{} + b := eligibleBot(t, api, true, nil) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, true)) + if len(api.restricts) != 0 { + t.Fatalf("restricts = %+v, want none for an eligible already-allowed member", api.restricts) + } +} + +func TestHandleChatMemberMutesIneligibleRestricted(t *testing.T) { + // An ineligible member who can still send is muted. + api := &chatAPI{} + b := eligibleBot(t, api, false, nil) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, true)) + if len(api.restricts) != 1 || api.restricts[0].canSend { + t.Fatalf("restricts = %+v, want one mute (can_send=false)", api.restricts) + } +} + +func TestHandleChatMemberSkipsNonMember(t *testing.T) { + // A restricted record for a user no longer in the chat (is_member=false) is not acted on. + api := &chatAPI{} + b := eligibleBot(t, api, false, nil) + b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, false)) + if len(api.restricts) != 0 { + t.Fatalf("restricts = %+v, want none for a non-member", api.restricts) } } func TestHandleChatMemberSkipsBotsOwnAction(t *testing.T) { - // The bot's own grant re-fires a chat_member update performed by the bot; it must be - // skipped so a grant never loops into another grant. + // The bot's own restrict re-fires a chat_member update performed by the bot; skip it + // so an action never loops (the resolver here would otherwise mute). api := &chatAPI{} - b := eligibleBot(t, api, true, nil) - upd := restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, false) + b := eligibleBot(t, api, false, nil) + upd := memberUpdate(testChatID, 777, models.ChatMemberTypeLeft) upd.From = models.User{ID: botSelfID} b.handleChatMember(context.Background(), upd) if len(api.restricts) != 0 { @@ -150,30 +177,21 @@ func TestHandleChatMemberSkipsBotsOwnAction(t *testing.T) { } } -func TestHandleChatMemberSkipsIneligible(t *testing.T) { +func TestHandleChatMemberNoChangeOnResolveError(t *testing.T) { api := &chatAPI{} - b := eligibleBot(t, api, false, nil) - b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false)) + b := eligibleBot(t, api, false, context.DeadlineExceeded) + b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft)) if len(api.restricts) != 0 { - t.Fatalf("an ineligible member was granted: %+v (want left muted)", api.restricts) - } -} - -func TestHandleChatMemberFailsClosedOnResolveError(t *testing.T) { - api := &chatAPI{} - b := eligibleBot(t, api, true, context.DeadlineExceeded) - b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeLeft, false)) - if len(api.restricts) != 0 { - t.Fatalf("a resolve error still granted write: %+v (want fail-closed)", api.restricts) + t.Fatalf("a resolve error still changed access: %+v (want no change)", api.restricts) } } func TestHandleChatMemberIgnoresOtherChatAndLeaves(t *testing.T) { api := &chatAPI{} - b := eligibleBot(t, api, true, nil) + b := eligibleBot(t, api, false, nil) // ineligible — would mute if it acted ctx := context.Background() - b.handleChatMember(ctx, restrictedUpdate(999, 777, models.ChatMemberTypeLeft, false)) // foreign chat - b.handleChatMember(ctx, leftUpdate(testChatID, 777)) // a leave + b.handleChatMember(ctx, memberUpdate(999, 777, models.ChatMemberTypeLeft)) // foreign chat + b.handleChatMember(ctx, leftUpdate(testChatID, 777)) // a leave if len(api.restricts) != 0 { t.Fatalf("restricts = %+v, want none for a foreign chat / a leave", api.restricts) } -- 2.52.0 From 1ba789a1f1af872ea8deb877fd70ee7ae1a35b69 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 17:15:10 +0200 Subject: [PATCH 214/223] docs(telegram): invert chat-gate strategy in docs; tune logs; i18n text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bake the final default-allow + mute-the-ineligible strategy into docs/ARCHITECTURE.md, docs/FUNCTIONAL.md (+_ru), platform/telegram/README.md, the deploy compose comment and the PRERELEASE tracker. The live test proved a per-user grant cannot exceed a deny-by-default group (Telegram intersects the chat default with the per-user permission), so the chat allows sending by default and the bot restricts the ineligible instead of granting the eligible. - Lower the per-event chat_member trace and eligibility evaluation to Debug; keep the actual mute/unmute actions, the startup self-check and warnings at Info, so prod logs only what the bot did. - Update game.searchingForOpponent (Searching -> Waiting for opponent / Поиск -> Ждём соперника) and the quickmatch e2e assertions to match. --- PRERELEASE.md | 26 +++++++++++++++----------- deploy/docker-compose.yml | 5 +++-- docs/ARCHITECTURE.md | 18 +++++++++++------- docs/FUNCTIONAL.md | 14 +++++++------- docs/FUNCTIONAL_ru.md | 15 ++++++++------- platform/telegram/README.md | 22 +++++++++++++--------- platform/telegram/internal/bot/bot.go | 6 +++--- ui/e2e/quickmatch.spec.ts | 16 ++++++++-------- ui/src/lib/i18n/en.ts | 2 +- ui/src/lib/i18n/ru.ts | 2 +- 10 files changed, 70 insertions(+), 56 deletions(-) diff --git a/PRERELEASE.md b/PRERELEASE.md index e7f3b80..b1239e8 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -41,7 +41,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) | | AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) | -| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat — granting on join when the Telegram user is registered and neither admin-suspended nor holding a new **`chat_muted`** role, and revoking/granting on the matching moderation change for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's join-time unary `ResolveChatEligibility` over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (idempotent, so a temporary-block-expiry sweeper may over-emit). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** | +| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) @@ -611,13 +611,17 @@ Then Stage 18. the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot `chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config parsing. - - **Post-contour-test fixes (same PR):** a live test surfaced gaps. (1) **Join detection** — a - default-deny discussion group reports a present member as `restricted`, not `member`, so the grant - trigger now fires for any eligible in-chat member (`member` or `restricted`) still lacking the send - right, with a loop guard (skip when send is already allowed, so the bot's own grant does not - re-fire); revoking a now-ineligible user stays the chat-gate path's job, so this never fights a - `chat_muted`/block. (2) **Join-before-register** — a user who joins before registering is covered by - no `chat_member` event, so `ProvisionTelegram` now reports first contact and the Telegram auth - handler emits `chat_access_changed` on it. (3) **Observability** — a startup self-check logs whether - the bot is an admin-with-restrict in the chat (it caught a misconfigured `TELEGRAM_CHAT_ID` set to a - channel id instead of the discussion-group id), plus per-event grant-path logging. + - **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy + inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot + work: Telegram intersects the chat default with each user's permission, so a per-user grant never + exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write). + The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible + member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted, + acting only when the current state differs (idempotent; the bot's own change is skipped by matching + the actor id to the bot). A present member in a default-allow group can appear as `restricted` with + `is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before + registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact + and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup + self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured + `TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at + Debug, the actual mute/unmute and warnings at Info. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e18fd1f..cd17de5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -269,8 +269,9 @@ services: TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} # The moderated discussion chat (a channel's linked group) the bot gates write - # access in. Empty disables gating; the bot must be an admin there with the - # "Ban users" right and receives chat_member updates only as an admin. + # access in. Empty disables gating. The group must ALLOW sending by default — the bot + # only restricts (mutes the ineligible) — and the bot must be an admin there with the + # "Ban users" right; chat_member updates are delivered only to a chat admin. TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} # The optional standalone promo bot (its own token) answering /start with a button # into the main bot's app. Empty disables it; when set it needs the main bot's diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b27b7d5..991a19a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -997,13 +997,17 @@ sweeper for the gate — it recomputes against `now`). No operator identity is r Basic-Auth). **Moderated discussion chat.** A channel's linked discussion group is gated by the Telegram bot -(`TELEGRAM_CHAT_ID`): the group defaults to no-send, and a user may write only while they are -**registered and neither admin-suspended nor holding the chat-only `chat_muted` role** -(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates). A -single backend resolver behind `POST /api/v1/internal/chat-access` answers both directions: the -bot's join-time `ResolveChatEligibility` (over the mTLS bot-link) grants write access to an -eligible joiner, and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` -grant/revoke, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no +(`TELEGRAM_CHAT_ID`). The group **allows sending by default** and the bot only **restricts**: Telegram +intersects the chat default with each user's permission, so a per-user grant can never exceed a +deny-by-default group — the gate must mute the ineligible, not grant the eligible. A user may write +while they are **registered and neither admin-suspended nor holding the chat-only `chat_muted` role** +(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates); the bot +**mutes** an ineligible member and **un-mutes** an eligible one it had muted, leaving an already-allowed +eligible member untouched (it acts only when the current state differs, so it is idempotent and never +loops on its own change). A single backend resolver behind `POST /api/v1/internal/chat-access` answers +both directions: the bot's `ResolveChatEligibility` on a `chat_member` event (over the mTLS bot-link), +and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` grant/revoke, a first +Telegram registration, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no request fires then) — drives a `ChatGate` command the gateway pushes to the bot. The bot applies it only to a member currently in the chat (a per-user `getChatMember` probe, since bots cannot list members); the signal is idempotent and is never an in-app or out-of-app message. `chat_muted` is an diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 07a63e6..c6206c6 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -323,13 +323,13 @@ plus the reason when one was given, and the app stops all background traffic wit temporary block lifts itself when it expires; the operator can also **unblock** from the user card at any time (games already lost stay lost). -Where the bot manages a channel's **linked discussion chat**, only a **registered** player who is -**not blocked** may write there: the bot grants the right to write when such a player joins, while an -unregistered or blocked one stays muted (the promo bot points newcomers at the game so they register). -An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card — -without a full account block; an account block mutes them in the chat regardless. Muting/unmuting and -blocking/unblocking take effect for a player already in the chat; one who is not in it is unaffected -until they next join. +Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the +bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register +or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the +game to register, after which the bot restores their voice), and a registered, unblocked player simply +writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card — +without a full account block; an account block mutes them in the chat regardless. Muting and unmuting +take effect for a player already in the chat; one who is not in it is unaffected until they next join. From the user card the operator can also **top up a player's hint wallet**: an additive grant (1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** — diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index a43119a..b5058fc 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -332,13 +332,14 @@ high-rate флага. С карточки пользователя операт истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент (уже проигранные партии не возвращаются). -Там, где бот ведёт **привязанный к каналу чат-обсуждение**, писать в нём может только -**зарегистрированный** игрок, который **не заблокирован**: при входе такого игрока бот выдаёт право -писать, а незарегистрированному или заблокированному — оставляет немым (промо-бот направляет -новичков в игру, чтобы они зарегистрировались). Оператор также может **замьютить игрока только в -чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка -аккаунта всё равно мьютит его в чате. Мьют/размьют и блокировка/разблокировка срабатывают для -игрока, уже находящегося в чате; того, кого в чате нет, это не затрагивает до его следующего входа. +Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот +**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот +зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат, +бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а +зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только +в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка +аккаунта всё равно мьютит его в чате. Мьют и размьют срабатывают для игрока, уже находящегося в чате; +того, кого в чате нет, это не затрагивает до его следующего входа. С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 147f5e4..cdbee87 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -43,15 +43,19 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC code. This is **self-contained** — the bot never calls back into the game, so `/start` onboarding works even when the game is down. - **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion - group, the bot gates who may write there. The group is configured **default no-send** - (a human setting); the bot grants write access to a user who **joins** when they are - registered and neither admin-suspended nor `chat_muted`, asking the gateway over the - bot-link (`ResolveChatEligibility`). When an operator blocks/unblocks an account or - toggles its `chat_muted` role, the gateway pushes a `ChatGate` command and the bot - applies it — but only to a member currently in the chat (it probes one user with - `getChatMember`, since bots cannot list members). The bot must be an **administrator** - there with the **"Ban users"** right (the Bot API `can_restrict_members`), and it - subscribes to `chat_member` updates, which Telegram delivers only to a chat admin. + group, the bot gates who may write there. The group **allows sending by default** (a + human setting) and the bot only **restricts** — Telegram intersects the chat default with + each user's permission, so a per-user grant cannot exceed a deny-by-default group, and the + gate must mute the ineligible rather than grant the eligible. On a `chat_member` event the + bot asks the gateway (`ResolveChatEligibility`) and **mutes** a member who is not registered + or is admin-suspended or `chat_muted`, **un-mutes** an eligible one it had muted, and leaves + an already-allowed eligible member untouched (it acts only when the state differs, so it is + idempotent and skips its own change). When an operator blocks/unblocks an account, toggles + its `chat_muted` role, or a user first registers, the gateway pushes a `ChatGate` command and + the bot applies it — but only to a member currently in the chat (it probes one user with + `getChatMember`, since bots cannot list members). The bot must be an **administrator** there + with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to + `chat_member` updates, which Telegram delivers only to a chat admin. - **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also runs a **second, standalone** bot whose only job is to answer `/start` with a localized message and a button that opens the **main** bot's Mini App. The button is a **URL** to diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index cb1dc85..4b5e56d 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -270,7 +270,7 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated // Telegram delivers joins, for which chat, the transition, who performed it, and the // new member's send/membership state. canSend, isMember := restrictedSendState(cm.NewChatMember) - t.log.Info("chat_member update", + t.log.Debug("chat_member update", zap.Int64("chat_id", cm.Chat.ID), zap.Int64("configured_chat_id", t.chatID), zap.Int64("user_id", uid), @@ -318,7 +318,7 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err)) return } - t.log.Info("chat access evaluated", + t.log.Debug("chat access evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend)) // Desired: an eligible user may send, an ineligible one may not. Act only when the // current state differs — idempotent, a no-op for the common eligible member, and it @@ -356,7 +356,7 @@ func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow)) return true, nil default: - t.log.Info("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type))) + t.log.Debug("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type))) return false, nil // absent, or an admin/owner who cannot be restricted } } diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 3561745..36c27f1 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -19,7 +19,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async // Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the // history panel) is disabled. - await expect(page.getByText(/Searching for opponent/)).toBeVisible(); + await expect(page.getByText(/Waiting for opponent/)).toBeVisible(); await page.locator('.scoreboard').click(); // open the history panel await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled(); @@ -28,7 +28,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async // The opponent card shows its name, the placeholder is gone, and resign is enabled again. await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled(); }); @@ -48,7 +48,7 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a // The player lands in an active game at once (no "searching" wait); the opponent shows as 🤖. await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); // An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary // alone, which stays usable. @@ -96,7 +96,7 @@ async function enterOpenGame(page: import('@playwright/test').Page): Promise { @@ -106,7 +106,7 @@ test('quick game: a poll recovers a join missed while the live stream is down', await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop()); await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); }); test('quick game: a stream reconnect recovers a join missed while it was down', async ({ page }) => { @@ -117,7 +117,7 @@ test('quick game: a stream reconnect recovers a join missed while it was down', await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); await page.evaluate(() => (window as unknown as { __stream: { restore(): void } }).__stream.restore()); await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); }); test('quick game: a foreground resync recovers a join shed while the stream stayed alive', async ({ page }) => { @@ -125,11 +125,11 @@ test('quick game: a foreground resync recovers a join shed while the stream stay // The opponent joins but the event is shed with the stream still alive (no reconnect, and the // poll only runs while the stream is down): nothing has recovered yet. await page.evaluate(() => (window as unknown as { __mock: { joinOpponentSilently(): void } }).__mock.joinOpponentSilently()); - await expect(page.getByText(/Searching for opponent/)).toBeVisible(); + await expect(page.getByText(/Waiting for opponent/)).toBeVisible(); // Returning to the foreground resyncs the open game (pageshow drives goForeground). await page.evaluate(() => window.dispatchEvent(new Event('pageshow'))); await expect(page.getByText('Robo')).toBeVisible(); - await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); + await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0); }); // Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2e957fb..132915d 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -63,7 +63,7 @@ export const en = { 'game.yourTurn': 'Your turn', 'game.yourTurnBy': '{name}: Your turn!', 'game.opponentsTurn': "Opponent's turn", - 'game.searchingForOpponent': 'Searching for opponent…', + 'game.searchingForOpponent': 'Waiting for opponent…', 'game.waiting': "Waiting for {name}", 'game.makeMove': 'Make move', 'game.reset': 'Reset', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 53a4082..a94aaac 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -64,7 +64,7 @@ export const ru: Record = { 'game.yourTurn': 'Ваш ход', 'game.yourTurnBy': '{name}: Ваш ход!', 'game.opponentsTurn': 'Ход соперника', - 'game.searchingForOpponent': 'Поиск соперника...', + 'game.searchingForOpponent': 'Ждём соперника…', 'game.waiting': 'Ожидаем {name}', 'game.makeMove': 'Сделать ход', 'game.reset': 'Сброс', -- 2.52.0 From c494da553ac9ce2ed98c51af82d19d72ee5a4376 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 17:28:01 +0200 Subject: [PATCH 215/223] fix(telegram): reply to /start only in private chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main bot is now an admin in the moderated discussion group and receives its messages (allowed_updates includes message). Its default handler replied to every message with a Mini App launch button — an inline web_app button, which Telegram permits only in private chats — so replying in the group failed with BUTTON_TYPE_INVALID (silently: the send fails, no user-facing error). Reply only in a private chat; in the group the bot only manages permissions. The promo bot gets the same guard. --- platform/telegram/internal/bot/bot.go | 7 ++++++ platform/telegram/internal/bot/bot_test.go | 24 +++++++++++++++++++ .../telegram/internal/promobot/promobot.go | 5 ++++ .../internal/promobot/promobot_test.go | 18 +++++++++++++- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index 4b5e56d..6cde9d4 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -191,6 +191,13 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up if update.Message == nil { return } + // Reply only in a private chat: the Mini App launch button is an inline web_app + // button, which Telegram permits only in private chats — replying to a group message + // (the bot is an admin in the moderated chat and now receives its messages) fails with + // BUTTON_TYPE_INVALID. In the group the bot only manages permissions, it never chats. + if update.Message.Chat.Type != models.ChatTypePrivate { + return + } startParam := startPayload(update.Message.Text) if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: update.Message.Chat.ID, diff --git a/platform/telegram/internal/bot/bot_test.go b/platform/telegram/internal/bot/bot_test.go index 57b6521..15e883a 100644 --- a/platform/telegram/internal/bot/bot_test.go +++ b/platform/telegram/internal/bot/bot_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/go-telegram/bot/models" "go.uber.org/zap" ) @@ -103,6 +104,29 @@ func TestTestEnvironmentRoutesGetMe(t *testing.T) { } } +func TestHandleStartRepliesPrivateOnly(t *testing.T) { + t.Run("private replies", func(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/start g7", + }}) + if api.chatID != "42" || !strings.Contains(api.replyMarkup, "web_app") { + t.Errorf("private /start: chat=%q markup=%q, want a web_app reply", api.chatID, api.replyMarkup) + } + }) + t.Run("group ignored", func(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/start", + }}) + if api.chatID != "" { + t.Errorf("group /start got a reply (chat=%q); an inline web_app button is invalid in groups", api.chatID) + } + }) +} + func TestStartPayload(t *testing.T) { cases := map[string]string{ "/start g123": "g123", diff --git a/platform/telegram/internal/promobot/promobot.go b/platform/telegram/internal/promobot/promobot.go index f4911ff..4fe20a6 100644 --- a/platform/telegram/internal/promobot/promobot.go +++ b/platform/telegram/internal/promobot/promobot.go @@ -92,6 +92,11 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up if update.Message == nil { return } + // Only respond to a private /start: the promo bot is a one-on-one onboarding entry + // point and should never reply to group messages. + if update.Message.Chat.Type != models.ChatTypePrivate { + return + } if err := t.throttle(ctx); err != nil { return } diff --git a/platform/telegram/internal/promobot/promobot_test.go b/platform/telegram/internal/promobot/promobot_test.go index 459d650..4786ed9 100644 --- a/platform/telegram/internal/promobot/promobot_test.go +++ b/platform/telegram/internal/promobot/promobot_test.go @@ -85,7 +85,7 @@ func TestHandleStartReplies(t *testing.T) { } b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ - Chat: models.Chat{ID: 42}, + Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, From: &models.User{LanguageCode: "ru"}, Text: "/start f99", }}) @@ -103,3 +103,19 @@ func TestHandleStartReplies(t *testing.T) { t.Errorf("reply_markup = %q, want startapp=f99 (the /start payload forwarded)", api.replyMarkup) } } + +func TestHandleStartIgnoresGroup(t *testing.T) { + api := &fakeAPI{} + srv := httptest.NewServer(api) + t.Cleanup(srv.Close) + b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://t.me/b/a"}, zap.NewNop()) + if err != nil { + t.Fatalf("new: %v", err) + } + b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ + Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/start", + }}) + if api.chatID != "" { + t.Errorf("replied to a group message (chat=%q); want none", api.chatID) + } +} -- 2.52.0 From e2771826fd7c15464aaf58970cc46748716d46e6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 19:55:57 +0200 Subject: [PATCH 216/223] perf(gateway): pool backend conns; loadtest evaluate hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loadtest harness never modelled game.evaluate — the debounced per-tile play preview a real client fires several times per turn, the hottest gameplay call. Model it (one evaluate per placed tile + reconsideration re-previews + draft.save, human-paced; --eval / --eval-recon toggle it). That realistic load surfaced the real bottleneck: the gateway's backend HTTP client used the default transport (MaxIdleConnsPerHost=2), so every sync call to the single backend host churned a fresh TCP connection — ~26500 TIME_WAIT sockets at 500 players (near the ephemeral-port ceiling), burning ~1.75 gateway cores while the backend sat near-idle. It was the unfixed root of the residual transport_error the earlier passes chased on the client side. Widen the keep-alive pool (backendMaxIdleConns=512, ~2x the observed 225-conn peak). At 500 players the churn collapses to ~0 and peak gateway CPU drops ~7x (~1.75 -> ~0.26 cores); postgres (~1.65 cores) becomes the busiest service. This overturns the earlier "gateway is the binding constraint, scale it horizontally" sizing — that was sizing around this bug, not a real floor. Consolidate the loadtest trip reports into one loadtest/REPORT.md (drop the R2/R7 split) and bake the finding into README / PRERELEASE / ARCHITECTURE / TESTING. --- PRERELEASE.md | 11 +- docs/ARCHITECTURE.md | 6 +- docs/TESTING.md | 6 +- gateway/internal/backendclient/client.go | 20 +- .../backendclient/client_internal_test.go | 31 +++ loadtest/README.md | 20 +- loadtest/REPORT-R2.md | 162 ------------- loadtest/REPORT-R7.md | 212 ------------------ loadtest/REPORT.md | 184 +++++++++++++++ loadtest/cmd/loadtest/main.go | 3 + loadtest/internal/edge/client.go | 1 + loadtest/internal/edge/encode.go | 27 +++ loadtest/internal/edge/ops.go | 9 + loadtest/internal/scenario/scenario.go | 82 ++++++- 14 files changed, 378 insertions(+), 396 deletions(-) create mode 100644 gateway/internal/backendclient/client_internal_test.go delete mode 100644 loadtest/REPORT-R2.md delete mode 100644 loadtest/REPORT-R7.md create mode 100644 loadtest/REPORT.md diff --git a/PRERELEASE.md b/PRERELEASE.md index b1239e8..6c1d91b 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -311,7 +311,7 @@ Then Stage 18. hammer (99.97 % rejected, p99 2 ms). **Top finding:** ~14 % `transport_error` on `game.state` at 500 players, under CPU saturation (backend/gateway/Postgres each ~1 core) and amplified by the harness's single shared `http2.Transport`; the harness itself peaked at 86 % of a core on the same host, so the - figures are pessimistic. Full trip report in [`../loadtest/REPORT-R2.md`](../loadtest/REPORT-R2.md); + figures are pessimistic. Full trip report in [`../loadtest/REPORT.md`](../loadtest/REPORT.md); it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports, separate hardware, pool/limit sizing). - **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps. @@ -454,7 +454,12 @@ Then Stage 18. one connection per player it bursts into its 2-core cap (the residual 2.49 % `transport_error`); backend ~0.85 core and postgres ~1.4 cores had headroom; **tempo reached its 1 GiB cap**; the backend pool sat at its `MaxOpenConns=25` cap (28 backends); docker logs were unbounded (~14 MiB / 30 min on the backend at - info). Full write-up in [`../loadtest/REPORT-R7.md`](../loadtest/REPORT-R7.md). + info). Full write-up in [`../loadtest/REPORT.md`](../loadtest/REPORT.md). *(Superseded in part: a + later pass modelling the `game.evaluate` hot path traced the gateway's CPU appetite to + **gateway→backend connection churn** — the default 2-idle-connection HTTP transport — not proxying + work. Pooling the connections cut peak gateway CPU ~7× (~1.75 → ~0.26 cores at 500 players) and + removed the ephemeral-port-exhaustion cliff behind the residual `transport_error`, so the gateway is + no longer the binding constraint — postgres is. The 3-core gateway cap below is now generous headroom.)* - **Round-2 tuning (owner-agreed, all in `deploy/docker-compose.yml`, no code change):** gateway **2 → 3 cores + `GOMAXPROCS=3`**; tempo memory **1 → 2 GiB**; backend `MAX_OPEN_CONNS` **25 → 40**; a json-file **log-rotation** default (10m × 3) applied contour-wide via a YAML anchor (level stays info). @@ -464,7 +469,7 @@ Then Stage 18. **burst** run (a single 100 → 500 jump) pegged the gateway at 3 cores (≈296 % sustained, 9.27 % error), confirming it is **connection-CPU-bound** — a true arrival spike is a **horizontal-scaling** lever, not more cores per node (recorded in the prod-sizing recommendation). - - **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT-R7.md` (new), `loadtest/README.md`, + - **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT.md`, `loadtest/README.md`, `docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`. - **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 991a19a..a10d3f9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -128,7 +128,11 @@ dropped). Horizontal scaling is explicit future work. and GCG are unaffected** (they stay decoded concrete characters, §9.1). - **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects `X-User-ID` for authenticated requests; `backend` never re-derives identity - from the body. + from the body. Because every sync call targets the one backend host, the + gateway's REST client widens its keep-alive pool well past the stdlib default + of 2 idle connections per host; otherwise the per-request connection churn + exhausts ephemeral ports and burns gateway CPU under load (see + [`../loadtest/REPORT.md`](../loadtest/REPORT.md)). - **backend → gateway (live)**: a single gRPC server-stream carries live events (your-turn, opponent-moved, chat, nudge). The gateway bridges them to the client's in-app stream while the app is open. Out-of-app delivery uses diff --git a/docs/TESTING.md b/docs/TESTING.md index 4429c89..b0ffc7a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -133,9 +133,9 @@ tests or touching CI. engine tests do). It is **not** part of the per-PR suite's behavioural assertions: it runs ad hoc as a one-shot container against the contour, producing a trip report (bugs + a per-container resource profile) read off the **otelcol `docker_stats` + - postgres_exporter** Grafana dashboard on the contour. Two passes are recorded — the - early [`REPORT-R2.md`](../loadtest/REPORT-R2.md) and the final, tuned - [`REPORT-R7.md`](../loadtest/REPORT-R7.md). See [`../loadtest/README.md`](../loadtest/README.md). + postgres_exporter** Grafana dashboard on the contour. The findings — including the + `game.evaluate` hot-path model and the gateway→backend connection-pool fix — are written + up in [`REPORT.md`](../loadtest/REPORT.md). See [`../loadtest/README.md`](../loadtest/README.md). - **User feedback** — `internal/feedback` unit tests cover the attachment allow-list / content-type and the channel normaliser; the UI covers `detectChannel`, the attachment gate and the feedback wire round-trip (`channel` / `feedback` / `codec` tests) plus a Playwright e2e diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index 527e71a..50ccbb7 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -22,6 +22,19 @@ import ( pushv1 "scrabble/pkg/proto/push/v1" ) +// backendMaxIdleConns sizes the REST keep-alive pool to the single backend host. The +// default transport caps idle connections per host at 2 (http.DefaultMaxIdleConnsPerHost), +// which — since every synchronous client call proxies to that one host — forces a fresh +// TCP connection (and a lingering TIME_WAIT socket) for almost every request under load. +// That connection churn burns gateway CPU and exhausts ephemeral ports at scale, all +// while the backend itself sits near-idle. Pooling the connections lets them be reused. +// +// The stress harness measured the effect at 500 concurrent players: the churn collapsed +// from ~26 500 TIME_WAIT sockets to ~0 and peak gateway CPU from ~1.75 to ~0.26 cores, +// with the pool settling at ~225 live connections. 512 keeps ~2x headroom over that +// observed peak so a burst never re-caps the pool. See loadtest/REPORT.md. +const backendMaxIdleConns = 512 + // Client calls the backend's REST API and opens its push gRPC stream. type Client struct { baseURL string @@ -41,9 +54,14 @@ func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) { if err != nil { return nil, fmt.Errorf("backendclient: dial push %s: %w", grpcAddr, err) } + // Clone the default transport (keeping its proxy, dialer and timeouts) and widen the + // idle pool so REST calls to the backend reuse connections instead of churning them. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = backendMaxIdleConns + transport.MaxIdleConnsPerHost = backendMaxIdleConns return &Client{ baseURL: strings.TrimRight(httpURL, "/"), - http: &http.Client{Timeout: timeout}, + http: &http.Client{Timeout: timeout, Transport: transport}, conn: conn, push: pushv1.NewPushClient(conn), }, nil diff --git a/gateway/internal/backendclient/client_internal_test.go b/gateway/internal/backendclient/client_internal_test.go new file mode 100644 index 0000000..244d2a0 --- /dev/null +++ b/gateway/internal/backendclient/client_internal_test.go @@ -0,0 +1,31 @@ +package backendclient + +import ( + "net/http" + "testing" + "time" +) + +// TestBackendTransportPoolsConnections guards the fix for the gateway->backend +// connection churn. Every synchronous client call proxies to the single backend host, +// so the REST client must widen the idle-connection pool past the default per-host cap +// of 2 (http.DefaultMaxIdleConnsPerHost) — otherwise almost every request under load +// opens a fresh TCP connection that then lingers in TIME_WAIT, burning gateway CPU and +// exhausting ephemeral ports. Reverting to the default transport (`&http.Client{...}` +// with no Transport) would silently reintroduce that, so assert the pool is widened. +func TestBackendTransportPoolsConnections(t *testing.T) { + c, err := New("http://backend.invalid", "localhost:9090", time.Second) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { _ = c.Close() }() + + tr, ok := c.http.Transport.(*http.Transport) + if !ok { + t.Fatalf("REST transport = %T, want a *http.Transport with a widened idle pool", c.http.Transport) + } + if tr.MaxIdleConnsPerHost <= http.DefaultMaxIdleConnsPerHost { + t.Errorf("MaxIdleConnsPerHost = %d, want > default %d (else per-call connection churn)", + tr.MaxIdleConnsPerHost, http.DefaultMaxIdleConnsPerHost) + } +} diff --git a/loadtest/README.md b/loadtest/README.md index 5ffb762..f0b75eb 100644 --- a/loadtest/README.md +++ b/loadtest/README.md @@ -15,10 +15,12 @@ and prints a trip-report summary. It stays in the repo for repeats. 2. **Drive** (edge protocol over h2c): assembles real 2–4 player games via the invitation flow (`invitation.create` → `invitation.accept`, no robots), then runs each player's turn loop — poll `game.state`, replay `game.history`, generate a legal - **mid-ranked** move with the embedded `scrabble-solver`, and `game.submit_play` - (or pass/exchange). A fraction of turns exercise nudge / chat / check-word / draft / - profile-update / stats. Each player also holds a live `Subscribe` stream. The - moderate ramp is **50 → 200 → 500** concurrent players, ~12 min per step. + **mid-ranked** move with the embedded `scrabble-solver`, **compose it tile by tile with + the debounced `game.evaluate` preview a real client fires** (the hottest gameplay call), + persist a `draft.save`, and `game.submit_play` (or pass/exchange). A fraction of turns + exercise nudge / chat / check-word / draft / profile-update / stats. Each player also + holds a live `Subscribe` stream. The moderate ramp is **50 → 200 → 500** concurrent + players, ~12 min per step. `--eval=false` drops the evaluate model for an A/B baseline. 3. **Hammer**: drives `games.list` from one account far above the per-user rate limit to verify the limiter holds (`rate_limited` results) and measure its cost. 4. **Report**: per-operation latency percentiles, throughput, result-code breakdown, @@ -72,6 +74,8 @@ Key `run` flags (env in parentheses): | `--games-per-player` | `0` (random 3–5) | target concurrent games per player | | `--tick` | `800ms` | per-player op cadence (keeps a player under the per-user limit) | | `--secondary-prob` | `0.08` | chance per tick of a non-move op | +| `--eval` | `true` | model the per-tile `game.evaluate` preview (the gameplay hot path); `false` reproduces the pre-evaluate harness | +| `--eval-recon` | `1` | extra full-composition evaluate re-previews per play (reconsideration), beyond one per placed tile | | `--hammer-workers` / `--hammer-dur` | `20` / `15s` | gateway-hammer (0 workers disables) | | `--reset` / `--cleanup` | `false` | delete harness rows before / after the run | @@ -93,11 +97,11 @@ runs unconditionally. Use an **absolute** path (here via `$PWD`): `go test ./loa runs each package from its own directory, so a relative `BACKEND_DICT_DIR` would not resolve. -## Trip reports +## Trip report -The two stress passes are written up in the repo: the early pass in -[`REPORT-R2.md`](REPORT-R2.md) and the final, tuned pass in -[`REPORT-R7.md`](REPORT-R7.md). +The stress findings — the final run, the `game.evaluate` hot-path model, the +gateway→backend connection-pool fix, and the revised sizing — are written up in +[`REPORT.md`](REPORT.md). ## Caveat diff --git a/loadtest/REPORT-R2.md b/loadtest/REPORT-R2.md deleted file mode 100644 index a119b34..0000000 --- a/loadtest/REPORT-R2.md +++ /dev/null @@ -1,162 +0,0 @@ -# R2 — early stress-run trip report - -The early stress pass for `PRERELEASE.md` R2. It exercises the system through the -**edge protocol** with the `scrabble/loadtest` harness, to surface logic/concurrency -bugs and capture a resource baseline that feeds R3 (edge hardening), R6 (refactor) and -R7 (final tuning). Pass bar: **diagnostic** — the run "passes" by completing without the -harness crashing; findings are recorded below, not gated. - -## Method - -- **Driver:** the `scrabble/loadtest` module, run as a one-shot container on the - `scrabble-internal` docker network (reaching `postgres:5432` and `gateway:8081` - directly, bypassing the host→gateway hairpin). -- **Seed:** 10 000 durable + 1 000 guest accounts with pre-created sessions written - directly to Postgres (token hash matches `backend/internal/session`), so the driver - authenticates without the per-IP-limited auth ops. -- **Games:** assembled through the real **invitation** flow (`invitation.create` → - `invitation.accept`), 2–4 players each, no robots; variants spread over - scrabble_en / scrabble_ru / erudit_ru. -- **Play:** each virtual player holds a live `Subscribe` stream and, per tick, polls - `game.state`, replays `game.history` and submits a **mid-ranked** legal move generated - locally by the embedded `scrabble-solver` (the edge carries no board), or - passes/exchanges; a fraction exercise nudge / chat / check-word / draft / profile / - stats. A separate **gateway-hammer** floods `games.list` from one account. -- **Scale:** moderate ramp **50 → 200 → 500** concurrent players, 10 min/step (the - agreed moderate profile; harness and contour share this host's CPU). -- **Resource capture:** `docker stats` (docker API) sampled every 28 s for per-container - CPU/memory; Prometheus for edge latency/throughput, `postgres_exporter` internals and - per-service Go runtime metrics. - -## Run configuration - -``` -loadtest run --durable 10000 --guest 1000 --steps 50,200,500 --step-dur 10m \ - --tick 800ms --hammer-workers 20 --hammer-dur 15s --cleanup -``` - -Date: 2026-06-09. Contour: the R1-baseline schema, freshly deployed with the R2 -exporters. Seeded population removed by `--cleanup` afterwards. - -## Findings - -### Validated (fixed within R2) -- **Harness draft payload.** `draft.save` first returned `bad_request`: the backend - draft DTO's `rack_order` is a string (the harness sent `[]`). Fixed → `ok`. -- **Harness profile marker.** `profile.update` first returned `invalid_profile`: the - editable-display-name validator (`backend/internal/account/profile.go`) forbids digits - and colons, but the seed marker was `lt:…`. Switched the marker to a distinctive - letters-only string → `ok`. Cleanup still matches it. - -### By-design behaviour (correctly exercised, not bugs) -- **`chat_not_your_turn`** — chat is gated to the sender's turn - (`backend/internal/social/chat.go`); off-turn posts are correctly rejected. -- **`nudge_own_turn`** — you nudge the player whose turn it is, so a nudge on your own - turn is correctly rejected. The harness nudges/chats at random ticks, so a share of - these codes is expected. - -### Observability gap (key R7 input) -- **cAdvisor yields only the root cgroup on the contour host.** Its docker factory - registers, but per-container init fails — `failed to identify the read-write layer ID - … /rootfs/var/lib/docker/image/overlayfs/…: no such file or directory` — because this - host's `/var/lib/docker` is a **separate XFS mount** not visible under cAdvisor's - `/rootfs` bind (the existing galaxy deployment on the same host has the same - limitation). So the **Scrabble — Resources** dashboard's per-container panels are empty - here, and per-container CPU/RSS for this run was captured via `docker stats` instead. - Postgres internals (`postgres_exporter`) and per-service Go runtime metrics - (`go_*` by `service_name`) work. **Recommendation for R7:** adopt the otelcol - **`docker_stats`** receiver (already the contrib image) — it reads per-container stats - via the docker API with no cgroup dependency — and/or run the final pass on hardware - where cAdvisor resolves containers. (Decision to confirm with the owner.) - -### Run results - -The ramp ran clean to 500 players with no harness crash, no deadlock and -`stream errors: 0`; cleanup removed all 11 000 seeded accounts (and their ~941 games). - -- **Ramp:** step 1 = 50 players / 90 games, step 2 = 200 / 282, step 3 = 500 / 569. -- **Volume (30 min):** 1.20 M total edge calls, 659 req/s average. Real gameplay at - scale: **48 870 committed plays**, 52 772 `your_turn` + 159 631 `opponent_moved` - events, **2 798 games finished**. -- **Latency under load (peak, step 3):** `game.state` p50 ≈ 100 ms, p90/p99 in the - 200–500 ms buckets, max 849 ms; `game.submit_play` similar (p99 ≤ 500 ms, max 490 ms). - Lobby ops stayed fast (invitation/games.list p99 ≤ 10 ms). -- **Rate limiter holds.** The gateway-hammer sent 522 667 `games.list` from one account; - **522 486 (99.97 %) were `rate_limited`**, only 135 `ok` (the burst). Rejections are - cheap — p99 = 2 ms — and the gateway sustained ~16 k req/s of rejections during the - flood. The per-user limiter behaves as designed (R3 input: the cost is negligible). - -**Top finding — `transport_error` under saturation.** At 500 players ~14 % of -`game.state` calls (72 429 / 519 067) and a few % of the other ops returned a Connect -`transport_error` (not a domain code). It correlates with the CPU saturation below: the -backend/gateway are pinned near one core each while the host also runs the 86 %-core -harness, so the edge sheds load (resets/timeouts) at the knee. It is **amplified by a -harness artifact** — all 500 virtual players multiplex over a *single* shared -`http2.Transport`, so 500 persistent `Subscribe` streams plus Execute calls press on one -HTTP/2 connection's concurrent-stream limit; real clients each use their own connection. -**Actions:** R7 harness — give each player (or a pool) its own transport, and run on -hardware not shared with the contour; R3 — confirm the gateway's h2c -`MaxConcurrentStreams` and edge timeouts are sized for many persistent streams. - -**Minor findings:** -- `unauthenticated` on a tiny share (188 / 519 067 `game.state`, ~0.04 %) — transient - session-resolve failures under load; worth a glance in R3 but not material. -- one `internal` on `game.pass` (1 / 4 788). -- `game_finished` dominates `chat.nudge`/`chat.post` (≈ 3 900 each): the harness keeps - secondary ops on games that already ended. Harness refinement — drop finished games - from the rotation (R7). -- `nudge_own_turn` / `chat_not_your_turn` / `nudge_too_soon` are the expected turn/rate - gates, correctly exercised. - -## Resource baseline - -Per-container peak during step 3 (500 players), from `docker stats`: - -| container | peak CPU | memory | -|-----------|---------:|-------:| -| scrabble-backend | **99 %** (~1 core) | 91 MiB | -| scrabble-gateway | **93 %** | 76 MiB | -| scrabble-postgres | **90 %** | 69 MiB | -| scrabble-loadtest (harness) | **86 %** | 42 MiB | -| scrabble-otelcol | 10 % | 110 MiB | -| scrabble-tempo | 9 % | 446 MiB | -| prometheus / postgres-exporter | ~0 % | 46 / 16 MiB | - -- **The contour is CPU-bound at 500 concurrent players:** backend, gateway and Postgres - each saturate ~1 core (single-instance MVP config), so the system draws ~3 cores at - this scale; memory is modest (≤ 100 MiB per Go service). This is the sizing input for - R7 (pool sizes, GOMAXPROCS, container limits) and the prod cutover. -- **Caveat:** the harness itself peaked at **86 % of a core** on the *same host*, so the - step-3 latency and `transport_error` figures are pessimistic — the contour competed - with the generator for CPU. A clean ceiling needs separate hardware (R7). -- **Postgres:** peak 28 backend connections, ~5 581 commits/s at the peak, **100 % cache - hit ratio** (no disk reads) — the DB was comfortable; CPU, not I/O, is its limit here. -- **Goroutines:** backend 638, gateway **1 698** (it holds the 500 `Subscribe` streams + - per-request goroutines), telegram 49 — all stable, no leak across the ramp. - -## Recommendations feeding later phases -- **R3 (edge hardening):** the per-user limiter holds (99.97 % rejected, p99 2 ms) — add - the per-IP body-size cap on top. Investigate the **~14 % `transport_error` on - `game.state` at 500 players**: confirm the gateway h2c `MaxConcurrentStreams` and edge - read/write timeouts are sized for many persistent `Subscribe` streams, and glance at the - ~0.04 % transient `unauthenticated` resolves under load. -- **R6 (refactor):** no logic bug forced a code change beyond the two harness-payload - fixes; the run surfaced no deadlock or goroutine leak across the ramp. -- **R7 (final tuning + stress):** (1) fix the per-container observability gap — adopt the - otelcol `docker_stats` receiver so Grafana shows per-container CPU/RSS on the contour; - (2) refine the harness — per-player/pooled transports and dropping finished games from - the rotation — and run on hardware **not** shared with the contour; (3) size pools / - GOMAXPROCS / container limits from the CPU-bound peak (~1 core each for backend, gateway, - Postgres at 500 players). - -## Re-running - -See [`README.md`](README.md). Briefly, from the repo root: - -```sh -docker build -f loadtest/Dockerfile -t scrabble-loadtest . -docker run --rm --name scrabble-loadtest --network scrabble-internal \ - -e POSTGRES_PASSWORD=… scrabble-loadtest run # add --reset on a re-run -``` - -The harness stays in the repo for the R7 repeat. diff --git a/loadtest/REPORT-R7.md b/loadtest/REPORT-R7.md deleted file mode 100644 index 2f03999..0000000 --- a/loadtest/REPORT-R7.md +++ /dev/null @@ -1,212 +0,0 @@ -# R7 — final stress-run trip report - -The final pre-release stress pass for [`PRERELEASE.md`](../PRERELEASE.md) R7. It re-runs -the R2 harness (`scrabble/loadtest`) against the **final, refactored system** on a -freshly redeployed contour, to confirm the system holds at scale and to settle the -resource sizing (container limits, `GOMAXPROCS`, pools, rate limits, log levels) before -the Stage 18 prod cutover. Pass bar: **diagnostic + a tuning decision** — the run -"passes" by completing cleanly; the per-container resource profile drives the tuning -recorded below. Companion to the early pass, [`REPORT-R2.md`](REPORT-R2.md). - -## What changed since the R2 pass - -- **Harness — per-player transports.** Each virtual player now owns its `edge.Client` - (its own `http2.Transport` / h2c connection carrying both its `Subscribe` stream and - its `Execute` calls), instead of all players multiplexing over one shared transport. - R2 traced the ~14 % `transport_error` on `game.state` at 500 players to that single - shared connection's stream limit; per-player connections mirror real clients and - remove the artifact, so this pass measures the system, not the harness. -- **Harness — drop finished games.** `playTurn` reports a finished game and the player - drops it from its rotation, so secondary ops stop hitting `game_finished` on ended - games (the other R2 harness finding). -- **Observability — otelcol `docker_stats`.** cAdvisor (which resolves only the root - cgroup on this host — separate-XFS `/var/lib/docker`) is replaced by the otelcol - `docker_stats` receiver, reading per-container CPU/memory/network from the Docker API. - Per-container panels now populate on the contour host. (`api_version` pinned to 1.44; - the daemon's minimum is 1.40.) -- **Contour — container limits + `GOMAXPROCS`.** `deploy.resources.limits` now bound - every service; the Go services pin `GOMAXPROCS` to their CPU limit so the runtime - matches the cgroup quota. Starting values were generous over the R2 peak; this pass - validates them and settles the agreed sizing (below). - -## Method - -Unchanged from R2 except for the per-player transports and the dropped-finished-games -refinement above: - -- **Driver:** the `scrabble/loadtest` module, run as a one-shot container on the - `scrabble-internal` docker network (reaching `postgres:5432` / `gateway:8081` - directly), capped at `--cpus 3` so the contour keeps the host's spare cores. -- **Seed:** 10 000 durable + 1 000 guest accounts with pre-created sessions written - straight to Postgres (token hash matches `backend/internal/session`). -- **Games:** assembled through the real **invitation** flow, 2–4 players each, no - robots; variants over scrabble_en / scrabble_ru / erudit_ru. -- **Play:** each player holds a live `Subscribe` stream and, per tick, polls - `game.state`, replays `game.history` and submits a **mid-ranked** legal move generated - locally by the embedded `scrabble-solver`, or passes / exchanges; a fraction exercise - nudge / chat / check-word / draft / profile / stats. A separate **gateway-hammer** - floods `games.list` from one account. -- **Scale:** the same moderate ramp **50 → 200 → 500** concurrent players, 10 min/step. -- **Resource capture:** `docker stats` (docker API) sampled every ~20 s for per-container - CPU/memory; the otelcol **`docker_stats`** receiver → Prometheus → the Grafana - **Scrabble — Resources** dashboard for the same per-container series; `postgres_exporter` - internals and per-service Go runtime metrics. - -## Run configuration - -``` -docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \ - -e POSTGRES_PASSWORD=… scrabble-loadtest \ - run --durable 10000 --guest 1000 --steps 50,200,500 --step-dur 10m \ - --tick 800ms --hammer-workers 20 --hammer-dur 15s --reset --cleanup -``` - -Date: 2026-06-10. Contour: the R1-baseline schema, freshly redeployed with the R7 -container limits / `GOMAXPROCS` (backend/gateway/postgres capped at 2 cores + 512 MiB, -`GOMAXPROCS=2`) and the `docker_stats` observability. Seeded population removed by -`--cleanup` afterwards. - -## Findings - -The ramp ran clean to 500 players — no harness crash, no deadlock, `stream errors: 0` — -and cleanup removed all 11 000 seeded accounts. - -- **Volume (1827 s):** 821 680 edge calls (449.7 req/s incl. the hammer). Real gameplay - at scale: **50 916 committed plays**, 4 817 passes, 2 931 games finished; 165 755 - `opponent_moved` + 54 864 `your_turn` events. -- **The per-player transport fix worked.** `game.state` returned `transport_error` on - **3 173 / 127 403 = 2.49 %** of calls — down from R2's ~14 % on the same step. Other - ops were lower still (`game.history` 0.43 %, `game.submit_play` 0.28 %). The residual - is the gateway bursting into its 2-core cap (see the profile below), not the harness. -- **Dropping finished games worked.** `game_finished` on `chat.nudge` / `chat.post` fell - to **35 / 36** (R2: ≈ 3 900 each) — secondary ops no longer hammer ended games. -- **The limiter holds.** The gateway-hammer sent 565 152 `games.list`; **564 979 - (99.97 %) were `rate_limited`** (154 ok burst, 19 deadline), p99 = 2 ms, ~309 req/s of - rejections sustained — unchanged from R2. -- **Latency (peak):** `game.state` p50 ≈ 100 ms, p99 in the 2000 ms bucket (max 2549 ms); - `game.submit_play` p50 100 / p99 1000 ms bucket. Lobby ops stayed fast - (invitation / games.list p99 ≤ 10 ms). The p99 tail correlates with the gateway - burst-throttling, not the backend (which stayed at ~0.85 core). - -## Resource profile - -Per-container peak during step 3 (500 players), with the R7 starting limits in force -(backend/gateway/postgres capped at 2 cores / 512 MiB). Two CPU columns: `docker stats` -samples a ~1 s window (catches bursts); the otelcol `docker_stats` receiver averages over -its 30 s collection interval (smooths them) — they agree within sampling error, which -validates the new observability path. - -| container | CPU burst (1 s) | CPU sustained (30 s) | CPU cap | mem peak | mem cap | -|-----------|----------------:|---------------------:|--------:|---------:|--------:| -| scrabble-gateway | **217 %** (at cap) | ~145 % | 200 % | 167 MiB | 512 MiB | -| scrabble-postgres | 138 % | ~153 % | 200 % | 117 MiB | 512 MiB | -| scrabble-backend | 85 % | ~89 % | 200 % | 116 MiB | 512 MiB | -| scrabble-tempo | 33 % | — | (none) | **1024 MiB** (at cap) | 1024 MiB | -| scrabble-otelcol | 11 % | — | (none) | 131 MiB | 512 MiB | -| scrabble-loadtest (harness) | 157 % | — | 300 % | 369 MiB | — | - -- **The gateway is the binding constraint.** With one h2c connection per player it draws - ~1.45 cores sustained and **bursts to its 2-core cap** at 500 players, throttling - briefly — the source of the 2.49 % `transport_error`. R2 saw only ~0.93 core because - all 500 players shared one connection; the +~0.5 core is the realistic per-connection - overhead (500 separate HTTP/2 connections). This is a sizing fact, not a regression. -- **backend is over-provisioned** (~0.85 core vs a 2-core cap); **postgres** (~1.4 cores) - has headroom; both stayed ≤ 120 MiB. -- **tempo reached its 1 GiB memory cap** (R2: 446 MiB) — an OOM risk under sustained - tracing. -- **Postgres backends peaked at 28**, with the backend pool at its `MaxOpenConns=25` cap. - Cache hit stayed ~100 % (no disk reads); CPU, not I/O, is the limit. -- **docker log volume (30 min):** backend 14.2 MiB, gateway 4.6 MiB, postgres 0.04 MiB — - the backend's per-request latency line at info dominates, and json-file logs had no - rotation. - -## Tuning applied - -Agreed from the profile (all in `deploy/docker-compose.yml`; no code change — the pool -is already env-driven): - -| knob | from | to | why | -|------|------|----|-----| -| gateway CPU + `GOMAXPROCS` | 2 cores / 2 | **3 cores / 3** | it bursts into the 2-core cap at 500 players (the 2.49 % `transport_error`); 3 absorbs the bursts | -| tempo memory | 1 GiB | **2 GiB** | it reached the 1 GiB cap (OOM risk) | -| backend `MAX_OPEN_CONNS` | 25 | **40** | the pool sat at its 25-conn cap at peak; headroom trims the p99 tail | -| docker logs | unbounded | **json-file 10m × 3** | bound the ~14 MiB / 30 min backend log; level stays `info` | - -Left as-is: backend / postgres at 2 cores / 512 MiB (peak ~0.85 / ~1.4 cores — headroom -is cheap on the shared host); the per-user rate limiter and `h2cMaxConcurrentStreams=250` -(per-connection now, ~1 stream each — ample) and cache TTLs (no pressure observed). - -### Validation re-run - -Re-running the **same gradual ramp** (50 → 200 → 500) on the tuned contour confirms the -fix: - -- **`game.state` `transport_error` fell to 0.72 %** (853 / 119 051), down from 2.49 % at - 2 cores. The latency tail also improved — p99 in the 1000 ms bucket, max 1220 ms (was - the 2000 ms bucket, max 2549 ms). -- The **gateway peaked at ~2 cores** (≈196 % on the 30 s gauge) — now comfortably **under - the 3-core cap**, so it no longer throttles. backend ~1 core, postgres ~1.3 cores. -- **tempo peaked at ~1.27 GiB** — under the new 2 GiB cap (it would have OOM-ed at 1 GiB). -- Drop-finished still holds (`game_finished` on chat 41/42); the limiter still rejects - 99.97 % of the hammer at p99 2 ms; `stream errors: 0`. - -A separate **burst stress** (a single 100 → 500 jump — 400 players connecting at once) -**pegged the gateway at 3 cores** (≈296 % sustained) and pushed `game.state` -`transport_error` to 9.27 %. The gateway is **connection-CPU-bound and bursty**: average -load is ~1 core, but a mass-simultaneous connection storm saturates whatever single-node -cap it is given. Real arrivals are gradual (the canonical run), where 3 cores has -headroom; the lever for a true arrival spike is **horizontal scaling**, not more cores per -node — carried into the prod recommendation below. - -## Prod-sizing recommendation (Stage 18) - -The contour is **CPU-bound and gateway-led** at 500 concurrent players. Carry these to the -prod contour env (the same compose, `PROD_*` values): - -- **gateway: ≥ 3 cores** per ~500 concurrent players, `GOMAXPROCS` pinned to the limit — - it scales with the **connection count**, not just the request rate; beyond one node's - worth, scale the gateway **horizontally** rather than vertically. -- **backend: ~1–2 cores**, pool 40 — comfortable; the work is light per request. -- **postgres: ~2 cores / ≥ 512 MiB** — ~1.4 cores at 500 players, 100 % cache hit. -- **tempo: ≥ 2 GiB**; the Go services run under ~170 MiB (256 MiB would suffice, 512 is - safe); pin `GOMAXPROCS` to each CPU limit; keep json-file rotation. -- Memory is not the constraint anywhere; CPU is. - -### VPS / VDS sizing (single-host contour) - -The whole contour (the app + the observability stack) runs on one host via -`docker-compose`. The tiers below are grounded in the R7 profile (**≈5.5 cores / ≈2.5 GiB -RAM peak at 500 concurrent players**; ≈0.5 GiB idle) and the **measured** on-disk -footprint: prod images ≈2.4 GB; the Tempo volume **3.1 GB at 72 h** retention; Prometheus -≈1–2 GB at 15 d; the game DB 23 MiB and growing with history. CPU and disk grow; RAM has -the most slack. - -| tier | CPU | RAM | disk | handles | -|------|-----|-----|------|---------| -| **Minimum** | 2 cores | 2 GiB | 20 GiB | ~up to ~150 concurrent; lower the compose limits (gateway 1.5 / backend·postgres 1 / tempo 1 GiB) to fit the box | -| **Average** (reasonable load) | 4 cores | 4 GiB | 40 GiB | ~300–400 concurrent comfortably; the tested 500 with occasional gateway burst-throttling | -| **Maximum** (worry-free) | 8 cores | 8 GiB | 80 GiB | 500+ concurrent with full gateway burst headroom (its 3-core cap) + room to grow; the compose limits fit as-is | - -- The per-service limits in `docker-compose.yml` are tuned for the **Average/Maximum** - target (the gateway alone caps at 3 cores). On the **Minimum** tier, scale them down to - match the host or the caps over-subscribe it. -- **Disk is dominated by observability retention + DB growth.** Tempo (72 h traces) and - Prometheus (15 d metrics) are the main levers — shorten the windows (or move Tempo to - object storage) to cut disk; Postgres grows with game history, so budget for months of - it; container logs are already capped (json-file 10m × 3 ≈ 30 MiB each). -- **RAM** rarely binds: the contour peaks ≈2.5 GiB at 500 players and the sum of all - configured limits is ≈5.6 GiB, so 8 GiB never strains. -- Beyond one host's worth of players, scale the **gateway horizontally** (it is - connection-CPU-bound) rather than ordering an ever-bigger box. - -## Re-running - -See [`README.md`](README.md). Briefly, from the repo root: - -```sh -docker build -f loadtest/Dockerfile -t scrabble-loadtest . -docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \ - -e POSTGRES_PASSWORD=… scrabble-loadtest run --reset --cleanup -``` - -The harness stays in the repo for future repeats. diff --git a/loadtest/REPORT.md b/loadtest/REPORT.md new file mode 100644 index 0000000..ef6361d --- /dev/null +++ b/loadtest/REPORT.md @@ -0,0 +1,184 @@ +# loadtest — stress trip report + +The pre-release stress write-up for [`PRERELEASE.md`](../PRERELEASE.md). It drives the +`scrabble/loadtest` harness against a freshly redeployed test contour to confirm the +system holds at scale and to settle resource sizing before the prod cutover. The harness +stays in the repo for repeats; see [`README.md`](README.md) for how to run it. + +This report supersedes the earlier per-phase notes. The harness has been through three +passes: an early diagnostic, a tuning pass that sized container limits / `GOMAXPROCS`, and +this final pass — which **added the per-tile `game.evaluate` preview to the model** (the +hottest real gameplay call, previously unmodelled) and, with it, surfaced and fixed the +**gateway→backend connection-pool bottleneck** described below. The numbers here are from +that final pass. + +## What it models + +The harness seeds a large account population with pre-created sessions directly in +Postgres, then drives virtual players through the **gateway edge protocol** (h2c) in real +games assembled via the invitation flow. Each player owns its own `edge.Client` (its own +h2c connection, like a real client), holds a live `Subscribe` stream, and per tick polls +`game.state`, replays `game.history`, generates a legal **mid-ranked** move with the +embedded `scrabble-solver`, and submits it (or passes/exchanges). A fraction of ticks +exercise nudge / chat / check-word / draft / profile / stats. A separate **gateway-hammer** +floods `games.list` to verify the rate limiter. + +### The evaluate hot path (this pass) + +A real client previews every tentative play as the user arranges tiles: the UI fires a +debounced `game.evaluate` (legality + score) on each placement change while it is the +player's turn. Over a single composed word that is **several evaluate calls per turn** — +far more than the one `submit_play` — so `game.evaluate` is the single hottest gameplay +request at scale. The earlier passes did not model it at all (they submitted directly), +which understated the real load. + +This pass models it: when a player composes a play of *K* newly-placed tiles, it fires one +`evaluate` per landed tile (a growing prefix of the tiles), plus a small number of +full-composition re-previews for reconsideration, spaced by a human-paced gap (the client's +250 ms debounce), then one `draft.save`, then `submit_play`. `--eval=false` reproduces the +pre-evaluate harness for an A/B baseline; `--eval-recon` tunes the reconsideration count. + +`game.check_word` is a *different*, manual "look this word up" panel (throttled, on demand) +— not the per-tile call — and is exercised separately as a secondary op. + +## Final run (eval-on, after the connection-pool fix) + +Contour: backend / postgres capped at 2 cores / 512 MiB (`GOMAXPROCS=2`), gateway at +3 cores / 512 MiB (`GOMAXPROCS=3`), per the tuned `deploy/docker-compose.yml`. Gradual ramp +**50 → 200 → 500** concurrent players, 4 min/step, `--tick 800ms`, gateway-hammer on. The +harness ran as a one-shot container on `scrabble-internal`, capped at `--cpus 3`. The DB was +wiped before the run (`DROP SCHEMA backend CASCADE`); the seeded population was removed by +`--cleanup` afterwards. + +Per-operation results at the 500-player peak (740 s, gameplay rows; the hammer row is the +limiter probe): + +| operation | count | req/s | p50 | p99 | max | notes | +|-----------|------:|------:|----:|----:|----:|-------| +| game.evaluate | 85 721 | 115.9 | 1 ms | 200 ms | 193 ms | **the hot path** — all ok | +| game.state | 115 926 | 156.7 | 100 ms | 200 ms | 260 ms | transport_error 86 (0.07 %) | +| game.history | 22 258 | 30.1 | 5 ms | 100 ms | 195 ms | all ok | +| draft.save | 23 031 | 31.1 | 2 ms | 200 ms | 194 ms | all ok | +| game.submit_play | 21 704 | 29.3 | 1 ms | 200 ms | 274 ms | ok 3 902; not_your_turn / illegal_play are concurrent-play races (see caveat) | +| hammer:games.list | 522 756 | 706.7 | 1 ms | 2 ms | 53 ms | **99.97 % rate_limited** — limiter holds | + +- **Volume:** 802 200 total edge calls (1 084 req/s incl. the hammer; ~377 req/s of real + gameplay). `stream errors: 0`. Live events: 11 199 `opponent_moved`, 4 153 `your_turn`. +- **`game.evaluate` is now the dominant gameplay write-path call** at ~116 req/s — second + only to the `game.state` poll — and it is cheap: p50 1 ms, p99 200 ms, effectively zero + errors. The backend serves it from the in-memory live-game cache plus a single + `GetGame` read. +- **Latency stayed healthy** under the heavier evaluate load: every gameplay op p99 ≤ 200 ms. +- **The limiter holds** unchanged: 99.97 % of the hammer rejected at p99 2 ms. + +### Peak CPU (500 players) + +| container | CPU peak | cap | +|-----------|---------:|----:| +| scrabble-postgres | **165 %** (~1.65 cores) | 200 % | +| scrabble-backend | 77 % (~0.77 core) | 200 % | +| scrabble-gateway | **26 %** (~0.26 core) | 300 % | +| scrabble-loadtest (harness) | 42 % | 300 % | + +Memory stayed modest everywhere (Go services ≤ ~90 MiB). **Postgres is now the busiest +service** — it has headroom (1.65 of 2 cores) but is the scaling axis. The gateway, after +the fix below, is near-idle. + +## The headline finding: gateway→backend connection churn + +The gateway proxies every synchronous client call to the single backend host over REST. +Its backend HTTP client used the default transport, whose **`MaxIdleConnsPerHost` is 2** +(`http.DefaultMaxIdleConnsPerHost`). So the gateway kept only **2** keep-alive connections +to the backend and opened — then closed — a fresh TCP connection for almost every other +call. Measured at the gateway's network namespace: + +| | gateway→backend sockets | +|---|---| +| before (eval-on, 500 players) | **TIME_WAIT ≈ 26 500**, ESTABLISHED 2 | +| after (eval-on, 500 players) | TIME_WAIT ≈ 0 (steady state), **ESTABLISHED ≈ 225 (reused)** | + +26 500 TIME_WAIT sockets is the connection **churn**: ~440 new connections per second, +each a full TCP handshake + teardown, the socket then lingering 60 s. That count sits right +under the ~28 000 ephemeral-port ceiling — the latent cliff that produced the residual +`transport_error` the earlier passes chased on the *client* side (h2c streams) but never +eliminated, because the real cause was here, on the *backend* side. + +The fix is one custom `http.Transport` with a wide idle pool +(`gateway/internal/backendclient/client.go`, `backendMaxIdleConns`). Before / after, same +eval-on workload at 500 players: + +| metric | before fix | after fix | +|--------|-----------:|----------:| +| gateway→backend TIME_WAIT | ~26 500 | **~0** | +| gateway CPU peak | **175 %** (~1.75 cores) | **26 %** (~0.26 core) | +| game.state p99 | 500 ms | 200 ms | + +**The churn was burning ~1.5 gateway cores of pure connection setup/teardown.** Removing it +cut peak gateway CPU ~7× and erased the port-exhaustion cliff. The backend and postgres CPU +are unchanged — they do the real work; only the gateway's wasted overhead disappeared. The +pool settles at ~225 live connections at 500 players; the constant is set to 512 for ~2× +headroom. + +## Sizing — why the old "≈150 concurrent / 2-core" figure was a bug, not a floor + +The earlier tuning pass concluded the gateway was the binding constraint — "size it for +≥ 3 cores per 500 players, scale it horizontally" — and the single-host "minimum" tier +topped out near ~150 concurrent. **That was sizing around the connection-churn bug.** The +gateway drew ~1.75–3 cores not from proxying work but from churning backend connections; +the backend behind it sat near-idle the whole time. + +With the churn fixed, at **500 concurrent players** the app draws roughly: + +- **gateway ≈ 0.26 core** (was ~3) — no longer the constraint, +- **backend ≈ 0.77 core**, +- **postgres ≈ 1.65 cores** — now the busiest, with headroom, + +≈ **2.7 app cores total** (down from the ~5.5-core contour peak the tuning pass recorded, +*and* under a heavier, more realistic workload that now includes `game.evaluate`). Postgres, +not the gateway, is the scaling axis. + +Revised single-host guidance (app + co-resident observability stack on one box): + +| tier | CPU | RAM | handles | +|------|-----|-----|---------| +| **Minimum** | 2 cores | 2 GiB | comfortably the low hundreds of concurrent — the gateway no longer eats cores; postgres + the observability stack set the limit | +| **Average** | 4 cores | 4 GiB | 500 concurrent with headroom | +| **Maximum** | 8 cores | 8 GiB | 500+ with full burst headroom and room to grow | + +The gateway's compose limit can drop well below its old 3 cores; it is now connection-pool +bound, not connection-CPU bound. Memory was never the constraint. Disk is still dominated +by observability retention (Tempo, Prometheus) + DB growth — unchanged from before. + +## Next optimisation (noted, not done) + +`game.evaluate` reads `GetGame` from Postgres on **every** call (to re-check seat +membership and status) before validating against the cached live game. At ~116 evaluate +req/s on top of the `game.state` / `game.history` reads, that is the bulk of the postgres +load now. Caching the game metadata alongside the live engine game in +`backend/internal/game` would cut it, but it touches persistence/cache coherency (a higher +blast-radius change) and postgres still has headroom, so it is left as a deliberate +follow-up rather than bundled here. + +## Caveat — harness fidelity + +The harness's `not_your_turn` and `illegal_play` on `submit_play` are concurrent-play +artifacts, not system errors: it generates a move from a locally replayed board, and a +fast opponent (or a transport hiccup) can move between the state fetch and the submit, +leaving the move out of turn or illegal on the now-changed board. A real client previews +with `evaluate` and only submits a legal, in-turn play. These rejections are cheap domain +outcomes (HTTP-ok with a stable code) and do not change the request *load*, which is what +the run measures. The harness also shares the host CPU with the contour (capped with +`--cpus`); a fully isolated ceiling on separate hardware remains future work. + +## Re-running + +From the repo root: + +```sh +docker build -f loadtest/Dockerfile -t scrabble-loadtest . +docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \ + -e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" scrabble-loadtest run --reset --cleanup +``` + +`--eval=false` reproduces the pre-evaluate baseline for comparison. The authoritative hard +reset of the contour DB remains `DROP SCHEMA backend CASCADE` + a backend restart. diff --git a/loadtest/cmd/loadtest/main.go b/loadtest/cmd/loadtest/main.go index aaec899..29fb3fc 100644 --- a/loadtest/cmd/loadtest/main.go +++ b/loadtest/cmd/loadtest/main.go @@ -73,6 +73,8 @@ func cmdRun(ctx context.Context, log *slog.Logger, args []string) error { gpp := fs.Int("games-per-player", 0, "target concurrent games per player (0 => random 3..5)") tick := fs.Duration("tick", 800*time.Millisecond, "per-player operation cadence") secProb := fs.Float64("secondary-prob", 0.08, "chance per tick of a non-move operation") + eval := fs.Bool("eval", true, "model the per-tile evaluate preview (the realistic gameplay hot path); --eval=false reproduces the pre-evaluate harness for an A/B baseline") + evalRecon := fs.Int("eval-recon", 1, "extra full-composition evaluate re-previews per play (reconsideration), beyond one per placed tile") hammerWorkers := fs.Int("hammer-workers", 20, "gateway-hammer concurrent callers (0 disables)") hammerDur := fs.Duration("hammer-dur", 15*time.Second, "gateway-hammer duration") reset := fs.Bool("reset", false, "delete prior harness rows before seeding") @@ -117,6 +119,7 @@ func cmdRun(ctx context.Context, log *slog.Logger, args []string) error { cfg := scenario.RealisticConfig{ Steps: steps, StepDur: *stepDur, GamesPerPlayer: *gpp, Tick: *tick, SecondaryProb: *secProb, + Eval: *eval, EvalRecon: *evalRecon, } if err := drv.RunRealistic(ctx, pool, cfg); err != nil && !errors.Is(err, context.Canceled) { return err diff --git a/loadtest/internal/edge/client.go b/loadtest/internal/edge/client.go index 9b7d41b..7cb702d 100644 --- a/loadtest/internal/edge/client.go +++ b/loadtest/internal/edge/client.go @@ -24,6 +24,7 @@ const ( msgSubmitPlay = "game.submit_play" msgPass = "game.pass" msgExchange = "game.exchange" + msgEvaluate = "game.evaluate" msgState = "game.state" msgHistory = "game.history" msgGamesList = "games.list" diff --git a/loadtest/internal/edge/encode.go b/loadtest/internal/edge/encode.go index eeb6c3a..8075f76 100644 --- a/loadtest/internal/edge/encode.go +++ b/loadtest/internal/edge/encode.go @@ -63,6 +63,33 @@ func submitPlay(gameID string, tiles []PlayTile) []byte { return b.FinishedBytes() } +// evalReq builds an EvalRequest payload (game id plus the tentative newly-placed tiles). +// It mirrors submitPlay's shape — the backend infers the play's orientation the same way — +// so a preview previews exactly what submitting those tiles would score. +func evalReq(gameID string, tiles []PlayTile) []byte { + b := flatbuffers.NewBuilder(256) + gid := b.CreateString(gameID) + offs := make([]flatbuffers.UOffsetT, len(tiles)) + for i, t := range tiles { + fb.PlayTileStart(b) + fb.PlayTileAddRow(b, int32(t.Row)) + fb.PlayTileAddCol(b, int32(t.Col)) + fb.PlayTileAddLetter(b, t.Letter) + fb.PlayTileAddBlank(b, t.Blank) + offs[i] = fb.PlayTileEnd(b) + } + fb.EvalRequestStartTilesVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + tilesVec := b.EndVector(len(offs)) + fb.EvalRequestStart(b) + fb.EvalRequestAddGameId(b, gid) + fb.EvalRequestAddTiles(b, tilesVec) + b.Finish(fb.EvalRequestEnd(b)) + return b.FinishedBytes() +} + // exchange builds an ExchangeRequest payload swapping the listed rack tiles (alphabet // indices; 255 a blank). func exchange(gameID string, tiles []byte) []byte { diff --git a/loadtest/internal/edge/ops.go b/loadtest/internal/edge/ops.go index ab86fa1..7c782ee 100644 --- a/loadtest/internal/edge/ops.go +++ b/loadtest/internal/edge/ops.go @@ -53,6 +53,15 @@ func (c *Client) Exchange(ctx context.Context, token, gameID string, tiles []byt return decodeMoveResultGame(r.Payload), r.Code, nil } +// Evaluate previews a tentative play's legality and score without committing it. It is +// the per-tile composition call a real client fires (debounced) on every change while +// arranging a word, so it is the hottest gameplay request at scale. The harness records +// only the result code and latency; an illegal preview is a successful "ok" call. +func (c *Client) Evaluate(ctx context.Context, token, gameID string, tiles []PlayTile) (string, error) { + r, err := c.execute(ctx, token, msgEvaluate, evalReq(gameID, tiles)) + return r.Code, err +} + // Nudge prods the opponent whose turn it is. func (c *Client) Nudge(ctx context.Context, token, gameID string) (string, error) { r, err := c.execute(ctx, token, msgNudge, gameAction(gameID)) diff --git a/loadtest/internal/scenario/scenario.go b/loadtest/internal/scenario/scenario.go index 29bf611..fb4213a 100644 --- a/loadtest/internal/scenario/scenario.go +++ b/loadtest/internal/scenario/scenario.go @@ -42,19 +42,35 @@ type RealisticConfig struct { GamesPerPlayer int // target concurrent games per player; 0 => random 3..5 Tick time.Duration // per-player operation cadence (keeps a player under the per-user limit) SecondaryProb float64 // chance per tick of a non-move operation + Eval bool // model the per-tile evaluate preview (the gameplay hot path); false reproduces the pre-evaluate harness + EvalRecon int // extra full-composition evaluate re-previews per play, beyond one per placed tile } // DefaultRealistic returns the moderate ramp: 50 -> 200 -// -> 500 concurrent players, ~12 minutes per step, ~1 op/s per player. +// -> 500 concurrent players, ~12 minutes per step, ~1 op/s per player, with the +// per-tile evaluate preview modelled (the realistic hot path). func DefaultRealistic() RealisticConfig { return RealisticConfig{ Steps: []int{50, 200, 500}, StepDur: 12 * time.Minute, Tick: 800 * time.Millisecond, SecondaryProb: 0.08, + Eval: true, + EvalRecon: 1, } } +// evalGapBase and evalGapSpan bound the modelled pause between successive tile +// placements: the client's 250 ms debounce coalesces faster drags into a single +// evaluate, so a thoughtful player's previews are spaced by a gap drawn from +// [base, base+span] — wide enough that a normal composition stays under the per-user +// rate limit, the way a real one does (the limiter's cost is measured by the hammer, +// not by self-inflicted rejections here). +const ( + evalGapBase = 250 * time.Millisecond + evalGapSpan = 500 * time.Millisecond +) + // RunRealistic runs the staged ramp. Each step activates more players (drawn from the // seeded pool), assembles a cohort of games for them and starts their turn loops; the // loops run until the whole ramp ends. Players from earlier steps keep playing, so @@ -128,7 +144,7 @@ func (d *Driver) playerLoop(ctx context.Context, p seed.Account, games []*Game, d.secondaryOp(ctx, c, p, g, rng) continue } - if d.playTurn(ctx, c, p, g, rng) { + if d.playTurn(ctx, c, p, g, cfg, rng) { active = slices.DeleteFunc(active, func(x *Game) bool { return x == g }) gi = 0 if len(active) == 0 { @@ -161,10 +177,10 @@ func (d *Driver) subscribeLoop(ctx context.Context, c *edge.Client, p seed.Accou } // playTurn plays one turn in g over the player's client when it is the player's -// move: fetch state, replay history, pick a legal move and submit it (or exchange / -// pass). It reports whether the game has finished, so the caller can drop it from the -// rotation. -func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g *Game, rng *rand.Rand) (finished bool) { +// move: fetch state, replay history, pick a legal move, compose it (the per-tile +// evaluate previews a real client fires) and submit it (or exchange / pass). It reports +// whether the game has finished, so the caller can drop it from the rotation. +func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g *Game, cfg RealisticConfig, rng *rand.Rand) (finished bool) { seat := g.seatOf(p.ID.String()) if seat < 0 { return false @@ -196,6 +212,7 @@ func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g } switch action.Kind { case "play": + d.composePlay(ctx, c, p, g, action.Tiles, cfg, rng) t0 = time.Now() _, code, _ := c.SubmitPlay(ctx, p.Token, g.ID, action.Tiles) d.rec.Record("game.submit_play", code, time.Since(t0)) @@ -211,6 +228,59 @@ func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g return false } +// composePlay models a player arranging the chosen play tile by tile before committing: +// the debounced evaluate preview the real client fires on each placement (a growing prefix +// of the tiles), a few full-composition re-previews for reconsideration (recall a tile, try +// another spot), and the single draft persistence the client debounces out. evaluate is the +// hottest gameplay request at scale, so omitting it (the pre-evaluate harness) understated +// the load; cfg.Eval false reproduces that baseline for an A/B comparison. Every step +// honours ctx, so end-of-run cancellation never blocks on a sleep or an in-flight preview. +func (d *Driver) composePlay(ctx context.Context, c *edge.Client, p seed.Account, g *Game, tiles []edge.PlayTile, cfg RealisticConfig, rng *rand.Rand) { + if !cfg.Eval || len(tiles) == 0 { + return + } + // One evaluate per landed tile: the growing prefix mirrors the client re-previewing + // after each placement (an early prefix is often illegal, which is still a successful + // "ok" round trip — exactly the backend work a real composition triggers). + for n := 1; n <= len(tiles); n++ { + if !jitterSleep(ctx, rng, evalGapBase, evalGapSpan) { + return + } + t0 := time.Now() + code, _ := c.Evaluate(ctx, p.Token, g.ID, tiles[:n]) + d.rec.Record("game.evaluate", code, time.Since(t0)) + } + for r := 0; r < cfg.EvalRecon; r++ { + if !jitterSleep(ctx, rng, evalGapBase, evalGapSpan) { + return + } + t0 := time.Now() + code, _ := c.Evaluate(ctx, p.Token, g.ID, tiles) + d.rec.Record("game.evaluate", code, time.Since(t0)) + } + // The client persists the in-progress composition (debounced to one upsert). Its opaque + // JSON content does not affect the call's cost, so a minimal valid shape stands in. + t0 := time.Now() + code, _ := c.DraftSave(ctx, p.Token, g.ID, `{"rack_order":"","board_tiles":[]}`) + d.rec.Record("draft.save", code, time.Since(t0)) +} + +// jitterSleep pauses for a randomised gap in [base, base+span], modelling the human pause +// between tile placements that the client's debounce coalesces into one evaluate. It +// returns false if ctx is cancelled during the wait, so a composition unwinds promptly at +// end of run. +func jitterSleep(ctx context.Context, rng *rand.Rand, base, span time.Duration) bool { + d := base + time.Duration(rng.Int63n(int64(span)+1)) + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} + // secondaryOp exercises one of the non-move edge operations the plan calls out, so // the run touches nudge / chat / check-word / draft / profile / stats too, over the // player's own client. -- 2.52.0 From ecb21bd21861ae6853a49cacb7b962176e35046f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 20:28:24 +0200 Subject: [PATCH 217/223] perf(backend): cut evaluate's DB round-trips; load the game in one query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EvaluatePlay (the hottest gameplay call, fired on every tile placement) now uses the warm live-game cache directly: an active game stays cached (mutated in place across moves, evicted only on finish), so the cached engine game and its immutable seat list answer the membership check and the score with no DB read. The cold path (eviction / first load) still loads and validates via the store. The seat list is cached alongside the engine game for the membership fast path. GetGame also folds its two round-trips (game, then seats) into one LEFT JOIN, preserving the contract (same Game, a seatless game still returns empty seats, seat order kept) — one round-trip for every remaining caller. Measured at 500 players: evaluate p99 halves (200 -> 100 ms) and the per-op query count drops. It does NOT cut postgres CPU — that is write-bound (per-move CommitMove plus draft upserts and journal replays), the cheap indexed GetGame reads were never its bottleneck, and postgres runs with headroom (~1.5 of 2 cores). So this is a latency / query-volume optimization, not a DB-CPU one. Regression cover: a non-player evaluate against a warm game asserts the cached-seat membership path; the integration suite exercises GetGame's join across every game op. --- backend/internal/game/cache.go | 20 +++++++----- backend/internal/game/helpers_test.go | 6 ++-- backend/internal/game/service.go | 45 ++++++++++++++++----------- backend/internal/game/store.go | 40 ++++++++++++++---------- backend/internal/game/types.go | 12 +++++++ backend/internal/inttest/game_test.go | 6 ++++ loadtest/REPORT.md | 34 +++++++++++++------- 7 files changed, 105 insertions(+), 58 deletions(-) diff --git a/backend/internal/game/cache.go b/backend/internal/game/cache.go index 8ec2c2f..88d050c 100644 --- a/backend/internal/game/cache.go +++ b/backend/internal/game/cache.go @@ -63,6 +63,7 @@ type gameCache struct { type cachedGame struct { game *engine.Game + seats []Seat variant string lastAccess time.Time } @@ -71,24 +72,27 @@ func newGameCache(ttl time.Duration, now func() time.Time) *gameCache { return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now} } -// get returns the live game for id and refreshes its idle timer, or (nil, false). -func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) { +// get returns the live game and its immutable seat list for id and refreshes its idle +// timer, or (nil, nil, false). The seats let a read check membership (and label seats) +// without re-loading the game from the store, since seats never change after a game starts. +func (c *gameCache) get(id uuid.UUID) (*engine.Game, []Seat, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[id] if !ok { - return nil, false + return nil, nil, false } e.lastAccess = c.now() - return e.game, true + return e.game, e.seats, true } -// put stores g as the live game for id. variant labels the entry so the active- -// games gauge can report counts by variant without inspecting engine internals. -func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) { +// put stores g as the live game for id together with its seat list. variant labels the +// entry so the active-games gauge can report counts by variant without inspecting engine +// internals; seats are the game's immutable seat standings for the membership fast path. +func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string, seats []Seat) { c.mu.Lock() defer c.mu.Unlock() - c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()} + c.entries[id] = &cachedGame{game: g, seats: seats, variant: variant, lastAccess: c.now()} } // remove drops id from the cache (used on a finished game and after a failed diff --git a/backend/internal/game/helpers_test.go b/backend/internal/game/helpers_test.go index 80509e0..3c6d0bd 100644 --- a/backend/internal/game/helpers_test.go +++ b/backend/internal/game/helpers_test.go @@ -94,8 +94,8 @@ func TestGameCacheEviction(t *testing.T) { cur := time.Unix(1_700_000_000, 0) cache := newGameCache(time.Hour, func() time.Time { return cur }) id := uuid.New() - cache.put(id, nil, "scrabble_en") - if _, ok := cache.get(id); !ok { + cache.put(id, nil, "scrabble_en", nil) + if _, _, ok := cache.get(id); !ok { t.Fatal("game must be resident after put") } cur = cur.Add(30 * time.Minute) @@ -104,7 +104,7 @@ func TestGameCacheEviction(t *testing.T) { if n := cache.sweep(); n != 1 { t.Errorf("sweep evicted %d, want 1", n) } - if _, ok := cache.get(id); ok { + if _, _, ok := cache.get(id); ok { t.Error("game must be evicted after idle TTL") } if cache.size() != 0 { diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 1c0dbd0..6a8dffb 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -287,12 +287,12 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil { return Game{}, err } - svc.cache.put(id, g, params.Variant.String()) svc.metrics.recordStarted(ctx, params.Variant, params.VsAI) created, err := svc.store.GetGame(ctx, id) if err != nil { return Game{}, err } + svc.cache.put(id, g, params.Variant.String(), created.Seats) // Honest-AI game seated with a robot: if the robot moves first, reply at once // (the periodic driver is the fallback). No-op for every human-only game. svc.triggerAI(created) @@ -890,26 +890,35 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time. // EvaluatePlay previews a tentative play for a seated player against the current // board without committing it: whether it is legal and what it would score. func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) { - pre, err := svc.store.GetGame(ctx, gameID) - if err != nil { - return EvalResult{}, err - } - if _, ok := pre.seatOf(accountID); !ok { - return EvalResult{}, ErrNotAPlayer - } - if pre.Status == StatusFinished { - return EvalResult{}, ErrFinished - } - unlock := svc.locks.lock(gameID) defer unlock() - g, err := svc.liveGame(ctx, pre) - if err != nil { - return EvalResult{}, err + + // Hot path: an active game stays cached — the engine game is mutated in place across + // moves and evicted only when it finishes — so on a hit the cached live game and its + // immutable seat list answer the membership check and the score with no DB read. This + // preview is fired on every tile placement, the hottest gameplay call at scale. + g, seats, ok := svc.cache.get(gameID) + if !ok { + // Cold path: load and validate from the store, then replay into the cache. + pre, err := svc.store.GetGame(ctx, gameID) + if err != nil { + return EvalResult{}, err + } + if pre.Status == StatusFinished { + return EvalResult{}, ErrFinished + } + if g, err = svc.liveGame(ctx, pre); err != nil { + return EvalResult{}, err + } + seats = pre.Seats } + if !seatedIn(seats, accountID) { + return EvalResult{}, ErrNotAPlayer + } + validateStart := time.Now() rec, err := g.EvaluatePlay(tiles) - svc.metrics.recordValidate(ctx, pre.Variant, validateStart) + svc.metrics.recordValidate(ctx, g.Variant(), validateStart) if err != nil { if errors.Is(err, engine.ErrIllegalPlay) { return EvalResult{Valid: false}, nil @@ -1359,7 +1368,7 @@ func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, er // liveGame returns the live engine.Game for pre, rebuilding it from the journal // on a cache miss. Callers must hold the per-game lock. func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) { - if g, ok := svc.cache.get(pre.ID); ok { + if g, _, ok := svc.cache.get(pre.ID); ok { return g, nil } g, err := svc.replay(ctx, pre) @@ -1374,7 +1383,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error } } if !g.Over() { - svc.cache.put(pre.ID, g, pre.Variant.String()) + svc.cache.put(pre.ID, g, pre.Variant.String(), pre.Seats) } return g, nil } diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 30add2e..0e034d8 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -355,27 +355,33 @@ func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, err // GetGame loads the games row joined with its seats (ordered by seat), or // ErrNotFound. func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) { - gstmt := postgres.SELECT(table.Games.AllColumns). - FROM(table.Games). + // One round-trip: the game joined with its seats. A LEFT JOIN keeps a (would-be) + // seatless game returning the game with no seats, exactly as the prior two-query + // version did; ORDER BY seat preserves seat order. The games columns repeat per seat + // row — cheap at 2-4 seats, and one round-trip instead of two, which matters because + // GetGame is the universal "load the game" step on every game operation. + stmt := postgres.SELECT(table.Games.AllColumns, table.GamePlayers.AllColumns). + FROM(table.Games.LEFT_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))). WHERE(table.Games.GameID.EQ(postgres.UUID(id))). - LIMIT(1) - var grow model.Games - if err := gstmt.QueryContext(ctx, s.db, &grow); err != nil { - if errors.Is(err, qrm.ErrNoRows) { - return Game{}, ErrNotFound - } + ORDER_BY(table.GamePlayers.Seat.ASC()) + var rows []struct { + model.Games + model.GamePlayers + } + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return Game{}, fmt.Errorf("game: get %s: %w", id, err) } - - sstmt := postgres.SELECT(table.GamePlayers.AllColumns). - FROM(table.GamePlayers). - WHERE(table.GamePlayers.GameID.EQ(postgres.UUID(id))). - ORDER_BY(table.GamePlayers.Seat.ASC()) - var srows []model.GamePlayers - if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil { - return Game{}, fmt.Errorf("game: get seats %s: %w", id, err) + if len(rows) == 0 { + return Game{}, ErrNotFound } - return projectGame(grow, srows) + seats := make([]model.GamePlayers, 0, len(rows)) + for i := range rows { + // Skip the phantom all-NULL seat row a LEFT JOIN yields for a seatless game. + if rows[i].GamePlayers.GameID == id { + seats = append(seats, rows[i].GamePlayers) + } + } + return projectGame(rows[0].Games, seats) } // GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 75b472f..cd89df3 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -184,6 +184,18 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) { return 0, false } +// seatedIn reports whether accountID holds a seat in seats. It backs the read-side +// membership check against the cached, immutable seat list, so a hot read can skip +// loading the game from the store. +func seatedIn(seats []Seat, accountID uuid.UUID) bool { + for _, s := range seats { + if s.AccountID == accountID { + return true + } + } + return false +} + // MoveResult is the outcome of a committed transition: the decoded move and the // post-move game, plus the actor's own refilled rack and the bag size after the draw // (Rack/BagLen), so the mover renders the next state from the response without a diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 0bab0c4..9eaa017 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -543,6 +543,12 @@ func TestEvaluatePlayPreview(t *testing.T) { if bad.Valid { t.Error("disconnected play must be invalid") } + + // A non-seated account cannot preview: with the game warm in the live cache, the + // membership check runs against the cached seat list (the hot path that skips GetGame). + if _, err := svc.EvaluatePlay(ctx, g.ID, provisionAccount(t), hint.Tiles); !errors.Is(err, game.ErrNotAPlayer) { + t.Errorf("evaluate by a non-player = %v, want ErrNotAPlayer", err) + } } // TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two diff --git a/loadtest/REPORT.md b/loadtest/REPORT.md index ef6361d..5f80d34 100644 --- a/loadtest/REPORT.md +++ b/loadtest/REPORT.md @@ -64,10 +64,10 @@ limiter probe): - **Volume:** 802 200 total edge calls (1 084 req/s incl. the hammer; ~377 req/s of real gameplay). `stream errors: 0`. Live events: 11 199 `opponent_moved`, 4 153 `your_turn`. -- **`game.evaluate` is now the dominant gameplay write-path call** at ~116 req/s — second - only to the `game.state` poll — and it is cheap: p50 1 ms, p99 200 ms, effectively zero - errors. The backend serves it from the in-memory live-game cache plus a single - `GetGame` read. +- **`game.evaluate` is the dominant gameplay write-path call** at ~116 req/s — second only + to the `game.state` poll — and it is cheap: p50 1 ms, effectively zero errors. The backend + serves it straight from the in-memory live-game cache; on a warm hit it skips the database + entirely (see *Postgres read path* below, which halved its p99 to 100 ms). - **Latency stayed healthy** under the heavier evaluate load: every gameplay op p99 ≤ 200 ms. - **The limiter holds** unchanged: 99.97 % of the hammer rejected at p99 2 ms. @@ -149,15 +149,25 @@ The gateway's compose limit can drop well below its old 3 cores; it is now conne bound, not connection-CPU bound. Memory was never the constraint. Disk is still dominated by observability retention (Tempo, Prometheus) + DB growth — unchanged from before. -## Next optimisation (noted, not done) +## Postgres read path (warm-cache optimization) -`game.evaluate` reads `GetGame` from Postgres on **every** call (to re-check seat -membership and status) before validating against the cached live game. At ~116 evaluate -req/s on top of the `game.state` / `game.history` reads, that is the bulk of the postgres -load now. Caching the game metadata alongside the live engine game in -`backend/internal/game` would cut it, but it touches persistence/cache coherency (a higher -blast-radius change) and postgres still has headroom, so it is left as a deliberate -follow-up rather than bundled here. +Following this pass, `game.evaluate` no longer reads the database on the hot path. An +active game is already resident in the in-memory live-game cache (mutated in place across +moves, evicted only on finish), so the preview answers its seat-membership check from the +cached immutable seat list and scores against the cached engine game — **no `GetGame` on a +warm hit**. `GetGame` itself was also folded from two round-trips (game, then seats) into a +single `LEFT JOIN`. Measured at 500 players, **`game.evaluate` p99 halved (200 → 100 ms)** +and the per-operation query count dropped. + +It did **not** cut postgres CPU, and the measurement says why: postgres is **write-bound**, +not read-bound. `pg_stat_user_tables` puts the cost in the per-move `CommitMove` +transaction (a `game_moves` insert plus `games` / `game_players` updates), the debounced +`game_drafts` upserts (~60 k in one run), and the journal replays — not the cheap, indexed, +fully-cached `GetGame` lookups this change removed (one re-run even committed 28 % more +plays, whose extra writes masked the saved reads). Postgres also runs with headroom +(~1.5 of 2 cores), and the gateway fix freed ~3 cores on the box, so the lever if postgres +ever caps is **more cores** (it is CPU-bound, not I/O), not riskier write-path surgery. So +this change is a latency / query-volume win, deliberately not a DB-CPU one. ## Caveat — harness fidelity -- 2.52.0 From e336638ca8cf8d5a5fa7ccd8477002abf1974ff0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 21:23:27 +0200 Subject: [PATCH 218/223] fix(ui): retry Mini App launch on backend failure; hide account linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside Telegram, a failed initData authentication (e.g. the backend down during a deploy) dropped the user onto the web login screen — the /app/ experience, which has no place inside the Mini App. bootstrap now retries the launch a few times in silence and then renders a dedicated boot-error screen with a Retry button (new BootError.svelte, app.bootError), never falling back to the web sign-in. A blocked account is still terminal and goes straight to the blocked screen. The profile "Link an account" section (email + Telegram link) is hidden while sign-in is provider-only; the anonymous /app/ guest whose upgrade path this is comes later. The flow is kept wired (`hidden` on .emailbox) and its two e2e specs are skipped, both to be re-enabled together. Adds i18n boot.* copy (en/ru), a mock authTelegram failure hook plus an e2e covering the retry screen, and bakes both behaviours into FUNCTIONAL(.md/_ru). --- docs/FUNCTIONAL.md | 9 ++++- docs/FUNCTIONAL_ru.md | 9 ++++- ui/e2e/social.spec.ts | 7 +++- ui/e2e/telegram.spec.ts | 24 ++++++++++++ ui/src/App.svelte | 7 +++- ui/src/lib/app.svelte.ts | 66 +++++++++++++++++++++++++++++---- ui/src/lib/i18n/en.ts | 3 ++ ui/src/lib/i18n/ru.ts | 3 ++ ui/src/lib/mock/client.ts | 5 ++- ui/src/screens/BootError.svelte | 63 +++++++++++++++++++++++++++++++ ui/src/screens/Profile.svelte | 7 ++-- 11 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 ui/src/screens/BootError.svelte diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c6206c6..30a4875 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -31,7 +31,10 @@ ephemeral guest. The gateway validates the credential once and mints a thin session token; the backend resolves it to an internal `user_id`. A **Telegram Mini App** launch authenticates from the platform's signed `initData`, themes the UI to the Telegram colours, and — on first contact — seeds the new account's interface -language from the Telegram client. Telegram runs a **single bot**: every player uses +language from the Telegram client. If a launch cannot reach the backend (for example during a +deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a +**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram. +Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the main one — its only job is to answer `/start` with a short message and a button that opens the @@ -56,6 +59,10 @@ reconnect), and pending reads resume on their own — the interface stays usable flashing a red banner each time. ### Accounts, linking & merge +_Sign-in is currently provider-only, so the in-profile linking UI is temporarily hidden; it +returns once the anonymous `/app/` guest (whose upgrade path this is) ships. The flow below +describes it for when it does._ + First platform contact auto-provisions a durable account. From the profile a player links an email (via a confirm code) or their Telegram (via the web sign-in); a guest who links their first identity becomes a durable account. The "already taken" status diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index b5058fc..43c47a3 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -32,7 +32,10 @@ top-1 подсказку, безлимитную проверку слова с session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по -языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним +языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время +деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось +загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри +Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный **промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, @@ -57,6 +60,10 @@ Mini App** авторизует по подписанным `initData` плат рабочим вместо красного баннера каждый раз. ### Аккаунты, привязка и слияние +_Вход сейчас только через провайдера, поэтому UI привязки в профиле временно скрыт; он +вернётся, когда появится анонимный `/app/`-гость (для апгрейда которого он и нужен). Описание +ниже — на этот случай._ + Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index d07d1ec..ae01d0f 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -238,7 +238,10 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa await expect(save).toBeEnabled(); }); -test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { +// Account linking is hidden in Profile.svelte while we target provider sign-in (the anonymous +// /app/ guest who upgrades by linking comes later). The flow is kept wired; re-enable these two +// specs together with the `.emailbox` section. +test.skip('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { await loginLobby(page); await openProfile(page); @@ -258,7 +261,7 @@ test('link account: a taken email opens the irreversible merge confirmation', as await expect(page.getByText('Merge accounts?')).toBeHidden(); }); -test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => { +test.skip('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => { await loginLobby(page); await openProfile(page); await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index dbc3e00..d402033 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -83,6 +83,30 @@ test('tg-fullscreen header keeps a constant native-nav gap as the font scales', expect(large.overflows).toBe(false); }); +test('inside Telegram, a failed launch shows the retry screen, not the web login', async ({ page }) => { + // initData carrying the mock's "bootfail" sentinel makes authTelegram reject, simulating a + // backend outage during launch (e.g. a deploy rolling). The Mini App must surface its own + // boot-error/retry screen and never fall back to the web (guest/email) login. + await page.addInitScript(() => { + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=bootfail&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + ready() {}, + expand() {}, + }, + }, + }); + }); + await page.goto('/'); + + // After the silent retries, the boot-error screen with its Retry button shows… + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); + // …and the web login (guest) is never shown inside Telegram. + await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); +}); + test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => { await page.goto('/telegram/'); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index b0920dc..2368666 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -18,6 +18,7 @@ import CommsHub from './game/CommsHub.svelte'; import Feedback from './screens/Feedback.svelte'; import Blocked from './screens/Blocked.svelte'; + import BootError from './screens/BootError.svelte'; onMount(() => { void bootstrap(); @@ -83,6 +84,10 @@ {#if !routeIsLobby}
    {t('common.loading')}
    {/if} +{:else if app.bootError} + + {:else if app.blocked} {:else} @@ -123,7 +128,7 @@ -{#if routeIsLobby && !app.splashDone && !app.blocked} +{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError} {/if} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 3f1a818..f494291 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -18,6 +18,7 @@ import { telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, + type TelegramLaunch, telegramOnEvent, telegramRequestFullscreen, telegramSetChrome, @@ -41,6 +42,10 @@ export interface Toast { export const app = $state<{ ready: boolean; + /** Inside a Mini App, set when the launch failed to authenticate after its retries (e.g. the + * backend was down during a deploy). App.svelte then renders the boot-error retry screen + * instead of the web login — a Mini App has no manual sign-in to fall back to. */ + bootError: boolean; /** Whether the lobby's first cold load has settled (success or error). The loading splash * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ lobbyReady: boolean; @@ -90,6 +95,7 @@ export const app = $state<{ resync: number; }>({ ready: false, + bootError: false, lobbyReady: false, splashDone: false, streamAlive: false, @@ -563,14 +569,7 @@ export async function bootstrap(): Promise { // listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size // window. No-op on clients predating Bot API 8.0. telegramRequestFullscreen(); - try { - await adoptSession(await gateway.authTelegram(launch.initData)); - // A blocked account skips deep-link routing — the blocked screen overlays every route. - if (!app.blocked) await routeStartParam(launch.startParam); - } catch (err) { - handleError(err); - navigate('/login'); - } + await bootTelegram(launch); app.ready = true; return; } @@ -585,6 +584,57 @@ export async function bootstrap(): Promise { app.ready = true; } +// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall +// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a +// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the +// same path (retryTelegramBoot). +const TELEGRAM_BOOT_RETRIES = 2; +const TELEGRAM_BOOT_RETRY_MS = 1200; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * bootTelegram authenticates a Mini App launch from its initData and routes any deep-link start + * parameter, retrying a few times on a transient failure before raising the boot-error screen + * (app.bootError). A blocked account is terminal — it switches straight to the blocked screen + * without retrying. + */ +async function bootTelegram(launch: TelegramLaunch): Promise { + for (let attempt = 0; ; attempt++) { + try { + await adoptSession(await gateway.authTelegram(launch.initData)); + // A blocked account skips deep-link routing — the blocked screen overlays every route. + if (!app.blocked) await routeStartParam(launch.startParam); + app.bootError = false; + return; + } catch (err) { + if (err instanceof GatewayError && err.code === 'account_blocked') { + await enterBlocked(); + return; + } + if (attempt >= TELEGRAM_BOOT_RETRIES) { + app.bootError = true; + return; + } + await delay(TELEGRAM_BOOT_RETRY_MS); + } + } +} + +/** + * retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It + * clears the error and shows the loading state again, then runs the same retrying boot; on success + * the app renders normally, otherwise the boot-error screen returns. + */ +export async function retryTelegramBoot(): Promise { + app.bootError = false; + app.ready = false; + await bootTelegram(telegramLaunch()); + app.ready = true; +} + /** * routeStartParam navigates a Telegram deep-link start parameter to its target: a * specific game, the friends screen with a friend-code redemption, or the lobby diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 132915d..71e07b4 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -11,6 +11,9 @@ export const en = { 'blocked.temporary': 'Your account is blocked until {until}.', 'blocked.reason': 'Reason:', + 'boot.errorTitle': "Couldn't load the game", + 'boot.errorBody': 'Please try again in a moment.', + 'common.back': 'Back', 'common.cancel': 'Cancel', 'common.ok': 'OK', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index a94aaac..122260f 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -12,6 +12,9 @@ export const ru: Record = { 'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.', 'blocked.reason': 'Причина:', + 'boot.errorTitle': 'Не удалось загрузить игру', + 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', + 'common.back': 'Назад', 'common.cancel': 'Отмена', 'common.ok': 'ОК', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 941ea33..61a5224 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -136,7 +136,10 @@ export class MockGateway implements GatewayClient { } // --- auth --- - async authTelegram(): Promise { + async authTelegram(initData: string): Promise { + // e2e hook: an initData carrying this sentinel simulates a backend that rejects the launch, + // so the Mini App boot-failure path (silent retries → boot-error screen) can be exercised. + if (initData.includes('bootfail')) throw new GatewayError('unavailable'); return { ...SESSION, isGuest: false }; } async authGuest(): Promise { diff --git a/ui/src/screens/BootError.svelte b/ui/src/screens/BootError.svelte new file mode 100644 index 0000000..92ddee5 --- /dev/null +++ b/ui/src/screens/BootError.svelte @@ -0,0 +1,63 @@ + + +
    +
    +

    {t('boot.errorTitle')}

    +

    {t('boot.errorBody')}

    + +
    +
    + + diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 12561fa..f12a500 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -244,9 +244,10 @@ {/if} - -
    + +