diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..fe5a438 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,375 @@ +name: CI + +# Single gated pipeline for the test contour. 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. +# +# Path-conditional jobs: `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. + +on: + pull_request: + branches: [development, master] + push: + branches: [development] + +# The dictionary release the test suite validates against — the current +# scrabble-dictionary release. Centralised here so a release bump is one edit; the +# unit/integration jobs inherit it. The deploy job overrides it per contour with +# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md. +env: + DICT_VERSION: v1.2.1 + +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/|loadtest/|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: + 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/* + 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/... ./loadtest/... + + - name: build + run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/... + + - name: test + env: + BACKEND_DICT_DIR: ${{ github.workspace }}/dawg + run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/... + + integration: + needs: changes + if: ${{ needs.changes.outputs.go == 'true' }} + 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/* + 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: + needs: changes + if: ${{ needs.changes.outputs.ui == 'true' }} + 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 + + # 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. + # 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: + 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: ${{ 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" + VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} + VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} + VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} + VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} + # 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: | + # 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" + # Bot-link mTLS material for the test contour: a private CA + gateway/bot + # leaves (CN=gateway, the service name the bot dials). Prod supplies these + # from PROD_ secrets instead. Regenerated each deploy; both ends redeploy + # together so they always share the fresh CA (see deploy/gen-certs.sh). + bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs" + # 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)" + # The telegram-local profile brings the bot + its VPN sidecar; prod runs the + # bot on its own host instead (deploy/docker-compose.bot.yml), and the prod + # main host omits both. Without the profile they would not start here. + docker compose --ansi never --profile telegram-local build --progress plain + docker compose --ansi never --profile telegram-local up -d --remove-orphans + # The config-only services bind-mount the reseeded config dir. A plain `up -d` + # leaves them on the previous bind mount (the dir was rm'd + recreated), so a + # changed Caddyfile or Grafana dashboard is ignored — force-recreate them to + # pick up the fresh config. + docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana + + - name: Probe the landing, gateway and backend + run: | + set -u + # Three probes. "/" is the static landing container and "/app/" the + # gateway-served SPA shell (both through the contour caddy on the edge net). + # The backend /readyz is probed on the internal net as well: the caddy probes + # are blind to a crash-looping backend (the landing is static and the SPA + # shell is served without it), which let a bad deploy go green while the + # backend was down — so check it directly here. + 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/ && + docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ && + docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then + echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz" + exit 0 + fi + sleep 3 + done + echo "probe failed; recent landing + gateway + backend logs:" + docker logs --tail 50 scrabble-landing || true + docker logs --tail 50 scrabble-gateway || true + docker logs --tail 50 scrabble-backend || true + exit 1 + + - name: Probe the Telegram validator and bot liveness + run: | + set -u + # The gateway/backend probes cannot see a crash-looping validator or bot + # (the validator answers only internal gRPC; the bot long-polls + egresses + # through the VPN sidecar with no public ingress). Inspect the containers + # directly: each must be running, not restarting, with a stable restart + # count. A grace period lets the VPN handshake and the bot-link dial settle. + sleep 20 + for name in scrabble-telegram-validator scrabble-telegram-bot; do + ok= + for i in $(seq 1 20); do + status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)" + restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)" + if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then + c1="$(docker inspect -f '{{.RestartCount}}' "$name")" + sleep 5 + c2="$(docker inspect -f '{{.RestartCount}}' "$name")" + if [ "$c1" = "$c2" ]; then + echo "$name healthy: status=$status restarts=$c2" + ok=1 + break + fi + echo "$name still restarting ($c1 -> $c2); waiting" + fi + sleep 3 + done + if [ -z "$ok" ]; then + echo "$name not healthy; recent logs:" + docker logs --tail 80 "$name" || true + exit 1 + fi + done + + - 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/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml new file mode 100644 index 0000000..8ebd502 --- /dev/null +++ b/.gitea/workflows/prod-deploy.yaml @@ -0,0 +1,219 @@ +# Manual production rollout. Runs ONLY from master, ONLY on an explicit +# workflow_dispatch with confirm=deploy (development->master is merged + green first; +# this is the separate, deliberate prod step). It builds and pushes the images to the +# registry, then deploys over SSH: the main host via deploy/prod-deploy.sh (rolling, +# health-gated, auto-rollback; a maintenance window + consistent dump on a migration), +# then the bot host. See deploy/README.md (prod runbook) for operator steps. +name: prod-deploy +run-name: "prod deploy ${{ github.sha }}" + +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "deploy" to confirm a production rollout from master.' + required: true + default: "" + +permissions: + contents: read + +jobs: + deploy: + if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'deploy' }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + env: + NO_COLOR: "1" + DOCKER_CLI_HINTS: "false" + REGISTRY: docker.iliadenisov.ru/developer + # SSH + registry + PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }} + PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }} + PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }} + MAIN_HOST: ${{ vars.PROD_MAIN_HOST }} + TG_HOST: ${{ vars.PROD_TG_HOST }} + # SPA build args (baked into the gateway/landing images) + VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }} + VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} + VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} + VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }} + # Runtime secrets + POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} + GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} + GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} + TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} + TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_PROMO_BOT_TOKEN }} + PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }} + PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} + PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} + PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }} + PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }} + # Runtime variables + GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }} + GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }} + CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }} + LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }} + DICT_VERSION: ${{ vars.PROD_DICT_VERSION }} + POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }} + POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }} + TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }} + TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }} + TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }} + TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }} + TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute image tag and app version + run: | + echo "TAG=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_ENV" + echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV" + + - name: Registry login + run: echo "$PROD_REGISTRY_PASSWORD" | docker login "${REGISTRY%%/*}" -u "$PROD_REGISTRY_USER" --password-stdin + + - name: Build and push images + working-directory: deploy + run: | + export TAG SCRABBLE_CONFIG_DIR=. + # The four main-stack images via compose (reuses the compose build args); the + # bot separately, since it is profiled out of the prod compose. + docker compose -f docker-compose.yml -f docker-compose.prod.yml build + docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator + docker build -f ../platform/telegram/Dockerfile --target bot -t "$REGISTRY/scrabble-telegram-bot:$TAG" .. + docker push "$REGISTRY/scrabble-telegram-bot:$TAG" + + - name: Set up SSH + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + + - name: Determine previous tag and migration + run: | + ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; } + PREV_TAG="$(ssh_main 'cat /opt/scrabble/DEPLOYED_TAG 2>/dev/null || echo none')" + MIGRATION=0 + if [ "$PREV_TAG" != none ]; then + if ! git cat-file -e "$PREV_TAG^{commit}" 2>/dev/null; then + MIGRATION=1 # unknown previous tag: take the safe window + dump + elif git diff --name-only "$PREV_TAG..$TAG" -- backend/internal/postgres/migrations/ | grep -q .; then + MIGRATION=1 + fi + fi + echo "PREV_TAG=$PREV_TAG" >> "$GITHUB_ENV" + echo "MIGRATION=$MIGRATION" >> "$GITHUB_ENV" + echo "prev=$PREV_TAG migration=$MIGRATION" + + - name: Render env files and certs + run: | + umask 077 + mkdir -p stage/certs-main stage/certs-bot + # Main-host runtime env (single-quoted so the literal '$' in the bcrypt hash + # survives; prod-deploy.sh sources this, compose reads it from the environment). + cat > stage/env.sh < stage/env.bot.sh < stage/certs-main/ca.crt + printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt + printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key + printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt + printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt + printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key + chmod 644 stage/certs-main/* stage/certs-bot/* + + - name: Deploy the main host + run: | + ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; } + ssh_main 'mkdir -p /opt/scrabble/compose' + # Compose files + config + script. + tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \ + | ssh_main 'tar -C /opt/scrabble/compose -xzf -' + tar -C deploy -czf - caddy otelcol prometheus tempo grafana \ + | ssh_main 'tar -C /opt/scrabble -xzf -' + tar -C stage -czf - certs-main \ + | ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -' + scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.sh "deploy@$MAIN_HOST:/opt/scrabble/env.sh" + # Registry login on the host so compose can pull the private images. + echo "$PROD_REGISTRY_PASSWORD" | ssh_main "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin" + ssh_main "TAG='$TAG' PREV_TAG='$PREV_TAG' MIGRATION='$MIGRATION' bash /opt/scrabble/compose/prod-deploy.sh" + + - name: Deploy the bot host + run: | + ssh_tg() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$TG_HOST" "$@"; } + ssh_tg 'mkdir -p /opt/scrabble/compose' + tar -C deploy -czf - docker-compose.bot.yml | ssh_tg 'tar -C /opt/scrabble/compose -xzf -' + tar -C stage -czf - certs-bot \ + | ssh_tg 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -' + scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.bot.sh "deploy@$TG_HOST:/opt/scrabble/env.bot.sh" + echo "$PROD_REGISTRY_PASSWORD" | ssh_tg "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin" + ssh_tg 'set -a; . /opt/scrabble/env.bot.sh; set +a; cd /opt/scrabble/compose; + docker compose -f docker-compose.bot.yml pull; + docker compose -f docker-compose.bot.yml up -d' + # Bot liveness: running, not restarting, stable restart count. + ssh_tg 'for i in $(seq 1 20); do + s=$(docker inspect -f "{{.State.Status}}" scrabble-telegram-bot 2>/dev/null || echo missing) + r=$(docker inspect -f "{{.State.Restarting}}" scrabble-telegram-bot 2>/dev/null || echo true) + if [ "$s" = running ] && [ "$r" = false ]; then + c1=$(docker inspect -f "{{.RestartCount}}" scrabble-telegram-bot); sleep 5 + c2=$(docker inspect -f "{{.RestartCount}}" scrabble-telegram-bot) + [ "$c1" = "$c2" ] && { echo "bot healthy"; exit 0; } + fi + sleep 3 + done + echo "bot not healthy:"; docker logs --tail 80 scrabble-telegram-bot; exit 1' + + - name: Verify the public site + run: | + ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; } + domain="${CADDY_SITE_ADDRESS%% *}" # first domain of "erudit-game.ru www..." + # Probe through caddy with the right vhost (force-resolved to the host); -k + # tolerates an ACME-pending cert. Also check the backend is actually ready. + ssh_main "for i in \$(seq 1 20); do + if curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/ -o /dev/null && + curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/app/ -o /dev/null && + docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then + echo 'public site + /app/ + backend healthy'; exit 0 + fi + sleep 5 + done + echo 'public verify failed; recent caddy + gateway + backend logs:' + docker logs --tail 40 scrabble-caddy; docker logs --tail 40 scrabble-gateway; docker logs --tail 40 scrabble-backend + exit 1" 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/.gitignore b/.gitignore index 259564b..446e138 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,10 @@ # Local, unstaged env overrides **/.env.local **/.env.*.local + +# Bot-link mTLS material: private keys never belong in the repo. The test contour +# generates them with deploy/gen-certs.sh; prod supplies them from PROD_ secrets. +deploy/certs/ + +# Claude Code harness runtime artifacts +.claude/scheduled_tasks.lock diff --git a/CLAUDE.md b/CLAUDE.md index 3676bba..d0f7144 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ conversation memory — is the source of continuity. Keep it that way. - [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open details to interview*. +- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7 + before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`. - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security, the decision record. Always describes current state. - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) @@ -49,9 +51,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 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: `python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL` @@ -112,7 +125,10 @@ backend/ # module scrabble/backend internal/inttest/ # //go:build integration Postgres-backed tests 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 +platform/telegram/ # Telegram side-service, two binaries (Stage 9; split in phase TX): cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link) +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); gateway/Dockerfile has the `landing` target (R3), platform/telegram/Dockerfile has `validator`+`bot` targets (TX) +deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter ``` ## Build & test @@ -122,14 +138,20 @@ go build ./backend/... # per module ('./...' from the root won't span t go vet ./backend/... gofmt -l . # must print nothing go test -count=1 ./backend/... -go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9) +go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot (Stage 9; split in TX) 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 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 ``` -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..81955d1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -49,8 +49,10 @@ 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 | -| 17 | Prod contour deploy (SSH export/import, manual after merge) | todo | +| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** | +| 17 | Test-contour verification & defect fixes | **done** | +| 18 | Prod contour deploy (registry, two-host, rolling + auto-rollback; manual after merge) | machinery built; first cutover pending DNS | +| 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. @@ -244,7 +246,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 @@ -279,7 +281,13 @@ 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 +> **Superseded (2026-06-20, pre-release phase SB):** the two bots collapsed into **one** and the +> language-gated variant choice moved to a per-user **`variant_preferences`** profile set (default Erudit +> only). `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars, +> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` and game-language push routing are gone; the single bot renders in +> the recipient's `preferred_language`. Current state: `docs/ARCHITECTURE.md` §3/§10, `PRERELEASE.md` row SB. + +### 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), @@ -297,15 +305,167 @@ 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 -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. +### Stage 17 — Test-contour verification & defect fixes *(done)* +Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the +"does it actually work in the contour" pass before prod. Bring up the `development` deploy, then +verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin +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). + +#### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*) + +The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss), +discussed where they were forks, and resolved in one session with tests where practical. The +per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the +raw list is kept here as the record of what the first contour run surfaced. + +- /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/ + Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth. + +- нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход, + скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести + метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?), + а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа + способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров. + +- регистрация пользователя из телеграм (как и других коннекторов): + пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле. + если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр), + язык в зависимости от внешнего коннектора. + +- game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход". + Думаю, опечатка - "не" лишняя, проверь на всех языках. + +- если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная", + т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает + на самих стилях) + +- возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема. + когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar + рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка. + Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в + самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там + системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним. + +- не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios, + то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби. + интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли? + если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого? + нужен твой анализ и совет. + +- надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода + выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал + сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно. + На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону. + А то я сейчас поиграл, роботы на первых ходах по 15 минут думают. + Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры. + Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя + активного игрока. Этот пункт требует тщательного обсуждения, пожалуй. + +- при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает + каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то + неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать + каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь + проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п. + +- при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна. + +- а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой), + чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое? + ну и для собственного дефолтного стиля тоже надо выбрать соответствующие. + +- Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем". + Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся. + Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать + плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry. + +- при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка. + +- В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён + было не больше 20%. + +- Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации. + +- Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство + поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы + остальные игроки были как бы "утоплены" внутрь. + +- В игре клик/тач по плашке с именами игроков открывает/закрывает историю. + +- В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо. + +- В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить) + +- При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей. + +- Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка + "что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится. + +### Stage 18 — Prod contour deploy +Scope: the **production contour** on **two remote hosts** over SSH — main (full stack, `erudit-game.ru`) +and tg (the bot only). Resolved open details (re-interviewed): +- **Transport: a registry** (not export/import) — build + push to `docker.iliadenisov.ru`, the hosts pull by tag. +- **Cert: ACME** at the contour caddy (`CADDY_SITE_ADDRESS=erudit-game.ru www.erudit-game.ru`, no host caddy). +- **No prod VPN** — the bot host has native Bot API egress (verified `api.telegram.org` → 200). +- **Rollback** — rolling per-service deploy (least → most dependent), health-gated, auto-rollback to the + previous image tag; a maintenance window + consistent `pg_dump` only on a schema migration + (expand-contract keeps the auto-rollback image-only; the dump is a manual safety net). + +**Strictly manual** (`workflow_dispatch` from `master`, `confirm=deploy`) after `development → master` +is merged green. `TEST_`/`PROD_` prefixed Gitea secrets/variables (Gitea 1.26 has no deployment +environments — the `environments` API 404s). Hosts are provisioned by **`deploy/ansible/`** (docker, a +non-sudo `deploy` user with the CI key, key-only sshd, ufw, fail2ban). The main host is **launch-sized** +(2 vCPU / 1.9 GiB): `docker-compose.prod.yml` trims the R7 limits (`GOMAXPROCS=2`, smaller caps, 7d +Prometheus retention) and adds `node_exporter` for host-memory monitoring (launch undersized, resize at +Selectel reactively). `vpn`+`bot` are gated to a `telegram-local` compose profile (test only); the prod +bot runs standalone from `docker-compose.bot.yml`. `GATEWAY_ABUSE_BAN_ENABLED=true`. + +**Built:** `deploy/ansible/` (both hosts provisioned + verified), the compose split + `node_exporter`, +`.gitea/workflows/prod-deploy.yaml` + `deploy/prod-deploy.sh`, the full `PROD_` secret/variable set. +**Remaining (acceptance):** the **first live cutover** — waits on the `erudit-game.ru` DNS delegation +(`A`/`www` → the main host) that ACME requires; then run the workflow and verify the public site end-to-end. + +### 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 @@ -504,7 +664,10 @@ caddy; prod VPN; rollback. - **Margin** (interview): pick the candidate whose resulting margin (own+move−opp) is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose, tie-broken toward the conservative edge; no legal play → exchange the full rack - when the bag can refill it, else pass. + when the bag can refill it, else pass. *(Later refined — separate PR: on ≈20% of + opening/midgame moves the robot flips its intent for that one move, the chance + tapering to 0 over the last 14 bag tiles and 0 at an empty bag, so the endgame + stays strict; deterministic from the seed.)* - **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`) substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`), `NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human @@ -629,7 +792,7 @@ caddy; prod VPN; rollback. reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms); board **zoom reworked** to a width-based zoom in a fixed viewport (real native scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter - tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings + tiles, last-word dark-tile highlight, and a Settings **bonus-label style** (beginner/ classic/none); **hint lays its tiles on the board** (no spend when no move — a new `no_hint_available` result code); the history opens as an in-place **slide-down** @@ -901,7 +1064,7 @@ 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), @@ -981,7 +1144,7 @@ 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 @@ -1036,6 +1199,358 @@ 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 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 + `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 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 + `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. + - **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 18) leaves it `false`. + +- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's + collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session. + - **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical + string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in + the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`, + `Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged + (persisted games, GCG and the `variant` metric attribute keep it). + - **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with + `game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct + `nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn. + - **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the + editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by + language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor + validation (e.g. "Peter J."). + - **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names + paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only / + first + surname initial / first + full surname), composed deterministically per pool slot (stable + across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20% + Latin, an English game the Latin pool. Robot identities are keyed `robot--`. + - **Robot names — per-game variety** (post-release, 2026-06): the seeded pools above now only back + each account's *fallback* name. The disguised reaper stamps a **fresh per-game name** on the robot's + seat — a new `game_players.display_name` snapshot, which also captures humans' names so a later rename + no longer rewrites past games (a reader falls back to the account name for a pre-migration row). The + name is drawn from a wide composed corpus (`internal/robot/namevariety.go`): Western locales + (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed Russian pool, and human-style + handles (stem + optional `.`/`_` + optional trailing number). Routing keeps the spirit of `Pick` + (Russian: Cyrillic + ≤20% Latin, never a CJK script; English: the full corpus) via `robot.PickNamed`. + `account.ValidateDisplayName` was loosened to admit a **trailing run of ≤5 digits** (mirrored in + `ui/src/lib/profileValidation.ts`), so "Player2007"-style handles are valid for humans too and the + disguised robot stays indistinguishable. + - **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the + band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min + by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime + nudge pulls the reply toward the move's lower band. + - **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own + other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the + actor), and the gateway already fans each event out to all of a user's live streams. + - **Move-duration analytics** (#1, interview): a live `game_move_duration{variant,phase}` histogram + (opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console — + min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number, + computed from the journal (`game_moves.created_at` deltas; no schema change). Per-user stays offline, + not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots + included), so the per-human admin view is authoritative. + - **CI** (#9/#10, interview): `unit`/`integration`/`ui` are path-conditional behind a `changes` job; an + always-running `gate` job aggregates them (success-or-skipped) and is the single branch-protection + required check (`CI / gate`), so a skipped job never blocks a merge. The deploy job gained a + Telegram-connector liveness probe (`docker inspect`: running, not restarting, stable restart count, + with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector + was invisible to the gateway-only probe. + - **UI theming / UX**: inside Telegram the colour scheme is forced from `WebApp.colorScheme` over the OS + `prefers-color-scheme` (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden + (#11); the nav bar takes Telegram's bg and the announcement banner a subtle `--ad-bg` accent (#14/#15); + the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint + zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others + with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow + to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders + instantly on re-entry and refreshes in the background (#13). + - **Grafana repeated password (#8) — not a server defect**: verified live that caddy challenges `/_gm` + and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a + browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify, + no server change. **Multi-word history (#22)** was already implemented (all formed words shown). + - **Contour-verification follow-ups** (rounds 2–3, from live testing): the Grafana + double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is + disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the + move-duration panel was invisible because the deploy reseed (`rm -rf`) left the + config-only services on a stale bind mount — the deploy now **force-recreates** + caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 → + 300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram + reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide + + Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted — + chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native + **BackButton**, **HapticFeedback**, **closing confirmation** in a game, + **disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat + pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint + button disables at zero hints; plus **board-only vertical scroll** (#9) and a + **keyboard-overlay** check-word dialog (#10). + - **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised + [1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel + toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a + single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap** + or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over + a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to + their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings + toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming + ~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native + scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom; + **robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed + across the game/robot package boundary, to be picked up when that seam is added. + - **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn** + now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit + the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op + (only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the + matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later + robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win — + `result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend + request to a robot** is accepted as pending and expires like a human ignore (robots no longer set + `BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the + chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish: + **even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no + lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch + zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop + capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known- + illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit + → Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game + card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's + turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would + show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites. + - **Contour-verification follow-ups** (round 6, from live testing) — **shipped & deployed:** profile drops + the hint-balance line; no mobile tap-flash on a board cell (`-webkit-tap-highlight-color`); variant + display names keyed by the game's **alphabet**, not the UI language (english → "Scrabble", + russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game + title shows the variant name; **chat & nudge are mutually exclusive by turn** (message field on your + turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced + server-side to your own turn (`ErrChatNotYourTurn`); the **nudge cooldown resets** once the player has + moved or chatted since the last nudge (`game.LastMoveAt` + last chat vs last nudge; the UI mirrors it); + the **About** screen got localized titles + a rules link + the random/friends sections, and the app + **version comes from `git describe`** (Vite define `__APP_VERSION__` ← Docker build-arg in the deploy + step, default "dev"); the **quick-game buttons** became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a + bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences + from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when + available; redraw the USSR emblem as a thin schematic hammer & sickle); **#3 drag-reorder of rack tiles** + with a visual gap (the dragged tile lifts out, the rest slide to open a slot; `reorderIndices` + unit-tested; only with no pending tiles); and the **persistence backend foundation** (#4/#5/#6): a + `game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every + committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play + overlapped — 5 integration tests. + - **Stage 17 round 6 — 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. + - **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). + - **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle, + 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. + - **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. + - **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** 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 + 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 + word-check are now their own routed screens** (`/game/:id/chat`, + `/game/:id/check`, header back to the game, no tab-bar) so the soft keyboard simply resizes + the **visible viewport** — mirrored into a `--vvh` CSS var the `Screen` height uses, since + iOS doesn't shrink `dvh` for the keyboard — with the input pinned to the bottom: no modal + relayout, no page jump (this superseded a first bottom-sheet-`Modal` attempt). New chat + messages raise an **unread badge** on the in-game hamburger + the Chat menu row (per game, + cleared on open), mirroring the lobby badge; the chat screen is routable for a future + Telegram deep-link. The TG-fullscreen header was also finalised over a couple of review + passes: title + menu as a centred pair inside Telegram's nav band (between `--tg-safe-top` + and `--tg-content-top`), with a small padding bump so the native controls aren't flush. + - **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`. + - **Hide finished games (#5, shipped):** a player can remove a finished game from their own + *my games* list — **per-account, finished-only and irreversible** (the game stays for the + other players; there is no un-hide). On a finished row a **swipe-left** (touch) or a tap on + its **kebab ⋮** (the desktop affordance) reveals a **❌** that hides it; active rows carry an + inert **›** chevron purely to keep the right-edge icons aligned. New table + `game_hidden(account_id, game_id)` + migration `00012`; `ListGamesForAccount` filters the + hidden set; `POST /api/v1/user/games/:id/hide` behind the `game.hide` edge op (reusing + `GameActionRequest` → an `Ack`); the lobby drops the card optimistically and keeps the cache + in sync. Covered by an integration test (active→`ErrGameActive`, outsider→`ErrNotAPlayer`, + per-account visibility, idempotent), a gateway transcode test, and a mock e2e (kebab → ❌). + - **Enriched out-of-app push (#4, shipped):** the "your turn" Telegram notification now names + the opponent and recaps their last move — voiced as the opponent, `«{name}: my move — + «WORD». Score 120:95»` for a scoring play, or a short "swapped / passed, your turn" — and a + new **game-over** notification reports the result + final score when a game ends (any path: + closing play, all-pass, resign, timeout). Scores are **recipient-first** (the reader's + score leads), 2- to 4-player (`120:95:80`). `YourTurnEvent` gained `opponent_name`/ + `last_action`/`last_word`/`score_line` (appended, backward-compatible) and a new + `GameOverEvent` carries `result`/`score_line`; both emit per-recipient from the game commit + (`emitMove`), join the out-of-app whitelist, and render per language (en/ru) in the Telegram + connector. The backend resolves the mover's display name (the score line and result are + built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and + routing tests. + - **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot + already long-polls and the library advances the offset for every delivered update (the default + handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only + `message_reaction` / `message_reaction_count` / `chat_member` by default, and — being + unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default + (zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break + `getUpdates` entirely); a specific type will be requested when a concrete handler needs it. + - **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became + **state, not toasts**. A global `online` signal (`lib/connection.svelte.ts`) flips on a + transport `unavailable` / `rate_limited` (and on the live stream's drop), driving a pure-CSS + header **spinner + "Connecting…"** in place of the title and softly disabling the in-game + server actions (commit / exchange / pass / hint; local board/rack/reset stay live). The + transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads + only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while + offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A + reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the + old red `error.unavailable` toast is gone (the indicator replaces it). A server-data screen + still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry), + so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn` + hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear + on reconnect). The visual soft-disable spans the server-action buttons across the app: the + game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge, + friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby + hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live. + - **Nudge defects (owner-reported, shipped):** two from a live contour game. + **(A) Frequency** — the robot's proactive nudge fired hourly for 12 h+ (12 h idle threshold + + the 1 h cooldown, uncapped). Replaced with a **lengthening, randomized schedule** in the + robot strategy/driver: the first nudge ~60-90 min into the human's turn, each later gap + growing toward 1-6 h (the gap is a uniform sample in `[60 min, ceil]`, `ceil` ramping from + 90 min to 6 h over 12 h of idle, measured from the previous nudge), so a long wait gets a + handful of increasingly-spaced reminders. **(B) Language** — the out-of-app push routed by + the recipient's **global `service_language`** (last-login-wins), so after re-logging through + the RU bot an English game's nudges came from the RU bot. Now a game push (your_turn, + game_over, nudge, match_found) carries the **game's own language** (`engine.Variant.Language`) + on `push.Event`, and the gateway routes by it (falling back to `service_language` for + non-game pushes); the New-Game variant-gating guarantees deliverability. Covered by the + `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 @@ -1075,15 +1590,24 @@ caddy; prod VPN; rollback. unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was discharged in Stage 14: the dict repo builds against the published solver + pinned `dafsa`/`alphabet`, byte-identical to the fixtures. -- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:* - the deep-link scheme now exists (`f`, shared Go ↔ TS), the bot redeems it on - launch, and the UI shows a **share-to-Telegram** link for an issued code when - `VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a - friend can add you by scanning rather than tapping/typing. The code semantics - (12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes. +- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9, extended later:* + the deep-link scheme exists (`f`, shared Go ↔ TS), the bot redeems it on launch, and the UI + offers a real **Share** control for an issued code — Telegram's native share-to-chat picker inside + the Mini App (`openTelegramLink` + `t.me/share/url`), the system share sheet (`navigator.share`) on + the web, else copy-the-link. The shared link points at the **same bot the player signed in through**: + it carries the session's `service_language` (now on the `Session` wire) and picks + `VITE_TELEGRAM_LINK_EN`/`_RU`, falling back to `VITE_TELEGRAM_LINK`. **Still open:** render the link + as a **QR** so a friend can add you by scanning. The code semantics (12 h TTL, single use, one active + per issuer) stay as-is; only the delivery changes. - **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).** The play-with-friends form has no preselected variant today (an empty, required pick). Default it from the player's history (the variant they play most, from `account_stats` or a games query), falling back to their interface language (en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing wrong. +- **TODO-7 — animate a lobby card's move between sections (owner's idea, 2026-06).** + When a game changes bucket — *your turn* → *finished*, *opponent's turn* → *your turn*, etc. — + its card simply re-renders in the new section; only the status emoji **blinks twice** (added + alongside the named-nudge + lobby-blink work). A FLIP / slide animation that visibly relocates + the card to its new section would be a nicer touch. Deferred — purely cosmetic, nothing depends + on it. diff --git a/PRERELEASE.md b/PRERELEASE.md new file mode 100644 index 0000000..32417ee --- /dev/null +++ b/PRERELEASE.md @@ -0,0 +1,632 @@ +# Pre-release plan — hardening before Stage 18 + +Living tracker for the pre-release hardening pass that runs **before Stage 18** (the +prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session, +**interview the owner on the open details** at the start of each phase, bake every +decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in +the **same** PR, get CI green, then mark the phase done. Phases run as +`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges. + +**Why now:** the system is feature-complete through Stage 17 and the test contour is +green, but there is **no prod data yet** — schema, wire labels and the dictionary +layout can still change for free. These phases spend that one-time freedom and harden +the edge before prod. Each phase maps back to the owner's raw pre-release TODO list +(numbers in the tracker). + +## Phase tracker + +| # | Phase | Raw TODOs | Status | +|---|-------|-----------|--------| +| 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 | **done** | +| R5 | Bundle slimming | 6 | **done** | +| 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** | +| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** | +| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | 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** | +| 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 | **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. 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** | +| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** | +| 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, an SSH deploy of both hosts together — is **built in Stage 18** (the two-host registry rollout; first cutover pending the `erudit-game.ru` DNS). | owner ad-hoc | **done** (code + test contour; prod wiring built — 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 on in prod via Stage 18 — machinery built, cutover pending DNS) | +| 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) + +- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is + safe precisely because there is no prod data and the contour DB is wiped. Folding the + new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline + makes the rename need **no data migration and no back-compat mapping**. Today's labels + (`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`, + `game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep + on a clean DB. +- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on + `your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only** + genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s, + `ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching + push events to carry payloads so the UI stops re-fetching after each signal. +- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from + the session (`Authorization: Bearer` → `X-User-ID`); the client cannot inject identity, + the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real + gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit + rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not** + covered by the gateway token bucket (it only guards `Execute`). +- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name + `backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe + current state; `PLAN.md` keeps the decision history. + +## Locked decisions (owner interview) + +- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB + through the gateway, moves generated by the solver) **plus a separate gateway-hammer** + saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**. + Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only + Go-runtime metrics exist). The harness stays in the repo for repeats. +- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep + poll as the ws-down fallback) **and** enrich push events with payloads. +- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list** — + behaviour-preserving, test-gated, contentious items surfaced to the owner before applying. +- **Landing (TODO 3):** **separate static container** behind the project caddy + (`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway + `go:embed`. +- **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**. +- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS* + defence — it detects/deceives but does not shed volumetric load, cannot cover the real + endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern, + out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot + and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision + governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed, + **prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the + existing `ratelimit` package (smallest surface); the decoy path list is a **single source of + truth in the caddy** (it tags requests with a header — the gateway keeps no second list); + bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin + console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice). + An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total` + counter cover it) to keep the diff focused. +- **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. +- **Telegram egress off-host (TX, owner ad-hoc):** the driver is **removing VPN/Telegram + traffic from the main host** (OPSEC / one fewer analysis vector), not only notification + resilience. Login is local HMAC, so it stays up regardless of the bot — confirmed in the + code and made structural by the split. **Unified topology in code** (validator + bot, the + bot dialing the gateway) in **both** contours, differing only in deployment; the test bot + keeps its VPN sidecar. Transport = a **reverse gRPC bidi stream, mTLS, bot-dials-gateway** + (no inbound/static IP on the bot), reusing the push-stream pattern; **webhook rejected** + (one URL per token, adds inbound + a static address). Delivery **at-most-once** (a dropped + nudge beats a duplicate). **One bot now**, seams (registry + `owns_updates` + command ids) + for N later. **Cert rotation** by a scheduled CI job from a long-lived CA. **Prod deploy by + SSH** (pull excluded), the bot rolled **together** with the main app (the bot-link protocol + kept back-compatible by one version as the non-atomic-two-host-deploy safety net). The bot + is monitored **from the gateway** (connection + ack metrics). The bot-host token-at-rest is + **accepted**. + +## Phases + +Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open +details below**, implement within scope, then update the tracker + docs/code and get CI +green before marking it done. + +### R1 — Schema & naming reset *(TODO 1 + 10)* — first +Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql` +(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline → +prove a fresh migrate yields a schema identical to the 12-migration chain via the +integration suite → delete the old files; keep goose). Bake the new variant labels into the +baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend +(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire +(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`, +fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy +`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact +names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them +byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions +on the next deploy. +- Critical files: `backend/internal/postgres/migrations/`, + `backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`, + `ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`. +- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is + one PR or split; how to script the contour DB wipe in the deploy. + +### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)* +Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`, +`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts +**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in +the DB and hands the plaintext tokens to the client; a driver that runs N virtual users, +each in 3–5 concurrent 2–4-player games, exercising submit-play / pass / exchange / nudge / +chat / check-word / draft-move / profile-save through the **edge protocol**, in +**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate +**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure +its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana +resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a +**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6. +- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`, + `docs/TESTING.md`. +- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move + for realistic game progress); run duration; the pass/fail bar. + +### R3 — Edge hardening *(TODO 2 + 8 + 3)* +Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add +**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured +log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes +visible without per-user label cardinality, honouring the Stage 12/17 discipline); an +**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single- +instance, reset-on-restart, like the `active_users` gauge). Add the **conservative +auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft, +reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the +admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into +its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop +`landing.html` from the gateway `go:embed`. +- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`, + `gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`, + `deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`. +- Open details: the auto-flag threshold/window + whether the marker is persisted vs + in-memory; the landing image base (caddy vs nginx). + +### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)* +Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down +fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI +renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency +the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit → +UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing +spot; regenerate FB. +- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`, + `ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`. +- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the + stream is down. + +### 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)* — done +Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical +**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service +READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile +`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift +and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of +proposed optimizations / test-suite consolidations to the owner, apply only the approved, +behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the +regression gate. Incorporates the early-run (R2) bug fixes not already shipped. +- Open details: the structural-changes list itself (owner-approved before applying); the test + consolidation targets. + +### R7 — Final stress run + tuning *(TODO 9, part 2)* — done +Re-run the R2 harness against the final, refactored system on a clean contour; analyse +resource consumption across **all** components (gateway, backend, Postgres, the +metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate +limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record +the methodology + results in the repo. + +→ **Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md). + +## Sequencing rationale + +`R1` first (cheapest now; everything builds on the final schema/naming and the stress test +must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and +a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system. +`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and +benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it. +Then Stage 18. + +## Regression-safety discipline (cross-cutting) + +- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the + `CI / gate` check) must be green before the owner merges; watch the post-merge contour + deploy with `gitea-ci-watch.py`. +- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical + sweeps; contentious items are owner-approved first. +- The two stress runs (`R2` early, `R7` final) are the system-level regression gate. + +## Verification (per phase) + +- `go build .//...`, `go vet`, `gofmt -l .` clean, `go test -count=1 .//...`; + UI: `pnpm check && pnpm test:unit && pnpm build`; the integration suite + (`-tags integration`) for DB/schema changes; `docker compose config` for deploy changes; + green CI on the PR + a healthy contour deploy. +- `R1`: prove the squashed baseline yields a schema identical to the 12-migration chain + (integration suite on a fresh DB) **before** deleting the old files. +- `R2`/`R7`: the harness runs end-to-end against the contour; the trip report lists concrete + defects + a resource profile from the Grafana cAdvisor/postgres_exporter panels. + +## Refinements logged during implementation + +- **R1** (interview + implementation): + - **Variant labels** `english`/`russian_scrabble`/`erudit` → **`scrabble_en`/`scrabble_ru`/`erudit_ru`** + across the backend (`engine.Variant.String`/`ParseVariant`; the `games`/`game_invitations` `variant` + CHECK in the baseline; GCG `#lexicon` and the `variant` metric attribute both flow from `String`), + the wire (`pkg/fbs` `variant` is a `string` field — values change with **no FlatBuffers regen**) and + the UI (`model.ts` union, `variants.ts` records, `codec`/`premiums`/mocks/tests, the admin + `dictionary.gohtml`). **Kept:** the Go enum identifiers (`VariantEnglish`…, internal) and the i18n + display keys (`new.english`/`new.russian`/`new.erudit`, display-only). `complaints.variant` stays + free-text (no CHECK, as before). + - **dawg filenames kept descriptive** (`en_sowpods`/`ru_scrabble`/`ru_erudit`) — only the registry's + `Variant` key carries the rename, so `registry.go`, the published `scrabble-solver` fixtures and the + dictionary release artifact are untouched (decouples the three repos). + - **Migrations squashed** 12 → one hand-written `00001_baseline.sql`. Verified by a + `pg_dump --schema-only` diff (the chain vs the baseline are **identical** but for the two intended + variant-CHECK values) plus the green integration suite. **No data migration** (no production data). + - **Done (cross-repo + contour):** the **`scrabble-dictionary` tidy** merged (PR #2) and was re-cut as + the **byte-identical `v1.0.1`** release for clean provenance (the backend stays on `v1.0.0` — same + bytes, no rewire; the backend pulls a version-pinned release artifact, not master). Post-merge the + contour `backend` schema was wiped (`DROP SCHEMA backend CASCADE` + restart, not a volume drop) and + re-migrated to the baseline — verified the new variant CHECK (`scrabble_en/scrabble_ru/erudit_ru`), + `games`=0 and a clean boot. + +- **R2** (interview + implementation): + - **Locked decisions:** game assembly via **invitations** (real path, no robots; not direct game-row + inserts); **moderate** ramp **50 → 200 → 500** at 10 min/step; **diagnostic** pass bar (no SLO gate); + run as a **one-shot container on `scrabble-internal`** in this PR. + - **Harness** = new `scrabble/loadtest` module (`use ./loadtest` + a `replace scrabble/gateway` for the + dot-free edge-proto import). It seeds 1000 guest + 10000 durable accounts + sessions **directly in + Postgres** (token hash mirrors `backend/internal/session`), drives players over the **edge protocol**, + generates **mid-ranked legal moves locally** with the embedded `scrabble-solver` by replaying + `game.history` (the edge carries no board — mirrors `engine.ReplayBoard` via the public API), and a + **gateway-hammer**. Compact CLI (`run` / `cleanup`), distroless Dockerfile (DAWGs baked), Go unit tests. + - **Adding the module broke the other images' builds** — backend/gateway/telegram Dockerfiles reduce the + workspace but still referenced `./loadtest` (not in their context); each now also + `-dropuse=./loadtest` (backend/telegram additionally `-dropreplace` the gateway replace). Caught by the + first deploy run; verified by building all four images. + - **Harness payload fixes found by the smoke pass:** the draft DTO's `rack_order` is a string (was sent + as `[]` → `bad_request`); the display-name validator forbids digits/colons, so the cleanup marker + became a letters-only `Zzloadtest` so `profile.update` resends the seeded name. `chat_not_your_turn` / + `nudge_own_turn` are **by-design** turn gates, correctly exercised. + - **Observability:** added **cAdvisor + postgres_exporter** + the **Scrabble — Resources** dashboard + + two Prometheus jobs. **Finding:** cAdvisor yields only the root cgroup on the contour host (separate + XFS `/var/lib/docker` breaks its layer-ID resolution — the existing galaxy deploy has the same limit), + so per-container CPU/RSS for the early pass was captured via `docker stats`. **R7:** adopt the otelcol + `docker_stats` receiver (already the contrib image) for per-container metrics in Grafana. + - **Early run (2026-06-09):** ramped clean to 500 players, no crash/deadlock, cleanup removed all 11000 + accounts. 1.2 M edge calls, 48 870 plays, 2 798 games finished; the per-user limiter held under the + 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.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. + +- **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). + +- **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. + +- **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`. + +- **R6** (interview + implementation): + - **Locked decisions:** apply **both** wire/code structural changes (**B** + **A**) and **only C1+C2** of + the test consolidation (not C3/C5); strip the `*(Stage N)*` tags from **all current-state docs** + (ARCHITECTURE / FUNCTIONAL+`_ru` / TESTING / UI_DESIGN), keeping PLAN.md / PRERELEASE.md / CLAUDE.md as + history; **split `stage6_test.go`** by domain. The `h2cMaxConcurrentStreams` sizing stays an **R7** + concern (tuning, not behaviour-preserving); the R2 early run forced no code fix, so nothing was carried in. + - **(a) De-staging:** removed the `Stage N` / `TODO-N` / `(RN)` references across code, comments, service + READMEs and the current-state docs, rewording narratives to present tense (no technical content lost). + Renamed the only stage-named identifiers (`registerStage8`→`registerSocialOps`, + `registerStage11`→`registerLinkOps`) and split `stage6_test.go` (`TestEmailLoginFlow`→`email_test.go`; + `TestGuestAutoMatchLeavesNoStats`+`provisionGuest`→`account_test.go`). De-staged the `.fbs`/`.proto` + comments and regenerated: only the `.proto`-derived Go docstrings (`*_grpc.pb.go`, `push.pb.go`) changed — + flatc strips schema comments, so the FB Go/TS bindings were untouched. + - **(b) Reconciliation:** the docs were accurate (each R-phase baked its own); the one drift was a stale + "guest-reaping deferred (TODO-3)" note in `ARCHITECTURE.md` §3 — guest reaping is implemented, so the + note was replaced with the current behaviour (FUNCTIONAL/TESTING already described it). + - **(c) B — dead `opponent_moved` scalars:** removed `seat/action/score/total` from `OpponentMovedEvent` + (`pkg/fbs/scrabble.fbs` + the `notify` emit + the round-trip test); regenerated FB Go + TS. No reader + used them (the UI codec/mock take `move`/`game`/`bag_len`; the gateway forwards the payload verbatim). + A pre-release wire-slot renumber — free with no prod data, no DB change. + - **(c) A — shared FB builders:** new `scrabble/pkg/wire` holds the single definition of the nested wire + tables (GameView / MoveRecord / StateView / AccountRef / Invitation) shared by the backend `notify` + encoder and the gateway `transcode`; both map their own source types to neutral `wire.*` structs and + delegate. **Honest tradeoff:** the verbose `Start/Add/End` + reverse-prepend boilerplate is now written + once, but the field *set* is still mapped per side, and the new package makes the change net **+~145 LOC** + — a single-source / anti-drift win for the fiddly mechanics rather than a line-count cut. Behaviour- + preserving: the two sides' field sets were verified identical and the round-trip tests pass unchanged. + - **(c) C1+C2 — inttest fixtures:** moved the cross-file service/game fixtures (`newGameService` was used by + 10 files) into `backend/internal/inttest/helpers.go`; single-file helpers stay local. Pure relocation. + - **No schema change → no contour DB wipe.** Regression gate: the full unit + integration + UI suites plus + the R7 stress run. + +- **R7** (interview + implementation): + - **Locked decisions:** run the harness **same-host** (one-shot container on `scrabble-internal`, capped + `--cpus=3` so the contour keeps spare cores); **apply container limits + `GOMAXPROCS` now** (not just a + prod recommendation); **replace cAdvisor with the otelcol `docker_stats` receiver** (it resolved only the + root cgroup on this host); keep rate-limit / h2c knobs **compiled-in** (change values only if the data + demands — it did not). + - **Harness refinements (pre-run):** each virtual player builds its **own `edge.Client`** (its own h2c + connection for its Subscribe stream + Execute calls) instead of all players sharing one `http2.Transport` — + the R2 `transport_error` artifact; and `playTurn` now reports a **finished** game so the player drops it + from rotation. Effect, measured: `game.state` `transport_error` 14 % (R2) → **2.49 %**; `game_finished` on + chat ≈ 3 900 → **35**. + - **Observability:** added the `docker_stats` receiver to `otelcol` (`api_version: "1.44"` — the daemon's + minimum is 1.40; the receiver defaults to 1.25 and crash-looped until pinned), mounted the docker socket + read-only with `group_add` (the contrib image runs as UID 10001), dropped the cAdvisor service + its + Prometheus job, and retargeted the **Scrabble — Resources** dashboard to the docker_stats metric names + (`container_cpu_utilization`/100 == cores). Cross-checked against `docker stats` within sampling error. + - **Profile (final run, 500 players, limits in force):** the **gateway is the binding constraint** — with + 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.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). + backend/postgres kept at 2 cores / 512 MiB (headroom is cheap on the shared host). + - **Validation:** the same gradual ramp on the tuned contour cut `game.state` `transport_error` to **0.72 %** + (gateway ~2 cores, now under the 3-core cap, no throttle; tempo ~1.27 GiB, under 2 GiB). A separate + **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.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 + `Menu.svelte` everywhere (it fought the Telegram-fullscreen layout, where it had to be re-centred). + - **Locked decisions (interview):** the in-Settings sub-nav is a **bottom TabBar with the active tab + highlighted** (icon-only); **Export GCG** moves to the left slot of the move-history header (free in a + finished game, where 🏁 *leave* does not apply); the lobby **⚙️ badge counts incoming friend requests + only** (invitations keep their own lobby section); unread chat is badged on **the score bar and the 💬**. + - **What shipped:** a ⚙️ **Settings hub** (`screens/SettingsHub.svelte`) over the existing + Settings/Profile/Friends/About bodies and an in-game **comms hub** (`game/CommsHub.svelte`) over + chat + dictionary, both with in-place tabs and a fixed back target; the game's menu items relocate into + the open move history (🏁 leave / 📤 export + 💬 comms header) and the player cards (🤝 add-friend); a + shared **TapConfirm** (`components/TapConfirm.svelte`, `lib/tapconfirm.ts`) — tap → fading ✅ → tap — + replaces the Skip/Hint press-and-hold popovers and drives the add-friend confirm. Fixed the move-history + "jump" bug (the slid board is now inert and the stage can't scroll, so a swipe up genuinely closes it). + `Menu.svelte` + `HoldConfirm.svelte` removed. + - **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md` + (+`_ru`). Regression gate: UI `check` + unit (`tapconfirm`) + build + bundle budget + e2e (Chromium & + WebKit), all green. + +- **UI — Merge Exchange/Pass; drop the dead Tournaments tab** (owner ad-hoc, not on the raw TODO + list): the lobby's 🏆 *Tournaments* tab was an inert `lobby.soon` toast — removed (the lobby is back + to three tabs, matching `docs/FUNCTIONAL.md`). In-game the separate 🥺 *Skip* (pass) tab folds into + the 🔄 tab, now **Exchange/Pass**, whose dialog passes when no tile is selected and exchanges when + tiles are. + - **Decision — a pass is NOT an exchange of zero (verified against the rules + GCG):** the merge is + **UI-only**. Pass and exchange stay distinct game actions end-to-end — wire (`GameActionRequest` vs + `ExchangeRequest`), engine (`ActionPass` vs `ActionExchange`), and the GCG Poslfit dialect (a pass is + a bare `-`, an exchange is `-TILES`). The engine forbids a zero-tile exchange (`ErrNothingToExchange`) + and allows an exchange only with a full rack left in the bag (`ErrNotEnoughTilesToExchange`), while a + pass is always legal — collapsing them would lose a real distinction. The dialog dispatches the + existing `gateway.pass` / `gateway.exchange`. + - **What shipped:** `Lobby.svelte` (tab removed); `Game.svelte` (one 🔄 Exchange/Pass tab no longer + gated on an empty bag; the dialog disables tile selection while the bag is below a full rack + (`bagLen >= RACK_SIZE`), its confirm button reading **Pass without exchanging** / **Exchange N**); + i18n (`game.draw` → Exchange/Pass, new `game.passNoExchange`, dropped `game.skip` / + `lobby.tournaments` / `lobby.soon`). No backend/wire/history/GCG change. + - **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md` + (+`_ru`). Regression gate: UI `check` + unit + build + bundle budget + e2e (Chromium & WebKit). + +- **AI — Honest AI opponent in quick game** (owner ad-hoc, not on the raw TODO list): a second quick-game + opponent the player *knowingly* chooses, distinct from the disguised robot of the random/open path + (which is kept as-is). New Game's quick-game mode replaces the "auto-match" subtitle with a two-button + selector **🤖 AI / 👤 Random player** (the `.seg`/`.opt` segmented style, AI the default); for AI the + move-clock line reads "Loss after 7 days of inactivity" and the "searching" hint is hidden. + - **Locked decisions (interview):** AI move is **event-driven** (the robot replies the instant the + player's move commits; the 30 s driver is the fallback); AI games **do not touch `account_stats`** + (practice, like guests); the **Stage 5 strength logic is reused unchanged** (`playToWin` 40 % from the + seed + margin band); **no per-move timeout — a 7-day inactivity loss** instead; the 7-day line lives on + the New Game screen (the in-game screen has no move-clock line); chat + nudge **disabled**, word-check + kept, add-friend never drawn, opponent shown as **🤖** everywhere. + - **The 7-day rule reuses the existing per-turn timeout:** an AI game is created with + `turn_timeout_secs = AIInactivityTimeout` (7 days) and the existing timeout sweeper resigns the overdue + seat — since the robot moves at once, only the human is ever on the clock, so the per-turn timeout *is* + the abandon rule (no new column, no new sweeper). + - **One game flag drives everything:** `games.vs_ai` (edited into the R1 baseline — pre-release, so a + contour DB wipe after merge). It is set **only** on AI-started games, so a robot-filled random game keeps + `vs_ai=false` and the disguised opponent is never revealed; the UI derives 🤖 / the gates **from the flag, + never from the opponent account**. New backend path `Matchmaker.StartVsAI` (picks a pooled robot via the + existing `Pick`, creates an **active** seated game via `game.Service.Create`, random seat order) — the AI + request never enters the open pool, so the open-game reaper never touches it. The robot driver gains a + `vs_ai` branch (no sleep, no proactive nudge, zero delay) and a focused `DriveGame`/`TriggerMove` fast + path wired from the game service's after-create/after-commit hook (`SetAITrigger`, a func value so the + game package never imports the robot package). Chat/nudge gated by a new `social` `VsAI` check + (`ErrGameVsAI` → 409 `ai_game`); statistics skipped in `commit` when `vs_ai`. + - **Wire:** `EnqueueRequest` += `vs_ai`, `GameView` += `vs_ai` (trailing FB fields, regenerated Go + TS), + threaded through the backend DTO, the gateway transcode and the `pkg/wire` + `notify` builders. + - **Tests:** `lobby` unit (StartVsAI seats a robot + flags the game; empty pool leaves no game); backend + integration (`ai_game_test.go`: active+seated+vs_ai+7-day clock, robot moves immediately, stats skipped, + 7-day timeout resigns the human, chat/nudge rejected); UI codec round-trip (`vs_ai` on enqueue + game + view); e2e (an AI game shows 🤖, no "searching", chat disabled, the dictionary still works) + the + existing quick-match e2e updated to pick **Random player** (the default is now AI). + - **Schema/wire change → a contour DB wipe** after merge (`DROP SCHEMA backend CASCADE` + restart, the + R1/R3 pattern). Bake-back: `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `docs/UI_DESIGN.md`, + `backend/README.md`, Go Doc comments. + - **Post-review refinements (owner, same PR):** (1) the **GCG export labels the robot seat "AI"** rather + than its human-like pool name (`ExportGCG` overrides the name via `accounts.IsRobot`; the in-app 🤖 is + unchanged); (2) honest-AI games **emit no `your_turn`** — the robot replies instantly, so the signal + would arrive with the move and be pointless; `opponent_moved` still advances the UI; (3) the **admin + console surfaces the AI flag** — a **🤖 column** in `/games` and an "AI game" line on the game card + (`GameRow`/`GameDetailView` gain `VsAI`); (4) `games_started_total` / `games_abandoned_total` gain a + **`vs_ai`** attribute and the Grafana *Game domain* dashboard splits started/abandoned into **human** + and **AI** panels. + - **Follow-up (separate PR — strategy deviation):** the robot now plays **≈20%** of opening/midgame moves + *against* its per-game `playToWin` intent (toward the opposite margin band — a winning robot eases off, a + losing one surges ahead), tapering linearly to **0 over the last 14 bag tiles** and **0 once the bag is + empty**, so the endgame follows the chosen strategy strictly while earlier outcomes can swing the human's + way. Deterministic from the seed (`mix(seed,"deviate",moveCount)`), applied to **both** robot paths via + the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit + (taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back: + `docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5. + +- **GL — Simultaneous quick-game cap** (owner ad-hoc, not on the raw TODO list): a player may hold at + most **10** active quick games; at the cap the lobby greys **New Game** and shows a plain notice + "Вы достигли лимита одновременных партий", both clearing automatically when an active game finishes. + - **Locked decisions (interview):** what counts = active **+** open (searching) quick games, **including + AI** (`vs_ai`); friend games (invitation-linked) **never** count. The backend gate refuses **all** new-game + creation at the cap — `lobby/enqueue` **and** `invitations` — with **409 `game_limit_reached`**; **accepting** + an invitation is never gated, so friend games are capped "from the other end". Delivery = a boolean + **`at_game_limit`** on the existing `games.list` (no per-event payload: a turn change does not move the count, + and the lobby already re-fetches `games.list` on entry + every game event); the first uncached lobby frame + defaults the button **enabled** (the backend gate is the authority). + - **What shipped:** `game.MaxActiveQuickGames` + `Store/Service.CountActiveQuickGames` (active/open seats, no + `game_invitations` row; hidden games still count → a dedicated count, not a filter over the lobby list); + `Server.atGameLimit`/`ensureUnderGameLimit` gating `handleEnqueue` + `handleCreateInvitation`; + `gameListDTO.at_game_limit`; the FB `GameList` trailing `at_game_limit` (regenerated Go + TS) threaded through + the gateway transcode + UI codec; `lib/model` + `lobbycache` snapshot + `Lobby.svelte` (disabled tab + a muted + `.limit` notice); i18n `lobby.limitReached` (en authoritative + ru). + - **Caveat (logged):** the gate is a pre-check, not transaction-atomic — concurrent creates from one account could + momentarily exceed by 1–2 (harmless soft cap; the UI disables the button regardless). Strict atomicity was judged + a disproportionate diff across the two create paths. + - **No schema change → no contour DB wipe** (only a trailing FB field, no migration). Tests: backend integration + (`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. + - **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/README.md b/README.md index 12f6ac4..bd33240 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит. - **`gateway`** — the only public ingress: anti-abuse, platform authentication (resolves the player and injects `X-User-ID`), routing to `backend`, and an - admin surface behind Basic Auth. *(added in a later stage)* + admin surface behind Basic Auth. - **`backend`** — internal-only service that owns every domain concern and embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process. - **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC + FlatBuffers, embeddable in platform webviews and packageable to native via Capacitor. See [`ui/README.md`](ui/README.md). - **`platform/*`** — per-platform side-services (e.g. the Telegram bot). - *(added in a later stage)* ## Documentation (sources of truth) @@ -80,3 +79,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`. 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..31cad95 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,53 @@ +# 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 +# — 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.2.1 +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. loadtest and the +# gateway replace it requires are not in this context, so drop both. +RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0 +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend + +# --- runtime ----------------------------------------------------------------- +FROM gcr.io/distroless/static-debian12:nonroot +# Re-declare the build arg in this stage so it labels the seed dictionary. One +# DICT_VERSION drives both the artifact the dawg stage downloads and the version +# label the binary pins, so the resident version equals the release tag. +ARG DICT_VERSION=v1.2.1 +COPY --from=build /out/backend /usr/local/bin/backend +# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume +# mounted at /opt/dawg inherits this ownership on first use, so the admin console +# can write new version subdirectories at runtime. The volume preserves uploaded +# versions across deploys and, once seeded, is not re-seeded — so after bootstrap +# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5). +COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg +ENV BACKEND_DICT_DIR=/opt/dawg +ENV BACKEND_DICT_VERSION=${DICT_VERSION} +ENTRYPOINT ["/usr/local/bin/backend"] diff --git a/backend/README.md b/backend/README.md index e52e1fd..a833500 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,93 +1,141 @@ # backend Internal-only domain service for the Scrabble platform (module `scrabble/backend`). -It owns identity/sessions, accounts, and — in later stages — the lobby, game -runtime, robot, chat, history and administration. Its only network consumers are -the `gateway` and the platform side-services; it is never exposed publicly. +It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history +and administration. Its only network consumers are the `gateway` and the platform +side-services; it is never exposed publicly. -As of Stage 1 the backend provides the foundation: configuration, the HTTP -listener with the `/api/v1` route-group skeleton and probes, the Postgres pool -with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache, -and the durable accounts / identities / sessions data model. The session and -account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the -store/service layer they will call. +The backend provides the foundation: configuration, the HTTP listener with the +`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose +migrations, OpenTelemetry wiring, an in-memory session cache, and the durable +accounts / identities / sessions data model. The session and account REST +endpoints live in the `gateway`; the backend ships the store/service layer they +call. -Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver` +`internal/engine` is the in-process bridge to the `scrabble-solver` library: a versioned dictionary registry, a deterministic tile bag, and a pure rules `Game` (legal plays, passes, exchanges, resignations and end-condition detection) that emits dictionary-independent move records. It is a library only; -the game domain wires it into the process in Stage 3. +the game domain wires it into the process. -Stage 3 adds `internal/game`, the game domain over the engine. Active games are +`internal/game` is the game domain over the engine. Active games are event-sourced: a `games` row plus an append-only decoded move journal, with the live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It provides create, the play/pass/exchange/resign transitions, an unlimited -score/legality preview, the hint (per-game allowance plus a profile wallet), the +word/score/legality preview, the hint (per-game allowance plus a profile wallet), the word-check tool with complaint capture, per-player game state, history and GCG 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 -Stages 1–2 it is a service/store layer; the HTTP surface lands with the -`gateway` (Stage 6). +the engine it is a service/store layer; the HTTP surface lives in the `gateway`. -Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory -matchmaking pool (FIFO per variant, 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), +invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a +player's active quick games — status `active`/`open`, excluding invitation-linked friend +games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and +`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation +is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby. +`internal/social` owns the friend graph (request/accept), per-user blocks, and per-game chat with nudges folded in as a message kind; chat messages are length-capped, content-filtered (no links/emails/phone numbers, -including obfuscated forms) and stored with the sender's IP. `internal/account` +including obfuscated forms) and stored with the sender's IP. Each message carries an +`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 a 3–4 player game a resignation or timeout drops that seat and the rest play on (the tile disposition is a per-game setting), the game ending when one active seat remains. As before this is a service/store layer — chat and nudges are persisted -but their live delivery, and all REST endpoints, arrive with the `gateway` -(Stage 6); the services are exposed via `Server` accessors for those handlers. +but their live delivery, and all REST endpoints, live in the `gateway`; the +services are exposed via `Server` accessors for those handlers. -Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts — +The robot opponent (`internal/robot`). A pool of durable accounts — each a `kind='robot'` identity, provisioned at startup with chat and friend -requests blocked — backs a human-like name pool. A background driver plays the +requests blocked — carries human-like names. The disguised reaper stamps a **fresh per-game name** on +the robot's seat (a `game_players.display_name` snapshot — which also freezes humans' names per game, so +a later rename never rewrites past games) drawn from a wide composed corpus (Western locales, native +Japanese/Chinese, a gender-agreed Russian pool, and handles); a Russian game stays Cyrillic with ≤20% +Latin and no CJK script, an English game uses the full corpus (`namevariety.go`, `PickNamed`). A +background driver plays the robot's moves through the public game API as an ordinary seated player (so only `internal/engine` imports the solver): it decides once per game whether to play to -win (≈ 40%), targets a small score margin, and times its moves with a right-skewed -delay, a night-sleep window anchored to the opponent's timezone, and nudge +win (≈ 40%), targets a small score margin — with an occasional off-strategy move that tapers to +none as the bag empties — and times its moves with a move-number-aware +right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra -state. The matchmaker now substitutes a pooled robot after a 10-second wait and -exposes `Poll` so a waiting player can collect the started game (the live -match-found notification arrives with the `gateway`). +state. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound +to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think +time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal +delay, so a decided game is not dragged out while the robot never moves slower than usual. 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. -Stage 6 opens the backend to the edge. The route groups gain their first +The same robot also backs an **honest-AI quick game** (`games.vs_ai`), the alternative to the random +path that the player chooses on New Game. `Matchmaker.StartVsAI` picks a pooled robot and creates a +game **already seated and active** (random seat order) — it never enters the open pool. The robot +driver has a `vs_ai` branch (no sleep, no proactive nudge, zero delay) plus a focused +`DriveGame`/`TriggerMove` fast path wired from the game service's after-create/after-commit hook +(`SetAITrigger`), so the robot replies the instant the player moves. AI games keep the same strength +(`playToWin`), have **no per-move timeout** (`turn_timeout_secs = AIInactivityTimeout`, 7 days, so an +abandoned game is lost after a week of inactivity — only the human is ever on the clock), record **no +statistics** (skipped in `commit`), and disable chat/nudge (`social` `VsAI` → `ErrGameVsAI`). + +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). Stage 8 fills in the social/account/history -operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, +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}`, -`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a +`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the -gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's -Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway -uses to route out-of-app push to the Telegram connector, extends the Telegram login to -seed a new account's language and display name from the launch fields, and adds -migration `00007` (`accounts.notifications_in_app_only`, default true). -Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row -with no identity, excluded from statistics. **Stage 10** adds the server-rendered +gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's +Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway +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`; the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the -**complaint resolution** lifecycle (migration `00008` adds `disposition`/`resolution_note`/ -`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change -pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR//` -(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a -backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each -broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds -migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram -user last signed in through, written on every login and returned by -`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes -to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. +**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/ +`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change +pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz` release +archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` + +`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the +`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a +backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's +**bot-link relay** — each broadcast renders through the bot in an operator-chosen language +and the relay awaits the bot's delivery ack. There is one bot, +so `/internal/push-target` returns the recipient's `preferred_language` as the render +language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + +`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional +window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches +the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 +&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs +publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire +contracts live in the sibling [`../pkg`](../pkg) module. -Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link` +**Account linking & merge** (`/api/v1/user/link/*`). `internal/link` orchestrates it: an email confirm-code or a gateway-validated Telegram identity is attached to the current account, and when the identity already has its own account the two are merged in one transaction (`internal/accountmerge`) — stats and the hint @@ -96,8 +144,23 @@ friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (s shared finished game's foreign keys hold); a shared **active** game blocks the merge. The current account is primary, except a guest initiator whose linked identity has a durable owner — then the durable account wins and a fresh session is minted for it. -Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the -Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). +The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the +former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). + +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). + +The gateway also syncs its active IP bans (prod-only — see ARCHITECTURE §11) to +`POST /api/v1/internal/bans/sync`; `internal/banview` mirrors them for the console's +**Throttled** page (an **Active IP bans** panel with an **Unban** action) and returns +the operator's pending unbans in the response, which the gateway applies on its next +sync. Like `ratewatch` it is in-memory and resets on restart — the enforced ban lives +in the gateway, not here. ## Package layout @@ -110,17 +173,21 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations migrations/ # embedded *.sql (goose), schema `backend` jet/ # generated go-jet models + table builders (committed) internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives -internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11) -internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11) +internal/accountmerge/ # single-transaction merge of a secondary account into a primary +internal/link/ # link/merge orchestrator over account + accountmerge + session internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount) 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/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 -internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) +internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet) +internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts) +internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag +internal/banview/ # gateway active-ban mirror: the console's Active IP bans panel + the operator unban backchannel ``` ## Configuration (environment) @@ -139,7 +206,7 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b | `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). | | `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. | | `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. | -| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. | +| `BACKEND_DICT_VERSION` | `v1` | Version label for the flat dictionary dir. Recorded in a `.seed_version` marker on first boot and authoritative after: on a seeded volume a changed value is ignored (it seeds only a fresh volume) — the seed-drift guard (ARCHITECTURE.md §5). | | `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. | | `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. | | `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | @@ -150,16 +217,18 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b | `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | | `BACKEND_SMTP_PASSWORD` | — | SMTP password. | | `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | -| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | +| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay 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. | +| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. | ## Run ```sh docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine # DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg): -mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg +mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ BACKEND_DICT_DIR=/tmp/dawg \ GOPRIVATE='gitea.iliadenisov.ru/*' \ @@ -176,7 +245,10 @@ warmed. ## Migrations & generated code Migrations are plain goose SQL under `internal/postgres/migrations` (sequential -`NNNNN_name.sql`), embedded and applied at startup. After changing the schema, +`NNNNN_name.sql`), embedded and applied at startup. The incremental history was +squashed into a single `00001_baseline.sql` before the first production deploy +(there was no production data); new schema changes append as `00002_*` onward. +After changing the schema, regenerate the committed go-jet code (needs Docker): ```sh @@ -194,9 +266,15 @@ local solver co-development you may add a temporary replace — see `go.work`). (`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact** from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary) repo (one semver per set); the engine loads them by `(variant, dict_version)` from -`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency -(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14 -(TODO-1/TODO-2). +`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency +(a missing dictionary aborts the boot). The flat directory is the seed version, +labelled `BACKEND_DICT_VERSION`; uploaded versions live in `/` +subdirectories the admin console writes and a restart re-loads. Because the DAWGs +carry no embedded version, the first boot records the seed in a `.seed_version` +marker that is authoritative after: on a seeded volume a changed `BACKEND_DICT_VERSION` +is ignored (it seeds only a fresh volume) — the seed-drift guard — so a live contour's +dictionary is changed through the console, never by bumping the build seed +(ARCHITECTURE.md §5). ## Tests diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 4b8ce31..69ee34a 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -15,19 +15,24 @@ import ( "syscall" "time" + "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/accountmerge" + "scrabble/backend/internal/ads" + "scrabble/backend/internal/banview" "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" "scrabble/backend/internal/notify" "scrabble/backend/internal/postgres" "scrabble/backend/internal/pushgrpc" + "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/robot" "scrabble/backend/internal/server" "scrabble/backend/internal/session" @@ -107,7 +112,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.String("dir", cfg.Game.DictDir), zap.String("version", cfg.Game.DictVersion)) - // Stage 10 admin console: an optional backend client to the Telegram connector + // Admin console: an optional backend client to the Telegram connector // side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty) // leaves broadcasts disabled — the console shows a "not configured" notice. var conn *connector.Client @@ -132,14 +137,23 @@ 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) + // Reconcile the persisted active dictionary version with the registry: a + // version activated through the admin console (and written to the dictionary + // volume) is adopted again after a restart; otherwise the configured seed + // version is kept and persisted (docs/ARCHITECTURE.md §5). + if err := games.InitActiveVersion(ctx); err != nil { + return fmt.Errorf("init active dictionary version: %w", err) + } + logger.Info("active dictionary version", zap.String("version", games.ActiveVersion())) games.SetNotifier(hub) games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game")) go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval) logger.Info("game turn-timeout sweeper started", zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) - // Stage 12 TODO-3: reap abandoned guest accounts (no game seat, account age past + // Reap abandoned guest accounts (no game seat, account age past // the retention window). Dependent rows fall away via ON DELETE CASCADE. guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger) go guestReaper.Run(ctx, cfg.GuestReapInterval) @@ -147,34 +161,74 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("retention", cfg.GuestRetention)) - // Stage 4 lobby & social domains. Their REST and stream surface is added with - // the gateway in Stage 6, so they are handed to the server (like the route - // groups) for the handlers to come. + // 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) emails := account.NewEmailService(accounts, mailer) - // Stage 11 account linking & merge: the orchestrator over the account, merge and + // Account linking & merge: the orchestrator over the account, merge and // session layers. Wired to the /api/v1/user/link REST surface below. links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc.SetNotifier(hub) 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) - // Stage 5 robot opponent: provision its durable account pool (a hard startup + // Robot opponent: provision its durable account pool (a hard startup // dependency, like the dictionaries) and start its move driver. The matchmaker // substitutes a pooled robot for a missing human after the wait window. robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger) if err := robots.EnsurePool(ctx); err != nil { return fmt.Errorf("provision robot pool: %w", err) } + // Honest-AI fast path: a move in a vs_ai game triggers the robot's reply at once + // (the periodic driver below is the fallback). Set after the pool is provisioned. + games.SetAITrigger(robots.TriggerMove) 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) + matchmaker.SetBlocker(socialSvc) 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. + rateWatch := ratewatch.New(cfg.RateWatch, accounts, logger) + logger.Info("rate watch ready", + zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold), + zap.Duration("flag_window", cfg.RateWatch.FlagWindow)) + + // Ban observability: mirror the gateway's active IP bans for the admin console's + // active-bans panel and collect operator unban requests. + banView := banview.New() + + // Advertising-banner domain: campaign rotation feeding the profile.get banner + // block and the banner admin console section. + adsSvc := ads.NewService(ads.NewStore(db)) srv := server.New(cfg.HTTPAddr, server.Deps{ Logger: logger, @@ -184,6 +238,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, @@ -192,6 +247,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Registry: registry, DictDir: cfg.Game.DictDir, Connector: conn, + RateWatch: rateWatch, + BanView: banView, + Ads: adsSvc, + Notifier: hub, }) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) diff --git a/backend/go.mod b/backend/go.mod index c33416d..bf06733 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.1 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/account/account.go b/backend/internal/account/account.go index 4d26099..816ba4b 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" @@ -25,8 +24,8 @@ import ( // Identity kinds recognised by the backend. Email is modelled as an identity // alongside platform identities; its confirmed flag is driven by the email -// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled -// robot opponent is a durable account bound to one robot identity (Stage 5). +// confirm-code flow. Robot is a synthetic kind: each pooled +// robot opponent is a durable account bound to one robot identity. const ( KindTelegram = "telegram" KindEmail = "email" @@ -56,29 +55,34 @@ type Account struct { HintBalance int BlockChat bool BlockFriendRequests bool - // ServiceLanguage is the language tag (en/ru) of the bot the account last - // authenticated through (its last Telegram ValidateInitData); it routes the - // account's out-of-app push back through the right bot. Empty when the account - // has never signed in through a tagged bot. Distinct from PreferredLanguage (the - // interface language) and from a game's variant language. - ServiceLanguage string + // VariantPreferences is the set of game variants (engine.Variant stable labels: + // "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched + // into. It gates the New Game picker, the matchmaker and the friend-invite the + // player creates; an invited friend may still accept any variant. A new account + // defaults to Erudit only. Never empty — enforced on update and by a DB check. + VariantPreferences []string // IsGuest marks an ephemeral guest account: a durable row with no identity, // excluded from statistics, friends and history. IsGuest bool // NotificationsInAppOnly confines notifications to the in-app live stream when // true (the default): the platform side-service skips out-of-app push for the - // account (Stage 9). + // account. NotificationsInAppOnly bool // PaidAccount marks a lifetime one-time-payment account. It is a service field // (no purchase flow yet); an account linking & merge ORs it so a paid status is - // never lost when accounts are consolidated (Stage 11). + // never lost when accounts are consolidated. PaidAccount bool // MergedInto is the primary account a retired (merged) secondary points at, or // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade - // foreign keys of a shared finished game stay valid (Stage 11). + // foreign keys of a shared finished game stay valid. MergedInto uuid.UUID - CreatedAt time.Time - UpdatedAt time.Time + // FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the + // zero time for an unflagged account, otherwise when the gateway-reported + // rate-limiter rejections first crossed the sustained threshold. An + // operator clears it in the admin console; it never gates any request. + FlaggedHighRateAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } // Identity is one of an account's platform/email identities, surfaced on the @@ -93,12 +97,17 @@ 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 + // 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. +// 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(), suspensions: newSuspensionCache()} } // ProvisionByIdentity returns the account bound to (kind, externalID), creating @@ -110,13 +119,58 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string return s.provision(ctx, kind, externalID, provisionSeed{}) } -// 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 -// 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)) +// ProvisionRobot provisions (or finds) the durable account backing a robot pool +// member: a KindRobot identity carrying displayName, with chat blocked but friend +// requests NOT blocked — a request to a robot is accepted as pending and, since the +// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human +// who ignores the request. Robot names are system-generated, not player-edited, so they +// bypass the editable display-name validation and may carry forms the editor rejects (an +// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the +// display name and both block flags. +func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) { + acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName}) + if err != nil { + return Account{}, err + } + if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests { + return acc, nil + } + stmt := table.Accounts.UPDATE( + table.Accounts.DisplayName, table.Accounts.BlockChat, + table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt, + ).SET( + postgres.String(displayName), postgres.Bool(true), + postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))). + RETURNING(table.Accounts.AllColumns) + + var row model.Accounts + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err) + } + return modelToAccount(row), nil +} + +// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity, +// 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, @@ -153,19 +207,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 @@ -259,6 +315,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) { @@ -331,6 +395,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 +424,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 } @@ -380,22 +450,63 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { return n > 0, nil } -// SetServiceLanguage records the service language (en/ru) of the bot a Telegram -// user authenticated through. It is called on every Telegram login — new and -// existing accounts — so it tracks the bot the user last came through (last-login- -// wins), and the out-of-app push routes by it. It is a no-op for an empty language -// (a non-Telegram login carries none) and does not bump updated_at (an infra -// routing field, not a user profile edit). -func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error { - if language == "" { - return 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.ServiceLanguage). - SET(postgres.String(language)). + 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 +// a profile edit, so updated_at is untouched; it never gates any request. +// It reports whether the flag was newly set. +func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) { + stmt := table.Accounts. + UPDATE(table.Accounts.FlaggedHighRateAt). + SET(postgres.TimestampzT(at.UTC())). + WHERE( + table.Accounts.AccountID.EQ(postgres.UUID(id)). + AND(table.Accounts.FlaggedHighRateAt.IS_NULL()), + ) + res, err := stmt.ExecContext(ctx, s.db) + if err != nil { + return false, fmt.Errorf("account: flag high rate %s: %w", id, err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("account: flag high rate rows %s: %w", id, err) + } + return n > 0, nil +} + +// ClearHighRateFlag removes the high-rate marker — the operator's reversible +// action in the admin console. Clearing an unflagged account is a no-op. +func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error { + stmt := table.Accounts. + UPDATE(table.Accounts.FlaggedHighRateAt). + SET(postgres.NULL). WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { - return fmt.Errorf("account: set service language %s: %w", id, err) + return fmt.Errorf("account: clear high-rate flag %s: %w", id, err) } return nil } @@ -406,15 +517,15 @@ func modelToAccount(row model.Accounts) Account { if row.MergedInto != nil { mergedInto = *row.MergedInto } - var serviceLanguage string - if row.ServiceLanguage != nil { - serviceLanguage = *row.ServiceLanguage + var flaggedHighRateAt time.Time + if row.FlaggedHighRateAt != nil { + flaggedHighRateAt = *row.FlaggedHighRateAt } return Account{ ID: row.AccountID, DisplayName: row.DisplayName, PreferredLanguage: row.PreferredLanguage, - ServiceLanguage: serviceLanguage, + VariantPreferences: []string(row.VariantPreferences), TimeZone: row.TimeZone, AwayStart: row.AwayStart, AwayEnd: row.AwayEnd, @@ -425,6 +536,7 @@ func modelToAccount(row model.Accounts) Account { NotificationsInAppOnly: row.NotificationsInAppOnly, PaidAccount: row.PaidAccount, MergedInto: mergedInto, + FlaggedHighRateAt: flaggedHighRateAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index ebbb775..4efbbfe 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -33,7 +33,7 @@ var ( // ErrInvalidEmail is returned for an unparseable email address. ErrInvalidEmail = errors.New("account: invalid email address") // ErrEmailTaken is returned when the email is already confirmed by another - // account; binding it would be a merge, which Stage 11 owns. + // account; binding it would be a merge, which the link/merge flow owns. ErrEmailTaken = errors.New("account: email already confirmed by another account") // ErrAlreadyConfirmed is returned when the email is already confirmed by the // requesting account. @@ -52,8 +52,8 @@ var ( // Mailer and verifies it, binding a confirmed email identity to the requesting // account. Only the SHA-256 hash of a code is stored (never the plaintext), // matching the session model. Binding an email already confirmed by a different -// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and -// using an email as a login is Stage 6, which reuses this mechanism. +// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow — +// and using an email as a login reuses this mechanism. type EmailService struct { store *Store mailer Mailer @@ -128,7 +128,7 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema // RequestLoginCode issues a login confirm-code to the account that owns email, // provisioning a fresh (unconfirmed) durable account when the email is new. It is -// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode, +// the unauthenticated email-login entry point and, unlike RequestCode, // does not refuse an already-confirmed email — that is the ordinary returning-user // login. The code is mailed to the address, so only its real owner can complete // the login. It returns the target account id for the subsequent LoginWithCode. diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index bcc18fb..4aa2f18 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -13,14 +13,14 @@ import ( ) // ErrIdentityTaken is returned when a platform identity being linked already -// belongs to another account; the caller turns it into a merge (Stage 11). +// belongs to another account; the caller turns it into a merge. var ErrIdentityTaken = errors.New("account: identity already linked to another account") // RequestLinkCode issues and mails a confirm-code for email to accountID, // replacing any prior pending code. Unlike RequestCode it never refuses up front // (taken or already-confirmed): possession of the address is the authorization for // a later link or merge, and the merge is only revealed once the code is verified, -// so a probe cannot learn whether an address is registered (Stage 11). +// so a probe cannot learn whether an address is registered. func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error { addr, err := normalizeEmail(email) if err != nil { @@ -94,7 +94,7 @@ func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUI // AccountIDByIdentity returns the account owning (kind, externalID) and true, or // (uuid.Nil, false) when the identity is free. It backs the platform-identity link -// flow (Stage 11). +// flow. func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) { acc, err := s.findByIdentity(ctx, kind, externalID) if errors.Is(err, ErrNotFound) { @@ -109,7 +109,7 @@ func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string // AttachIdentity links a new (kind, externalID) identity to an existing account. // A unique-constraint violation means the identity was taken meanwhile, surfaced // as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram) -// to the current account during linking (Stage 11). +// to the current account during linking. func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error { id, err := uuid.NewV7() if err != nil { @@ -129,7 +129,7 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e } // ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest -// to a durable account once it gains its first identity (Stage 11). It is a no-op +// to a durable account once it gains its first identity. It is a no-op // for an already-durable account. func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error { upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt). 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/backend/internal/account/profile.go b/backend/internal/account/profile.go index 03f6150..fbee7a7 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -4,14 +4,17 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "regexp" "strings" "time" + "unicode" "unicode/utf8" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" + "github.com/lib/pq" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" @@ -21,14 +24,23 @@ import ( // is unbounded; auto-provisioned platform names bypass this editor validation). const maxDisplayName = 32 +// maxDisplayNameSpecials caps the total special characters (every name rune that is +// neither a letter, a space, nor a digit — i.e. the "." / "_" separators) an editable +// display name may carry, so a still-well-formed name cannot be made of mostly +// punctuation. A trailing digit run is bounded separately by displayNameRe. +const maxDisplayNameSpecials = 5 + // maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). const maxAwayWindow = 12 * time.Hour -// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters +// displayNameRe enforces the editable display-name format: Unicode letters // joined by single space / "." / "_" separators, where a "." or "_" may be followed -// by a single space. No leading or trailing separator and no two adjacent separators, -// except " ". So "Name_P. Last" is valid, "Name P._Last" is not. -var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`) +// by a single space. No leading separator and no two adjacent separators (except +// " "). The name may end with EITHER a single trailing "." +// (an initial, "Anna B.") OR a run of 1–5 digits (a handle's number or year, +// "Player2007"), but not both; digits never appear elsewhere. So "Name_P. Last", +// "Anna B." and "Аня2007" are valid, while "Name P._Last" and "Dark2Wolf" are not. +var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*(?:\.|[0-9]{1,5})?$`) // ErrInvalidProfile is returned when a profile update carries an unacceptable // field (an unknown language, an invalid timezone, or an over-long display name). @@ -47,6 +59,46 @@ type ProfileUpdate struct { BlockChat bool BlockFriendRequests bool NotificationsInAppOnly bool + // VariantPreferences is the set of game variants the player allows themselves to + // be matched into (engine.Variant stable labels). UpdateProfile cleans it to a + // deduplicated, canonically ordered subset of the known variants and rejects an + // empty set. + VariantPreferences []string +} + +// knownVariants is the closed set of game-variant labels (engine.Variant stable +// labels) a profile's variant preferences may contain. It lives here so the store +// does not depend on the engine package; the server handler additionally validates +// against engine.ParseVariant, and a DB check enforces the same subset. +var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true} + +// canonicalVariantOrder is the deterministic order variant preferences are stored +// in (Erudit, Russian Scrabble, English), independent of the client's order. +var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"} + +// validateVariantPreferences cleans a profile's variant-preference set: it drops +// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and +// returns the preferences in canonicalVariantOrder so the stored value is +// deterministic regardless of the order the client sent. +func validateVariantPreferences(prefs []string) ([]string, error) { + seen := make(map[string]bool, len(prefs)) + for _, p := range prefs { + p = strings.TrimSpace(p) + if !knownVariants[p] { + return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p) + } + seen[p] = true + } + if len(seen) == 0 { + return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile) + } + out := make([]string, 0, len(seen)) + for _, v := range canonicalVariantOrder { + if seen[v] { + out = append(out, v) + } + } + return out, nil } // UpdateProfile validates and overwrites the editable fields of the account, then @@ -68,17 +120,26 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil { return Account{}, err } + prefs, err := validateVariantPreferences(p.VariantPreferences) + if err != nil { + return Account{}, err + } stmt := table.Accounts.UPDATE( table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.AwayStart, table.Accounts.AwayEnd, table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, - table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt, + table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences, + table.Accounts.UpdatedAt, ).SET( postgres.String(name), postgres.String(lang), postgres.String(tz), postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd), postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), - postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()), + postgres.Bool(p.NotificationsInAppOnly), + // prefs are validated against the closed knownVariants set; bind as a text[] + // parameter (lib/pq encodes the array, the cast pins the column type). + postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}), + postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). RETURNING(table.Accounts.AllColumns) @@ -107,9 +168,51 @@ func ValidateDisplayName(raw string) (string, error) { if !displayNameRe.MatchString(name) { return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile) } + specials := 0 + for _, r := range name { + if r != ' ' && !unicode.IsLetter(r) && !unicode.IsDigit(r) { + specials++ + } + } + if specials > maxDisplayNameSpecials { + return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials) + } 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/profile_test.go b/backend/internal/account/profile_test.go index a0b7d5b..5003dd9 100644 --- a/backend/internal/account/profile_test.go +++ b/backend/internal/account/profile_test.go @@ -3,6 +3,7 @@ package account import ( "context" "errors" + "slices" "strings" "testing" "time" @@ -12,11 +13,11 @@ import ( // TestUpdateProfileValidation checks that bad fields are rejected before any // database access, so a nil-backed Store is enough to exercise the guards. It also -// confirms UpdateProfile wires the Stage 8 validators (name format, away window, +// confirms UpdateProfile wires the validators (name format, away window, // offset/IANA timezone), not just their unit tests in validate_test.go. func TestUpdateProfileValidation(t *testing.T) { s := &Store{} - base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"} + base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}} hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) } tests := []struct { name string @@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) { {"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }}, {"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }}, {"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }}, + {"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }}, + {"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -39,3 +42,22 @@ func TestUpdateProfileValidation(t *testing.T) { }) } } + +// TestValidateVariantPreferences checks the cleaning of a profile's variant set: +// duplicates collapse, the result is canonically ordered (Erudit, Russian Scrabble, +// English) regardless of input order, and an empty or unknown set is rejected. +func TestValidateVariantPreferences(t *testing.T) { + got, err := validateVariantPreferences([]string{"scrabble_en", "erudit_ru", "scrabble_en"}) + if err != nil { + t.Fatalf("validate: %v", err) + } + if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + if _, err := validateVariantPreferences(nil); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("empty err = %v, want ErrInvalidProfile", err) + } + if _, err := validateVariantPreferences([]string{"chess"}); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("unknown err = %v, want ErrInvalidProfile", err) + } +} 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/account/roles.go b/backend/internal/account/roles.go new file mode 100644 index 0000000..acc1d6d --- /dev/null +++ b/backend/internal/account/roles.go @@ -0,0 +1,105 @@ +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" + + // RoleNoBanner suppresses the in-app advertising banner for the account + // 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, RoleChatMuted} + +// 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/account/stats.go b/backend/internal/account/stats.go index 707361f..8530754 100644 --- a/backend/internal/account/stats.go +++ b/backend/internal/account/stats.go @@ -2,6 +2,7 @@ package account import ( "context" + "encoding/json" "errors" "fmt" @@ -13,16 +14,43 @@ import ( "scrabble/backend/internal/postgres/jet/backend/table" ) +// BestMoveTile is one letter cell of a best-move word: its concrete letter (the +// designated letter for a blank), its tile point value (0 for a blank) and whether it +// is a blank. It is the persisted/served shape: the game domain marshals a slice of +// these into account_best_move.tiles, and the statistics screen renders them as game +// tiles without consulting the variant's alphabet. +type BestMoveTile struct { + Letter string `json:"letter"` + Value int `json:"value"` + Blank bool `json:"blank"` +} + +// BestMove is an account's highest-scoring single play within one game variant: the +// move's total score (every word it formed plus the all-tiles bonus, matching +// MaxWordPoints) and its main word as an ordered slice of tiles. +type BestMove struct { + Variant string + Score int + Tiles []BestMoveTile +} + // Stats is a durable account's lifetime record, written by the game domain on each // finish and read for the player's statistics screen. MaxGamePoints is the best // single game's total; MaxWordPoints is the best single move's score (which already -// includes every word it formed plus the all-tiles bonus). +// includes every word it formed plus the all-tiles bonus). BestMoves holds the same +// best move broken down per variant, with the word itself — empty for an account with +// no recorded play yet, and never carrying a variant the account has not played. type Stats struct { Wins int Losses int Draws int MaxGamePoints int MaxWordPoints int + // Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the + // lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves). + Moves int + HintsUsed int + BestMoves []BestMove } // GetStats returns the lifetime statistics for id. An account with no account_stats @@ -40,11 +68,48 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) { } return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err) } + best, err := s.bestMoves(ctx, id) + if err != nil { + return Stats{}, err + } return Stats{ Wins: int(row.Wins), Losses: int(row.Losses), Draws: int(row.Draws), MaxGamePoints: int(row.MaxGamePoints), MaxWordPoints: int(row.MaxWordPoints), + Moves: int(row.Moves), + HintsUsed: int(row.HintsUsed), + BestMoves: best, }, nil } + +// bestMoves reads an account's per-variant best moves, ordered by variant for a stable +// response. Each row's tiles JSON is decoded into the served BestMoveTile slice. An +// account with no recorded play yields an empty (nil) slice rather than an error. +func (s *Store) bestMoves(ctx context.Context, id uuid.UUID) ([]BestMove, error) { + stmt := postgres.SELECT( + table.AccountBestMove.Variant, + table.AccountBestMove.Score, + table.AccountBestMove.Tiles, + ). + FROM(table.AccountBestMove). + WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(id))). + ORDER_BY(table.AccountBestMove.Variant.ASC()) + var rows []model.AccountBestMove + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("account: best moves %s: %w", id, err) + } + out := make([]BestMove, 0, len(rows)) + for _, r := range rows { + var tiles []BestMoveTile + if err := json.Unmarshal([]byte(r.Tiles), &tiles); err != nil { + return nil, fmt.Errorf("account: decode best-move tiles %s/%s: %w", id, r.Variant, err) + } + out = append(out, BestMove{Variant: r.Variant, Score: int(r.Score), Tiles: tiles}) + } + return out, nil +} diff --git a/backend/internal/account/suspension.go b/backend/internal/account/suspension.go new file mode 100644 index 0000000..fe55962 --- /dev/null +++ b/backend/internal/account/suspension.go @@ -0,0 +1,375 @@ +package account + +import ( + "context" + "errors" + "fmt" + "sync" + "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" +) + +// Suspension is an account's currently-in-force manual block, the operator's hard counterpart +// to the soft, reversible FlaggedHighRateAt marker. It is named "suspension" to stay distinct +// from the peer-to-peer blocks in internal/social (one player muting another); the vocabulary +// the player sees is "blocked". BlockedUntil is nil for a permanent block and the expiry +// instant for a temporary one. ReasonEn and ReasonRu are the reason-text snapshot taken at +// block time (both empty when no reason was cited), so editing or deleting the picklist entry +// never changes what an already-blocked player is shown. +type Suspension struct { + AccountID uuid.UUID + BlockedAt time.Time + BlockedUntil *time.Time + ReasonEn string + ReasonRu string +} + +// Permanent reports whether the suspension has no expiry. +func (s Suspension) Permanent() bool { return s.BlockedUntil == nil } + +// LocalizedReason returns the reason text in the given language ("ru" selects Russian, anything +// else English), or empty when no reason was cited. The two snapshots are always both set or +// both empty, since a reason is chosen from the en+ru picklist. +func (s Suspension) LocalizedReason(language string) string { + if language == "ru" { + return s.ReasonRu + } + return s.ReasonEn +} + +// Reason is one entry of the operator-editable suspension-reason picklist, carrying the English +// and Russian text shown to a blocked player in their language. +type Reason struct { + ID uuid.UUID + TextEn string + TextRu string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Suspend records a new manual block on the account: permanent when until is nil, otherwise in +// force until that instant. reasonEn and reasonRu are the optional reason-text snapshot (both +// empty for no reason) and reasonID is the loose picklist link (nil when none, nulled later if +// that entry is deleted). It returns the persisted Suspension. Suspending an already-blocked +// account simply appends another block; CurrentSuspension always reflects the strongest. +func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Time, reasonEn, reasonRu string, reasonID *uuid.UUID) (Suspension, error) { + suspensionID, err := uuid.NewV7() + if err != nil { + return Suspension{}, fmt.Errorf("account: new suspension id: %w", err) + } + stmt := table.AccountSuspensions.INSERT( + table.AccountSuspensions.SuspensionID, + table.AccountSuspensions.AccountID, + table.AccountSuspensions.BlockedUntil, + table.AccountSuspensions.ReasonEn, + table.AccountSuspensions.ReasonRu, + table.AccountSuspensions.ReasonID, + ).VALUES( + suspensionID, + accountID, + nullableTimestamp(until), + nullableString(reasonEn), + nullableString(reasonRu), + nullableUUID(reasonID), + ).RETURNING(table.AccountSuspensions.AllColumns) + + var row model.AccountSuspensions + 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 +} + +// LiftSuspension lifts every in-force block on the account (the operator's manual unblock), +// stamping lifted_at on each. It is a no-op when the account is not currently blocked. Lifting +// does not un-resign games already forfeited at block time — those stay lost. +func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error { + now := time.Now().UTC() + stmt := table.AccountSuspensions. + UPDATE(table.AccountSuspensions.LiftedAt). + SET(postgres.TimestampzT(now)). + WHERE( + table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)). + AND(activeSuspensionPredicate(now)), + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: lift suspension %s: %w", accountID, err) + } + s.invalidateSuspension(accountID) + return nil +} + +// CurrentSuspension returns the account's in-force block and true, or false when the account is +// not blocked. When several blocks overlap it returns the strongest — a permanent one first, +// otherwise the latest-expiring — which is what the player's blocked screen shows. The gate +// middleware calls it on every authenticated request, served by account_suspensions_account_idx. +func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Suspension, bool, error) { + // A store with no database (the zero-value store used by the routing unit tests) has no + // suspensions; report not-blocked rather than dereferencing a nil pool. A real pool that + // errors still surfaces the error to the gate, which fails closed. + if s.db == nil { + 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( + table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)). + AND(activeSuspensionPredicate(now)), + ). + ORDER_BY(table.AccountSuspensions.BlockedUntil.DESC().NULLS_FIRST()). + LIMIT(1) + + var row model.AccountSuspensions + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Suspension{}, false, nil + } + return Suspension{}, false, fmt.Errorf("account: current suspension %s: %w", accountID, err) + } + 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) { + 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 { + return table.AccountSuspensions.LiftedAt.IS_NULL(). + AND( + table.AccountSuspensions.BlockedUntil.IS_NULL(). + OR(table.AccountSuspensions.BlockedUntil.GT(postgres.TimestampzT(now))), + ) +} + +// ListReasons returns the suspension-reason picklist, oldest first. +func (s *Store) ListReasons(ctx context.Context) ([]Reason, error) { + stmt := postgres.SELECT(table.SuspensionReasons.AllColumns). + FROM(table.SuspensionReasons). + ORDER_BY(table.SuspensionReasons.CreatedAt.ASC()) + var rows []model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("account: list reasons: %w", err) + } + out := make([]Reason, 0, len(rows)) + for _, r := range rows { + out = append(out, modelToReason(r)) + } + return out, nil +} + +// GetReason loads one picklist entry, or ErrNotFound when it is absent. +func (s *Store) GetReason(ctx context.Context, id uuid.UUID) (Reason, error) { + stmt := postgres.SELECT(table.SuspensionReasons.AllColumns). + FROM(table.SuspensionReasons). + WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Reason{}, ErrNotFound + } + return Reason{}, fmt.Errorf("account: get reason %s: %w", id, err) + } + return modelToReason(row), nil +} + +// CreateReason inserts a new picklist entry with the given English and Russian text. +func (s *Store) CreateReason(ctx context.Context, textEn, textRu string) (Reason, error) { + id, err := uuid.NewV7() + if err != nil { + return Reason{}, fmt.Errorf("account: new reason id: %w", err) + } + stmt := table.SuspensionReasons.INSERT( + table.SuspensionReasons.ReasonID, + table.SuspensionReasons.TextEn, + table.SuspensionReasons.TextRu, + ).VALUES(id, textEn, textRu). + RETURNING(table.SuspensionReasons.AllColumns) + var row model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Reason{}, fmt.Errorf("account: create reason: %w", err) + } + return modelToReason(row), nil +} + +// UpdateReason rewrites a picklist entry's English and Russian text, returning ErrNotFound when +// it is absent. Existing suspensions keep their text snapshot, so the change only affects future +// blocks. +func (s *Store) UpdateReason(ctx context.Context, id uuid.UUID, textEn, textRu string) (Reason, error) { + stmt := table.SuspensionReasons. + UPDATE(table.SuspensionReasons.TextEn, table.SuspensionReasons.TextRu, table.SuspensionReasons.UpdatedAt). + SET(postgres.String(textEn), postgres.String(textRu), postgres.TimestampzT(time.Now().UTC())). + WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))). + RETURNING(table.SuspensionReasons.AllColumns) + var row model.SuspensionReasons + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Reason{}, ErrNotFound + } + return Reason{}, fmt.Errorf("account: update reason %s: %w", id, err) + } + return modelToReason(row), nil +} + +// DeleteReason removes a picklist entry. It is a hard delete: the reason_id link on past +// suspensions is nulled by the foreign key, but their text snapshot is untouched. +func (s *Store) DeleteReason(ctx context.Context, id uuid.UUID) error { + stmt := table.SuspensionReasons. + DELETE(). + WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: delete reason %s: %w", id, err) + } + return nil +} + +// modelToSuspension projects a generated row into the public Suspension struct. +func modelToSuspension(row model.AccountSuspensions) Suspension { + s := Suspension{AccountID: row.AccountID, BlockedAt: row.BlockedAt, BlockedUntil: row.BlockedUntil} + if row.ReasonEn != nil { + s.ReasonEn = *row.ReasonEn + } + if row.ReasonRu != nil { + s.ReasonRu = *row.ReasonRu + } + return s +} + +// modelToReason projects a generated row into the public Reason struct. +func modelToReason(row model.SuspensionReasons) Reason { + return Reason{ID: row.ReasonID, TextEn: row.TextEn, TextRu: row.TextRu, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} +} + +// nullableString renders an empty string as SQL NULL, otherwise the string literal. +func nullableString(v string) postgres.Expression { + if v == "" { + return postgres.NULL + } + return postgres.String(v) +} + +// nullableTimestamp renders a nil time as SQL NULL, otherwise the UTC timestamp literal. +func nullableTimestamp(v *time.Time) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.TimestampzT(v.UTC()) +} + +// nullableUUID renders a nil id as SQL NULL, otherwise the uuid literal. +func nullableUUID(v *uuid.UUID) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.UUID(*v) +} 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") + } +} 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/account/timezone.go b/backend/internal/account/timezone.go index 3158ab1..41f0663 100644 --- a/backend/internal/account/timezone.go +++ b/backend/internal/account/timezone.go @@ -7,7 +7,7 @@ import ( ) // offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the -// Stage 8 profile editor stores (an offset dropdown rather than an IANA name). +// profile editor stores (an offset dropdown rather than an IANA name). var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`) // parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go new file mode 100644 index 0000000..f2c39a3 --- /dev/null +++ b/backend/internal/account/userlist.go @@ -0,0 +1,149 @@ +package account + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// UserListItem is the admin user-list projection: a small subset of the account plus +// whether it is a robot (derived from its identities), so the console can label the kind +// without a per-row identity query. +type UserListItem struct { + ID uuid.UUID + DisplayName string + PreferredLanguage string + IsGuest bool + IsRobot bool + // FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown + // as a badge in the console list. + FlaggedHighRateAt time.Time + CreatedAt time.Time +} + +// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the +// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' = +// one char) matched case-insensitively against the display name / any identity's external +// id. An empty mask means no filter on that field. +type UserFilter struct { + Robots bool + NameMask string + ExternalIDMask string +} + +// robotExists is the correlated subquery testing whether account a is a robot. +const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')` + +// IsRobot reports whether the account is a robot pool member (it carries a robot +// identity). The admin console uses it to label a game's robot seats. +func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) { + var ok bool + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`, + accountID).Scan(&ok) + if err != nil { + return false, fmt.Errorf("account: is-robot %s: %w", accountID, err) + } + return ok, nil +} + +// userListWhere builds the shared WHERE clause and its positional args (from $1). +func userListWhere(f UserFilter) (string, []any) { + args := []any{f.Robots} + where := robotExists + ` = $1` + if name := LikePattern(f.NameMask); name != "" { + args = append(args, name) + where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args)) + } + if ext := LikePattern(f.ExternalIDMask); ext != "" { + args = append(args, ext) + where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args)) + } + return where, args +} + +// ListUsers returns the filtered admin user list, newest first, paginated. +func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) { + where, args := userListWhere(f) + q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot +FROM backend.accounts a WHERE ` + where + + fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("account: list users: %w", err) + } + defer rows.Close() + var out []UserListItem + for rows.Next() { + var it UserListItem + var flagged sql.NullTime + if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil { + return nil, fmt.Errorf("account: scan user: %w", err) + } + if flagged.Valid { + it.FlaggedHighRateAt = flagged.Time + } + out = append(out, it) + } + return out, rows.Err() +} + +// FlaggedAccount is one row of the console's high-rate review queue. +type FlaggedAccount struct { + ID uuid.UUID + DisplayName string + FlaggedHighRateAt time.Time +} + +// flaggedListCap bounds the console's flagged-account list; the operator clears +// flags as they are reviewed, so the queue stays short in practice. +const flaggedListCap = 200 + +// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most +// recently flagged first. +func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT account_id, display_name, flagged_high_rate_at +FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL +ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap) + if err != nil { + return nil, fmt.Errorf("account: list flagged: %w", err) + } + defer rows.Close() + var out []FlaggedAccount + for rows.Next() { + var fa FlaggedAccount + if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil { + return nil, fmt.Errorf("account: scan flagged: %w", err) + } + out = append(out, fa) + } + return out, rows.Err() +} + +// CountUsers counts the filtered admin user list, for pagination. +func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) { + where, args := userListWhere(f) + var n int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("account: count users: %w", err) + } + return n, nil +} + +// LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern, +// escaping the SQL wildcards already in the input first. An empty/blank mask returns "". +func LikePattern(mask string) string { + mask = strings.TrimSpace(mask) + if mask == "" { + return "" + } + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask) + escaped = strings.ReplaceAll(escaped, "*", "%") + return strings.ReplaceAll(escaped, "?", "_") +} diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go index 1e8c978..7ccf2ad 100644 --- a/backend/internal/account/validate_test.go +++ b/backend/internal/account/validate_test.go @@ -12,19 +12,33 @@ func TestValidateDisplayName(t *testing.T) { want string ok bool }{ - "plain": {"Kaya", "Kaya", true}, - "cyrillic": {"Кая", "Кая", true}, - "dot underscore mix": {"Name_P. Last", "Name_P. Last", true}, - "single dot": {"Mr.Smith", "Mr.Smith", true}, - "dot then space": {"Mr. Smith", "Mr. Smith", true}, - "trim surrounding": {" Kaya ", "Kaya", true}, - "adjacent specials": {"Name P._Last", "", false}, - "two spaces": {"Name Last", "", false}, - "leading special": {"_Name", "", false}, - "trailing special": {"Name.", "", false}, - "digit rejected": {"Name2", "", false}, - "blank": {" ", "", false}, - "too long": {strings.Repeat("a", 33), "", false}, + "plain": {"Kaya", "Kaya", true}, + "cyrillic": {"Кая", "Кая", true}, + "dot underscore mix": {"Name_P. Last", "Name_P. Last", true}, + "single dot": {"Mr.Smith", "Mr.Smith", true}, + "dot then space": {"Mr. Smith", "Mr. Smith", true}, + "trim surrounding": {" Kaya ", "Kaya", true}, + "adjacent specials": {"Name P._Last", "", false}, + "two spaces": {"Name Last", "", false}, + "leading special": {"_Name", "", false}, + "trailing underscore": {"Name_", "", false}, + "trailing dot ok": {"Anna B.", "Anna B.", true}, + "double trailing dot": {"Name..", "", false}, + "trailing digit ok": {"Name2", "Name2", true}, + "trailing year ok": {"Аня2007", "Аня2007", true}, + "five digits ok": {"Player12345", "Player12345", true}, + "six digits rejected": {"Player123456", "", false}, + "mid digit rejected": {"Dark2Wolf", "", false}, + "all digits rejected": {"12345", "", false}, + "digit then dot": {"Name2.", "", false}, + "dot then digit": {"Anna B.2", "", false}, + "sep plus digits ok": {"Night.Fox2007", "Night.Fox2007", true}, // "." is the only special; digits do not count + "max specials+digits": {"a.a.a.a.a.a2007", "a.a.a.a.a.a2007", true}, // 5 dots + a digit run still passes + "blank": {" ", "", false}, + "too long": {strings.Repeat("a", 33), "", false}, + "five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots + "six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots + "initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count } for name, tc := range cases { t.Run(name, func(t *testing.T) { diff --git a/backend/internal/accountmerge/merge.go b/backend/internal/accountmerge/merge.go index f7fe606..216e373 100644 --- a/backend/internal/accountmerge/merge.go +++ b/backend/internal/accountmerge/merge.go @@ -1,8 +1,9 @@ // Package accountmerge retires a secondary account into a primary one in a single -// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints +// transaction: it sums statistics (merging the per-variant best moves), sums the hint +// wallet, ORs the paid flag, repoints // the secondary's identities, transfers its games/chat/complaints/invitations, // de-duplicates friends and blocks, and leaves the secondary as an audit tombstone -// (accounts.merged_into). It is the data core of Stage 11 account linking & merge +// (accounts.merged_into). It is the data core of account linking & merge // (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated // one layer up (the link service), since the in-memory session cache lives there. package accountmerge @@ -68,6 +69,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error if err := mergeStats(ctx, tx, primary, secondary, now); err != nil { return err } + if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil { + return err + } if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil { return err } @@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid return out, nil } -// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws -// summed, max points kept) and deletes the secondary row. +// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and +// the moves/hints-used counters summed, max points kept) and deletes the secondary row. func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { var sec model.AccountStats err := postgres.SELECT(table.AccountStats.AllColumns). @@ -178,13 +182,16 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n upd := table.AccountStats.UPDATE( table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws, - table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt, + table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, + table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt, ).SET( postgres.Int(int64(pri.Wins+sec.Wins)), postgres.Int(int64(pri.Losses+sec.Losses)), postgres.Int(int64(pri.Draws+sec.Draws)), postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))), postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))), + postgres.Int(int64(pri.Moves+sec.Moves)), + postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)), postgres.TimestampzT(now), ).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))) if _, err := upd.ExecContext(ctx, tx); err != nil { @@ -198,6 +205,41 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n return nil } +// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the +// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes +// the secondary's rows — the secondary is only tombstoned, not removed, so without this +// they would linger on a dead account and never reach the merged statistics screen. +func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { + var srows []model.AccountBestMove + err := postgres.SELECT(table.AccountBestMove.AllColumns). + FROM(table.AccountBestMove). + WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))). + QueryContext(ctx, tx, &srows) + if err != nil && !errors.Is(err, qrm.ErrNoRows) { + return fmt.Errorf("accountmerge: load secondary best moves: %w", err) + } + for _, s := range srows { + ins := table.AccountBestMove. + INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant, + table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt). + VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)). + ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant). + DO_UPDATE(postgres.SET( + table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score), + table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles), + table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt), + ).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score))) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err) + } + } + del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))) + if _, err := del.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: delete secondary best moves: %w", err) + } + return nil +} + // mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag; // all other profile fields stay the primary's. func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 780d1cd..3cda4a0 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } .subnav a.active { color: var(--ink); } .form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; } +.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; } .form.col { flex-direction: column; align-items: stretch; max-width: 540px; } .form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); } .form input, .form select, .form textarea { @@ -85,6 +86,22 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } font: inherit; } .form textarea { min-height: 4rem; resize: vertical; } +/* A static help aside (e.g. message-formatting hints) beside an intro note at the top of a + section: the note fills the row, the aside takes ~40% and both wrap on a narrow viewport. */ +.help-top { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin: 0.2rem 0 0.6rem; } +.help-top .note { flex: 1 1 16rem; margin: 0; } +.help { + flex: 0 1 40%; + min-width: 16rem; + background: var(--panel-hi); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.5rem 0.8rem; + font-size: 0.85rem; + color: var(--ink-dim); +} +.help h4 { margin: 0 0 0.4rem; font-size: 0.85rem; color: var(--ink); } +.help p { margin: 0.35rem 0; } button { background: var(--accent); color: #06121f; @@ -101,3 +118,78 @@ 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); } + +/* 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; } + +/* 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/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, "Users Games Complaints + Feedback + Messages + Throttled + Reasons + Banners Dictionary Broadcast + Grafana ↗
diff --git a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml new file mode 100644 index 0000000..aeeb1dc --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml @@ -0,0 +1,58 @@ +{{define "content" -}} +{{with .Data}} +

