Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
608 changed files with 57462 additions and 6879 deletions
+375
View File
@@ -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
-81
View File
@@ -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/...
-71
View File
@@ -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/...
+219
View File
@@ -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 <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
# Bot-host runtime env.
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
# Bot-link certs (world-readable: the distroless gateway/bot run as UID 65532
# and bind-mount the dir read-only).
printf '%s\n' "$PROD_BOTLINK_CA" > 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"
-67
View File
@@ -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@<version> 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
+7
View File
@@ -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
+29 -7
View File
@@ -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 R1R7
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 015 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`).
+547 -23
View File
@@ -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 1517** below and this
deploy + observability + the two-bots idea were split into **Stages 1518** 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 `<img>` (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+moveopp)
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 1517.)
observability + the dual-bot idea split into Stages 1518.)
- **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 1517**. The deploy
observability + the dual-bot idea were written into the plan as new **Stages 1518**. 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 `<test domain> → 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<token>/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-<lang>-<index>`.
- **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 23, 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 + #1620), 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 (#1620, 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/<name>`; 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<code>`, 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<code>`, 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.
+632
View File
@@ -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 90180 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 090 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 35 concurrent 24-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 ./<module>/...`, `go vet`, `gofmt -l .` clean, `go test -count=1 ./<module>/...`;
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 12 (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/<bot>?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.
+22 -2
View File
@@ -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.
+53
View File
@@ -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"]
+141 -63
View File
@@ -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 12 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 24 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 34 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 **090 s** (so **90180 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/<version>/`
(`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 `<version>/`
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
+68 -9
View File
@@ -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)
+1 -1
View File
@@ -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
+158 -46
View File
@@ -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,
}
+4 -4
View File
@@ -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.
+5 -5
View File
@@ -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).
+53
View File
@@ -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)))
}
+109 -6
View File
@@ -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 "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
// by a single space. No leading separator and no two adjacent separators (except
// "<dot|underscore> <space>"). The name may end with EITHER a single trailing "."
// (an initial, "Anna B.") OR a run of 15 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.
+24 -2
View File
@@ -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)
}
}
+37 -10
View File
@@ -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) {
+105
View File
@@ -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()
}
+66 -1
View File
@@ -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
}
+375
View File
@@ -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)
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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")
}
+1 -1
View File
@@ -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
+149
View File
@@ -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, "?", "_")
}
+27 -13
View File
@@ -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) {
+47 -5
View File
@@ -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 {
@@ -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; }
+108
View File
@@ -0,0 +1,108 @@
package adminconsole
import (
"fmt"
"html/template"
"strings"
"time"
)
// ChartPoint is one move-number sample of the move-duration chart: the min, mean and
// max think time (seconds) the account took on its Ordinal-th move across its games.
type ChartPoint struct {
Ordinal int
Min float64
Max float64
Avg float64
}
// FormatDuration renders a think-time in seconds as a compact human string
// ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels.
func FormatDuration(secs float64) string {
d := time.Duration(secs * float64(time.Second))
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()+0.5))
default:
h := int(d.Hours())
if m := int(d.Minutes()) - h*60; m > 0 {
return fmt.Sprintf("%dh%dm", h, m)
}
return fmt.Sprintf("%dh", h)
}
}
// MoveDurationChart renders the per-move-number think-time chart as a self-contained,
// script-free inline SVG with three series (min, mean, max). The coordinates and
// labels are all derived from numeric data, so the result is safe template.HTML.
// An empty series renders nothing.
func MoveDurationChart(points []ChartPoint) template.HTML {
if len(points) == 0 {
return ""
}
const (
w, h = 640, 240
padL = 46
padR = 12
padT = 10
padB = 28
)
maxOrd := points[len(points)-1].Ordinal
if maxOrd < 1 {
maxOrd = 1
}
var maxY float64
for _, p := range points {
maxY = max(maxY, p.Max)
}
if maxY <= 0 {
maxY = 1
}
xOf := func(ord int) float64 {
if maxOrd == 1 {
return padL
}
return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR)
}
yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) }
line := func(get func(ChartPoint) float64) string {
pts := make([]string, len(points))
for i, p := range points {
pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p)))
}
return strings.Join(pts, " ")
}
var b strings.Builder
fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" class="chart" role="img" aria-label="Move duration by move number">`, w, h)
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%.1f" class="axis"/>`, padL, padT, padL, float64(h-padB))
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="axis"/>`, padL, float64(h-padB), w-padR, float64(h-padB))
for _, frac := range []float64{0, 0.5, 1} {
v := maxY * frac
y := yOf(v)
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="grid"/>`, padL, y, w-padR, y)
fmt.Fprintf(&b, `<text x="%d" y="%.1f" class="lbl" text-anchor="end">%s</text>`, padL-5, y+3, FormatDuration(v))
}
for _, ord := range xTicks(maxOrd) {
fmt.Fprintf(&b, `<text x="%.1f" y="%d" class="lbl" text-anchor="middle">%d</text>`, xOf(ord), h-padB+15, ord)
}
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-max"/>`, line(func(p ChartPoint) float64 { return p.Max }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-avg"/>`, line(func(p ChartPoint) float64 { return p.Avg }))
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-min"/>`, line(func(p ChartPoint) float64 { return p.Min }))
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
// xTicks returns up to three distinct ordinal labels for the chart's X axis.
func xTicks(maxOrd int) []int {
if maxOrd <= 2 {
out := make([]int, 0, maxOrd)
for i := 1; i <= maxOrd; i++ {
out = append(out, i)
}
return out
}
return []int{1, (maxOrd + 1) / 2, maxOrd}
}
@@ -0,0 +1,51 @@
package adminconsole
import (
"strings"
"testing"
)
func TestFormatDuration(t *testing.T) {
cases := map[float64]string{
0: "0s", 30: "30s", 59: "59s", 60: "1m", 150: "3m", 3600: "1h", 3660: "1h1m", 7800: "2h10m",
}
for secs, want := range cases {
if got := FormatDuration(secs); got != want {
t.Errorf("FormatDuration(%v) = %q, want %q", secs, got, want)
}
}
}
func TestMoveDurationChartEmpty(t *testing.T) {
if got := MoveDurationChart(nil); got != "" {
t.Errorf("empty chart = %q, want empty", got)
}
}
func TestMoveDurationChart(t *testing.T) {
pts := []ChartPoint{{Ordinal: 1, Min: 5, Max: 20, Avg: 10}, {Ordinal: 2, Min: 8, Max: 40, Avg: 18}, {Ordinal: 3, Min: 12, Max: 90, Avg: 30}}
svg := string(MoveDurationChart(pts))
for _, want := range []string{"<svg", "ln-min", "ln-avg", "ln-max", "</svg>"} {
if !strings.Contains(svg, want) {
t.Errorf("chart missing %q\n%s", want, svg)
}
}
if n := strings.Count(svg, "<polyline"); n != 3 {
t.Errorf("polylines = %d, want 3", n)
}
}
func TestXTicks(t *testing.T) {
cases := map[int][]int{1: {1}, 2: {1, 2}, 3: {1, 2, 3}, 10: {1, 5, 10}}
for maxOrd, want := range cases {
got := xTicks(maxOrd)
if len(got) != len(want) {
t.Fatalf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("xTicks(%d) = %v, want %v", maxOrd, got, want)
}
}
}
}
+75 -6
View File
@@ -2,6 +2,7 @@ package adminconsole
import (
"bytes"
"html/template"
"io/fs"
"strings"
"testing"
@@ -20,14 +21,32 @@ func TestRendererRendersEveryPage(t *testing.T) {
data any
want string
}{
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
}, `/_gm/users/c3`},
{"throttled", ThrottledView{
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
FlagThreshold: 1000, FlagWindow: "10m0s",
}, "Recent episodes"},
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "scrabble_en", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
}
@@ -48,6 +67,56 @@ func TestRendererRendersEveryPage(t *testing.T) {
}
}
// TestPagerLinksPreserveFilterQuery guards the paginated lists whose links carry a
// pre-encoded filter query (url.Values.Encode) past the page number. The query fragment
// must reach the link verbatim: the contextual escaper would otherwise re-encode its
// structural "=" and "&" (turning "kind=robots" into the broken "kind%3drobots"), dropping
// the active filter on every page step. FilterQuery is typed template.URL to prevent that.
func TestPagerLinksPreserveFilterQuery(t *testing.T) {
r := MustNewRenderer()
t.Run("users", func(t *testing.T) {
var buf bytes.Buffer
view := UsersView{Robots: true, FilterQuery: template.URL("kind=robots"), Pager: NewPager(2, 50, 200)}
if err := r.Render(&buf, "users", PageData{Title: "Users", Data: view}); err != nil {
t.Fatalf("render users: %v", err)
}
out := buf.String()
for _, want := range []string{
`href="/_gm/users?kind=robots&amp;page=1"`,
`href="/_gm/users?kind=robots&amp;page=3"`,
} {
if !strings.Contains(out, want) {
t.Errorf("users pager: missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "kind%3drobots") {
t.Error("users pager: filter query was double-encoded (kind%3drobots)")
}
})
t.Run("messages", func(t *testing.T) {
var buf bytes.Buffer
view := MessagesView{FilterQuery: template.URL("game=abc&user=def"), Pager: NewPager(2, 50, 200)}
if err := r.Render(&buf, "messages", PageData{Title: "Messages", Data: view}); err != nil {
t.Fatalf("render messages: %v", err)
}
out := buf.String()
for _, want := range []string{
`href="/_gm/messages.csv?game=abc&amp;user=def"`,
`href="/_gm/messages?game=abc&amp;user=def&amp;page=1"`,
`href="/_gm/messages?game=abc&amp;user=def&amp;page=3"`,
} {
if !strings.Contains(out, want) {
t.Errorf("messages pager: missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "%3d") || strings.Contains(out, "%26") {
t.Error("messages pager: filter query was double-encoded (%3d / %26)")
}
})
}
// TestRendererUnknownPage reports an error for a page that does not exist.
func TestRendererUnknownPage(t *testing.T) {
r := MustNewRenderer()
@@ -16,8 +16,14 @@
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
</nav>
</header>
<main class="content">
@@ -0,0 +1,58 @@
{{define "content" -}}
{{with .Data}}
<p class="note"><a href="/_gm/banners">← all campaigns</a></p>
<h1>{{.Name}}{{if .IsDefault}} <span class="pill">default</span>{{end}}</h1>
<section class="panel"><h2>Settings</h2>
{{if .IsDefault}}<p class="note">The default campaign is perpetual and fills the unsold remainder — only its name and messages are editable.</p>{{end}}
<form class="form col" method="post" action="/_gm/banners/{{.ID}}">
<label>Name <input type="text" name="name" value="{{.Name}}" maxlength="80" required></label>
{{if not .IsDefault}}
<label>Weight (%) <input type="number" name="weight" min="1" max="100" value="{{.Weight}}" required></label>
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
{{end}}
<div><button type="submit">Save</button></div>
</form>
{{if not .IsDefault}}
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
<button type="submit" class="danger">Delete campaign</button>
</form>
{{end}}
</section>
<section class="panel"><h2>Messages</h2>
<div class="help-top">
<p class="note">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.</p>
<aside class="help">
<h4>Formatting</h4>
<p>Plain text is shown as-is (any HTML is escaped).</p>
<p>Add a link with markdown — <code>[visible text](https://example.com)</code> — which renders as the linked <em>visible text</em>.</p>
<p>Only <code>https://</code>, <code>http://</code> and root-relative <code>/path</code> targets become links; any other scheme (e.g. <code>javascript:</code>, <code>ftp:</code>) is dropped and shown as plain text.</p>
<p>Keep it short: an over-long line scrolls in the strip; the same formatting applies when editing a message above.</p>
</aside>
</div>
{{range .Messages}}
<div class="row">
<form class="form col" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}">
<label>English <textarea name="body_en" rows="3" maxlength="500" required>{{.BodyEn}}</textarea></label>
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required>{{.BodyRu}}</textarea></label>
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
<div class="actions">
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="up"><button type="submit"{{if .First}} disabled{{end}}>↑</button></form>
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="down"><button type="submit"{{if .Last}} disabled{{end}}>↓</button></form>
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/delete" onsubmit="return confirm('Delete this message?')"><button type="submit" class="danger">Delete</button></form>
</div>
</div>
{{else}}<p class="note">no messages yet</p>{{end}}
<h3>Add message</h3>
<form class="form col" method="post" action="/_gm/banners/{{.ID}}/messages">
<label>English <textarea name="body_en" rows="3" maxlength="500" required></textarea></label>
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required></textarea></label>
<div><button type="submit">Add message</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,18 @@
{{define "content" -}}
<p class="note"><a href="/_gm/banners">← all campaigns</a></p>
<h1>Banner display timings</h1>
{{with .Data}}
<p class="note">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).</p>
<section class="panel">
<form class="form col" method="post" action="/_gm/banner-settings">
<label>Hold (ms) — how long one message shows <input type="number" name="hold_ms" min="3000" max="600000" value="{{.HoldMs}}" required></label>
<label>Edge pause (ms) — pause at each end before/after scrolling a long message <input type="number" name="edge_pause_ms" min="0" max="60000" value="{{.EdgePauseMs}}" required></label>
<label>Scroll speed (px/s) — for a message wider than the strip <input type="number" name="scroll_px_per_sec" min="5" max="1000" value="{{.ScrollPxPerSec}}" required></label>
<label>Fade-out (ms) <input type="number" name="fade_out_ms" min="0" max="5000" value="{{.FadeOutMs}}" required></label>
<label>Gap (ms) — pause between fade-out and fade-in <input type="number" name="gap_ms" min="0" max="5000" value="{{.GapMs}}" required></label>
<label>Fade-in (ms) <input type="number" name="fade_in_ms" min="0" max="5000" value="{{.FadeInMs}}" required></label>
<div><button type="submit">Save</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,32 @@
{{define "content" -}}
<h1>Advertising banners</h1>
{{with .Data}}
<p class="note">Campaigns compete for the one-line banner shown to free users (no paid account, an empty hint wallet, and no <code>no_banner</code> role). A campaign's weight is a show percent; the perpetual <strong>default</strong> campaign fills the remainder up to 100% and drops out of the rotation when timed campaigns already total 100%. <a href="/_gm/banner-settings">Display timings →</a></p>
<section class="panel"><h2>Add campaign</h2>
<form class="form col" method="post" action="/_gm/banners">
<label>Name <input type="text" name="name" maxlength="80" required></label>
<label>Weight (%) <input type="number" name="weight" min="1" max="100" value="10" required></label>
<label>Starts (UTC) <input type="datetime-local" name="starts_at"></label>
<label>Ends (UTC) <input type="datetime-local" name="ends_at"></label>
<label><input type="checkbox" name="enabled" checked> Enabled</label>
<div><button type="submit">Add</button></div>
</form>
</section>
<section class="panel"><h2>Campaigns</h2>
<table class="list">
<thead><tr><th>Name</th><th class="num">Weight</th><th>Window</th><th class="num">Messages</th><th>Status</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/banners/{{.ID}}">{{.Name}}</a>{{if .IsDefault}} <span class="pill">default</span>{{end}}</td>
<td class="num">{{if .IsDefault}}remainder{{else}}{{.Weight}}%{{end}}</td>
<td>{{.Window}}</td>
<td class="num">{{.Messages}}</td>
<td>{{if .ActiveNow}}live{{else if .Enabled}}idle{{else}}disabled{{end}}</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no campaigns</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -5,7 +5,6 @@
{{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/broadcast">
<label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Post to channel</button></div>
</form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
@@ -0,0 +1,28 @@
{{define "content" -}}
{{with .Data}}
<h1>Message</h1>
<nav class="subnav"><a href="/_gm/messages">&laquo; messages</a> · <a href="/_gm/games/{{.GameID}}">game</a> · <a href="/_gm/messages?game={{.GameID}}">game messages</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Time</b> {{.CreatedAt}}</li>
<li><b>Sender</b> <a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>IP</b> {{.IP}}</li>
<li><b>Kind</b> {{.Kind}}</li>
<li><b>Read</b> {{if .Unread}}unread{{else}}read{{end}}</li>
<li><b>Message</b> {{.Body}}</li>
</ul>
</section>
<section class="panel"><h2>Read by seat</h2>
<table class="list">
<thead><tr><th>Seat</th><th>Player</th><th>Status</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}</a></td><td>{{if eq .Role "read"}}<span class="ok">read</span>{{else if eq .Role "unread"}}unread{{else}}sender{{end}}</td></tr>
{{else}}
<tr><td colspan="3"><span class="note">no seats</span></td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -7,6 +7,7 @@
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
<a class="card" href="/_gm/feedback?status=unread"><h2>Unread feedback</h2><p class="bignum">{{.OpenFeedback}}</p></a>
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
</div>
<section class="panel">
@@ -1,21 +1,24 @@
{{define "content" -}}
<h1>Dictionary</h1>
{{with .Data}}
<section class="panel"><h2>Active version</h2>
<p>New games pin <span class="pill">{{.ActiveVersion}}</span>. Games already in progress keep the version they started on.</p>
</section>
<section class="panel"><h2>Resident versions</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
<thead><tr><th>Variant</th><th>Resident</th></tr></thead>
<tbody>
{{range .Variants}}
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
<tr><td>{{.Variant}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Hot-reload a version</h2>
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/&lt;version&gt;/ first, then load it here.</p>
<form class="form" method="post" action="/_gm/dictionary/reload">
<label>Version <input type="text" name="version" placeholder="v2" required></label>
<div><button type="submit">Reload</button></div>
<section class="panel"><h2>Update dictionaries</h2>
<p class="note">Upload the release archive <code>scrabble-dawg-vX.Y.Z.tar.gz</code> downloaded from the scrabble-dictionary repository. The next step previews the added and removed words per variant; nothing is installed until you confirm.</p>
<form class="form" method="post" action="/_gm/dictionary/upload" enctype="multipart/form-data">
<label>Release archive <input type="file" name="archive" accept=".gz,.tgz,application/gzip" required></label>
<div><button type="submit">Upload &amp; preview</button></div>
</form>
</section>
<section class="panel"><h2>Pending dictionary changes</h2>
@@ -30,12 +33,12 @@
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
<label>Mark applied for variant
<select name="variant">
<option value="english">english</option>
<option value="russian_scrabble">russian_scrabble</option>
<option value="erudit">erudit</option>
<option value="scrabble_en">scrabble_en</option>
<option value="scrabble_ru">scrabble_ru</option>
<option value="erudit_ru">erudit_ru</option>
</select>
</label>
<label>In version <input type="text" name="version" placeholder="v2" required></label>
<label>In version <input type="text" name="version" value="{{.ActiveVersion}}" required></label>
<div><button type="submit">Mark applied</button></div>
</form>
</section>
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Dictionary update — preview</h1>
{{with .Data}}
<section class="panel">
<p>Comparing the uploaded archive against the active version <span class="pill">{{.ActiveVersion}}</span>. Review the changes below, then confirm to install and activate.</p>
<form class="form" method="post" action="/_gm/dictionary/install">
<input type="hidden" name="token" value="{{.Token}}">
<label>Version <input type="text" name="version" value="{{.Version}}" required></label>
<div><button type="submit">Update dictionaries</button></div>
</form>
</section>
{{range .Variants}}
<section class="panel">
<h2>{{.Variant}}</h2>
<p><strong>{{.AddedCount}}</strong> added, <strong>{{.RemovedCount}}</strong> removed.
{{if .LargeRemoval}}<span class="warn">Large removal — double-check this is expected before updating.</span>{{end}}</p>
<h3>Added{{if .AddedTruncated}} (showing first {{len .AddedSample}}){{end}}</h3>
<p>{{range .AddedSample}}<code>{{.}}</code> {{else}}<span class="note">none</span>{{end}}</p>
{{if .AddedTruncated}}<p class="note">… and more; the full list is not shown.</p>{{end}}
<h3>Removed{{if .RemovedTruncated}} (showing first {{len .RemovedSample}}){{end}}</h3>
<p>{{range .RemovedSample}}<code>{{.}}</code> {{else}}<span class="note">none</span>{{end}}</p>
{{if .RemovedTruncated}}<p class="note">… and more; the full list is not shown.</p>{{end}}
</section>
{{end}}
<p><a href="/_gm/dictionary">Cancel</a></p>
{{end}}
{{- end}}
@@ -0,0 +1,38 @@
{{define "content" -}}
<h1>Feedback</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/feedback?status=unread"{{if eq .Status "unread"}} class="active"{{end}}>unread</a> ·
<a href="/_gm/feedback?status=read"{{if eq .Status "read"}} class="active"{{end}}>read</a> ·
<a href="/_gm/feedback?status=archived"{{if eq .Status "archived"}} class="active"{{end}}>archived</a>
</nav>
<form class="form" method="get" action="/_gm/feedback">
<input type="hidden" name="status" value="{{.Status}}">
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="external id mask (* ?)">
<button type="submit">Filter</button>
</form>
<table class="list">
<thead><tr><th>Sender</th><th>Source</th><th>Channel</th><th>Attach</th><th>Reply</th><th>State</th><th>Filed</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/feedback/{{.ID}}">{{.SenderName}}</a></td>
<td>{{.Source}}</td>
<td>{{.Channel}}</td>
<td>{{if .HasAttachment}}yes{{else}}<span class="note">—</span>{{end}}</td>
<td>{{if .Replied}}<span class="ok">replied</span>{{else}}<span class="note">—</span>{{end}}</td>
<td>{{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}<tr><td colspan="7"><span class="note">no feedback</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/feedback?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/feedback?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -0,0 +1,46 @@
{{define "content" -}}
{{with .Data}}
<h1>Feedback</h1>
<nav class="subnav"><a href="/_gm/feedback">&laquo; feedback</a> · <a href="/_gm/users/{{.AccountID}}">user</a></nav>
<section class="panel"><h2>Message</h2>
<ul class="kv">
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li>
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
</ul>
<div class="msgbody">{{.Body}}</div>
{{if .HasAttachment}}
<h3>Attachment</h3>
{{if .IsImage}}<p><img class="attach" src="/_gm/feedback/{{.ID}}/attachment" alt="attachment"></p>{{end}}
<p><a href="/_gm/feedback/{{.ID}}/attachment" download>download {{.AttachmentName}}</a></p>
{{end}}
</section>
{{if .Replied}}
<section class="panel"><h2>Current reply</h2>
<div class="msgbody">{{.ReplyBody}}</div>
<p class="note">sent {{.RepliedAt}}</p>
</section>
{{end}}
<section class="panel"><h2>{{if .Replied}}Re-reply{{else}}Reply{{end}}</h2>
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/reply">
<label>Reply <textarea name="reply" required></textarea></label>
<div><button type="submit">Send reply</button></div>
</form>
</section>
<section class="panel"><h2>Actions</h2>
{{if not .Read}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/read"><button type="submit">Mark read</button></form>{{end}}
{{if not .Archived}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/archive"><button type="submit">Archive</button></form>{{end}}
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/delete">
<label><input type="checkbox" name="block" value="1"> ban the player from feedback</label>
<div>
<button type="submit">Delete</button>
<button type="submit" formaction="/_gm/feedback/{{.ID}}/delete-all">Delete all from this player</button>
</div>
</form>
</section>
{{end}}
{{- end}}
@@ -1,12 +1,13 @@
{{define "content" -}}
{{with .Data}}
<h1>Game {{.ID}}</h1>
<nav class="subnav"><a href="/_gm/games">&laquo; games</a></nav>
<nav class="subnav"><a href="/_gm/games">&laquo; games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Variant</b> {{.Variant}}</li>
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li>
@@ -17,13 +18,96 @@
</section>
<section class="panel"><h2>Seats</h2>
<table class="list">
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td><td>{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}<br><small>next move {{.NextMove}}</small>{{end}}{{end}}</td></tr>
{{end}}
</tbody>
</table>
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
</section>
{{if .SetupDraws}}
<section class="panel"><h2>First-move draw</h2>
<p class="note">Each player draws a tile; the one closest to &ldquo;A&rdquo; moves first (a blank beats every letter), ties re-drawing until a single leader remains.{{if .FirstMover}} <b>{{.FirstMover}}</b> leads.{{end}}</p>
<table class="list">
<thead><tr><th>Round</th><th>Player</th><th>Tile</th><th>Rank</th></tr></thead>
<tbody>
{{range .SetupDraws}}
<tr><td>{{.Round}}</td><td>{{if .AccountID}}<a href="/_gm/users/{{.AccountID}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Letter}}{{if .Blank}} <small>(blank)</small>{{end}}</td><td>{{.Rank}}</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{if .HasReplay}}
<section class="panel"><h2>Replay</h2>
<div class="replay-stage">
<div class="rack-slot rack-top" data-seat="0"></div>
<div class="rack-slot rack-left" data-seat="2"></div>
<div class="replay-board" id="replay-board"></div>
<div class="rack-slot rack-right" data-seat="3"></div>
<div class="rack-slot rack-bottom" data-seat="1"></div>
</div>
<div class="replay-controls">
<button type="button" id="replay-prev">&#9664; prev</button>
<span class="replay-pos" id="replay-pos"></span>
<button type="button" id="replay-next">next &#9654;</button>
</div>
<ol class="replay-log" id="replay-log"></ol>
<script>
const REPLAY = {{.ReplayJSON}};
(function(){
if(!REPLAY||!REPLAY.steps){return;}
const N=15, COLS="ABCDEFGHIJKLMNO", PREM={tw:"3W",dw:"2W",tl:"3L",dl:"2L"};
const boardEl=document.getElementById("replay-board"), logEl=document.getElementById("replay-log");
const posEl=document.getElementById("replay-pos"), prevBtn=document.getElementById("replay-prev"), nextBtn=document.getElementById("replay-next");
let step=0;
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":s;return d.innerHTML;}
function tileHTML(t){const sub=(t.v&&t.v>0)?"<sub>"+t.v+"<\/sub>":"";return "<span class=\"tile"+(t.b?" blank":"")+"\">"+esc(t.l)+sub+"<\/span>";}
function placedAt(k){const m={};for(let s=1;s<=k;s++){const mv=REPLAY.steps[s]&&REPLAY.steps[s].move;if(mv&&mv.placements){for(const p of mv.placements){m[p.r+","+p.c]=p;}}}return m;}
function renderBoard(){
const placed=placedAt(step);let h="<div class=\"board-grid\"><div class=\"bh corner\"><\/div>";
for(let c=0;c<N;c++){h+="<div class=\"bh\">"+COLS[c]+"<\/div>";}
for(let r=0;r<N;r++){h+="<div class=\"bh\">"+(r+1)+"<\/div>";
for(let c=0;c<N;c++){const p=placed[r+","+c];
if(p){h+="<div class=\"cell filled\">"+tileHTML(p)+"<\/div>";continue;}
const prem=REPLAY.premium[r][c], centre=(r===REPLAY.centre[0]&&c===REPLAY.centre[1]);
const label=centre?"&#9733;":(prem?PREM[prem]:"");
h+="<div class=\"cell "+(prem||"")+(centre?" centre":"")+"\">"+(label?"<span class=\"prem\">"+label+"<\/span>":"")+"<\/div>";}}
h+="<\/div>";boardEl.innerHTML=h;
}
function renderRacks(){
const st=REPLAY.steps[step];
document.querySelectorAll(".rack-slot").forEach(function(slot){
const seat=parseInt(slot.dataset.seat,10), info=REPLAY.seats.find(function(s){return s.seat===seat;}), rack=st.racks[seat];
if(!info||!rack){slot.style.display="none";slot.innerHTML="";return;}
slot.style.display="";slot.classList.toggle("active",st.toMove===seat);
const nm=info.accountId?"<a href=\"/_gm/users/"+info.accountId+"\">"+esc(info.name)+"<\/a>":esc(info.name||("seat "+seat));
slot.innerHTML="<div class=\"rack-name\">"+nm+" &middot; "+(st.scores[seat]||0)+"<\/div><div class=\"rack-tiles\">"+rack.map(tileHTML).join("")+"<\/div>";
});
}
function renderLog(){
let h="";
for(let s=1;s<=step;s++){const st=REPLAY.steps[s], m=st.move;if(!m){continue;}
const who=((REPLAY.seats.find(function(x){return x.seat===m.seat;})||{}).name)||("seat "+m.seat);
let desc;
if(m.action==="play"){desc="played "+((m.words&&m.words.length)?m.words.join(", "):"")+" for "+m.score;}
else if(m.action==="exchange"){desc="exchanged "+((m.exchanged&&m.exchanged.length)||0)+" tiles";}
else if(m.action==="pass"){desc="passed";}
else{desc=m.action;}
const drew=(st.drawn&&st.drawn.length)?" &middot; drew "+st.drawn.map(function(t){return t.l;}).join(""):"";
h+="<li class=\""+(s===step?"cur":"")+"\">"+esc(who)+" "+esc(desc)+drew+" &middot; bag "+st.bagLen+"<\/li>";}
logEl.innerHTML=h||"<li class=\"note\">opening position<\/li>";
}
function render(){renderBoard();renderRacks();renderLog();posEl.textContent=step+" / "+(REPLAY.steps.length-1);prevBtn.disabled=step<=0;nextBtn.disabled=step>=REPLAY.steps.length-1;}
prevBtn.onclick=function(){if(step>0){step--;render();}};
nextBtn.onclick=function(){if(step<REPLAY.steps.length-1){step++;render();}};
document.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"){prevBtn.click();}else if(e.key==="ArrowRight"){nextBtn.click();}});
render();
})();
</script>
</section>
{{end}}
{{end}}
{{- end}}
@@ -3,15 +3,16 @@
{{with .Data}}
<nav class="subnav">
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
<a href="/_gm/games?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
@@ -0,0 +1,40 @@
{{define "content" -}}
<h1>Messages</h1>
{{with .Data}}
<form class="form" method="get" action="/_gm/messages">
{{if .GameID}}<input type="hidden" name="game" value="{{.GameID}}">{{end}}
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<label class="check"><input type="checkbox" name="unread" value="1"{{if .UnreadOnly}} checked{{end}}> unread only</label>
<button type="submit">Filter</button>
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
</form>
{{if or .GameID .UserID}}
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
{{end}}
<table class="list">
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Read</th><th>Game</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/messages/{{.ID}}">{{.CreatedAt}}</a></td>
<td>{{.Source}}</td>
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
<td>{{.IP}}</td>
<td>{{.Body}}</td>
<td>{{if .Unread}}unread{{else}}read{{end}}</td>
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
</tr>
{{else}}
<tr><td colspan="7"><span class="note">no messages</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/messages?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Suspension reasons</h1>
{{with .Data}}
<p class="note">Reasons offered when blocking a user; each is shown to the blocked player in their own language. Editing or deleting a reason does not change a reason already shown to a currently-blocked player (blocks snapshot the text).</p>
<section class="panel"><h2>Add reason</h2>
<form class="form col" method="post" action="/_gm/reasons">
<label>English <input type="text" name="text_en" required></label>
<label>Russian <input type="text" name="text_ru" required></label>
<div><button type="submit">Add</button></div>
</form>
</section>
<section class="panel"><h2>Existing reasons</h2>
{{range .Items}}
<div class="row">
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/update">
<input type="text" name="text_en" value="{{.TextEn}}" aria-label="English" required>
<input type="text" name="text_ru" value="{{.TextRu}}" aria-label="Russian" required>
<button type="submit">Save</button>
</form>
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/delete">
<button type="submit">Delete</button>
</form>
</div>
{{else}}<p class="note">no reasons yet</p>{{end}}
</section>
{{end}}
{{- end}}
@@ -0,0 +1,59 @@
{{define "content" -}}
<h1>Throttled</h1>
{{with .Data}}
<p class="note">Rate-limiter rejections reported periodically by the gateway. The episode
list is in-memory and resets on a backend restart. An account sustaining
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
below — never banned automatically; clear the flag on the user card.</p>
<section class="panel"><h2>Active IP bans</h2>
<p class="note">Temporary IP bans the gateway is currently enforcing (in-memory, prod-only;
reset on a gateway restart). Unban applies on the gateway's next sync.</p>
<table class="list">
<thead><tr><th>IP</th><th>Reason</th><th>Since</th><th>Expires</th><th></th></tr></thead>
<tbody>
{{range .Bans}}
<tr>
<td><code>{{.IP}}</code></td>
<td>{{.Reason}}</td>
<td>{{.Since}}</td>
<td>{{.Expires}}</td>
<td><form class="form" method="post" action="/_gm/bans/unban"><input type="hidden" name="ip" value="{{.IP}}"><button type="submit">Unban</button></form></td>
</tr>
{{else}}
<tr><td colspan="5"><span class="note">no active bans</span></td></tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Recent episodes</h2>
<table class="list">
<thead><tr><th>Class</th><th>Key</th><th class="num">Rejected</th><th>First seen</th><th>Last seen</th></tr></thead>
<tbody>
{{range .Episodes}}
<tr>
<td>{{.Class}}</td>
<td>{{if .UserID}}<a href="/_gm/users/{{.UserID}}">{{.Key}}</a>{{else}}<code>{{.Key}}</code>{{end}}</td>
<td class="num">{{.Rejected}}</td>
<td>{{.FirstSeen}}</td>
<td>{{.LastSeen}}</td>
</tr>
{{else}}
<tr><td colspan="5"><span class="note">nothing throttled recently</span></td></tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Flagged accounts</h2>
<table class="list">
<thead><tr><th>Account</th><th>Display name</th><th>Flagged</th></tr></thead>
<tbody>
{{range .Flagged}}
<tr><td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td><td>{{.DisplayName}}</td><td>{{.FlaggedAt}}</td></tr>
{{else}}
<tr><td colspan="3"><span class="note">no flagged accounts</span></td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -1,7 +1,7 @@
{{define "content" -}}
{{with .Data}}
<h1>{{.DisplayName}}</h1>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a></nav>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a> · <a href="/_gm/feedback?user={{.ID}}">feedback</a></nav>
<div class="cards">
<section class="panel"><h2>Account</h2>
<ul class="kv">
@@ -13,8 +13,18 @@
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li>
</ul>
{{if .FlaggedHighRateAt}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/clear-high-rate-flag">
<button type="submit">Clear high-rate flag</button>
</form>
{{end}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
<button type="submit">Grant</button>
</form>
</section>
<section class="panel"><h2>Statistics</h2>
{{if .HasStats}}
@@ -22,12 +32,66 @@
<li><b>Wins</b> {{.Stats.Wins}}</li>
<li><b>Losses</b> {{.Stats.Losses}}</li>
<li><b>Draws</b> {{.Stats.Draws}}</li>
<li><b>Moves</b> {{.Stats.Moves}}</li>
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
</ul>
{{else}}<p class="note">no statistics</p>{{end}}
</section>
</div>
<section class="panel"><h2>Account block</h2>
{{$uid := .ID}}
{{with .Suspension}}{{if .Blocked}}
<ul class="kv">
<li><b>Status</b> <span class="warn">{{if .Permanent}}blocked (permanent){{else}}blocked until {{.Until}} (UTC){{end}}</span></li>
{{if .BlockedAt}}<li><b>Since</b> {{.BlockedAt}}</li>{{end}}
{{if .ReasonEn}}<li><b>Reason</b> {{.ReasonEn}} / {{.ReasonRu}}</li>{{end}}
</ul>
<form class="form" method="post" action="/_gm/users/{{$uid}}/unblock">
<button type="submit">Unblock</button>
</form>
{{else}}<p class="note">not blocked</p>{{end}}{{end}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/block">
<label>Duration <select name="duration">
<option value="permanent">permanent</option>
<option value="1d">1 day</option>
<option value="3d">3 days</option>
<option value="1w">1 week</option>
<option value="1m">1 month</option>
<option value="custom">custom date (UTC)</option>
</select></label>
<label>Custom until (UTC; used when duration is "custom") <input type="datetime-local" name="until"></label>
<label>Reason <select name="reason">
<option value="">&mdash; none &mdash;</option>
{{range .Reasons}}<option value="{{.ID}}">{{.TextEn}}</option>{{end}}
</select></label>
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
</form>
</section>
<section class="panel"><h2>Roles</h2>
{{$id := .ID}}
{{if .Roles}}
<table class="list">
<thead><tr><th>Role</th><th></th></tr></thead>
<tbody>
{{range .Roles}}
<tr><td><code>{{.}}</code></td><td><form class="form" method="post" action="/_gm/users/{{$id}}/revoke-role"><input type="hidden" name="role" value="{{.}}"><button type="submit">Revoke</button></form></td></tr>
{{end}}
</tbody>
</table>
{{else}}<p class="note">no roles</p>{{end}}
<form class="form col" method="post" action="/_gm/users/{{$id}}/grant-role">
<label>Grant role <select name="role">{{range .KnownRoles}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<div><button type="submit">Grant</button></div>
</form>
</section>
{{if .MoveChart}}
<section class="panel"><h2>Move timing</h2>
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
{{.MoveChart}}
</section>
{{end}}
<section class="panel"><h2>Identities</h2>
<table class="list">
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
@@ -38,12 +102,41 @@
</tbody>
</table>
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
<tbody>
{{range .Friends}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocks</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .Blocks}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocked by</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .BlockedBy}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{if .TelegramID}}
<section class="panel"><h2>Send Telegram message</h2>
{{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
<label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Send to user</button></div>
</form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
@@ -1,26 +1,37 @@
{{define "content" -}}
<h1>Users</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
</nav>
<form class="form" method="get" action="/_gm/users">
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
<button type="submit">Filter</button>
</form>
<table class="list">
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}</td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
</tr>
{{else}}
<tr><td colspan="5"><span class="note">no users</span></td></tr>
<tr><td colspan="8"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
+370 -16
View File
@@ -1,5 +1,7 @@
package adminconsole
import "html/template"
// The *View types are the display models the gin handlers fill and the templates
// render. Time values are pre-formatted to strings by the handlers so the
// templates stay logic-free.
@@ -40,24 +42,100 @@ type DashboardView struct {
Games int
ActiveGames int
OpenComplaints int
OpenFeedback int
PendingChanges int
Variants []VariantVersions
// ActiveVersion is the dictionary version new games pin (the persisted active
// version), distinct from the per-variant resident versions.
ActiveVersion string
Variants []VariantVersions
}
// UsersView is the paginated account list.
type UsersView struct {
Items []UserRow
Pager Pager
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
// glob filters; FilterQuery is those URL-encoded for the pager links. It is an
// already-escaped query fragment (url.Values.Encode), so it is typed template.URL to
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
// percent-encoded again by the contextual escaper.
Robots bool
NameMask string
ExternalIDMask string
FilterQuery template.URL
}
// UserRow is one account row in the list.
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
// pre-formatted move-duration summary (empty when it has no timed move);
// FlaggedHighRate marks the soft high-rate badge.
type UserRow struct {
ID string
ID string
DisplayName string
Kind string
Language string
Guest bool
FlaggedHighRate bool
CreatedAt string
HasMoveStats bool
MoveMin string
MoveAvg string
MoveMax string
}
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
// game or user card); FilterQuery is the active filters URL-encoded for the pager and CSV
// links — an already-escaped query fragment, hence template.URL so it is not re-encoded
// inside the link (see UsersView.FilterQuery).
type MessagesView struct {
Items []MessageRow
Pager Pager
NameMask string
ExtMask string
GameID string
UserID string
UnreadOnly bool
FilterQuery template.URL
}
// MessageRow is one chat message in the moderation list: its sender (linked to the user
// card), source, IP, body, game (linked to the game card), time, and whether it is still
// unread by at least one recipient.
type MessageRow struct {
ID string
SenderID string
SenderName string
Source string
IP string
Body string
GameID string
CreatedAt string
Unread bool
}
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
// console message card.
type ChatMessageDetailView struct {
ID string
GameID string
SenderID string
SenderName string
Source string
Kind string
Body string
IP string
CreatedAt string
Unread bool
Seats []ChatSeatStatusRow
}
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
type ChatSeatStatusRow struct {
Seat int
AccountID string
DisplayName string
Kind string
Language string
Guest bool
CreatedAt string
Role string
}
// UserDetailView is one account with its stats, identities and recent games.
@@ -70,9 +148,15 @@ type UserDetailView struct {
NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a
// merge (Stage 11), or empty for a live account.
MergedInto string
HintBalance int
// merge, or empty for a live account.
MergedInto string
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string
HintBalance int
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
// server's maxHintGrant), passed through so the policy value lives in one place.
HintGrantMax int
CreatedAt string
HasStats bool
Stats StatsRow
@@ -80,6 +164,51 @@ type UserDetailView struct {
Games []GameRow
TelegramID string
ConnectorEnabled bool
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
// time (min/mean/max), empty when the account has no timed move.
MoveChart template.HTML
// Suspension is the account's current manual-block state, shown with the block/unblock
// form; Reasons is the operator-editable reason picklist offered in the block form.
Suspension SuspensionView
Reasons []ReasonOption
// Roles is the account's current roles (each revocable); KnownRoles is the set the
// grant form offers. The first role is the feedback ban (see internal/account).
Roles []string
KnownRoles []string
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
// other account with the date it happened. They are the full truth; the asymmetric block
// suppression that hides relationships from players never applies to the console.
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
type RelationRow struct {
AccountID string
DisplayName string
Date string
}
// SuspensionView is an account's current manual-block state shown on the user card: whether it
// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent),
// when it was applied, and the reason snapshot in both languages (empty when none was cited).
type SuspensionView struct {
Blocked bool
Permanent bool
Until string
BlockedAt string
ReasonEn string
ReasonRu string
}
// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown.
type ReasonOption struct {
ID string
TextEn string
TextRu string
}
// StatsRow is an account's lifetime statistics.
@@ -89,6 +218,8 @@ type StatsRow struct {
Draws int
MaxGamePoints int
MaxWordPoints int
Moves int
HintsUsed int
}
// IdentityRow is one platform/email identity of an account.
@@ -106,6 +237,8 @@ type GameRow struct {
Status string
Players int
UpdatedAt string
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
VsAI bool
}
// GamesView is the paginated games list, optionally filtered by status.
@@ -128,10 +261,39 @@ type GameDetailView struct {
CreatedAt string
UpdatedAt string
FinishedAt string
Seats []SeatRow
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool
RobotTargetPct int
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
ReplayJSON template.JS
HasReplay bool
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
// and FirstMover is the resolved name of the seat-0 player the draw elected.
SetupDraws []SetupDrawRow
FirstMover string
}
// SeatRow is one seat of a game.
// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the
// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an
// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a
// blank) and its draw rank.
type SetupDrawRow struct {
Round int
Name string
AccountID string
Letter string
Blank bool
Rank int
}
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
type SeatRow struct {
Seat int
DisplayName string
@@ -139,6 +301,9 @@ type SeatRow struct {
Score int
HintsUsed int
Winner bool
IsRobot bool
RobotIntent string
NextMove string
}
// ComplaintsView is the paginated complaint review queue.
@@ -176,11 +341,13 @@ type ComplaintDetailView struct {
Resolved bool
}
// DictionaryView lists the resident versions per variant and the pending
// wordlist changes from accepted complaints.
// DictionaryView lists the resident versions per variant, the active version new
// games pin, and the pending wordlist changes from accepted complaints.
type DictionaryView struct {
Variants []VariantVersions
Changes []DictChangeRow
// ActiveVersion is the dictionary version new games pin; the update form sets it.
ActiveVersion string
Variants []VariantVersions
Changes []DictChangeRow
}
// DictChangeRow is one pending wordlist edit.
@@ -191,14 +358,201 @@ type DictChangeRow struct {
ResolvedAt string
}
// DictionaryPreviewView is the second step of a dictionary update: the version
// parsed from the uploaded archive (editable before confirming), the staging token
// that names the uploaded files on disk, the active version the diff is against, and
// the per-variant word diff.
type DictionaryPreviewView struct {
Version string
Token string
ActiveVersion string
Variants []VariantDiffRow
}
// VariantDiffRow summarises one variant's word diff in the update preview.
// AddedCount/RemovedCount are the full totals; AddedSample/RemovedSample are the
// first words shown (capped); AddedTruncated/RemovedTruncated mark a capped list;
// LargeRemoval flags a removal large enough to warrant caution before confirming.
type VariantDiffRow struct {
Variant string
AddedCount int
RemovedCount int
AddedSample []string
RemovedSample []string
AddedTruncated bool
RemovedTruncated bool
LargeRemoval bool
}
// BroadcastView is the operator-broadcast form page.
type BroadcastView struct {
ConnectorEnabled bool
}
// ThrottledView is the rate-limit observability page: the temporary IP bans the
// gateway is currently enforcing, the recent gateway-reported throttle episodes
// (in-memory, reset on restart) and the accounts currently carrying the high-rate
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning.
type ThrottledView struct {
Bans []BanRow
Episodes []ThrottleEpisodeRow
Flagged []FlaggedAccountRow
FlagThreshold int
FlagWindow string
}
// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its
// since/expiry timestamps; the row carries an unban action.
type BanRow struct {
IP string
Reason string
Since string
Expires string
}
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
// user card and is set only for the user class (the other classes key by IP).
type ThrottleEpisodeRow struct {
Class string
Key string
UserID string
Rejected int
FirstSeen string
LastSeen string
}
// FlaggedAccountRow is one account carrying the high-rate flag.
type FlaggedAccountRow struct {
ID string
DisplayName string
FlaggedAt string
}
// ReasonsView is the suspension-reason picklist management page: every editable reason entry.
type ReasonsView struct {
Items []ReasonRow
}
// ReasonRow is one editable suspension-reason entry, with its English and Russian text.
type ReasonRow struct {
ID string
TextEn string
TextRu string
CreatedAt string
}
// MessageView is the result page shown after a POST action.
type MessageView struct {
Heading string
Body string
Back string
}
// BannersView is the advertising-campaign list page.
type BannersView struct {
Items []BannerCampaignRow
}
// BannerCampaignRow is one campaign in the list. Window is a human-readable
// validity window ("perpetual" for the default); ActiveNow reports whether it
// would rotate right now (enabled and within its window).
type BannerCampaignRow struct {
ID string
Name string
Weight int
IsDefault bool
Enabled bool
Window string
Messages int
ActiveNow bool
}
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
type BannerDetailView struct {
ID string
Name string
Weight int
IsDefault bool
Enabled bool
StartsAt string
EndsAt string
Messages []BannerMessageRow
}
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
// reorder buttons (disabled at the ends).
type BannerMessageRow struct {
ID string
BodyEn string
BodyRu string
First bool
Last bool
}
// BannerSettingsView is the global display-timings form.
type BannerSettingsView struct {
HoldMs int
EdgePauseMs int
ScrollPxPerSec int
FadeOutMs int
GapMs int
FadeInMs int
}
// FeedbackView is the paginated user-feedback queue. Status is the active
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
// UserID pins the list to one account (the per-user link from /users);
// FilterQuery is the active filters URL-encoded for the pager links (already
// escaped, hence template.URL — see UsersView.FilterQuery).
type FeedbackView struct {
Items []FeedbackRow
Status string
NameMask string
ExtMask string
UserID string
Pager Pager
FilterQuery template.URL
}
// FeedbackRow is one feedback message in the queue: its sender (linked to the user
// card), source, channel, whether it has an attachment / a reply, its state and
// time.
type FeedbackRow struct {
ID string
AccountID string
SenderName string
Source string
Channel string
HasAttachment bool
Read bool
Replied bool
Archived bool
CreatedAt string
}
// FeedbackDetailView is one feedback message with its body, attachment, state and
// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are
// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline
// <img> preview; Banned shows whether the sender already holds the feedback ban.
type FeedbackDetailView struct {
ID string
AccountID string
SenderName string
Source string
Channel string
// InterfaceLanguage is the sender's interface language (account preference).
InterfaceLanguage string
IP string
Body string
HasAttachment bool
AttachmentName string
IsImage bool
Read bool
Archived bool
Replied bool
ReplyBody string
RepliedAt string
CreatedAt string
Banned bool
}
+188
View File
@@ -0,0 +1,188 @@
// Package ads owns the server-driven advertising banner ("advertising network"):
// operator-managed campaigns, their bilingual messages and the global display
// timings the client's one-line announcement strip rotates through.
//
// A campaign is one placement order with a show weight (an integer percent).
// Simultaneously active campaigns compete for display slots in proportion to
// their weights; the single perpetual default campaign fills the unsold
// remainder up to 100%. The actual rotation runs client-side (a smooth weighted
// round-robin over campaigns, round-robin over a campaign's messages); the server
// only computes the effective weighted, language-resolved set a viewer should
// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible.
package ads
import (
"errors"
"time"
"github.com/google/uuid"
)
// ErrNotFound is returned when a campaign or message does not exist.
var ErrNotFound = errors.New("ads: not found")
// ErrDefaultImmutable is returned when an operation is refused on the perpetual
// default campaign (it cannot be deleted, disabled, windowed, or have its
// weight or default flag changed).
var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way")
// ErrValidation wraps an operator-facing validation failure (a bad weight,
// window or message body).
var ErrValidation = errors.New("ads: validation")
// Campaign is one advertising placement order with its messages.
type Campaign struct {
ID uuid.UUID // uuid.Nil on create
Name string
Weight int // show percent, 1..100; nominal (ignored) for the default
IsDefault bool
Enabled bool
StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message
CreatedAt time.Time
UpdatedAt time.Time
}
// Message is one bilingual creative of a campaign. Both bodies are mandatory;
// the client shows the variant for the viewer's bot (service) language. Position
// orders the messages within a campaign (the round-robin order).
type Message struct {
ID uuid.UUID // uuid.Nil on create
CampaignID uuid.UUID
Position int
BodyEn string
BodyRu string
}
// Timings are the global banner display timings, in milliseconds except
// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and
// ScrollPxPerSec drive the scroll of a message wider than the strip; a
// transition between messages is FadeOutMs then GapMs then FadeInMs.
type Timings struct {
HoldMs int
EdgePauseMs int
ScrollPxPerSec int
FadeOutMs int
GapMs int
FadeInMs int
}
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order.
type ActiveCampaign struct {
Weight int
Messages []string
}
// Eligible reports whether an account should be shown the advertising banner: a
// free account (not paid) with an empty hint wallet and without the no_banner
// role. The no_banner role suppresses the banner unconditionally; buying a paid
// account or any hints also removes it.
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
return !paidAccount && hintBalance <= 0 && !hasNoBanner
}
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang. Campaigns outside their validity window, and
// campaigns with no messages, are dropped. The default campaign's effective
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
// weights) — so it fills unsold inventory and is dropped entirely when timed
// campaigns already reach 100%. Weights are then reduced by their GCD so the
// fair rotation cycle stays short. The input is expected to be the enabled
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
var timed []Campaign
var def *Campaign
for i := range campaigns {
c := campaigns[i]
if len(c.Messages) == 0 {
continue // a campaign with no creative cannot fill a slot
}
if c.IsDefault {
def = &campaigns[i]
continue
}
if withinWindow(c, now) {
timed = append(timed, c)
}
}
sumTimed := 0
for _, c := range timed {
sumTimed += c.Weight
}
out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed {
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
}
if def != nil {
if dw := 100 - sumTimed; dw > 0 {
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
}
}
reduceByGCD(out)
return out
}
// ActiveAt reports whether the campaign would rotate at time now: enabled, and
// either the perpetual default or within its validity window. It mirrors the
// filter computeActiveSet applies (a campaign with no messages still reports
// active here — that is a content gap the console surfaces, not an inactive
// campaign).
func (c Campaign) ActiveAt(now time.Time) bool {
return c.Enabled && (c.IsDefault || withinWindow(c, now))
}
// withinWindow reports whether now lies inside the campaign's validity window. A
// nil bound is open-ended on that side.
func withinWindow(c Campaign, now time.Time) bool {
if c.StartsAt != nil && now.Before(*c.StartsAt) {
return false
}
if c.EndsAt != nil && now.After(*c.EndsAt) {
return false
}
return true
}
// resolveBodies projects each message to the body for lang ("ru" picks the
// Russian body; anything else picks English), preserving display order.
func resolveBodies(msgs []Message, lang string) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
if lang == "ru" {
out[i] = m.BodyRu
} else {
out[i] = m.BodyEn
}
}
return out
}
// reduceByGCD divides every weight by the greatest common divisor of all weights
// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A
// no-op when the set is empty or already coprime.
func reduceByGCD(cs []ActiveCampaign) {
g := 0
for _, c := range cs {
g = gcd(g, c.Weight)
}
if g <= 1 {
return
}
for i := range cs {
cs[i].Weight /= g
}
}
// gcd returns the greatest common divisor of a and b (gcd(0, n) == n).
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a < 0 {
return -a
}
return a
}
+183
View File
@@ -0,0 +1,183 @@
package ads
import (
"reflect"
"testing"
"time"
)
func TestEligible(t *testing.T) {
tests := []struct {
name string
paidAccount bool
hintBalance int
hasNoBanner bool
want bool
}{
{name: "free, empty wallet, no role", want: true},
{name: "paid", paidAccount: true, want: false},
{name: "has hints", hintBalance: 3, want: false},
{name: "no_banner role", hasNoBanner: true, want: false},
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
}
})
}
}
func TestComputeActiveSet(t *testing.T) {
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
past := now.Add(-24 * time.Hour)
future := now.Add(24 * time.Hour)
// msg builds a one-message slice with distinct bodies so resolution and
// campaign identity are visible in assertions.
msg := func(tag string) []Message {
return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}}
}
def := func(msgs []Message) Campaign {
return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs}
}
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
}
tests := []struct {
name string
campaigns []Campaign
lang string
want []ActiveCampaign
}{
{
name: "default only reduces to weight 1",
campaigns: []Campaign{def(msg("house"))},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "default fills the remainder",
campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))},
lang: "en",
// timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"promo-en"}},
{Weight: 7, Messages: []string{"house-en"}},
},
},
{
name: "timed fills 100, default dropped",
campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}},
},
{
name: "timed over 100, default dropped, proportional",
campaigns: []Campaign{
def(msg("house")),
timed("a", 60, nil, nil, msg("a")),
timed("b", 80, nil, nil, msg("b")),
},
lang: "en",
// default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4.
want: []ActiveCampaign{
{Weight: 3, Messages: []string{"a-en"}},
{Weight: 4, Messages: []string{"b-en"}},
},
},
{
name: "future and past windows exclude timed",
campaigns: []Campaign{
def(msg("house")),
timed("future", 50, &future, nil, msg("future")),
timed("past", 50, nil, &past, msg("past")),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "active window included",
campaigns: []Campaign{
def(msg("house")),
timed("live", 25, &past, &future, msg("live")),
},
lang: "en",
// timed 25, default 75; gcd 25 -> 1 and 3.
want: []ActiveCampaign{
{Weight: 1, Messages: []string{"live-en"}},
{Weight: 3, Messages: []string{"house-en"}},
},
},
{
name: "campaign without messages is skipped and not counted",
campaigns: []Campaign{
def(msg("house")),
timed("empty", 40, nil, nil, nil),
},
lang: "en",
// empty campaign skipped; default fills full 100 -> reduced to 1.
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
},
{
name: "russian bodies resolved",
campaigns: []Campaign{def(msg("house"))},
lang: "ru",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}},
},
{
name: "three timed split by gcd",
campaigns: []Campaign{
def(msg("house")),
timed("a", 50, nil, nil, msg("a")),
timed("b", 30, nil, nil, msg("b")),
timed("c", 20, nil, nil, msg("c")),
},
lang: "en",
// sum 100, default dropped; gcd 10 -> 5,3,2.
want: []ActiveCampaign{
{Weight: 5, Messages: []string{"a-en"}},
{Weight: 3, Messages: []string{"b-en"}},
{Weight: 2, Messages: []string{"c-en"}},
},
},
{
name: "campaign with multiple messages keeps order",
campaigns: []Campaign{
def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}),
},
lang: "en",
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeActiveSet(tt.campaigns, now, tt.lang)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
}
})
}
}
func TestComputeActiveSetEmpty(t *testing.T) {
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
}
}
func TestGCD(t *testing.T) {
tests := []struct{ a, b, want int }{
{0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1},
}
for _, tt := range tests {
if got := gcd(tt.a, tt.b); got != tt.want {
t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
package ads
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Operator-input bounds and the display-timing clamps. The timings are clamped
// (not rejected) on update so the client rotator can never be driven into a
// broken state by a typo in the console.
const (
maxCampaignName = 80
maxMessageBody = 500
minHoldMs = 3000 // 3s — below this the fade transition would dominate
maxHoldMs = 600000 // 10 min
maxEdgePauseMs = 60000
minScrollPxPerSec = 5
maxScrollPxPerSec = 1000
maxFadeMs = 5000
)
// Service is the domain layer over Store: it validates operator input, enforces
// the default campaign's invariants, clamps the display timings, and assembles
// the resolved rotation feed (ActiveSet) for a viewer.
type Service struct {
store *Store
}
// NewService constructs a Service over store.
func NewService(store *Store) *Service { return &Service{store: store} }
// ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin.
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
campaigns, err := s.store.ActiveCampaigns(ctx)
if err != nil {
return nil, Timings{}, err
}
timings, err := s.store.Settings(ctx)
if err != nil {
return nil, Timings{}, err
}
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
}
// ListCampaigns returns every campaign with its messages, for the admin console.
func (s *Service) ListCampaigns(ctx context.Context) ([]Campaign, error) {
return s.store.ListCampaigns(ctx)
}
// Campaign returns one campaign with its messages, or ErrNotFound.
func (s *Service) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
return s.store.Campaign(ctx, id)
}
// CreateCampaign validates and creates a new time-limited campaign (never the
// default) and returns its id.
func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
name, err := validName(c.Name)
if err != nil {
return uuid.Nil, err
}
if err := validWeight(c.Weight); err != nil {
return uuid.Nil, err
}
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return uuid.Nil, err
}
return s.store.CreateCampaign(ctx, Campaign{
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
})
}
// UpdateCampaign validates and updates a campaign. The default campaign keeps its
// weight (nominal), enabled flag and (empty) window — only its name is editable;
// any submitted weight/window/enabled are ignored for it.
func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
existing, err := s.store.Campaign(ctx, c.ID)
if err != nil {
return err
}
name, err := validName(c.Name)
if err != nil {
return err
}
upd := Campaign{ID: existing.ID, Name: name}
if existing.IsDefault {
upd.Weight = existing.Weight
upd.Enabled = true
upd.StartsAt = nil
upd.EndsAt = nil
} else {
if err := validWeight(c.Weight); err != nil {
return err
}
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
return err
}
upd.Weight = c.Weight
upd.Enabled = c.Enabled
upd.StartsAt = c.StartsAt
upd.EndsAt = c.EndsAt
}
return s.store.UpdateCampaign(ctx, upd)
}
// DeleteCampaign removes a campaign, refusing the perpetual default.
func (s *Service) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
existing, err := s.store.Campaign(ctx, id)
if err != nil {
return err
}
if existing.IsDefault {
return ErrDefaultImmutable
}
return s.store.DeleteCampaign(ctx, id)
}
// AddMessage validates a bilingual message and appends it to a campaign.
func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, bodyRu string) (uuid.UUID, error) {
en, ru, err := validBodies(bodyEn, bodyRu)
if err != nil {
return uuid.Nil, err
}
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return uuid.Nil, err
}
return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru})
}
// EditMessage validates and updates the bilingual bodies of a message that
// belongs to campaignID (its position is unchanged). It returns ErrNotFound when
// the message is not part of that campaign, so a mismatched campaign/message pair
// never edits an unrelated campaign's message.
func (s *Service) EditMessage(ctx context.Context, campaignID, messageID uuid.UUID, bodyEn, bodyRu string) error {
en, ru, err := validBodies(bodyEn, bodyRu)
if err != nil {
return err
}
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return err
}
if !campaignOwnsMessage(c, messageID) {
return ErrNotFound
}
return s.store.UpdateMessageBodies(ctx, messageID, en, ru)
}
// MoveMessage reorders a message within its campaign by swapping its position
// with the adjacent message in direction dir (-1 up, +1 down). A move past an
// edge is a no-op.
func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UUID, dir int) error {
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return err
}
idx := -1
for i, m := range c.Messages {
if m.ID == messageID {
idx = i
break
}
}
if idx < 0 {
return ErrNotFound
}
j := idx + dir
if j < 0 || j >= len(c.Messages) {
return nil // already at the edge
}
a, b := c.Messages[idx], c.Messages[j]
if err := s.store.SetMessagePosition(ctx, a.ID, j); err != nil {
return err
}
return s.store.SetMessagePosition(ctx, b.ID, idx)
}
// DeleteMessage removes a message that belongs to campaignID, refusing to remove
// the default campaign's last remaining message (the default must always have a
// creative to show). It returns ErrNotFound when the message is not part of that
// campaign — so a mismatched campaign/message pair can neither delete an
// unrelated campaign's message nor bypass the default's last-message guard.
func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error {
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return err
}
if !campaignOwnsMessage(c, messageID) {
return ErrNotFound
}
if c.IsDefault && len(c.Messages) <= 1 {
return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation)
}
return s.store.DeleteMessage(ctx, messageID)
}
// campaignOwnsMessage reports whether messageID is one of the campaign's messages.
func campaignOwnsMessage(c Campaign, messageID uuid.UUID) bool {
for _, m := range c.Messages {
if m.ID == messageID {
return true
}
}
return false
}
// Settings returns the global display timings.
func (s *Service) Settings(ctx context.Context) (Timings, error) {
return s.store.Settings(ctx)
}
// UpdateSettings clamps every timing into its safe range and stores them.
func (s *Service) UpdateSettings(ctx context.Context, t Timings) error {
return s.store.UpdateSettings(ctx, clampTimings(t))
}
// validName trims and bounds a campaign name.
func validName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("%w: the campaign name is required", ErrValidation)
}
if len([]rune(name)) > maxCampaignName {
return "", fmt.Errorf("%w: the campaign name must be at most %d characters", ErrValidation, maxCampaignName)
}
return name, nil
}
// validWeight bounds a campaign show weight to the 1..100 percent range.
func validWeight(w int) error {
if w < 1 || w > 100 {
return fmt.Errorf("%w: the weight must be a percent between 1 and 100", ErrValidation)
}
return nil
}
// validWindow rejects an inverted validity window.
func validWindow(starts, ends *time.Time) error {
if starts != nil && ends != nil && ends.Before(*starts) {
return fmt.Errorf("%w: the end must not precede the start", ErrValidation)
}
return nil
}
// validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) {
en := strings.TrimSpace(bodyEn)
ru := strings.TrimSpace(bodyRu)
if en == "" || ru == "" {
return "", "", fmt.Errorf("%w: both the English and Russian message bodies are required", ErrValidation)
}
if len([]rune(en)) > maxMessageBody || len([]rune(ru)) > maxMessageBody {
return "", "", fmt.Errorf("%w: a message body must be at most %d characters", ErrValidation, maxMessageBody)
}
return en, ru, nil
}
// clampTimings forces every display timing into its safe range.
func clampTimings(t Timings) Timings {
return Timings{
HoldMs: clampInt(t.HoldMs, minHoldMs, maxHoldMs),
EdgePauseMs: clampInt(t.EdgePauseMs, 0, maxEdgePauseMs),
ScrollPxPerSec: clampInt(t.ScrollPxPerSec, minScrollPxPerSec, maxScrollPxPerSec),
FadeOutMs: clampInt(t.FadeOutMs, 0, maxFadeMs),
GapMs: clampInt(t.GapMs, 0, maxFadeMs),
FadeInMs: clampInt(t.FadeInMs, 0, maxFadeMs),
}
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+289
View File
@@ -0,0 +1,289 @@
package ads
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Store is the Postgres-backed query surface for advertising campaigns, their
// messages and the single global display-settings row.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// ListCampaigns returns every campaign with its messages, the default first then
// by creation time, for the admin console.
func (s *Store) ListCampaigns(ctx context.Context) ([]Campaign, error) {
return s.loadCampaigns(ctx, false)
}
// ActiveCampaigns returns the enabled campaigns with their messages, the default
// first then by creation time. Window filtering and the default-remainder weight
// are applied by the Service (ActiveSet), not here.
func (s *Store) ActiveCampaigns(ctx context.Context) ([]Campaign, error) {
return s.loadCampaigns(ctx, true)
}
// loadCampaigns reads campaigns (optionally only the enabled ones) and attaches
// each one's messages in display order, with a single messages query (the table
// is small, so this avoids both an N+1 and a join projection).
func (s *Store) loadCampaigns(ctx context.Context, enabledOnly bool) ([]Campaign, error) {
sel := postgres.SELECT(table.AdCampaigns.AllColumns).FROM(table.AdCampaigns)
if enabledOnly {
sel = sel.WHERE(table.AdCampaigns.Enabled.EQ(postgres.Bool(true)))
}
sel = sel.ORDER_BY(table.AdCampaigns.IsDefault.DESC(), table.AdCampaigns.CreatedAt.ASC())
var rows []model.AdCampaigns
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("ads: list campaigns: %w", err)
}
byID, err := s.messagesByCampaign(ctx)
if err != nil {
return nil, err
}
out := make([]Campaign, 0, len(rows))
for _, r := range rows {
c := modelToCampaign(r)
c.Messages = byID[r.CampaignID]
out = append(out, c)
}
return out, nil
}
// Campaign returns one campaign with its messages, or ErrNotFound.
func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
sel := postgres.SELECT(table.AdCampaigns.AllColumns).
FROM(table.AdCampaigns).
WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.AdCampaigns
if err := sel.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Campaign{}, ErrNotFound
}
return Campaign{}, fmt.Errorf("ads: get campaign %s: %w", id, err)
}
c := modelToCampaign(row)
msgs, err := s.messagesFor(ctx, id)
if err != nil {
return Campaign{}, err
}
c.Messages = msgs
return c, nil
}
// CreateCampaign inserts a campaign (always non-default) and returns its new id.
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
id := uuid.New()
now := time.Now().UTC()
stmt := table.AdCampaigns.INSERT(
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
).VALUES(
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
postgres.Bool(false), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
postgres.TimestampzT(now), postgres.TimestampzT(now),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return uuid.Nil, fmt.Errorf("ads: create campaign: %w", err)
}
return id, nil
}
// UpdateCampaign updates a campaign's name, weight, enabled flag and validity
// window. The default flag is never touched here. Returns ErrNotFound when no
// campaign matches.
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
stmt := table.AdCampaigns.UPDATE(
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
).SET(
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
return execOne(ctx, s.db, stmt, "update campaign")
}
// DeleteCampaign removes a campaign (its messages cascade). Returns ErrNotFound
// when no campaign matches. The Service refuses to delete the default.
func (s *Store) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
stmt := table.AdCampaigns.DELETE().WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id)))
return execOne(ctx, s.db, stmt, "delete campaign")
}
// AddMessage inserts a message into a campaign and returns its new id.
func (s *Store) AddMessage(ctx context.Context, m Message) (uuid.UUID, error) {
id := uuid.New()
now := time.Now().UTC()
stmt := table.AdMessages.INSERT(
table.AdMessages.MessageID, table.AdMessages.CampaignID, table.AdMessages.Position,
table.AdMessages.BodyEn, table.AdMessages.BodyRu,
table.AdMessages.CreatedAt, table.AdMessages.UpdatedAt,
).VALUES(
postgres.UUID(id), postgres.UUID(m.CampaignID), postgres.Int(int64(m.Position)),
postgres.String(m.BodyEn), postgres.String(m.BodyRu),
postgres.TimestampzT(now), postgres.TimestampzT(now),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return uuid.Nil, fmt.Errorf("ads: add message: %w", err)
}
return id, nil
}
// UpdateMessageBodies updates a message's bilingual bodies (its position is
// unchanged). Returns ErrNotFound when no message matches.
func (s *Store) UpdateMessageBodies(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error {
stmt := table.AdMessages.UPDATE(
table.AdMessages.BodyEn, table.AdMessages.BodyRu, table.AdMessages.UpdatedAt,
).SET(
postgres.String(bodyEn), postgres.String(bodyRu), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
return execOne(ctx, s.db, stmt, "update message")
}
// SetMessagePosition updates only a message's position (used to reorder).
func (s *Store) SetMessagePosition(ctx context.Context, id uuid.UUID, position int) error {
stmt := table.AdMessages.UPDATE(table.AdMessages.Position, table.AdMessages.UpdatedAt).
SET(postgres.Int(int64(position)), postgres.TimestampzT(time.Now().UTC())).
WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
return execOne(ctx, s.db, stmt, "reorder message")
}
// DeleteMessage removes a message. Returns ErrNotFound when no message matches.
func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) error {
stmt := table.AdMessages.DELETE().WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
return execOne(ctx, s.db, stmt, "delete message")
}
// Settings returns the global display timings.
func (s *Store) Settings(ctx context.Context) (Timings, error) {
sel := postgres.SELECT(table.AdSettings.AllColumns).FROM(table.AdSettings).LIMIT(1)
var row model.AdSettings
if err := sel.QueryContext(ctx, s.db, &row); err != nil {
return Timings{}, fmt.Errorf("ads: get settings: %w", err)
}
return Timings{
HoldMs: int(row.HoldMs),
EdgePauseMs: int(row.EdgePauseMs),
ScrollPxPerSec: int(row.ScrollPxPerSec),
FadeOutMs: int(row.FadeOutMs),
GapMs: int(row.GapMs),
FadeInMs: int(row.FadeInMs),
}, nil
}
// UpdateSettings overwrites the single global display-settings row.
func (s *Store) UpdateSettings(ctx context.Context, t Timings) error {
stmt := table.AdSettings.UPDATE(
table.AdSettings.HoldMs, table.AdSettings.EdgePauseMs, table.AdSettings.ScrollPxPerSec,
table.AdSettings.FadeOutMs, table.AdSettings.GapMs, table.AdSettings.FadeInMs, table.AdSettings.UpdatedAt,
).SET(
postgres.Int(int64(t.HoldMs)), postgres.Int(int64(t.EdgePauseMs)), postgres.Int(int64(t.ScrollPxPerSec)),
postgres.Int(int64(t.FadeOutMs)), postgres.Int(int64(t.GapMs)), postgres.Int(int64(t.FadeInMs)),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.AdSettings.ID.EQ(postgres.Bool(true)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("ads: update settings: %w", err)
}
return nil
}
// messagesByCampaign loads every message grouped by campaign id, in display order.
func (s *Store) messagesByCampaign(ctx context.Context) (map[uuid.UUID][]Message, error) {
sel := postgres.SELECT(table.AdMessages.AllColumns).
FROM(table.AdMessages).
ORDER_BY(table.AdMessages.CampaignID.ASC(), table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC())
var rows []model.AdMessages
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("ads: list messages: %w", err)
}
out := make(map[uuid.UUID][]Message, len(rows))
for _, r := range rows {
out[r.CampaignID] = append(out[r.CampaignID], modelToMessage(r))
}
return out, nil
}
// messagesFor loads one campaign's messages in display order.
func (s *Store) messagesFor(ctx context.Context, campaignID uuid.UUID) ([]Message, error) {
sel := postgres.SELECT(table.AdMessages.AllColumns).
FROM(table.AdMessages).
WHERE(table.AdMessages.CampaignID.EQ(postgres.UUID(campaignID))).
ORDER_BY(table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC())
var rows []model.AdMessages
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("ads: list messages for %s: %w", campaignID, err)
}
out := make([]Message, 0, len(rows))
for _, r := range rows {
out = append(out, modelToMessage(r))
}
return out, nil
}
// execOne runs a statement expected to touch exactly one row, mapping a zero
// row-count to ErrNotFound.
func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) error {
res, err := stmt.ExecContext(ctx, db)
if err != nil {
return fmt.Errorf("ads: %s: %w", what, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("ads: %s rows: %w", what, err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
func modelToCampaign(r model.AdCampaigns) Campaign {
return Campaign{
ID: r.CampaignID,
Name: r.Name,
Weight: int(r.Weight),
IsDefault: r.IsDefault,
Enabled: r.Enabled,
StartsAt: r.StartsAt,
EndsAt: r.EndsAt,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func modelToMessage(r model.AdMessages) Message {
return Message{
ID: r.MessageID,
CampaignID: r.CampaignID,
Position: int(r.Position),
BodyEn: r.BodyEn,
BodyRu: r.BodyRu,
}
}
// tsOrNull renders a nullable validity-window bound: NULL for a nil pointer,
// otherwise the UTC timestamp.
func tsOrNull(t *time.Time) postgres.Expression {
if t == nil {
return postgres.NULL
}
return postgres.TimestampzT(t.UTC())
}
+92
View File
@@ -0,0 +1,92 @@
// Package banview mirrors the gateway's active IP bans for the admin console and
// collects operator unban requests for the gateway to apply. Like ratewatch it is
// in-memory, single-instance and resets on a backend restart by design — the
// gateway re-reports its active set on the next sync, and the durable effect (the
// ban itself) lives in the gateway, not here.
package banview
import (
"sort"
"sync"
"time"
)
// Ban is one active IP ban as reported by the gateway.
type Ban struct {
IP string
Reason string
Since time.Time
Expires time.Time
}
// View holds the last-reported active bans and the operator's pending unbans.
type View struct {
now func() time.Time
mu sync.Mutex
bans map[string]Ban // last reported active set, keyed by IP
unban map[string]struct{} // IPs an operator marked for unban
}
// New constructs an empty View.
func New() *View {
return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})}
}
// Ingest replaces the mirrored active set with the gateway's latest report,
// skipping entries with an empty IP or one that has already expired.
func (v *View) Ingest(active []Ban) {
now := v.now()
v.mu.Lock()
defer v.mu.Unlock()
v.bans = make(map[string]Ban, len(active))
for _, b := range active {
if b.IP == "" || !now.Before(b.Expires) {
continue
}
v.bans[b.IP] = b
}
}
// Recent returns the mirrored active bans, most recently banned first.
func (v *View) Recent() []Ban {
now := v.now()
v.mu.Lock()
defer v.mu.Unlock()
out := make([]Ban, 0, len(v.bans))
for _, b := range v.bans {
if now.Before(b.Expires) {
out = append(out, b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
return out
}
// RequestUnban records an operator request to lift the ban on ip; the gateway
// applies it on its next sync (so the console reflects it within the sync
// interval). An empty ip is ignored.
func (v *View) RequestUnban(ip string) {
if ip == "" {
return
}
v.mu.Lock()
defer v.mu.Unlock()
v.unban[ip] = struct{}{}
}
// DrainUnbans returns and clears the IPs operators have marked for unban since the
// previous drain. It returns nil when there are none.
func (v *View) DrainUnbans() []string {
v.mu.Lock()
defer v.mu.Unlock()
if len(v.unban) == 0 {
return nil
}
out := make([]string, 0, len(v.unban))
for ip := range v.unban {
out = append(out, ip)
}
clear(v.unban)
return out
}
+64
View File
@@ -0,0 +1,64 @@
package banview
import (
"testing"
"time"
)
func viewAt(clk *time.Time) *View {
v := New()
v.now = func() time.Time { return *clk }
return v
}
func TestIngestRecentDropsExpired(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{
{IP: "1.1.1.1", Reason: "tripwire", Since: clk, Expires: clk.Add(time.Hour)},
{IP: "2.2.2.2", Reason: "rejections", Since: clk.Add(-2 * time.Hour), Expires: clk.Add(-time.Hour)}, // expired
{IP: "", Reason: "x", Since: clk, Expires: clk.Add(time.Hour)}, // empty IP
})
got := v.Recent()
if len(got) != 1 || got[0].IP != "1.1.1.1" || got[0].Reason != "tripwire" {
t.Fatalf("Recent = %+v, want one live ban for 1.1.1.1", got)
}
}
func TestIngestReplaces(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{{IP: "1.1.1.1", Since: clk, Expires: clk.Add(time.Hour)}})
v.Ingest([]Ban{{IP: "2.2.2.2", Since: clk, Expires: clk.Add(time.Hour)}})
got := v.Recent()
if len(got) != 1 || got[0].IP != "2.2.2.2" {
t.Fatalf("Recent = %+v, want only the latest report (2.2.2.2)", got)
}
}
func TestRecentOrdersBySince(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{
{IP: "old", Since: clk.Add(-10 * time.Minute), Expires: clk.Add(time.Hour)},
{IP: "new", Since: clk.Add(-1 * time.Minute), Expires: clk.Add(time.Hour)},
})
got := v.Recent()
if len(got) != 2 || got[0].IP != "new" || got[1].IP != "old" {
t.Fatalf("Recent order = %+v, want most recent first", got)
}
}
func TestUnbanRoundTrip(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.RequestUnban("3.3.3.3")
v.RequestUnban("") // ignored
drained := v.DrainUnbans()
if len(drained) != 1 || drained[0] != "3.3.3.3" {
t.Fatalf("DrainUnbans = %v, want [3.3.3.3]", drained)
}
if again := v.DrainUnbans(); again != nil {
t.Fatalf("second DrainUnbans = %v, want nil (cleared)", again)
}
}
+19
View File
@@ -12,6 +12,7 @@ import (
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
)
@@ -35,6 +36,9 @@ type Config struct {
Lobby lobby.Config
// Robot configures the robot opponent driver (scan cadence).
Robot robot.Config
// RateWatch tunes the conservative high-rate auto-flag applied to the
// gateway's rate-limiter rejection reports.
RateWatch ratewatch.Config
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig
@@ -96,6 +100,9 @@ func Load() (Config, error) {
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
return Config{}, err
}
if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil {
return Config{}, err
}
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
return Config{}, err
}
@@ -105,6 +112,14 @@ func Load() (Config, error) {
return Config{}, err
}
rw := ratewatch.DefaultConfig()
if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
return Config{}, err
}
if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
return Config{}, err
}
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
if err != nil {
return Config{}, err
@@ -131,6 +146,7 @@ func Load() (Config, error) {
Game: gm,
Lobby: lb,
Robot: rb,
RateWatch: rw,
SMTP: smtp,
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval,
@@ -170,6 +186,9 @@ func (c Config) validate() error {
if err := c.Robot.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if err := c.RateWatch.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if c.GuestReapInterval <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
}
+16 -18
View File
@@ -1,11 +1,10 @@
// Package connector is the backend's gRPC client for the Telegram platform
// connector side-service. The admin console uses it to send operator broadcasts:
// a direct message to one user, or a post to a game channel. Each broadcast
// selects the delivering bot by language (an operator choice, since the connector
// hosts one bot per service language). The connector lives on the trusted internal
// network, so the connection uses insecure (plaintext) transport credentials
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
// the two broadcast methods the admin surface needs.
// Package connector is the backend's gRPC client for operator broadcasts: a direct
// message to one user, or a post to the game channel. It calls the gateway's
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS
// link and reports back whether it was delivered). The relay lives on the trusted
// internal network, so the connection uses insecure (plaintext) transport
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
// narrowed to the two broadcast methods the admin surface needs.
package connector
import (
@@ -37,22 +36,21 @@ func New(addr string) (*Client, error) {
func (c *Client) Close() error { return c.conn.Close() }
// SendToUser sends an operator text message to one user, addressed by their
// platform external_id, through the bot for the given language. delivered reports
// whether the connector actually sent it (false when the user has not started that
// bot).
func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) {
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
// platform external_id, through the bot. delivered reports whether the connector
// actually sent it (false when the user has not started the bot).
func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
// SendToGameChannel posts an operator text message to the game channel of the bot
// for the given language. delivered reports whether the connector sent it (false
// when that bot has no channel configured).
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
// SendToGameChannel posts an operator text message to the bot's game channel.
// delivered reports whether the connector sent it (false when the bot has no
// channel configured).
func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
if err != nil {
return false, err
}
+262
View File
@@ -0,0 +1,262 @@
// Package dictadmin stages and installs scrabble-dictionary release archives for
// the admin console's online dictionary-update flow. It validates the release
// archive (scrabble-dawg-vX.Y.Z.tar.gz), extracts it safely into a per-version
// directory under BACKEND_DICT_DIR, and keeps a staging area for the two-step
// upload-then-confirm interaction. Extraction is hardened against the usual
// archive risks (path traversal, symlinks, decompression bombs).
package dictadmin
import (
"archive/tar"
"compress/gzip"
crand "crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"time"
"scrabble/backend/internal/engine"
)
// MaxArchiveBytes bounds an uploaded release archive; the handler wraps the request
// body in an http.MaxBytesReader with this limit. The three compressed DAWGs are a
// few MB together, so 64 MiB is generous.
const MaxArchiveBytes int64 = 64 << 20
// stagingRoot is the dot-prefixed directory under the dictionary dir holding
// not-yet-installed uploads. The engine's boot scan skips dot-prefixed dirs, so a
// staged upload never becomes a resident version.
const stagingRoot = ".staging"
// stagingTTL is how long an abandoned staged upload lingers before Stage sweeps it.
const stagingTTL = time.Hour
// maxMemberBytes and maxEntries bound a single archive member and the number of
// entries, defeating decompression and entry-count bombs. They are variables so
// tests can lower them. maxMemberBytes is per file; a real DAWG is a few MB.
var (
maxMemberBytes int64 = 32 << 20
maxEntries = 64
)
var (
nameRE = regexp.MustCompile(`^scrabble-dawg-(v[0-9]+\.[0-9]+\.[0-9]+)\.tar\.gz$`)
versionRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
tokenRE = regexp.MustCompile(`^[0-9a-f]{32}$`)
)
// ParseVersionFromName extracts the version from a release archive filename of the
// form scrabble-dawg-vX.Y.Z.tar.gz, or returns an error for any other shape.
func ParseVersionFromName(filename string) (string, error) {
m := nameRE.FindStringSubmatch(filename)
if m == nil {
return "", fmt.Errorf("dictadmin: %q is not a scrabble-dawg-vX.Y.Z.tar.gz archive", filename)
}
return m[1], nil
}
// ValidVersion reports whether v is a vMAJOR.MINOR.PATCH version label. It guards
// the operator's manual override before the version is used as a directory name.
func ValidVersion(v string) bool {
return versionRE.MatchString(v)
}
// Extract reads a gzip+tar release archive from r and writes the recognised
// dictionary files into destDir, returning the variants found in catalogue order.
// Only regular files whose base name is a known DAWG filename are written (their
// directory part is discarded, so a traversal entry cannot escape destDir);
// symlinks and other types are ignored, and oversize members or an excessive entry
// count are rejected.
func Extract(r io.Reader, destDir string) ([]engine.Variant, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("dictadmin: open gzip: %w", err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
byName := filenameToVariant()
seen := make(map[engine.Variant]bool)
entries := 0
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("dictadmin: read archive: %w", err)
}
entries++
if entries > maxEntries {
return nil, fmt.Errorf("dictadmin: archive has more than %d entries", maxEntries)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
base := filepath.Base(filepath.Clean(hdr.Name))
v, ok := byName[base]
if !ok {
continue
}
if seen[v] {
return nil, fmt.Errorf("dictadmin: duplicate %s in archive", base)
}
if hdr.Size > maxMemberBytes {
return nil, fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", base, maxMemberBytes)
}
if err := writeMember(filepath.Join(destDir, base), tr); err != nil {
return nil, err
}
seen[v] = true
}
var found []engine.Variant
for _, v := range engine.Variants() {
if seen[v] {
found = append(found, v)
}
}
return found, nil
}
// writeMember copies one archive member into path, bounding it at maxMemberBytes so
// a member whose declared size lies cannot exhaust the disk.
func writeMember(path string, tr *tar.Reader) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("dictadmin: create %s: %w", filepath.Base(path), err)
}
defer func() { _ = f.Close() }()
n, err := io.Copy(f, io.LimitReader(tr, maxMemberBytes+1))
if err != nil {
return fmt.Errorf("dictadmin: write %s: %w", filepath.Base(path), err)
}
if n > maxMemberBytes {
return fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", filepath.Base(path), maxMemberBytes)
}
return nil
}
// filenameToVariant inverts the engine's variant-to-filename map.
func filenameToVariant() map[string]engine.Variant {
files := engine.DictFiles()
out := make(map[string]engine.Variant, len(files))
for v, name := range files {
out[name] = v
}
return out
}
// Manager stages and installs release archives under a dictionary directory
// (BACKEND_DICT_DIR).
type Manager struct {
dir string
}
// New constructs a Manager rooted at the dictionary directory dir.
func New(dir string) *Manager {
return &Manager{dir: dir}
}
// Stage accepts an uploaded archive: it sweeps abandoned previews, allocates a
// staging directory under <dir>/.staging/<token>, extracts the archive there and
// returns the token and the variants found. The staging directory is removed on an
// extraction error.
func (m *Manager) Stage(r io.Reader) (string, []engine.Variant, error) {
m.cleanStaging()
token, err := newToken()
if err != nil {
return "", nil, err
}
dest := filepath.Join(m.dir, stagingRoot, token)
if err := os.MkdirAll(dest, 0o755); err != nil {
return "", nil, fmt.Errorf("dictadmin: create staging dir: %w", err)
}
variants, err := Extract(r, dest)
if err != nil {
_ = os.RemoveAll(dest)
return "", nil, err
}
return token, variants, nil
}
// StagedDir returns the directory of the staged upload named by token, after
// validating the token's shape and confirming the directory exists.
func (m *Manager) StagedDir(token string) (string, error) {
if !tokenRE.MatchString(token) {
return "", fmt.Errorf("dictadmin: invalid staging token")
}
dir := filepath.Join(m.dir, stagingRoot, token)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return "", fmt.Errorf("dictadmin: staged upload not found")
}
return dir, nil
}
// VersionExists reports whether the version directory <dir>/<version> is present.
func (m *Manager) VersionExists(version string) bool {
info, err := os.Stat(filepath.Join(m.dir, version))
return err == nil && info.IsDir()
}
// Install promotes a staged upload into its version directory <dir>/<version> by an
// atomic rename (same filesystem) and returns the destination path. It rejects an
// invalid version and refuses to overwrite an existing version: versions are
// immutable, protecting games pinned to that tag.
func (m *Manager) Install(token, version string) (string, error) {
if !ValidVersion(version) {
return "", fmt.Errorf("dictadmin: invalid version %q", version)
}
staged, err := m.StagedDir(token)
if err != nil {
return "", err
}
if m.VersionExists(version) {
return "", fmt.Errorf("dictadmin: version %s already exists", version)
}
dest := filepath.Join(m.dir, version)
if err := os.Rename(staged, dest); err != nil {
return "", fmt.Errorf("dictadmin: install %s: %w", version, err)
}
return dest, nil
}
// Discard removes the staging directory named by token, best effort. It is called
// when a preview is rejected (an incomplete or unusable archive).
func (m *Manager) Discard(token string) {
if dir, err := m.StagedDir(token); err == nil {
_ = os.RemoveAll(dir)
}
}
// cleanStaging removes staging directories older than stagingTTL, best effort.
func (m *Manager) cleanStaging() {
root := filepath.Join(m.dir, stagingRoot)
entries, err := os.ReadDir(root)
if err != nil {
return
}
cutoff := time.Now().Add(-stagingTTL)
for _, e := range entries {
if !e.IsDir() {
continue
}
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(filepath.Join(root, e.Name()))
}
}
}
// newToken returns a 32-hex-character random staging token.
func newToken() (string, error) {
var b [16]byte
if _, err := crand.Read(b[:]); err != nil {
return "", fmt.Errorf("dictadmin: generate token: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
@@ -0,0 +1,248 @@
package dictadmin
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"scrabble/backend/internal/engine"
)
// dawgNames are the three committed dictionary filenames a release archive holds.
func dawgNames() []string {
out := make([]string, 0, 3)
for _, name := range engine.DictFiles() {
out = append(out, name)
}
return out
}
// fullArchive builds a gzip+tar archive holding all three dictionary files with
// dummy content, the shape of a real release archive.
func fullArchive(t *testing.T) []byte {
t.Helper()
files := map[string][]byte{}
for _, n := range dawgNames() {
files[n] = []byte("dawg:" + n)
}
return tarGz(t, files, nil)
}
// tarGz builds a gzip+tar archive. regular maps a member name to its bytes; extra
// lets a test inject crafted headers (symlinks, oversized members, junk entries).
func tarGz(t *testing.T, regular map[string][]byte, extra []*tar.Header) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, data := range regular {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
t.Fatalf("write header %s: %v", name, err)
}
if _, err := tw.Write(data); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
for _, hdr := range extra {
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write extra header %s: %v", hdr.Name, err)
}
if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 {
if _, err := tw.Write(bytes.Repeat([]byte("x"), int(hdr.Size))); err != nil {
t.Fatalf("write extra %s: %v", hdr.Name, err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func TestParseVersionFromName(t *testing.T) {
cases := []struct {
name string
want string
wantErr bool
}{
{"scrabble-dawg-v1.2.3.tar.gz", "v1.2.3", false},
{"scrabble-dawg-v10.0.1.tar.gz", "v10.0.1", false},
{"scrabble-dawg-v1.2.tar.gz", "", true},
{"scrabble-dawg-1.2.3.tar.gz", "", true},
{"scrabble-dawg-v1.2.3.zip", "", true},
{"dawg-v1.2.3.tar.gz", "", true},
{"scrabble-dawg-v1.2.3.tar.gz.exe", "", true},
{"", "", true},
}
for _, c := range cases {
got, err := ParseVersionFromName(c.name)
if c.wantErr {
if err == nil {
t.Errorf("ParseVersionFromName(%q) = %q, want error", c.name, got)
}
continue
}
if err != nil || got != c.want {
t.Errorf("ParseVersionFromName(%q) = %q, %v; want %q", c.name, got, err, c.want)
}
}
}
func TestValidVersion(t *testing.T) {
valid := []string{"v1.0.0", "v10.20.30", "v0.0.1"}
invalid := []string{"v1.0", "1.0.0", "v1.0.0.0", "v1.0.0/..", "v1.0.0 ", "", "vx.y.z", "../v1.0.0"}
for _, v := range valid {
if !ValidVersion(v) {
t.Errorf("ValidVersion(%q) = false, want true", v)
}
}
for _, v := range invalid {
if ValidVersion(v) {
t.Errorf("ValidVersion(%q) = true, want false", v)
}
}
}
func TestExtractAcceptsFullArchive(t *testing.T) {
dir := t.TempDir()
variants, err := Extract(bytes.NewReader(fullArchive(t)), dir)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if len(variants) != len(engine.Variants()) {
t.Errorf("variants = %v, want all %d", variants, len(engine.Variants()))
}
for _, n := range dawgNames() {
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
t.Errorf("missing extracted %s: %v", n, err)
}
}
}
func TestExtractIgnoresUnexpectedAndTraversal(t *testing.T) {
dir := t.TempDir()
files := map[string][]byte{
"pkg/en_sowpods.dawg": []byte("ok"), // a subdir path is stripped to its base
"README.txt": []byte("junk"), // an unexpected name is ignored
}
// A traversal name with a recognised base must still land inside destDir.
traversal := &tar.Header{Name: "../ru_scrabble.dawg", Mode: 0o644, Size: 2, Typeflag: tar.TypeReg}
// A symlink, even named like a DAWG, is ignored.
symlink := &tar.Header{Name: "erudit_ru.dawg", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}
if _, err := Extract(bytes.NewReader(tarGz(t, files, []*tar.Header{traversal, symlink})), dir); err != nil {
t.Fatalf("Extract: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err == nil {
t.Error("README.txt was extracted; unexpected files must be ignored")
}
if _, err := os.Stat(filepath.Join(dir, "en_sowpods.dawg")); err != nil {
t.Errorf("en_sowpods.dawg not extracted from a subdir entry: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "ru_scrabble.dawg")); err != nil {
t.Errorf("ru_scrabble.dawg not extracted from a traversal entry: %v", err)
}
// Nothing escaped the destination directory.
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "ru_scrabble.dawg")); err == nil {
t.Error("path traversal wrote outside the destination directory")
}
if _, err := os.Stat(filepath.Join(dir, "erudit_ru.dawg")); err == nil {
t.Error("a symlink member was materialised")
}
}
func TestExtractRejectsOversizeMember(t *testing.T) {
old := maxMemberBytes
maxMemberBytes = 8
defer func() { maxMemberBytes = old }()
files := map[string][]byte{"en_sowpods.dawg": bytes.Repeat([]byte("x"), 64)}
if _, err := Extract(bytes.NewReader(tarGz(t, files, nil)), t.TempDir()); err == nil {
t.Error("Extract accepted an oversize member, want error")
}
}
func TestExtractRejectsTooManyEntries(t *testing.T) {
var extra []*tar.Header
for i := 0; i < maxEntries+1; i++ {
extra = append(extra, &tar.Header{Name: fmt.Sprintf("junk-%d.bin", i), Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
}
if _, err := Extract(bytes.NewReader(tarGz(t, nil, extra)), t.TempDir()); err == nil {
t.Error("Extract accepted an archive with too many entries, want error")
}
}
func TestManagerStageInstall(t *testing.T) {
dir := t.TempDir()
m := New(dir)
token, variants, err := m.Stage(bytes.NewReader(fullArchive(t)))
if err != nil {
t.Fatalf("Stage: %v", err)
}
if len(variants) != len(engine.Variants()) {
t.Fatalf("staged variants = %v, want all", variants)
}
staged, err := m.StagedDir(token)
if err != nil {
t.Fatalf("StagedDir: %v", err)
}
if !strings.Contains(staged, filepath.Join(".staging", token)) {
t.Errorf("staged dir %q not under .staging/%s", staged, token)
}
if m.VersionExists("v1.0.0") {
t.Fatal("v1.0.0 should not exist before install")
}
dest, err := m.Install(token, "v1.0.0")
if err != nil {
t.Fatalf("Install: %v", err)
}
if dest != filepath.Join(dir, "v1.0.0") {
t.Errorf("install dest = %q, want %q", dest, filepath.Join(dir, "v1.0.0"))
}
if !m.VersionExists("v1.0.0") {
t.Error("VersionExists(v1.0.0) = false after install")
}
for _, n := range dawgNames() {
if _, err := os.Stat(filepath.Join(dest, n)); err != nil {
t.Errorf("installed %s missing: %v", n, err)
}
}
}
func TestManagerInstallRejectsExistingVersion(t *testing.T) {
dir := t.TempDir()
m := New(dir)
token, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
if err != nil {
t.Fatalf("Stage: %v", err)
}
if _, err := m.Install(token, "v2.0.0"); err != nil {
t.Fatalf("first Install: %v", err)
}
token2, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
if err != nil {
t.Fatalf("second Stage: %v", err)
}
if _, err := m.Install(token2, "v2.0.0"); err == nil {
t.Error("Install overwrote an existing version, want error (versions are immutable)")
}
}
func TestStagedDirRejectsBadToken(t *testing.T) {
m := New(t.TempDir())
for _, bad := range []string{"", "../etc", "ABC", "zz", strings.Repeat("g", 32)} {
if _, err := m.StagedDir(bad); err == nil {
t.Errorf("StagedDir(%q) = nil error, want rejection", bad)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
package engine
import "testing"
// TestAbortFinishesAsDrawWithoutAdjustment covers Abort, the graceful close used when a
// committed game can no longer be reconstructed from its journal. The game ends as a draw
// (no winner) regardless of the running scores, and the scores are left untouched (no
// end-game rack adjustment).
func TestAbortFinishesAsDrawWithoutAdjustment(t *testing.T) {
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new game: %v", err)
}
g.scores[0], g.scores[1] = 10, 5 // a clear leader, so a draw cannot come from equal scores
g.Abort()
if !g.Over() {
t.Error("aborted game should be over")
}
if g.Reason() != EndAborted {
t.Errorf("reason = %v, want EndAborted", g.Reason())
}
res := g.Result()
if res.Winner != -1 {
t.Errorf("winner = %d, want -1 (draw)", res.Winner)
}
if res.Scores[0] != 10 || res.Scores[1] != 5 {
t.Errorf("scores = %v, want [10 5] (no rack adjustment on abort)", res.Scores)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
// concrete character and its tile point value. It is the dictionary-independent display
// table the edge sends to the client (Stage 13), produced from the variant's solver
// table the edge sends to the client, produced from the variant's solver
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
// dictionary.
type AlphabetEntry struct {
+7 -7
View File
@@ -8,11 +8,11 @@ import (
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
// contiguous indices, the concrete lower-case characters the solver emits and the standard
// tile values. This is the real parity check the UI no longer carries (Stage 13).
// tile values. This is the real parity check the UI no longer carries.
func TestAlphabetTableEnglish(t *testing.T) {
tab, err := AlphabetTable(VariantEnglish)
if err != nil {
t.Fatalf("AlphabetTable(english): %v", err)
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
}
if len(tab) != 26 {
t.Fatalf("size = %d, want 26", len(tab))
@@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) {
func TestAlphabetTableRussianVariants(t *testing.T) {
ru, err := AlphabetTable(VariantRussianScrabble)
if err != nil {
t.Fatalf("AlphabetTable(russian_scrabble): %v", err)
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
}
er, err := AlphabetTable(VariantErudit)
if err != nil {
t.Fatalf("AlphabetTable(erudit): %v", err)
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
}
if len(ru) != 33 || len(er) != 33 {
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
}
if ru[0].Letter != "а" || ru[0].Value != 1 {
t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
}
if ru[6].Letter != "ё" || ru[6].Value != 3 {
t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
}
if er[6].Letter != "ё" || er[6].Value != 0 {
t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
}
if ru[32].Letter != "я" || er[32].Letter != "я" {
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
+134
View File
@@ -0,0 +1,134 @@
package engine
import (
"bytes"
"fmt"
"maps"
"path/filepath"
"sort"
dawg "github.com/iliadenisov/dafsa"
)
// WordDiff is the set difference between two dictionaries of one variant: the
// words present only in the new dictionary (Added) and only in the old one
// (Removed), decoded to characters and each sorted by the variant's alphabet
// order. It drives the admin dictionary-update preview.
type WordDiff struct {
Added []string
Removed []string
}
// DictFiles returns a copy of the variant-to-committed-DAWG-filename map. It lets
// callers outside the package (the dictionary-admin upload validation) check an
// archive against the expected file set without sharing the engine's own map.
func DictFiles() map[Variant]string {
out := make(map[Variant]string, len(dictFiles))
maps.Copy(out, dictFiles)
return out
}
// OpenFinder loads the committed DAWG of variant v from dir and returns its
// finder. The caller owns the finder and must Close it. It backs enumeration of
// a staged, not-yet-registered dictionary version.
func OpenFinder(dir string, v Variant) (dawg.Finder, error) {
name, ok := dictFiles[v]
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
path := filepath.Join(dir, name)
finder, err := dawg.Load(path)
if err != nil {
return nil, fmt.Errorf("engine: load %s dictionary from %s: %w", v, path, err)
}
return finder, nil
}
// Finder returns the loaded finder for the (variant, version) pair so its words
// can be enumerated, or ErrUnknownVariant / ErrUnknownVersion when that
// dictionary is not resident. The finder remains owned by the registry; callers
// must not Close it.
func (r *Registry) Finder(v Variant, version string) (dawg.Finder, error) {
r.mu.RLock()
defer r.mu.RUnlock()
versions, ok := r.entries[v]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
e, ok := versions[version]
if !ok {
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
}
return e.finder, nil
}
// DiffWords compares the old and new finders of variant v and returns the words
// added and removed, decoded through v's alphabet. Both dictionaries are
// enumerated as alphabet-index bytes and merged; only the differing words are
// decoded, so a full-dictionary comparison does not materialise two large string
// sets.
func DiffWords(v Variant, old, updated dawg.Finder) (WordDiff, error) {
rs, ok := v.ruleset()
if !ok {
return WordDiff{}, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
oldWords := collectWords(old)
newWords := collectWords(updated)
var diff WordDiff
i, j := 0, 0
for i < len(oldWords) && j < len(newWords) {
switch cmp := bytes.Compare(oldWords[i], newWords[j]); {
case cmp == 0:
i++
j++
case cmp < 0:
s, err := rs.Alphabet.Decode(oldWords[i])
if err != nil {
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
}
diff.Removed = append(diff.Removed, s)
i++
default:
s, err := rs.Alphabet.Decode(newWords[j])
if err != nil {
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
}
diff.Added = append(diff.Added, s)
j++
}
}
for ; i < len(oldWords); i++ {
s, err := rs.Alphabet.Decode(oldWords[i])
if err != nil {
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
}
diff.Removed = append(diff.Removed, s)
}
for ; j < len(newWords); j++ {
s, err := rs.Alphabet.Decode(newWords[j])
if err != nil {
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
}
diff.Added = append(diff.Added, s)
}
return diff, nil
}
// collectWords enumerates every complete word of f as a copy of its
// alphabet-index bytes, sorted ascending. The dawg already enumerates in index
// order; the explicit sort makes the merge in DiffWords robust to that being an
// implementation detail.
func collectWords(f dawg.Finder) [][]byte {
out := make([][]byte, 0, f.NumAdded())
f.EnumerateB(func(_ int, word []byte, final bool) dawg.EnumerationResult {
if final && len(word) > 0 {
cp := make([]byte, len(word))
copy(cp, word)
out = append(out, cp)
}
return dawg.Continue
})
sort.Slice(out, func(a, b int) bool { return bytes.Compare(out[a], out[b]) < 0 })
return out
}
+134
View File
@@ -0,0 +1,134 @@
package engine
import (
"errors"
"testing"
"github.com/iliadenisov/alphabet"
dawg "github.com/iliadenisov/dafsa"
)
// buildFinderB builds an in-memory finder over idx from the given words, each
// expressed as alphabet-index bytes. The words must be supplied in strictly
// increasing alphabet-index order, as the builder requires.
func buildFinderB(t *testing.T, idx alphabet.Indexer, words ...[]byte) dawg.Finder {
t.Helper()
b := dawg.New(idx)
for _, w := range words {
if err := b.AddB(w); err != nil {
t.Fatalf("addB %v: %v", w, err)
}
}
return b.Finish()
}
// mustDecode decodes index bytes through idx or fails the test.
func mustDecode(t *testing.T, idx alphabet.Indexer, word []byte) string {
t.Helper()
s, err := idx.Decode(word)
if err != nil {
t.Fatalf("decode %v: %v", word, err)
}
return s
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestDiffWords checks that DiffWords reports the words present only in the new
// dictionary as added and those present only in the old one as removed, decoded
// to characters through the variant's alphabet.
func TestDiffWords(t *testing.T) {
rs, _ := VariantEnglish.ruleset()
idx := rs.Alphabet
old := buildFinderB(t, idx, []byte{0}, []byte{0, 1}, []byte{2})
defer func() { _ = old.Close() }()
updated := buildFinderB(t, idx, []byte{0}, []byte{2}, []byte{2, 3})
defer func() { _ = updated.Close() }()
diff, err := DiffWords(VariantEnglish, old, updated)
if err != nil {
t.Fatalf("DiffWords: %v", err)
}
wantAdded := []string{mustDecode(t, idx, []byte{2, 3})}
wantRemoved := []string{mustDecode(t, idx, []byte{0, 1})}
if !equalStrings(diff.Added, wantAdded) {
t.Errorf("added = %v, want %v", diff.Added, wantAdded)
}
if !equalStrings(diff.Removed, wantRemoved) {
t.Errorf("removed = %v, want %v", diff.Removed, wantRemoved)
}
}
// TestDiffWordsIdentical checks that comparing a dictionary with itself yields an
// empty diff.
func TestDiffWordsIdentical(t *testing.T) {
rs, _ := VariantEnglish.ruleset()
f := buildFinderB(t, rs.Alphabet, []byte{0}, []byte{1}, []byte{1, 2})
defer func() { _ = f.Close() }()
diff, err := DiffWords(VariantEnglish, f, f)
if err != nil {
t.Fatalf("DiffWords: %v", err)
}
if len(diff.Added) != 0 || len(diff.Removed) != 0 {
t.Errorf("diff = %+v, want empty", diff)
}
}
// TestDiffWordsUnknownVariant checks that an unrecognised variant is rejected.
func TestDiffWordsUnknownVariant(t *testing.T) {
rs, _ := VariantEnglish.ruleset()
f := buildFinderB(t, rs.Alphabet, []byte{0})
defer func() { _ = f.Close() }()
if _, err := DiffWords(Variant(250), f, f); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("err = %v, want ErrUnknownVariant", err)
}
}
// TestOpenFinder checks that OpenFinder loads a committed DAWG from a directory.
func TestOpenFinder(t *testing.T) {
f, err := OpenFinder(testDictDir(), VariantEnglish)
if err != nil {
t.Fatalf("OpenFinder: %v", err)
}
defer func() { _ = f.Close() }()
if f.NumAdded() == 0 {
t.Error("english dictionary is empty")
}
}
// TestRegistryFinder checks that Finder returns the resident finder for a pair
// and the right sentinel when the version is absent.
func TestRegistryFinder(t *testing.T) {
if _, err := testReg.Finder(VariantEnglish, testVersion); err != nil {
t.Errorf("Finder(scrabble_en, %q): %v", testVersion, err)
}
if _, err := testReg.Finder(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("Finder absent version err = %v, want ErrUnknownVersion", err)
}
}
// TestDictFiles checks that DictFiles exposes the variant filename map as a copy
// the caller cannot use to mutate the engine's own map.
func TestDictFiles(t *testing.T) {
files := DictFiles()
if len(files) != len(Variants()) {
t.Fatalf("DictFiles has %d entries, want %d", len(files), len(Variants()))
}
files[VariantEnglish] = "tampered"
if again := DictFiles(); again[VariantEnglish] == "tampered" {
t.Error("DictFiles returned a shared map; mutating it changed the engine's map")
}
}
+63
View File
@@ -0,0 +1,63 @@
package engine
import (
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// resolveDirection infers a play's orientation from the placed tiles and the
// board, so a caller need not declare it (docs/ARCHITECTURE.md §5). Two or more
// tiles fix the orientation by the line they share: a common row reads
// horizontally, otherwise vertically (a non-linear placement is left for
// Evaluate to reject). A single tile is ambiguous on its own — it may extend a
// word down a column or across a row — so the orientation is the axis along
// which it abuts existing tiles, preferring the axis that yields the longer word
// and horizontal on a tie. A tile that abuts nothing falls back to horizontal
// and is rejected downstream as disconnected (or, on the first move, as too
// short).
func resolveDirection(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
if len(placements) >= 2 {
row := placements[0].Row
for _, p := range placements[1:] {
if p.Row != row {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
if len(placements) == 1 {
p := placements[0]
h := runLength(b, p.Row, p.Col, scrabble.Horizontal)
v := runLength(b, p.Row, p.Col, scrabble.Vertical)
if v >= 2 && v > h {
return scrabble.Vertical
}
if h >= 2 {
return scrabble.Horizontal
}
if v >= 2 {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
// runLength returns how many cells the word through (row, col) along dir would
// span once a tile is placed on the empty target square: the square itself plus
// the runs of filled cells immediately before and after it along dir. A result
// below two means the tile forms no word on that axis. Filled treats
// off-board coordinates as empty, so the walks stop at the board edge.
func runLength(b *board.Board, row, col int, dir scrabble.Direction) int {
dr, dc := 0, 1
if dir == scrabble.Vertical {
dr, dc = 1, 0
}
n := 1
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
n++
}
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
n++
}
return n
}
+159
View File
@@ -0,0 +1,159 @@
package engine
import (
"errors"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// boardWith returns a 15x15 board with the given (row, col) cells occupied. The
// concrete letter is irrelevant to direction inference, which reads only
// occupancy, so every filler uses alphabet index 0.
func boardWith(cells ...[2]int) *board.Board {
b := board.New(15, 15)
ps := make([]scrabble.Placement, len(cells))
for i, c := range cells {
ps[i] = scrabble.Placement{Row: c[0], Col: c[1], Letter: 0}
}
scrabble.Apply(b, scrabble.Move{Tiles: ps})
return b
}
// TestRunLength covers the word-span measurement that drives single-tile
// direction inference, including the board edge.
func TestRunLength(t *testing.T) {
tests := []struct {
name string
filled [][2]int
row, col int
dir scrabble.Direction
want int
}{
{"isolated horizontal", nil, 7, 7, scrabble.Horizontal, 1},
{"isolated vertical", nil, 7, 7, scrabble.Vertical, 1},
{"neighbour below", [][2]int{{8, 7}}, 7, 7, scrabble.Vertical, 2},
{"neighbour above", [][2]int{{6, 7}}, 7, 7, scrabble.Vertical, 2},
{"run below", [][2]int{{8, 7}, {9, 7}}, 7, 7, scrabble.Vertical, 3},
{"bridge vertical", [][2]int{{6, 7}, {8, 7}}, 7, 7, scrabble.Vertical, 3},
{"neighbour left", [][2]int{{7, 6}}, 7, 7, scrabble.Horizontal, 2},
{"neighbour right", [][2]int{{7, 8}}, 7, 7, scrabble.Horizontal, 2},
{"bridge horizontal", [][2]int{{7, 6}, {7, 8}}, 7, 7, scrabble.Horizontal, 3},
{"perpendicular ignored", [][2]int{{7, 6}}, 7, 7, scrabble.Vertical, 1},
{"top edge", [][2]int{{1, 0}}, 0, 0, scrabble.Vertical, 2},
{"left edge", [][2]int{{0, 1}}, 0, 0, scrabble.Horizontal, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b := boardWith(tt.filled...)
if got := runLength(b, tt.row, tt.col, tt.dir); got != tt.want {
t.Errorf("runLength(%d,%d,%v) = %d, want %d", tt.row, tt.col, tt.dir, got, tt.want)
}
})
}
}
// TestResolveDirection covers orientation inference for both multi-tile plays
// (fixed by the shared line) and the ambiguous single tile (the axis it abuts,
// longer word winning and horizontal on a tie; disconnected falls back to
// horizontal for the downstream rejection).
func TestResolveDirection(t *testing.T) {
at := func(cells ...[2]int) []scrabble.Placement {
ps := make([]scrabble.Placement, len(cells))
for i, c := range cells {
ps[i] = scrabble.Placement{Row: c[0], Col: c[1]}
}
return ps
}
tests := []struct {
name string
filled [][2]int
play []scrabble.Placement
want scrabble.Direction
}{
{"single extends down", [][2]int{{8, 7}, {9, 7}}, at([2]int{7, 7}), scrabble.Vertical},
{"single extends up", [][2]int{{6, 7}, {5, 7}}, at([2]int{7, 7}), scrabble.Vertical},
{"single extends left", [][2]int{{7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single extends right", [][2]int{{7, 8}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single both axes vertical longer", [][2]int{{6, 7}, {8, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Vertical},
{"single both axes horizontal longer", [][2]int{{7, 6}, {7, 8}, {6, 7}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single both axes equal prefers horizontal", [][2]int{{6, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
{"single disconnected falls back to horizontal", nil, at([2]int{7, 7}), scrabble.Horizontal},
{"multi shared row is horizontal", nil, at([2]int{7, 7}, [2]int{7, 8}), scrabble.Horizontal},
{"multi shared column is vertical", nil, at([2]int{7, 7}, [2]int{8, 7}), scrabble.Vertical},
{"multi non-linear is vertical", nil, at([2]int{7, 7}, [2]int{8, 8}), scrabble.Vertical},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b := boardWith(tt.filled...)
if got := resolveDirection(b, tt.play); got != tt.want {
t.Errorf("resolveDirection = %v, want %v", got, tt.want)
}
})
}
}
// TestResolveDirectionEmpty checks the degenerate empty placement does not panic
// and falls back to horizontal (Evaluate rejects the empty play downstream).
func TestResolveDirectionEmpty(t *testing.T) {
if got := resolveDirection(boardWith(), nil); got != scrabble.Horizontal {
t.Errorf("resolveDirection(empty) = %v, want Horizontal", got)
}
}
// TestSubmitPlaySingleTileVerticalExtension is the regression for the reported
// bug: a single tile placed above an existing vertical word forms a legal play
// the engine must accept by inferring the vertical orientation. Trusting a
// horizontal orientation (the pre-fix client default) wrongly rejects it.
func TestSubmitPlaySingleTileVerticalExtension(t *testing.T) {
// БАК runs down column 7 (rows 7..9); the mover holds А and plays it at
// (6,7), prefixing АБАК. This mirrors the contour game that surfaced the bug.
setup := func(t *testing.T) (*Game, []TileRecord) {
t.Helper()
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
idx := func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 7, Col: 7, Letter: idx("б")},
{Row: 8, Col: 7, Letter: idx("а")},
{Row: 9, Col: 7, Letter: idx("к")},
}})
g.hands[0] = []byte{idx("а")}
return g, []TileRecord{{Row: 6, Col: 7, Letter: "а"}}
}
t.Run("inferred direction accepts the play", func(t *testing.T) {
g, tiles := setup(t)
rec, err := g.SubmitPlay(tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
if rec.Dir != Vertical {
t.Errorf("direction = %v, want Vertical", rec.Dir)
}
if len(rec.Words) == 0 || rec.Words[0] != "абак" {
t.Errorf("words = %v, want main word абак", rec.Words)
}
if rec.Score <= 0 {
t.Errorf("score = %d, want positive", rec.Score)
}
})
t.Run("trusting horizontal rejects it", func(t *testing.T) {
g, tiles := setup(t)
if _, err := g.SubmitPlayDir(Horizontal, tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("submit horizontal = %v, want ErrIllegalPlay", err)
}
})
}
+51 -7
View File
@@ -52,11 +52,25 @@ func fromScrabbleDir(d scrabble.Direction) Direction {
// SubmitPlay validates and applies the current player's play described in decoded
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
// when Blank is set) and a board coordinate. It encodes the tiles through the
// when Blank is set) and a board coordinate. It infers the play's orientation
// from the tiles and the board (resolveDirection), encodes the tiles through the
// ruleset alphabet and delegates to Play, so it returns the same errors
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
// letter is outside the variant's alphabet.
func (g *Game) SubmitPlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
placements, err := g.placements(tiles)
if err != nil {
return MoveRecord{}, err
}
return g.Play(g.playDirection(placements), placements)
}
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
// It exists for journal replay, which reproduces a committed game exactly from
// the stored "H"/"V" rather than re-deriving it (docs/ARCHITECTURE.md §9.1):
// re-derivation would tie historical reconstruction to the current resolver, so
// replay trusts the recorded direction. Live play uses SubmitPlay.
func (g *Game) SubmitPlayDir(dir Direction, tiles []TileRecord) (MoveRecord, error) {
placements, err := g.placements(tiles)
if err != nil {
return MoveRecord{}, err
@@ -78,10 +92,13 @@ func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
// EvaluatePlay scores and validates a tentative play without committing it,
// backing the unlimited "what would my next move score, and is it legal?" tool.
// It returns the decoded move (placed tiles, the words it forms and its score)
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
// are left untouched.
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
// It infers the play's orientation from the tiles and the board and applies the
// game's play options exactly as SubmitPlay does, so under the single-word rule
// perpendicular cross-words are ignored: the preview's legality and score then
// match what submitting the play would yield. It returns the decoded move (placed
// tiles, the words it forms, its orientation and its score) or ErrIllegalPlay when
// the solver rejects it. The board, racks, bag and turn are left untouched.
func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
@@ -89,7 +106,7 @@ func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, erro
if err != nil {
return MoveRecord{}, err
}
move, err := g.solver.ValidatePlay(g.board, dir.scrabbleDir(), placements)
move, err := g.solver.ValidatePlayOpts(g.board, g.playDirection(placements), placements, g.playOpts())
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
@@ -152,6 +169,33 @@ func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
return out, nil
}
// playDirection resolves the orientation for a live play. resolveDirection infers it from
// geometry alone, preferring the longer word when a single tile abuts the board on both
// axes; under the single-word rule that can pick an orientation whose word is not in the
// dictionary while the other orientation's is. So for a single tile under that rule the
// engine tries both orientations through the solver and keeps the higher-scoring legal one
// (horizontal breaks a tie). Multi-tile plays, and every play under the standard rule, keep
// the geometric resolution: a multi-tile play's orientation is fixed by the line its tiles
// share, and under the standard rule every word the play forms must be valid regardless of
// which one is named the main word.
func (g *Game) playDirection(placements []scrabble.Placement) scrabble.Direction {
geo := resolveDirection(g.board, placements)
if len(placements) != 1 || g.multipleWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := g.solver.ValidatePlayOpts(g.board, dir, placements, g.playOpts())
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
// concrete letter) into the internal byte form, wrapping a bad letter as
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
+7 -7
View File
@@ -25,7 +25,7 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
if !ok {
t.Fatal("opening game has no hint")
}
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
rec, err := g.SubmitPlay(hint.Tiles)
if err != nil {
t.Fatalf("submit play: %v", err)
}
@@ -85,7 +85,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
boardBefore := g.BoardClone()
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
rec, err := g.EvaluatePlay(hint.Tiles)
if err != nil {
t.Fatalf("evaluate play: %v", err)
}
@@ -106,7 +106,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
g := newEnglishGame(t, 1)
letter := g.Hand(0)[0]
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
_, err := g.EvaluatePlay([]TileRecord{{Row: 0, Col: 0, Letter: letter}})
if !errors.Is(err, ErrIllegalPlay) {
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
}
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
word string
want bool
}{
{"english hit", VariantEnglish, "cat", true},
{"english miss", VariantEnglish, "zzzz", false},
{"russian hit", VariantRussianScrabble, "кот", true},
{"erudit hit", VariantErudit, "кот", true},
{"scrabble_en hit", VariantEnglish, "cat", true},
{"scrabble_en miss", VariantEnglish, "zzzz", false},
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
{"erudit_ru hit", VariantErudit, "кот", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+13 -3
View File
@@ -38,15 +38,25 @@ const (
func (v Variant) String() string {
switch v {
case VariantEnglish:
return "english"
return "scrabble_en"
case VariantRussianScrabble:
return "russian_scrabble"
return "scrabble_ru"
case VariantErudit:
return "erudit"
return "erudit_ru"
}
return "unknown"
}
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
func (v Variant) Language() string {
if v == VariantEnglish {
return "en"
}
return "ru"
}
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
// (nil, false) for an unrecognised variant.
func (v Variant) ruleset() (*rules.Ruleset, bool) {
+75 -28
View File
@@ -25,6 +25,10 @@ const (
EndScoreless
// EndResign fires when a player resigns.
EndResign
// EndAborted fires when a committed game can no longer be reconstructed from its
// journal — a recorded move became illegal under tightened rules — and is closed as a
// draw rather than left unopenable. See (*Game).Abort.
EndAborted
)
// String renders the end reason for logs and diagnostics.
@@ -38,6 +42,8 @@ func (r EndReason) String() string {
return "scoreless"
case EndResign:
return "resign"
case EndAborted:
return "aborted"
}
return "unknown"
}
@@ -92,6 +98,12 @@ type Options struct {
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
// with three or more seats; the zero value removes them from play.
DropoutTiles DropoutTiles
// MultipleWordsPerTurn selects standard Scrabble when true: every cross-word a
// play forms must be a valid word and is scored. When false the game uses the
// "single word per turn" rule — only the main word is validated and scored and
// perpendicular cross-words are ignored. Callers always set this explicitly; the
// zero value (false) is the single-word rule.
MultipleWordsPerTurn bool
}
// Game is the in-memory state of a single match and the pure rules engine over
@@ -104,17 +116,18 @@ type Game struct {
variant Variant
version string
board *board.Board
bag *Bag
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
scores []int
toMove int
scorelessRun int
over bool
reason EndReason
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
log []MoveRecord
board *board.Board
bag *Bag
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
scores []int
toMove int
scorelessRun int
over bool
reason EndReason
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
log []MoveRecord
}
// New starts a game described by opts over a dictionary from reg. It resolves
@@ -140,16 +153,17 @@ func New(reg *Registry, opts Options) (*Game, error) {
rs := solver.Rules()
g := &Game{
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
solver: solver,
rules: rs,
variant: opts.Variant,
version: version,
board: board.New(rs.Rows, rs.Cols),
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
multipleWords: opts.MultipleWordsPerTurn,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -157,6 +171,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
return g, nil
}
// playOpts returns the solver play options for this game's rules. Under the single-word
// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main
// word is validated and scored, and move generation is not constrained by cross-words.
func (g *Game) playOpts() scrabble.PlayOptions {
return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords}
}
// Play validates and applies the current player's placement of tiles forming a
// word in direction dir. It scores the play, refills the rack from the bag,
// advances the turn and may end the game. It returns ErrTilesNotOnRack when the
@@ -170,7 +191,7 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
return MoveRecord{}, err
}
move, err := g.solver.ValidatePlay(g.board, dir, tiles)
move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
}
@@ -248,17 +269,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
// winning regardless of score. A missed-turn timeout reuses Resign in the game
// domain, so it inherits this win/loss.
func (g *Game) Resign() (MoveRecord, error) {
return g.ResignSeat(g.toMove)
}
// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
// skips resigned seats). The turn cursor only advances when the seat that resigned
// was the one to move; resigning an off-turn seat leaves the current player's turn
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
// already-resigned seat.
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resigned[player] = true
g.disposeHand(player)
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
return MoveRecord{}, ErrGameOver
}
g.resigned[seat] = true
g.disposeHand(seat)
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
g.log = append(g.log, rec)
if g.activeCount() <= 1 {
g.finish(EndResign)
} else {
} else if seat == g.toMove {
g.advance()
}
return rec, nil
@@ -267,7 +300,7 @@ func (g *Game) Resign() (MoveRecord, error) {
// GenerateMoves returns every legal play for the current player's rack, ranked
// by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move {
return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both)
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
}
// Hint returns the highest-scoring legal play for the current player and true,
@@ -343,6 +376,17 @@ func (g *Game) finish(reason EndReason) {
g.applyEndAdjustment(reason)
}
// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The
// service calls it when a committed game can no longer be reconstructed from its journal —
// a recorded move became illegal under tightened rules — so the game ends gracefully
// instead of being left unopenable. It is a no-op on an already-finished game.
func (g *Game) Abort() {
if g.over {
return
}
g.finish(EndAborted)
}
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
// empty, rack empty) they gain the sum of every opponent's rack value and each
// opponent loses their own. A scoreless stalemate forfeits each player's own
@@ -426,6 +470,9 @@ func (g *Game) winner() int {
if !g.over {
return -1
}
if g.reason == EndAborted {
return -1 // an aborted game is a draw regardless of the running scores
}
best, tie := -1, false
for i := range g.scores {
if g.resigned[i] {
+18 -5
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
dawg "github.com/iliadenisov/dafsa"
@@ -69,11 +70,21 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
// immediate subdirectory of dir: a subdirectory named V contributes, under
// version V, the variants whose committed DAWG it carries. This is the
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
// at runtime is resident again after a restart. A subdirectory named like the
// boot version is skipped (the flat dir already is the boot version). A partially
// loaded registry is closed before any error is returned.
// at runtime is resident again after a restart. The flat dir's version is resolved
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records
// bootVersion, an already-seeded dir keeps its recorded label and ignores bootVersion,
// so a bumped build seed never relabels live bytes. A subdirectory named like the
// resolved seed version is skipped (the flat dir already is it). A partially loaded
// registry is closed before any error is returned.
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
r, err := Open(dir, bootVersion)
// Resolve the flat dir's version from its seed marker first: on an already-seeded
// volume the marker wins and bootVersion is ignored, so a bumped build seed cannot
// relabel live bytes (see resolveSeedVersion).
seed, err := resolveSeedVersion(dir, bootVersion)
if err != nil {
return nil, err
}
r, err := Open(dir, seed)
if err != nil {
return nil, err
}
@@ -83,7 +94,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
}
for _, e := range entries {
if !e.IsDir() || e.Name() == bootVersion {
// Skip non-directories, the resolved seed version (already loaded as the flat
// dir) and dot-prefixed directories (the upload staging area, dir/.staging/).
if !e.IsDir() || e.Name() == seed || strings.HasPrefix(e.Name(), ".") {
continue
}
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
+1 -1
View File
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
func TestRegistryUnknownLookups(t *testing.T) {
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
if err != nil {
t.Fatalf("open english-only registry: %v", err)
t.Fatalf("open scrabble_en-only registry: %v", err)
}
defer reg.Close()
+126 -7
View File
@@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -45,13 +46,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
t.Fatalf("load available: %v", err)
}
if len(loaded) != 1 || loaded[0] != VariantEnglish {
t.Fatalf("loaded = %v, want [english]", loaded)
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("english v2 solver: %v", err)
t.Errorf("scrabble_en v2 solver: %v", err)
}
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("russian v2 should be absent: got %v", err)
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
}
}
@@ -77,17 +78,135 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) {
}
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Errorf("english versions = %v, want two", got)
t.Errorf("scrabble_en versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest english: %v", err)
t.Fatalf("latest scrabble_en: %v", err)
}
if latest != "v2" {
t.Errorf("latest english = %q, want v2", latest)
t.Errorf("latest scrabble_en = %q, want v2", latest)
}
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
t.Errorf("russian versions = %v, want one (no v2 file)", got)
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
}
}
// TestOpenWithVersionsSkipsDotDirs verifies the boot scan ignores dot-prefixed
// subdirectories (the upload staging area), so a half-extracted archive there
// never becomes a resident version.
func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
copyDawg(t, testDictDir(), filepath.Join(dir, ".staging"), VariantEnglish)
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("open with versions: %v", err)
}
defer func() { _ = reg.Close() }()
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("scrabble_en versions = %v, want only [v1] (.staging skipped)", got)
}
}
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
// version in the flat dir's marker, the marker is not mistaken for a version, and a
// reboot at the same seed version succeeds.
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("first open: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
}
_ = reg.Close()
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if err != nil {
t.Fatalf("read seed marker: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Fatalf("seed marker = %q, want v1", got)
}
reg2, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("reboot at same seed: %v", err)
}
_ = reg2.Close()
}
// TestOpenWithVersionsMarkerWinsOverBoot verifies the recorded .seed_version marker
// is authoritative: once a directory is seeded, a different bootVersion
// (BACKEND_DICT_VERSION) is ignored — the flat dir keeps its recorded label — so a
// bumped build seed on a live volume cannot relabel the already-seeded bytes.
func TestOpenWithVersionsMarkerWinsOverBoot(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1") // seeds the marker = v1
if err != nil {
t.Fatalf("seed open: %v", err)
}
_ = reg.Close()
// Reboot with a bumped boot version: the marker (v1) wins, no error, v2 ignored.
reg2, err := OpenWithVersions(dir, "v2")
if err != nil {
t.Fatalf("reboot with bumped boot version: %v", err)
}
defer func() { _ = reg2.Close() }()
if got := reg2.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want [v1] (marker wins, v2 ignored)", got)
}
if _, err := reg2.Solver(VariantEnglish, "v2"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("v2 must not be resident: got %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Errorf("marker = %q, want v1 (unchanged)", got)
}
}
// TestOpenWithVersionsBumpedBootKeepsSubdir mirrors the live-contour case: a volume
// seeded as v1 with a v2 subdirectory (uploaded via the console), booted with a bumped
// build seed bootVersion=v2. The marker (v1) wins for the flat dir, and the v2
// subdirectory is still loaded — not skipped as "the boot version" — so both versions
// stay resident. (Skipping it would silently leave only the flat v1 bytes under v2.)
func TestOpenWithVersionsBumpedBootKeepsSubdir(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg0, err := OpenWithVersions(dir, "v1") // seed marker = v1
if err != nil {
t.Fatalf("seed: %v", err)
}
_ = reg0.Close()
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // console upload
reg, err := OpenWithVersions(dir, "v2") // bumped build seed
if err != nil {
t.Fatalf("boot v2: %v", err)
}
defer func() { _ = reg.Close() }()
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("flat v1 must stay resident: %v", err)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("v2 subdir must be resident (not skipped): %v", err)
}
}
+37 -4
View File
@@ -12,7 +12,7 @@ func TestResignLeadingPlayerStillLoses(t *testing.T) {
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
played, err := g.SubmitPlay(hint.Tiles)
if err != nil {
t.Fatalf("player 0 play: %v", err)
}
@@ -56,7 +56,7 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 scores
t.Fatalf("player 0 play: %v", err)
}
@@ -69,6 +69,39 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
}
}
// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0
// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner
// loses, the opponent wins, and the game ends.
func TestResignSeatOffTurn(t *testing.T) {
g := openingGame(t)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 moves
t.Fatalf("player 0 play: %v", err)
}
if g.ToMove() != 1 {
t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove())
}
// Player 0 resigns although it is player 1's turn.
rec, err := g.ResignSeat(0)
if err != nil {
t.Fatalf("player 0 off-turn resign: %v", err)
}
if rec.Player != 0 || rec.Action != ActionResign {
t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
if res := g.Result(); res.Winner != 1 {
t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner)
}
}
// TestResignOnFinishedGame rejects a second transition.
func TestResignOnFinishedGame(t *testing.T) {
g := newEnglishGame(t, 1)
@@ -132,7 +165,7 @@ func TestMultiplayerLastActiveWins(t *testing.T) {
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 takes the lead
played, err := g.SubmitPlay(hint.Tiles) // seat 0 takes the lead
if err != nil {
t.Fatalf("seat 0 play: %v", err)
}
@@ -212,7 +245,7 @@ func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 leads
played, err := g.SubmitPlay(hint.Tiles) // seat 0 leads
if err != nil {
t.Fatalf("seat 0 play: %v", err)
}
+53
View File
@@ -0,0 +1,53 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// seedMarkerFile names the file, in the flat dictionary directory, that records the
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
// version scan skips it (like the .staging upload area).
const seedMarkerFile = ".seed_version"
// resolveSeedVersion returns the version label the flat dictionary directory is
// addressed by, recording it on first use.
//
// The contour's dictionary lives on a named volume seeded from the image once and
// never re-seeded (deploy/docker-compose.yml). The flat DAWGs carry no embedded
// version, so the version a volume was first seeded as is recorded in a
// .seed_version marker and is **authoritative** from then on:
//
// - fresh directory (no marker): record bootVersion (the build's
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion.
//
// So bumping the build seed on a live volume is a harmless no-op (it only takes
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// which would void games pinned to the prior label and mis-serve new ones. New games
// still pin the active version (DB-persisted, set by the admin console), which is the
// real way a running contour moves to a new release.
//
// A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is
// returned rather than swallowed, matching the package's fail-loud dictionary setup.
func resolveSeedVersion(dir, bootVersion string) (string, error) {
path := filepath.Join(dir, seedMarkerFile)
data, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err)
}
if err == nil {
if recorded := strings.TrimSpace(string(data)); recorded != "" {
return recorded, nil
}
// An empty/corrupt marker falls through and is rewritten from bootVersion.
}
if werr := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); werr != nil {
return "", fmt.Errorf("engine: record dictionary seed marker %s: %w", path, werr)
}
return bootVersion, nil
}
+52
View File
@@ -0,0 +1,52 @@
package engine
import "fmt"
// SetupTile is one tile of a variant's full bag, decoded for the first-move draw
// (docs/ARCHITECTURE.md §6): its concrete letter (or the blank marker), a blank
// flag, and its draw rank. Lower rank wins the draw — a blank ranks above every
// letter, and letters rank by alphabet index, so the tile closest to the start of
// the alphabet ("A") wins. It is dictionary-independent, built from the variant's
// solver ruleset alone.
type SetupTile struct {
// Letter is the concrete character (the case the solver ruleset emits), or
// the blank marker "?" for a blank.
Letter string
// Blank reports whether the tile is a blank.
Blank bool
// Rank orders the draw: BlankRank for a blank (best), else the letter's
// alphabet index (0 = closest to "A").
Rank int
}
// BlankRank is the first-move draw rank of a blank: below every letter index, so a
// blank always beats a lettered tile, matching the official rule that a blank
// supersedes all letters.
const BlankRank = -1
// SetupBag returns variant's full tile bag — every lettered tile expanded by its
// count, plus one entry per blank — decoded for the first-move seeding draw. The
// order is deterministic (alphabet order, blanks last); callers shuffle it with
// their own entropy. It needs no dictionary, so it is built from the variant's
// ruleset alone and reports ErrUnknownVariant for an unrecognised variant.
func SetupBag(v Variant) ([]SetupTile, error) {
rs, ok := v.ruleset()
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
bag := make([]SetupTile, 0, 128)
for i, n := range rs.Counts {
ch, err := rs.Alphabet.Character(byte(i))
if err != nil {
// An offered variant's alphabet never yields a bad index; skip defensively.
continue
}
for range n {
bag = append(bag, SetupTile{Letter: ch, Rank: i})
}
}
for range rs.Blanks {
bag = append(bag, SetupTile{Letter: blankLetter, Blank: true, Rank: BlankRank})
}
return bag, nil
}
+47
View File
@@ -0,0 +1,47 @@
package engine
import (
"errors"
"testing"
)
func TestSetupBagEnglish(t *testing.T) {
bag, err := SetupBag(VariantEnglish)
if err != nil {
t.Fatalf("SetupBag: %v", err)
}
// English Scrabble: 98 lettered tiles + 2 blanks = 100.
if len(bag) != 100 {
t.Fatalf("bag size = %d, want 100", len(bag))
}
blanks, aCount := 0, 0
for _, tl := range bag {
switch {
case tl.Blank:
blanks++
if tl.Rank != BlankRank {
t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank)
}
if tl.Letter != blankLetter {
t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter)
}
case tl.Letter == "a":
aCount++
if tl.Rank != 0 {
t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank)
}
}
}
if blanks != 2 {
t.Errorf("blanks = %d, want 2", blanks)
}
if aCount != 9 {
t.Errorf("'a' count = %d, want 9", aCount)
}
}
func TestSetupBagUnknownVariant(t *testing.T) {
if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
t.Fatalf("err = %v, want ErrUnknownVariant", err)
}
}
+306
View File
@@ -0,0 +1,306 @@
package engine
import (
"errors"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
// single-word game ignores perpendicular cross-words, so move generation from a shared
// position is a superset of the standard game's; the standard game does not relax them.
func TestSingleWordRuleWiring(t *testing.T) {
const seed = 7
mk := func(multipleWords bool) *Game {
g, err := New(testReg, Options{
Variant: VariantEnglish,
Version: testVersion,
Players: 2,
Seed: seed,
MultipleWordsPerTurn: multipleWords,
})
if err != nil {
t.Fatalf("new game: %v", err)
}
return g
}
std, single := mk(true), mk(false)
if std.playOpts().IgnoreCrossWords {
t.Error("standard game must not ignore cross-words")
}
if !single.playOpts().IgnoreCrossWords {
t.Error("single-word game must ignore cross-words")
}
// Play the same opening (the standard game's top move) in both games. Both share the
// seed, so the next rack is identical and both still have legal replies. The single-word
// rule is not a superset of the standard one — it forbids parallel plays the standard
// rule allows and admits in-line plays whose cross-words are invalid — so here the two
// move sets only need to be non-empty; their rule-specific differences are covered by the
// cross-word and connectivity tests.
hint, ok := std.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
if _, err := std.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("standard opening: %v", err)
}
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
t.Fatalf("single-word opening: %v", err)
}
if len(std.GenerateMoves()) == 0 || len(single.GenerateMoves()) == 0 {
t.Error("both games should have legal replies after the opening")
}
}
// setupSingleWordKran builds an Erudit position that reproduces the test-contour
// bug. It replaces the bag-dealt rack with к/а/н and places the existing Р the play
// bridges plus perpendicular neighbours (г, е, н) so that each of the three new
// tiles of the vertical КРАН forms an *invalid* cross-word (гк, еа, нн). The
// multipleWords argument selects the rule. It returns the game and the decoded КРАН
// placement (the three new tiles К, А, Н around the existing Р).
func setupSingleWordKran(t *testing.T, multipleWords bool) (*Game, []TileRecord) {
t.Helper()
g, err := New(testReg, Options{
Variant: VariantErudit,
Version: testVersion,
Players: 2,
Seed: 1,
MultipleWordsPerTurn: multipleWords,
})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
idx := func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 5, Col: 8, Letter: idx("р")}, // the existing tile the play bridges
{Row: 4, Col: 7, Letter: idx("г")}, // left of К(4,8): cross-word "гк"
{Row: 6, Col: 7, Letter: idx("е")}, // left of А(6,8): cross-word "еа"
{Row: 7, Col: 7, Letter: idx("н")}, // left of Н(7,8): cross-word "нн"
}})
g.hands[0] = []byte{idx("к"), idx("а"), idx("н")}
tiles := []TileRecord{
{Row: 4, Col: 8, Letter: "к"},
{Row: 6, Col: 8, Letter: "а"},
{Row: 7, Col: 8, Letter: "н"},
}
return g, tiles
}
// TestEvaluatePlayHonorsSingleWordRule is the regression for the contour bug: under
// the single-word rule the EvaluatePlay preview (the "what would this score, and is
// it legal?" tool) must honour the same rule as SubmitPlay and ignore perpendicular
// cross-words, so a play whose only flaw is invalid cross-words is reported legal and
// scored on its main word alone. Before the fix EvaluatePlay validated under standard
// rules and wrongly rejected it.
func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) {
// The main word must be a real Erudit word, so any rejection can only come from
// the (ignored) cross-words rather than the main word itself.
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кран"); err != nil || !ok {
t.Fatalf("precondition: кран must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
t.Run("single-word rule accepts the cross-invalid play", func(t *testing.T) {
g, tiles := setupSingleWordKran(t, false)
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate under single-word rule: %v", err)
}
if len(rec.Words) != 1 || rec.Words[0] != "кран" {
t.Errorf("words = %v, want [кран] only (cross-words ignored)", rec.Words)
}
if rec.Dir != Vertical {
t.Errorf("dir = %v, want Vertical", rec.Dir)
}
if rec.Score <= 0 {
t.Errorf("score = %d, want positive", rec.Score)
}
})
t.Run("standard rules reject the same play", func(t *testing.T) {
g, tiles := setupSingleWordKran(t, true)
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("evaluate under standard rules = %v, want ErrIllegalPlay", err)
}
})
t.Run("evaluate agrees with submit under the single-word rule", func(t *testing.T) {
g, tiles := setupSingleWordKran(t, false)
if _, err := g.SubmitPlay(tiles); err != nil {
t.Errorf("submit under single-word rule: %v", err)
}
})
}
// TestSingleWordRuleSingleTileDirection covers the single-tile half of the single-word
// rule: when a lone tile abuts the board on both axes, the engine picks the orientation that
// forms a real word, not the geometrically longer one. The lone 'о' spells the non-word
// "фоф" across (length 3) but the real word "до" down (length 2); the geometric resolver
// prefers the longer "фоф", so before the fix the play was wrongly rejected.
func TestSingleWordRuleSingleTileDirection(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "до"); err != nil || !ok {
t.Fatalf("precondition: \"до\" must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
if ok, _ := testReg.Lookup(VariantErudit, testVersion, "фоф"); ok {
t.Fatal("precondition: \"фоф\" must not be a word")
}
g, err := New(testReg, Options{
Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1, MultipleWordsPerTurn: false,
})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
idx := func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 4, Col: 8, Letter: idx("д")}, // above 'о': the vertical word "до"
{Row: 5, Col: 7, Letter: idx("ф")}, // left of 'о': the across non-word "фоф"
{Row: 5, Col: 9, Letter: idx("ф")}, // right of 'о'
}})
g.hands[0] = []byte{idx("о")}
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "о"}}
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate the single tile under the single-word rule: %v", err)
}
if rec.Dir != Vertical {
t.Errorf("dir = %v, want Vertical (the real word \"до\")", rec.Dir)
}
if len(rec.Words) != 1 || rec.Words[0] != "до" {
t.Errorf("words = %v, want [до]", rec.Words)
}
if _, err := g.SubmitPlay(tiles); err != nil {
t.Errorf("submit the single tile under the single-word rule: %v", err)
}
}
// TestSingleWordRuleSingleTileBestScore covers the rest of rule (2): when a single tile
// forms a real word on BOTH axes, the engine keeps the higher-scoring orientation (and
// horizontal on a tie), overriding the geometric resolver's tie preference. The lone 'с'
// spells "ас" across and "юс" down; the engine must pick whichever scores more.
func TestSingleWordRuleSingleTileBestScore(t *testing.T) {
for _, w := range []string{"ас", "юс"} {
if ok, err := testReg.Lookup(VariantErudit, testVersion, w); err != nil || !ok {
t.Fatalf("precondition: %q must be in the Erudit dictionary (ok=%v, err=%v)", w, ok, err)
}
}
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
idx := func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 5, Col: 7, Letter: idx("а")}, // left of 'с': the across word "ас"
{Row: 4, Col: 8, Letter: idx("ю")}, // above 'с': the down word "юс"
}})
g.hands[0] = []byte{idx("с")}
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "с"}}
ps := []scrabble.Placement{{Row: 5, Col: 8, Letter: idx("с")}}
across, aerr := g.solver.ValidatePlayOpts(g.board, scrabble.Horizontal, ps, g.playOpts())
down, derr := g.solver.ValidatePlayOpts(g.board, scrabble.Vertical, ps, g.playOpts())
if aerr != nil || derr != nil {
t.Fatalf("both orientations should be legal: across=%v down=%v", aerr, derr)
}
wantDir, wantScore := Horizontal, across.Score
if down.Score > across.Score {
wantDir, wantScore = Vertical, down.Score
}
rec, err := g.EvaluatePlay(tiles)
if err != nil {
t.Fatalf("evaluate the single tile: %v", err)
}
if rec.Dir != wantDir {
t.Errorf("dir = %v, want %v (higher of \"ас\"=%d, \"юс\"=%d)", rec.Dir, wantDir, across.Score, down.Score)
}
if rec.Score != wantScore {
t.Errorf("score = %d, want %d", rec.Score, wantScore)
}
}
// TestSingleWordRuleRejectsPerpendicularOnlyContour is the backend regression for the
// reported contour bug: a multi-tile play whose main word is a real word but which touches
// the board only perpendicular to its own line — forming a cross-word, not a word along that
// line — does not connect under the single-word rule and is rejected by both the preview and
// the submit path. Existing "до" sits down column 8; "кот" laid across row 6 is all-new along
// its row and touches the board only through the 'о' below the existing 'о'.
func TestSingleWordRuleRejectsPerpendicularOnlyContour(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
t.Fatalf("precondition: \"кот\" must be a real word (ok=%v, err=%v)", ok, err)
}
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
idx := func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 4, Col: 8, Letter: idx("д")},
{Row: 5, Col: 8, Letter: idx("о")},
}})
g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
tiles := []TileRecord{
{Row: 6, Col: 7, Letter: "к"},
{Row: 6, Col: 8, Letter: "о"},
{Row: 6, Col: 9, Letter: "т"},
}
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("EvaluatePlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
}
if _, err := g.SubmitPlay(tiles); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("SubmitPlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
}
}
// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same
// cross-word check while searching for its move: its move source, Candidates ->
// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged
// КРАН (whose cross-words are invalid) appears among the candidates the robot chooses
// from; under standard rules it is correctly absent. The robot submits its pick through
// SubmitPlay (covered above), so this holds both before and after the EvaluatePlay fix —
// the robot never uses EvaluatePlay.
func TestSingleWordRuleRobotCandidates(t *testing.T) {
hasKran := func(cands []MoveRecord) bool {
for _, c := range cands {
if len(c.Words) > 0 && c.Words[0] == "кран" {
return true
}
}
return false
}
single, _ := setupSingleWordKran(t, false)
if !hasKran(single.Candidates()) {
t.Error("single-word candidates must include the bridged кран play the robot can pick")
}
std, _ := setupSingleWordKran(t, true)
if hasKran(std.Candidates()) {
t.Error("standard candidates must not include кран (its cross-words are invalid)")
}
}
+19
View File
@@ -0,0 +1,19 @@
package engine
import "testing"
// TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app
// push by the game itself (English -> en, the Russian variants -> ru), rather than the recipient's
// last-login bot.
func TestVariantLanguage(t *testing.T) {
cases := map[Variant]string{
VariantEnglish: "en",
VariantRussianScrabble: "ru",
VariantErudit: "ru",
}
for v, want := range cases {
if got := v.Language(); got != want {
t.Errorf("%s.Language() = %q, want %q", v, got, want)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package feedback
import (
"path/filepath"
"strings"
)
// maxAttachmentBytes caps a single attachment's raw size. Chosen to fit, with the
// message text and the FlatBuffers framing, under the gateway's 1 MiB edge body
// cap, so the whole submit request passes without weakening that cap.
const maxAttachmentBytes = 1_000_000
// allowedExt is the attachment extension allow-list. It is mirrored on the UI as a
// pre-upload gate; the server re-checks here as the trust boundary (metadata only,
// the file content is never parsed). Images render inline in the console; the rest
// are download-only.
var allowedExt = map[string]bool{
"png": true, "jpg": true, "jpeg": true, "webp": true, "gif": true, // images
"pdf": true, "txt": true, "log": true, "doc": true, "docx": true,
"rtf": true, "zip": true, "gz": true, "7z": true,
}
// imageType maps an image extension to the content-type the console serves it with
// (loaded only via <img>, which never executes, so a renamed non-image is inert).
var imageType = map[string]string{
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "gif": "image/gif",
}
// ext returns name's lower-cased extension without the leading dot.
func ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
}
// AllowedAttachment reports whether name's extension is on the allow-list.
func AllowedAttachment(name string) bool {
return allowedExt[ext(name)]
}
// IsImage reports whether name is an inline-previewable image by its extension.
func IsImage(name string) bool {
_, ok := imageType[ext(name)]
return ok
}
// ContentType returns the safe content-type the console serves the attachment
// with: the matching image type for an image, else application/octet-stream so a
// non-image is downloaded rather than rendered.
func ContentType(name string) string {
if t, ok := imageType[ext(name)]; ok {
return t
}
return "application/octet-stream"
}
@@ -0,0 +1,81 @@
package feedback
import "testing"
func TestAllowedAttachment(t *testing.T) {
tests := []struct {
name string
file string
want bool
}{
{"png image", "shot.png", true},
{"jpeg upper-case ext", "Photo.JPG", true},
{"pdf doc", "report.pdf", true},
{"archive 7z", "logs.7z", true},
{"doc with dotted name", "my.notes.docx", true},
{"disallowed exe", "evil.exe", false},
{"disallowed svg (xss vector)", "x.svg", false},
{"disallowed html", "x.html", false},
{"no extension", "README", false},
{"empty name", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := AllowedAttachment(tt.file); got != tt.want {
t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want)
}
})
}
}
func TestIsImageAndContentType(t *testing.T) {
tests := []struct {
file string
isImage bool
ctype string
}{
{"a.png", true, "image/png"},
{"a.jpg", true, "image/jpeg"},
{"a.jpeg", true, "image/jpeg"},
{"a.webp", true, "image/webp"},
{"a.gif", true, "image/gif"},
{"a.pdf", false, "application/octet-stream"},
{"a.zip", false, "application/octet-stream"},
{"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml
{"noext", false, "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
if got := IsImage(tt.file); got != tt.isImage {
t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage)
}
if got := ContentType(tt.file); got != tt.ctype {
t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype)
}
})
}
}
func TestNormalizeChannel(t *testing.T) {
tests := []struct {
in string
want string
}{
{"telegram", "telegram"},
{"ios", "ios"},
{"android", "android"},
{"web", "web"},
{" iOS ", "ios"}, // trimmed + lower-cased
{"TELEGRAM", "telegram"},
{"", "web"}, // unknown -> web
{"windows", "web"}, // unknown -> web
{"'; DROP", "web"}, // junk -> web
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := normalizeChannel(tt.in); got != tt.want {
t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+259
View File
@@ -0,0 +1,259 @@
package feedback
import (
"context"
"errors"
"net/netip"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
const (
// maxBodyRunes caps a feedback message (and an operator reply) length.
maxBodyRunes = 1024
// replyVisibleFor is how long an operator reply stays shown to the player after
// it is delivered (read).
replyVisibleFor = 7 * 24 * time.Hour
)
// Submit / reply validation errors. The transport layer maps them to stable result
// codes; the UI maps those to the messages shown on the feedback screen.
var (
ErrEmptyMessage = errors.New("feedback: empty message")
ErrMessageTooLong = errors.New("feedback: message too long")
ErrAttachmentTooLarge = errors.New("feedback: attachment too large")
ErrAttachmentType = errors.New("feedback: attachment type not allowed")
ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback")
ErrBanned = errors.New("feedback: account is banned from feedback")
ErrPendingReview = errors.New("feedback: previous message still pending review")
)
// validChannels enumerates the submitting platforms a client may report; anything
// else is normalised to "web" (the channel is informational, never a gate).
var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true}
// Service is the feedback domain: the only writer of feedback_messages. It reads
// accounts for the guest/role gates and publishes the reply notification.
type Service struct {
store *Store
accounts *account.Store
pub notify.Publisher
now func() time.Time
}
// NewService constructs a Service. store owns feedback_messages; accounts supplies
// the guest flag and the feedback-ban role.
func NewService(store *Store, accounts *account.Store) *Service {
return &Service{
store: store,
accounts: accounts,
pub: notify.Nop{},
now: func() time.Time { return time.Now().UTC() },
}
}
// SetNotifier installs the live-event publisher used to push the "you have a reply"
// signal to the player. It must be called during startup wiring; the default is
// notify.Nop (no live events).
func (svc *Service) SetNotifier(p notify.Publisher) {
if p != nil {
svc.pub = p
}
}
// Submit stores a feedback message from accountID. It rejects guests, feedback-
// banned accounts and a sender who still has a message pending review, then
// validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
}
if acc.IsGuest {
return ErrGuestForbidden
}
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return err
}
if banned {
return ErrBanned
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return err
}
if pending {
return ErrPendingReview
}
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
if len(attachment) > 0 {
if len(attachment) > maxAttachmentBytes {
return ErrAttachmentTooLarge
}
if !AllowedAttachment(attachmentName) {
return ErrAttachmentType
}
} else {
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
// Snapshot the sender's interface language at submit time (acc is already loaded
// for the guest check) so the operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
return err
}
// State is the player's feedback screen state. Reason is "" (can send), "pending"
// (a previous message is unreviewed) or "banned". Reply is the operator's answer to
// show, or nil. Fetching it delivers any pending replies (delivery counts as read),
// clearing the badge.
type State struct {
CanSend bool
BlockedReason string
Reply *Reply
}
// Reply is the operator's answer shown back to the player.
type Reply struct {
Body string
RepliedAt time.Time
}
// State computes the feedback screen state for accountID and marks any pending
// replies delivered.
func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) {
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
if err != nil {
return State{}, err
}
pending, err := svc.store.HasUnread(ctx, accountID)
if err != nil {
return State{}, err
}
st := State{CanSend: !banned && !pending}
switch {
case banned:
st.BlockedReason = "banned"
case pending:
st.BlockedReason = "pending"
}
vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor))
if err != nil {
return State{}, err
}
if ok {
st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt}
}
// Opening the screen delivers every pending reply; mark them read to clear the
// badge (only the latest is shown, but all are now delivered).
if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil {
return State{}, err
}
return st, nil
}
// ReplyUnread reports whether the account has an operator reply not yet delivered —
// the lobby/Info badge condition. It has no side effect (the lobby may poll it).
func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
return svc.store.HasUnreadReply(ctx, accountID)
}
// AdminList returns the filtered console feedback list, paginated.
func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
return svc.store.AdminList(ctx, f, limit, offset)
}
// AdminCount counts the filtered console feedback list.
func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
return svc.store.AdminCount(ctx, f)
}
// AdminGet loads one message for the console detail view, or ErrNotFound.
func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
return svc.store.AdminGet(ctx, id)
}
// CountUnread counts the active (unread, not archived) feedback queue for the
// dashboard.
func (svc *Service) CountUnread(ctx context.Context) (int, error) {
return svc.store.CountUnread(ctx)
}
// Attachment returns a message's file name and bytes, reporting false when absent.
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
return svc.store.Attachment(ctx, id)
}
// MarkRead marks a message dealt-with (the manual "read" action).
func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error {
return svc.store.MarkRead(ctx, id, svc.now())
}
// Reply sets the operator reply on a message (marking it read), then pushes the
// "you have a reply" notification to the player.
func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error {
body = strings.TrimSpace(body)
if body == "" {
return ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxBodyRunes {
return ErrMessageTooLong
}
accountID, err := svc.store.Reply(ctx, id, body, svc.now())
if err != nil {
return err
}
svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply))
return nil
}
// Archive files a handled message away (marking it read).
func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error {
return svc.store.Archive(ctx, id, svc.now())
}
// Delete physically removes a message and its attachment.
func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error {
return svc.store.Delete(ctx, id)
}
// DeleteAllByAccount physically removes every message of an account.
func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
return svc.store.DeleteAllByAccount(ctx, accountID)
}
// parseIP returns a validated canonical IP string, or nil when raw is empty or not
// a valid address.
func parseIP(raw string) *string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return nil
}
canon := addr.String()
return &canon
}
// normalizeChannel lower-cases and validates the client-reported channel, falling
// back to "web" for anything unrecognised.
func normalizeChannel(c string) string {
c = strings.ToLower(strings.TrimSpace(c))
if validChannels[c] {
return c
}
return "web"
}
+380
View File
@@ -0,0 +1,380 @@
// Package feedback owns user feedback: the flat list of messages a registered
// player sends to the operators (each with an optional single attachment), the
// anti-spam "one pending message at a time" gate, and the operator's inline reply
// delivered back to the player's app. It is modelled on the admin chat-moderation
// surface (internal/social/adminchat.go) and uses raw SQL throughout for the
// attachment bytea, the COALESCE-based "set once" stamps, and the reply-visibility
// window.
package feedback
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// ErrNotFound is returned when no feedback message matches the lookup.
var ErrNotFound = errors.New("feedback: not found")
// Store is the Postgres-backed query surface for feedback_messages.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (the sender's interface language) is a snapshot
// taken now, so the operator later sees the state at submit time. created_at defaults
// to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
}
var att []byte // a nil []byte binds as bytea NULL
if len(attachment) > 0 {
att = attachment
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
}
// nullStr maps an empty string to a NULL text bind, else the value.
func nullStr(s string) *string {
if s == "" {
return nil
}
return &s
}
// HasUnread reports whether the account has any message the operator has not yet
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
var ok bool
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages WHERE account_id = $1 AND read_at IS NULL)`,
accountID).Scan(&ok); err != nil {
return false, fmt.Errorf("feedback: has-unread %s: %w", accountID, err)
}
return ok, nil
}
// HasUnreadReply reports whether the account has an operator reply not yet
// delivered to its app (reply_read_at IS NULL) — the badge's condition.
func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, error) {
var ok bool
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL)`,
accountID).Scan(&ok); err != nil {
return false, fmt.Errorf("feedback: has-unread-reply %s: %w", accountID, err)
}
return ok, nil
}
// VisibleReply is the operator reply shown back to the player: the reply on their
// most recent replied message still inside the visibility window.
type VisibleReply struct {
Body string
RepliedAt time.Time
}
// LatestVisibleReply returns the reply on the account's **most recent** message, if
// that message carries one still visible to the player — not yet delivered, or
// delivered after cutoff (one week ago). Binding it to the single latest message
// means that the moment the player sends a newer message the previous reply stops
// showing (the new message has no reply yet). Reports false when there is none.
func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) {
var vr VisibleReply
var repliedAt sql.NullTime
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(reply_body, ''), replied_at FROM (
SELECT reply_body, replied_at, reply_read_at FROM backend.feedback_messages
WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1
) latest
WHERE reply_body IS NOT NULL AND (reply_read_at IS NULL OR reply_read_at > $2)`,
accountID, cutoff).Scan(&vr.Body, &repliedAt)
if errors.Is(err, sql.ErrNoRows) {
return VisibleReply{}, false, nil
}
if err != nil {
return VisibleReply{}, false, fmt.Errorf("feedback: latest visible reply %s: %w", accountID, err)
}
if repliedAt.Valid {
vr.RepliedAt = repliedAt.Time
}
return vr, true, nil
}
// MarkRepliesDelivered stamps reply_read_at on every not-yet-delivered reply of the
// account (delivery counts as read), clearing the badge when the player opens the
// feedback screen.
func (s *Store) MarkRepliesDelivered(ctx context.Context, accountID uuid.UUID, at time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.feedback_messages SET reply_read_at = $2
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL`,
accountID, at); err != nil {
return fmt.Errorf("feedback: mark replies delivered %s: %w", accountID, err)
}
return nil
}
// MarkRead stamps read_at the first time, marking the message dealt-with without any
// other action; a re-mark keeps the original time.
func (s *Store) MarkRead(ctx context.Context, id uuid.UUID, at time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.feedback_messages SET read_at = COALESCE(read_at, $2) WHERE message_id = $1`,
id, at); err != nil {
return fmt.Errorf("feedback: mark read %s: %w", id, err)
}
return nil
}
// Reply sets (or replaces) the operator reply on a message, marks it read, and
// resets its delivery so the player is notified again. Returns the message's
// account_id for the live notification, or ErrNotFound.
func (s *Store) Reply(ctx context.Context, id uuid.UUID, body string, at time.Time) (uuid.UUID, error) {
var accountID uuid.UUID
err := s.db.QueryRowContext(ctx,
`UPDATE backend.feedback_messages
SET reply_body = $2, replied_at = $3, reply_read_at = NULL, read_at = COALESCE(read_at, $3)
WHERE message_id = $1
RETURNING account_id`,
id, body, at).Scan(&accountID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotFound
}
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: reply %s: %w", id, err)
}
return accountID, nil
}
// Archive files a handled message away and marks it read.
func (s *Store) Archive(ctx context.Context, id uuid.UUID, at time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.feedback_messages
SET archived_at = COALESCE(archived_at, $2), read_at = COALESCE(read_at, $2)
WHERE message_id = $1`,
id, at); err != nil {
return fmt.Errorf("feedback: archive %s: %w", id, err)
}
return nil
}
// Delete physically removes a message (with its attachment).
func (s *Store) Delete(ctx context.Context, id uuid.UUID) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM backend.feedback_messages WHERE message_id = $1`, id); err != nil {
return fmt.Errorf("feedback: delete %s: %w", id, err)
}
return nil
}
// DeleteAllByAccount physically removes every message of an account.
func (s *Store) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM backend.feedback_messages WHERE account_id = $1`, accountID); err != nil {
return fmt.Errorf("feedback: delete all %s: %w", accountID, err)
}
return nil
}
// Attachment returns a message's stored file name and bytes, reporting false when
// the message has no attachment or does not exist.
func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
var name string
var data []byte
err := s.db.QueryRowContext(ctx,
`SELECT COALESCE(attachment_name, ''), attachment FROM backend.feedback_messages WHERE message_id = $1`,
id).Scan(&name, &data)
if errors.Is(err, sql.ErrNoRows) {
return "", nil, false, nil
}
if err != nil {
return "", nil, false, fmt.Errorf("feedback: attachment %s: %w", id, err)
}
if data == nil {
return "", nil, false, nil
}
return name, data, true, nil
}
// AdminMessage is one feedback message in the operator console detail view: the
// message with its sender's resolved display name and source. The attachment bytes
// are not loaded here (served separately); only their presence and name are.
type AdminMessage struct {
ID uuid.UUID
AccountID uuid.UUID
SenderName string
Source string
Body string
Channel string
// Lang is the sender's interface language, snapshotted at submit time.
Lang string
SenderIP string
HasAttachment bool
AttachmentName string
Read bool
Archived bool
Replied bool
ReplyBody string
RepliedAt time.Time
CreatedAt time.Time
}
// AdminRow is one feedback message in the console list (a lighter projection).
type AdminRow struct {
ID uuid.UUID
AccountID uuid.UUID
SenderName string
Source string
Channel string
HasAttachment bool
Read bool
Replied bool
Archived bool
CreatedAt time.Time
}
// AdminFilter narrows the console feedback list. Status is one of "unread"
// (default), "read" or "archived". NameMask/ExtMask are glob masks
// (account.LikePattern) on the sender's display name / any identity's external id;
// AccountID, when set, restricts to one account (the per-user link from /users).
type AdminFilter struct {
Status string
NameMask string
ExtMask string
AccountID uuid.UUID
}
// feedbackSource projects a sender's source: guest, robot, or its oldest identity
// kind ("—" when it has none). Mirrors social.adminMessageSource.
const feedbackSource = `CASE
WHEN a.is_guest THEN 'guest'
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
END`
// adminWhere builds the shared WHERE clause and its positional args (from $1).
func adminWhere(f AdminFilter) (string, []any) {
var where string
switch f.Status {
case "archived":
where = `m.archived_at IS NOT NULL`
case "read":
where = `m.read_at IS NOT NULL AND m.archived_at IS NULL`
default: // unread
where = `m.read_at IS NULL AND m.archived_at IS NULL`
}
var args []any
if f.AccountID != uuid.Nil {
args = append(args, f.AccountID)
where += fmt.Sprintf(` AND m.account_id = $%d`, len(args))
}
if name := account.LikePattern(f.NameMask); name != "" {
args = append(args, name)
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
}
if ext := account.LikePattern(f.ExtMask); ext != "" {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
return where, args
}
// AdminList returns the filtered console feedback list, newest first, paginated.
func (s *Store) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
where, args := adminWhere(f)
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.channel,
(m.attachment IS NOT NULL), (m.read_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.archived_at IS NOT NULL), m.created_at
FROM backend.feedback_messages m
JOIN backend.accounts a ON a.account_id = m.account_id
WHERE ` + where +
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("feedback: admin list: %w", err)
}
defer rows.Close()
var out []AdminRow
for rows.Next() {
var r AdminRow
if err := rows.Scan(&r.ID, &r.AccountID, &r.SenderName, &r.Source, &r.Channel,
&r.HasAttachment, &r.Read, &r.Replied, &r.Archived, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("feedback: scan admin row: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// AdminCount counts the filtered console feedback list, for the pager.
func (s *Store) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
where, args := adminWhere(f)
var n int
q := `SELECT COUNT(*) FROM backend.feedback_messages m JOIN backend.accounts a ON a.account_id = m.account_id WHERE ` + where
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: admin count: %w", err)
}
return n, nil
}
// AdminGet loads one message for the console detail view, or ErrNotFound.
func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
var m AdminMessage
var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''),
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
FROM backend.feedback_messages m
JOIN backend.accounts a ON a.account_id = m.account_id
WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return AdminMessage{}, ErrNotFound
}
if err != nil {
return AdminMessage{}, fmt.Errorf("feedback: admin get %s: %w", id, err)
}
if repliedAt.Valid {
m.RepliedAt = repliedAt.Time
}
return m, nil
}
// CountUnread counts the active (unread, not archived) feedback queue, for the
// console dashboard.
func (s *Store) CountUnread(ctx context.Context) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM backend.feedback_messages WHERE read_at IS NULL AND archived_at IS NULL`,
).Scan(&n); err != nil {
return 0, fmt.Errorf("feedback: count unread: %w", err)
}
return n, nil
}
+116
View File
@@ -0,0 +1,116 @@
package game
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// A move's "duration" is the think time from the previous move's commit (the moment
// the turn started) to this move's commit. Only play/pass/exchange moves count;
// timeouts and resignations are not think time. The very first move of a game has no
// previous move, so its baseline is the game's creation time. The figures are derived
// from the move journal (game_moves.created_at), so no schema change is needed.
//
// timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for
// every timed move; the two reports aggregate it differently.
const timedMovesCTE = `
SELECT gp.account_id AS aid,
m.game_id AS gid,
ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord,
EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs
FROM backend.game_moves m
JOIN backend.games g ON g.game_id = m.game_id
LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1
JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat
WHERE m.action IN ('play', 'pass', 'exchange')`
// MoveDurationStat is the min, max and mean per-move think time (in seconds) for an
// account across all its games, with the number of timed moves counted.
type MoveDurationStat struct {
MinSecs float64
MaxSecs float64
AvgSecs float64
Moves int
}
// MoveDurationStats returns the move-duration summary for each of accountIDs that has
// at least one timed move; accounts with none are absent from the map. It powers the
// admin user-list columns. The scan over the journal is acceptable for the low-traffic
// console; per-human analysis is the authoritative use (the live metric aggregates all
// seats including robots).
func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
if len(accountIDs) == 0 {
return map[uuid.UUID]MoveDurationStat{}, nil
}
q := `WITH d AS (` + timedMovesCTE + `)
SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid`
rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs))
if err != nil {
return nil, fmt.Errorf("game: move-duration stats: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs))
for rows.Next() {
var id uuid.UUID
var st MoveDurationStat
if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil {
return nil, fmt.Errorf("game: scan move-duration stat: %w", err)
}
out[id] = st
}
return out, rows.Err()
}
// OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move
// (Ordinal) across all its games.
type OrdinalDuration struct {
Ordinal int
MinSecs float64
MaxSecs float64
AvgSecs float64
}
// MoveDurationByOrdinal returns the account's per-move-number think-time summary,
// ordered by move number, for the admin user-detail chart. The ordinal counts the
// account's own moves within each game (its 1st, 2nd, … move).
func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1)
SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord`
rows, err := s.db.QueryContext(ctx, q, accountID)
if err != nil {
return nil, fmt.Errorf("game: move-duration by ordinal: %w", err)
}
defer rows.Close()
var out []OrdinalDuration
for rows.Next() {
var od OrdinalDuration
if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil {
return nil, fmt.Errorf("game: scan ordinal duration: %w", err)
}
out = append(out, od)
}
return out, rows.Err()
}
// uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an
// ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe.
func uuidArrayLiteral(ids []uuid.UUID) string {
ss := make([]string, len(ids))
for i, id := range ids {
ss[i] = id.String()
}
return "{" + strings.Join(ss, ",") + "}"
}
// MoveDurationStats exposes the store report to the admin console handlers.
func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
return svc.store.MoveDurationStats(ctx, accountIDs)
}
// MoveDurationByOrdinal exposes the per-move-number report to the admin console.
func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
return svc.store.MoveDurationByOrdinal(ctx, accountID)
}
+12 -8
View File
@@ -63,6 +63,7 @@ type gameCache struct {
type cachedGame struct {
game *engine.Game
seats []Seat
variant string
lastAccess time.Time
}
@@ -71,24 +72,27 @@ func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
}
// get returns the live game for id and refreshes its idle timer, or (nil, false).
func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
// get returns the live game and its immutable seat list for id and refreshes its idle
// timer, or (nil, nil, false). The seats let a read check membership (and label seats)
// without re-loading the game from the store, since seats never change after a game starts.
func (c *gameCache) get(id uuid.UUID) (*engine.Game, []Seat, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[id]
if !ok {
return nil, false
return nil, nil, false
}
e.lastAccess = c.now()
return e.game, true
return e.game, e.seats, true
}
// put stores g as the live game for id. variant labels the entry so the active-
// games gauge can report counts by variant without inspecting engine internals.
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
// put stores g as the live game for id together with its seat list. variant labels the
// entry so the active-games gauge can report counts by variant without inspecting engine
// internals; seats are the game's immutable seat standings for the membership fast path.
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string, seats []Seat) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
c.entries[id] = &cachedGame{game: g, seats: seats, variant: variant, lastAccess: c.now()}
}
// remove drops id from the cache (used on a finished game and after a failed
+163
View File
@@ -0,0 +1,163 @@
package game
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// DraftTile is one tile a player has laid on the board but not yet submitted.
type DraftTile struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Blank bool `json:"blank"`
}
// Draft is a player's persisted client-side composition for a game: the
// preferred rack tile order and the board tiles laid but not yet submitted. The server
// keeps it so a reload or a second device resumes the same arrangement.
type Draft struct {
RackOrder string
BoardTiles []DraftTile
}
// GetDraft returns the player's draft for a game, or a zero Draft when none is stored.
func (svc *Service) GetDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
return svc.store.getDraft(ctx, gameID, accountID)
}
// SaveDraft upserts the player's draft; the account must be seated in the game.
func (svc *Service) SaveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
seats, _, _, err := svc.Participants(ctx, gameID)
if err != nil {
return err
}
if !slices.Contains(seats, accountID) {
return ErrNotAPlayer
}
return svc.store.saveDraft(ctx, gameID, accountID, d)
}
// getDraft reads one draft row, returning a zero Draft when absent.
func (s *Store) getDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
var rackOrder string
var boardJSON []byte
err := s.db.QueryRowContext(ctx,
`SELECT rack_order, board_tiles FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
gameID, accountID).Scan(&rackOrder, &boardJSON)
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, nil
}
if err != nil {
return Draft{}, fmt.Errorf("game: get draft %s: %w", gameID, err)
}
d := Draft{RackOrder: rackOrder}
if len(boardJSON) > 0 {
if err := json.Unmarshal(boardJSON, &d.BoardTiles); err != nil {
return Draft{}, fmt.Errorf("game: decode draft tiles: %w", err)
}
}
return d, nil
}
// saveDraft upserts the player's draft.
func (s *Store) saveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
tiles := d.BoardTiles
if tiles == nil {
tiles = []DraftTile{}
}
boardJSON, err := json.Marshal(tiles)
if err != nil {
return fmt.Errorf("game: encode draft tiles: %w", err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.game_drafts (game_id, account_id, rack_order, board_tiles, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (game_id, account_id)
DO UPDATE SET rack_order = $3, board_tiles = $4, updated_at = now()`,
gameID, accountID, d.RackOrder, boardJSON); err != nil {
return fmt.Errorf("game: save draft: %w", err)
}
return nil
}
// clearDraft drops a player's draft row (their composition is consumed or discarded).
func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) error {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
gameID, accountID); err != nil {
return fmt.Errorf("game: clear draft: %w", err)
}
return nil
}
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
// rack order is kept.
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
if len(cells) == 0 {
return nil
}
occupied := make(map[[2]int]bool, len(cells))
for _, c := range cells {
occupied[[2]int{c.Row, c.Col}] = true
}
rows, err := s.db.QueryContext(ctx,
`SELECT account_id, board_tiles FROM backend.game_drafts
WHERE game_id = $1 AND account_id <> $2 AND board_tiles <> '[]'::jsonb`,
gameID, actorID)
if err != nil {
return fmt.Errorf("game: scan drafts for conflict: %w", err)
}
var toClear []uuid.UUID
func() {
defer func() { _ = rows.Close() }()
for rows.Next() {
var acc uuid.UUID
var boardJSON []byte
if err = rows.Scan(&acc, &boardJSON); err != nil {
return
}
var tiles []DraftTile
if json.Unmarshal(boardJSON, &tiles) != nil {
continue // skip a malformed draft
}
for _, t := range tiles {
if occupied[[2]int{t.Row, t.Col}] {
toClear = append(toClear, acc)
break
}
}
}
err = rows.Err()
}()
if err != nil {
return fmt.Errorf("game: read drafts for conflict: %w", err)
}
for _, acc := range toClear {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.game_drafts SET board_tiles = '[]'::jsonb, updated_at = now()
WHERE game_id = $1 AND account_id = $2`,
gameID, acc); err != nil {
return fmt.Errorf("game: clear conflicting draft: %w", err)
}
}
return nil
}
// draftTilesFrom projects a play's committed tiles into draft cells, for the conflict scan.
func draftTilesFrom(rec engine.MoveRecord) []DraftTile {
out := make([]DraftTile, 0, len(rec.Tiles))
for _, t := range rec.Tiles {
out = append(out, DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return out
}
+105
View File
@@ -0,0 +1,105 @@
package game
import (
"context"
"slices"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
fb "scrabble/pkg/fbs/scrabblefb"
)
// recordingPublisher captures every published intent for assertions.
type recordingPublisher struct{ intents []notify.Intent }
func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) }
// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every
// seat — including the actor's own account, so the mover's other devices refresh —
// and your_turn only to the next mover.
func TestEmitMoveNotifiesActor(t *testing.T) {
actor, opp := uuid.New(), uuid.New()
pub := &recordingPublisher{}
svc := &Service{pub: pub}
g := Game{
ID: uuid.New(),
Status: StatusActive,
ToMove: 1,
TurnStartedAt: time.Now(),
TurnTimeout: time.Hour,
Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}},
}
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80)
kinds := map[uuid.UUID][]string{}
var yourTurn notify.Intent
for _, in := range pub.intents {
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
if in.UserID == opp && in.Kind == notify.KindYourTurn {
yourTurn = in
}
}
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
}
if !slices.Contains(kinds[opp], notify.KindOpponentMoved) {
t.Errorf("opponent should get opponent_moved, got %v", kinds[opp])
}
if !slices.Contains(kinds[opp], notify.KindYourTurn) {
t.Errorf("next mover should get your_turn, got %v", kinds[opp])
}
if slices.Contains(kinds[actor], notify.KindYourTurn) {
t.Errorf("actor is not next to move, should not get your_turn")
}
// The your_turn push is enriched: the last move's action and word, and a recipient-first
// score line (the next mover, seat 1, first). The opponent name needs the account store and
// is left empty by this store-less unit (covered at the render layer).
yt := fb.GetRootAsYourTurnEvent(yourTurn.Payload, 0)
if got := string(yt.LastAction()); got != "play" {
t.Errorf("your_turn last_action = %q, want play", got)
}
if got := string(yt.LastWord()); got != "HELLO" {
t.Errorf("your_turn last_word = %q, want HELLO", got)
}
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
t.Errorf("your_turn score_line = %q, want 13:19", got)
}
}
// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat,
// each with its own outcome and a recipient-first final score.
func TestEmitMoveAnnouncesGameOver(t *testing.T) {
winner, loser := uuid.New(), uuid.New()
pub := &recordingPublisher{}
svc := &Service{pub: pub}
g := Game{
ID: uuid.New(),
Status: StatusFinished,
Players: 2,
EndReason: "out_of_tiles",
Seats: []Seat{{Seat: 0, AccountID: winner, Score: 120, IsWinner: true}, {Seat: 1, AccountID: loser, Score: 95}},
}
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}, 0)
over := map[uuid.UUID]notify.Intent{}
for _, in := range pub.intents {
if in.Kind == notify.KindGameOver {
over[in.UserID] = in
}
}
if len(over) != 2 {
t.Fatalf("game_over should reach both seats, got %d", len(over))
}
w := fb.GetRootAsGameOverEvent(over[winner].Payload, 0)
if string(w.Result()) != "won" || string(w.ScoreLine()) != "120:95" {
t.Errorf("winner game_over = %q / %q, want won / 120:95", w.Result(), w.ScoreLine())
}
l := fb.GetRootAsGameOverEvent(over[loser].Payload, 0)
if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" {
t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine())
}
}
+90
View File
@@ -0,0 +1,90 @@
package game
import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
)
// The mappers below project the game domain into the wire-agnostic notify.* input
// structs the enriched live events carry. They keep the wire schema out of the
// game package: notify owns the FlatBuffers encoding, this file only resolves the
// values (seat display names, last-activity sort key) into its input shapes.
// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched
// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix
// mirrors the gateway view (the current turn's start while active, the finish time once
// finished).
func gameSummary(g Game, names []string) notify.GameSummary {
seats := make([]notify.SeatStanding, 0, len(g.Seats))
for _, s := range g.Seats {
name := ""
if s.Seat >= 0 && s.Seat < len(names) {
name = names[s.Seat]
}
// An open game's still-empty opponent seat carries no account: send an empty id
// (not the nil-UUID string) so the client renders it as "searching for opponent".
accountID := ""
if s.AccountID != uuid.Nil {
accountID = s.AccountID.String()
}
seats = append(seats, notify.SeatStanding{
Seat: s.Seat,
AccountID: accountID,
DisplayName: name,
Score: s.Score,
HintsUsed: s.HintsUsed,
IsWinner: s.IsWinner,
})
}
last := g.TurnStartedAt
if g.FinishedAt != nil {
last = *g.FinishedAt
}
return notify.GameSummary{
ID: g.ID.String(),
Variant: g.Variant.String(),
DictVersion: g.DictVersion,
Status: g.Status,
Players: g.Players,
ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
LastActivityUnix: last.Unix(),
}
}
// playerState projects a StateView into the notify.PlayerState carried by the
// match_found / game_started events. The rack is re-encoded to wire alphabet indices;
// the variant alphabet display table is embedded when includeAlphabet is set (an
// initial view whose recipient may not have cached the variant yet).
func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) {
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
if err != nil {
return notify.PlayerState{}, err
}
ps := notify.PlayerState{
Game: gameSummary(v.Game, names),
Seat: v.Seat,
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
if err != nil {
return notify.PlayerState{}, err
}
ps.Alphabet = make([]notify.AlphabetLetter, len(tab))
for i, e := range tab {
ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
}
}
return ps, nil
}
+5
View File
@@ -36,6 +36,11 @@ func writeGCG(g Game, names []string, moves []HistoryMove) string {
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
}
}
// An aborted game ends in a draw because it could no longer be reconstructed; record it
// as an impersonal organizer note (free-text #note; GCG readers ignore pragmas).
if g.EndReason == "aborted" {
fmt.Fprintln(&b, "#note [organizer] game could not be continued and was ended in a draw")
}
return b.String()
}
+16 -1
View File
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
"#character-encoding UTF-8",
"#player1 p1 Alice",
"#player2 p2 Bob",
"#lexicon english/v1",
"#lexicon scrabble_en/v1",
"#title game 00000000-0000-7000-8000-000000000001",
">p1: CATSER? 8H CAT +10 10",
">p2: AS?E I8 .s +2 2",
@@ -61,6 +61,21 @@ func TestWriteGCG(t *testing.T) {
}
}
func TestWriteGCGAbortedNote(t *testing.T) {
g := Game{
ID: uuid.MustParse("00000000-0000-7000-8000-000000000002"),
Variant: engine.VariantErudit,
DictVersion: "v1",
Players: 2,
Status: StatusFinished,
EndReason: "aborted",
}
out := writeGCG(g, []string{"Alice", "Bob"}, nil)
if !strings.Contains(out, "#note [organizer]") {
t.Errorf("aborted game GCG missing the organizer note:\n%s", out)
}
}
func TestGCGTilesUppercasesCyrillic(t *testing.T) {
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
t.Errorf("gcgTiles = %q, want КОТ?", got)
+3 -3
View File
@@ -94,8 +94,8 @@ func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New()
cache.put(id, nil, "english")
if _, ok := cache.get(id); !ok {
cache.put(id, nil, "scrabble_en", nil)
if _, _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
cur = cur.Add(30 * time.Minute)
@@ -104,7 +104,7 @@ func TestGameCacheEviction(t *testing.T) {
if n := cache.sweep(); n != 1 {
t.Errorf("sweep evicted %d, want 1", n)
}
if _, ok := cache.get(id); ok {
if _, _, ok := cache.get(id); ok {
t.Error("game must be evicted after idle TTL")
}
if cache.size() != 0 {
+40 -7
View File
@@ -16,12 +16,13 @@ import (
const meterName = "scrabble/backend/game"
// gameMetrics holds the game domain's operational instruments. Every game-scoped
// measurement carries a "variant" attribute (english/russian/erudit). The
// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The
// instruments default to no-ops (see defaultGameMetrics), so recording is always
// safe; SetMetrics installs the real meter during startup wiring.
type gameMetrics struct {
replay metric.Float64Histogram
validate metric.Float64Histogram
moveDur metric.Float64Histogram
started metric.Int64Counter
abandoned metric.Int64Counter
}
@@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics {
return &gameMetrics{
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."),
started: counter(meter, "games_started_total", "Games created and started."),
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
}
@@ -75,15 +77,46 @@ func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, star
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
}
// recordStarted counts one started game of variant.
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
m.started.Add(ctx, 1, variantAttr(v))
// recordMoveDuration records how long a seat spent on a committed move, attributed by
// variant and the game phase derived from moveCount. A non-positive duration (a clock
// skew or a move with no recorded turn start) is dropped.
func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) {
if d <= 0 {
return
}
m.moveDur.Record(ctx, d.Seconds(),
metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount))))
}
// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The
// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7).
func phaseOf(moveCount int) string {
switch {
case moveCount <= 8:
return "opening"
case moveCount <= 20:
return "middle"
default:
return "endgame"
}
}
// recordStarted counts one started game of variant, split by whether it is an
// honest-AI game (the vs_ai attribute), so AI and human games chart separately.
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant, vsAI bool) {
m.started.Add(ctx, 1, gameKindAttr(v, vsAI))
}
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
// variant.
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant) {
m.abandoned.Add(ctx, 1, variantAttr(v))
// variant, split by the vs_ai attribute (an AI game's abandon is the 7-day rule).
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant, vsAI bool) {
m.abandoned.Add(ctx, 1, gameKindAttr(v, vsAI))
}
// gameKindAttr is the (variant, vs_ai) attribute set shared by the started and
// abandoned counters so AI and human games are split on the same labels.
func gameKindAttr(v engine.Variant, vsAI bool) metric.MeasurementOption {
return metric.WithAttributes(attribute.String("variant", v.String()), attribute.Bool("vs_ai", vsAI))
}
// variantAttr is the shared "variant" attribute option, usable for both Record and
+30 -9
View File
@@ -20,12 +20,16 @@ func TestGameMetrics(t *testing.T) {
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newGameMetrics(meter)
m.recordStarted(ctx, engine.VariantEnglish)
m.recordStarted(ctx, engine.VariantEnglish)
m.recordStarted(ctx, engine.VariantRussianScrabble)
m.recordAbandoned(ctx, engine.VariantErudit)
m.recordStarted(ctx, engine.VariantEnglish, false)
m.recordStarted(ctx, engine.VariantEnglish, false)
m.recordStarted(ctx, engine.VariantRussianScrabble, false)
m.recordStarted(ctx, engine.VariantEnglish, true) // an honest-AI game
m.recordAbandoned(ctx, engine.VariantErudit, false)
m.recordAbandoned(ctx, engine.VariantEnglish, true) // an AI game abandoned (the 7-day rule)
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
@@ -33,11 +37,15 @@ func TestGameMetrics(t *testing.T) {
}
started := counterByAttr(t, rm, "games_started_total", "variant")
if started["english"] != 2 || started["russian_scrabble"] != 1 {
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
if started["scrabble_en"] != 3 || started["scrabble_ru"] != 1 {
t.Errorf("games_started_total = %v, want scrabble_en:3 scrabble_ru:1", started)
}
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 {
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
// The vs_ai attribute splits AI from human games for the per-kind Grafana panels.
if byKind := counterByAttr(t, rm, "games_started_total", "vs_ai"); byKind["false"] != 3 || byKind["true"] != 1 {
t.Errorf("games_started_total by vs_ai = %v, want false:3 true:1", byKind)
}
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "vs_ai"); abandoned["false"] != 1 || abandoned["true"] != 1 {
t.Errorf("games_abandoned_total by vs_ai = %v, want false:1 true:1", abandoned)
}
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
t.Errorf("game_replay_duration observations = %d, want 1", c)
@@ -45,6 +53,19 @@ func TestGameMetrics(t *testing.T) {
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
}
if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
}
}
// TestPhaseOf checks the move-ordinal to phase bucketing.
func TestPhaseOf(t *testing.T) {
cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
for mc, want := range cases {
if got := phaseOf(mc); got != want {
t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
}
}
}
// counterByAttr sums the int64 counter named name, grouped by the value of the
@@ -63,7 +84,7 @@ func counterByAttr(t *testing.T, rm metricdata.ResourceMetrics, name, attr strin
}
for _, dp := range sum.DataPoints {
v, _ := dp.Attributes.Value(attribute.Key(attr))
out[v.AsString()] += dp.Value
out[v.Emit()] += dp.Value // Emit renders any attribute type (string or bool) to a key
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package game
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// ReplayStep is one step of an admin game replay: the move that produced it (nil for the
// initial dealt state, step 0) and the resulting position — every seat's rack, the running
// scores, whose turn it is and the bag remainder. Step k's board is the union of every
// play's placements through step k, which the renderer accumulates onto an empty grid
// (docs/ARCHITECTURE.md §9.1 visual replay).
type ReplayStep struct {
// Move is the journalled move that produced this state, or nil for the initial deal.
Move *HistoryMove
// Drawn lists the tiles the mover drew from the bag after this move ("?" for a blank);
// empty for the initial deal, a pass or a resignation.
Drawn []string
// Racks holds every seat's rack at this step, indexed by seat ("?" for a blank).
Racks [][]string
// Scores holds every seat's running score, indexed by seat.
Scores []int
// ToMove is the seat to move at this step.
ToMove int
// BagLen is the number of tiles left in the bag at this step.
BagLen int
}
// ReplayTimelineView is the admin replay of a game: the persisted game plus the ordered
// replay steps (the initial deal followed by one step per journalled move).
type ReplayTimelineView struct {
Game Game
Steps []ReplayStep
}
// ReplayTimeline rebuilds a game from its pinned seed and journal and returns the ordered
// replay steps for the admin console: the initial deal (step 0) then one step per
// journalled move, each carrying the resulting racks, scores, turn cursor, bag size and the
// tiles the mover drew. The deterministic bag makes the reconstruction exact. It needs no
// dictionary beyond the engine the seed deals, and — like the live replay — stops early if a
// committed move became illegal under tightened rules rather than failing.
func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (ReplayTimelineView, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
seed, err := svc.store.GameSeed(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
})
if err != nil {
return ReplayTimelineView{}, err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
steps := make([]ReplayStep, 0, len(moves)+1)
steps = append(steps, snapshotStep(g, nil, nil))
for i := range moves {
mv := moves[i]
before := g.Hand(mv.Seat)
if err := replayMove(g, mv); err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
g.Abort()
break
}
return ReplayTimelineView{}, fmt.Errorf("game: replay-timeline %s move %d: %w", gameID, mv.Seq, err)
}
moveCopy := mv
steps = append(steps, snapshotStep(g, &moveCopy, drawnTiles(before, g.Hand(mv.Seat), usedTiles(mv))))
}
return ReplayTimelineView{Game: pre, Steps: steps}, nil
}
// snapshotStep captures the position after applying move (nil for the initial deal): every
// seat's rack and score, the turn cursor and the bag size, with the supplied drawn tiles.
func snapshotStep(g *engine.Game, move *HistoryMove, drawn []string) ReplayStep {
n := g.Players()
racks := make([][]string, n)
scores := make([]int, n)
for i := 0; i < n; i++ {
racks[i] = g.Hand(i)
scores[i] = g.Score(i)
}
return ReplayStep{Move: move, Drawn: drawn, Racks: racks, Scores: scores, ToMove: g.ToMove(), BagLen: g.BagLen()}
}
// usedTiles returns the rack tiles a move consumed ("?" for a blank): the placed tiles of a
// play or the swapped tiles of an exchange; a pass or resignation consumes none.
func usedTiles(mv HistoryMove) []string {
switch mv.Action {
case "play":
used := make([]string, len(mv.Tiles))
for i, t := range mv.Tiles {
if t.Blank {
used[i] = "?" // a placed blank leaves the rack as the blank marker
} else {
used[i] = t.Letter
}
}
return used
case "exchange":
return mv.Exchanged
}
return nil
}
// drawnTiles returns the tiles the mover drew from the bag: the post-move rack (after) minus
// the tiles kept (before minus used). It compares the racks as multisets, so duplicate
// letters are counted correctly.
func drawnTiles(before, after, used []string) []string {
kept := make(map[string]int, len(before))
for _, t := range before {
kept[t]++
}
for _, t := range used {
if kept[t] > 0 {
kept[t]--
}
}
var drawn []string
for _, t := range after {
if kept[t] > 0 {
kept[t]--
continue
}
drawn = append(drawn, t)
}
return drawn
}
@@ -0,0 +1,50 @@
package game
import (
"reflect"
"testing"
"scrabble/backend/internal/engine"
)
func TestUsedTiles(t *testing.T) {
tests := []struct {
name string
mv HistoryMove
want []string
}{
{"pass", HistoryMove{Action: "pass"}, nil},
{"resign", HistoryMove{Action: "resign"}, nil},
{"play with blank", HistoryMove{Action: "play", Tiles: []engine.TileRecord{{Letter: "a"}, {Letter: "b", Blank: true}}}, []string{"a", "?"}},
{"exchange", HistoryMove{Action: "exchange", Exchanged: []string{"a", "?"}}, []string{"a", "?"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := usedTiles(tt.mv); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("usedTiles = %v, want %v", got, tt.want)
}
})
}
}
func TestDrawnTiles(t *testing.T) {
tests := []struct {
name string
before, used []string
after []string
want []string
}{
{"play refill", []string{"a", "b", "c", "d"}, []string{"a", "b"}, []string{"c", "d", "e", "f"}, []string{"e", "f"}},
{"blank played", []string{"?", "a"}, []string{"?"}, []string{"a", "x"}, []string{"x"}},
{"pass keeps rack", []string{"a", "b"}, nil, []string{"a", "b"}, nil},
{"duplicate letters", []string{"e", "e", "e"}, []string{"e"}, []string{"e", "e", "q"}, []string{"q"}},
{"empty bag no refill", []string{"a", "b"}, []string{"a"}, []string{"b"}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := drawnTiles(tt.before, tt.after, tt.used); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("drawnTiles = %v, want %v", got, tt.want)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More