← all campaigns

+

{{.Name}}{{if .IsDefault}} default{{end}}

+

Settings

+{{if .IsDefault}}

The default campaign is perpetual and fills the unsold remainder — only its name and messages are editable.

{{end}} +
+ +{{if not .IsDefault}} + + + + +{{end}} +
+
+{{if not .IsDefault}} +
+ +
+{{end}} +
+

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/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml index 8aa559a..f570231 100644 --- a/backend/internal/adminconsole/templates/pages/broadcast.gohtml +++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml @@ -5,7 +5,6 @@ {{if .ConnectorEnabled}}
-
{{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} diff --git a/backend/internal/adminconsole/templates/pages/chatmessage.gohtml b/backend/internal/adminconsole/templates/pages/chatmessage.gohtml new file mode 100644 index 0000000..e74e71c --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/chatmessage.gohtml @@ -0,0 +1,28 @@ +{{define "content" -}} +{{with .Data}} +

Message

+ +

Summary

+
    +
  • Time {{.CreatedAt}}
  • +
  • Sender {{.SenderName}} ({{.Source}})
  • +
  • IP {{.IP}}
  • +
  • Kind {{.Kind}}
  • +
  • Read {{if .Unread}}unread{{else}}read{{end}}
  • +
  • Message {{.Body}}
  • +
+
+

Read by seat

+ + + +{{range .Seats}} + +{{else}} + +{{end}} + +
SeatPlayerStatus
{{.Seat}}{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}{{if eq .Role "read"}}read{{else if eq .Role "unread"}}unread{{else}}sender{{end}}
no seats
+
+{{end}} +{{- end}} 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/dictionary.gohtml b/backend/internal/adminconsole/templates/pages/dictionary.gohtml index aff7aa8..c6041ef 100644 --- a/backend/internal/adminconsole/templates/pages/dictionary.gohtml +++ b/backend/internal/adminconsole/templates/pages/dictionary.gohtml @@ -1,21 +1,24 @@ {{define "content" -}}

Dictionary

{{with .Data}} +

Active version

+

New games pin {{.ActiveVersion}}. Games already in progress keep the version they started on.

+

Resident versions

- + {{range .Variants}} - + {{end}}
VariantLatestResident
VariantResident
{{.Variant}}{{.Latest}}{{range .Versions}}{{.}} {{end}}
{{.Variant}}{{range .Versions}}{{.}} {{end}}
-

Hot-reload a version

-

Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.

-
- -
+

Update dictionaries

+

Upload the release archive scrabble-dawg-vX.Y.Z.tar.gz downloaded from the scrabble-dictionary repository. The next step previews the added and removed words per variant; nothing is installed until you confirm.

+ + +

Pending dictionary changes

@@ -30,12 +33,12 @@
- +
diff --git a/backend/internal/adminconsole/templates/pages/dictionary_preview.gohtml b/backend/internal/adminconsole/templates/pages/dictionary_preview.gohtml new file mode 100644 index 0000000..242fcf1 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/dictionary_preview.gohtml @@ -0,0 +1,27 @@ +{{define "content" -}} +

Dictionary update — preview

+{{with .Data}} +
+

Comparing the uploaded archive against the active version {{.ActiveVersion}}. Review the changes below, then confirm to install and activate.

+
+ + +
+
+
+{{range .Variants}} +
+

{{.Variant}}

+

{{.AddedCount}} added, {{.RemovedCount}} removed. +{{if .LargeRemoval}}Large removal — double-check this is expected before updating.{{end}}

+

Added{{if .AddedTruncated}} (showing first {{len .AddedSample}}){{end}}

+

{{range .AddedSample}}{{.}} {{else}}none{{end}}

+{{if .AddedTruncated}}

… and more; the full list is not shown.

{{end}} +

Removed{{if .RemovedTruncated}} (showing first {{len .RemovedSample}}){{end}}

+

{{range .RemovedSample}}{{.}} {{else}}none{{end}}

+{{if .RemovedTruncated}}

… and more; the full list is not shown.

{{end}} +
+{{end}} +

Cancel

+{{end}} +{{- end}} 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..6d217b9 --- /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}}
  • +
  • Interface language {{.InterfaceLanguage}}
  • +
  • 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

+{{if not .Read}}
{{end}} +{{if not .Archived}}
{{end}} +
+ +
+ + +
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml index 313b31f..0c33a48 100644 --- a/backend/internal/adminconsole/templates/pages/game_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -1,12 +1,13 @@ {{define "content" -}} {{with .Data}}

Game {{.ID}}

- +

Summary

  • Variant {{.Variant}}
  • Dictionary {{.DictVersion}}
  • Status {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}
  • +
  • AI game {{if .VsAI}}🤖 yes{{else}}no{{end}}
  • Players {{.Players}}
  • To move seat {{.ToMove}}
  • Moves {{.MoveCount}}
  • @@ -17,13 +18,96 @@

Seats

- + {{range .Seats}} - + +{{end}} + +
SeatPlayerScoreHintsWinner
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}} +
+{{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/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml index 2e21b88..e6ab08e 100644 --- a/backend/internal/adminconsole/templates/pages/games.gohtml +++ b/backend/internal/adminconsole/templates/pages/games.gohtml @@ -3,15 +3,16 @@ {{with .Data}} - + {{range .Items}} - -{{else}}{{end}} + +{{else}}{{end}}
    GameVariantStatusPlayersUpdated
    GameVariantStatus🤖PlayersUpdated
    {{.ID}}{{.Variant}}{{.Status}}{{.Players}}{{.UpdatedAt}}
    no games
    {{.ID}}{{.Variant}}{{.Status}}{{if .VsAI}}🤖{{end}}{{.Players}}{{.UpdatedAt}}
    no games