Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master
This commit was merged in pull request #104.
This commit is contained in:
@@ -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
|
||||||
@@ -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/...
|
|
||||||
@@ -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/...
|
|
||||||
@@ -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"
|
||||||
@@ -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
|
|
||||||
@@ -16,3 +16,10 @@
|
|||||||
# Local, unstaged env overrides
|
# Local, unstaged env overrides
|
||||||
**/.env.local
|
**/.env.local
|
||||||
**/.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
|
||||||
|
|||||||
@@ -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
|
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
|
||||||
details to interview*.
|
details to interview*.
|
||||||
|
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7
|
||||||
|
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
|
||||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
||||||
security, the decision record. Always describes current state.
|
security, the decision record. Always describes current state.
|
||||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
- [`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
|
## Branching & CI
|
||||||
|
|
||||||
- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*`
|
- **Two long-lived branches** (Stage 16 onward): **`development`** is the
|
||||||
and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on
|
integration branch; **`master`** is the production trunk. Cut `feature/*`
|
||||||
`master` by necessity (an empty branch has nothing to PR into).
|
branches **from `development`** and PR them back into it. (Stages 0–15 used
|
||||||
|
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
|
||||||
|
on `master` by necessity.)
|
||||||
|
- A commit to a `feature/*` branch triggers **nothing**. The single workflow
|
||||||
|
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
|
||||||
|
on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls
|
||||||
|
the **test contour** on a PR into — or a push to — `development`
|
||||||
|
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into
|
||||||
|
`master` is test-only.
|
||||||
|
- Merge `development → master` only when CI is green; the **prod** deploy is then a
|
||||||
|
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
|
||||||
|
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
|
||||||
- After any push, watch the run to green before declaring a stage done — use the
|
- After any push, watch the run to green before declaring a stage done — use the
|
||||||
ready-made watcher, never an inline poll loop:
|
ready-made watcher, never an inline poll loop:
|
||||||
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
`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
|
internal/inttest/ # //go:build integration Postgres-backed tests
|
||||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||||
gateway/ ui/ pkg/ # added by their stages
|
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
|
## Build & test
|
||||||
@@ -122,14 +138,20 @@ go build ./backend/... # per module ('./...' from the root won't span t
|
|||||||
go vet ./backend/...
|
go vet ./backend/...
|
||||||
gofmt -l . # must print nothing
|
gofmt -l . # must print nothing
|
||||||
go test -count=1 ./backend/...
|
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
|
go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
||||||
|
|
||||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
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
|
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job
|
||||||
`.gitea/workflows/ui-test.yaml`. Committed edge codegen under `ui/src/gen/`
|
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
|
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ independent (see ARCHITECTURE §9.1).
|
|||||||
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
|
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
|
||||||
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
|
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
|
||||||
| 15 | Dual Telegram bots & language-gated variants | **done** |
|
| 15 | Dual Telegram bots & language-gated variants | **done** |
|
||||||
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | todo |
|
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
|
||||||
| 17 | Prod contour deploy (SSH export/import, manual after merge) | todo |
|
| 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
|
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||||
adds the modules it needs.
|
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)
|
### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
|
||||||
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
|
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
|
||||||
deploy + observability + the two-bots idea were split into **Stages 15–17** below and this
|
deploy + observability + the two-bots idea were split into **Stages 15–18** below and this
|
||||||
stage took only the dependency/artifact split that everything else builds on. Scope: publish
|
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-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
|
`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`)
|
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
|
||||||
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
|
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 +
|
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
|
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),
|
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;
|
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
|
||||||
collector/Tempo/Prometheus retention.
|
collector/Tempo/Prometheus retention.
|
||||||
|
|
||||||
### Stage 17 — Prod contour deploy
|
### Stage 17 — Test-contour verification & defect fixes *(done)*
|
||||||
Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
|
Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the
|
||||||
(`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP
|
"does it actually work in the contour" pass before prod. Bring up the `development` deploy, then
|
||||||
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after a feature branch is merged to
|
verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin
|
||||||
`master`. Two-contour config uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no
|
console and Grafana sit behind the single `/_gm` Basic-Auth; the Telegram **bots** start (test
|
||||||
deployment environments (verified: the `environments` API 404s), so a flat prefixed namespace is the
|
environment) and the Mini App launches/authenticates; a game can be created and played through (web
|
||||||
convention.
|
+ Mini App); the **observability** stack receives data (Prometheus targets up, the dashboards
|
||||||
Open details (re-interview): export/import vs a registry trade-off; prod domain/TLS at the remote
|
populate incl. `accounts_created_total`/`active_users`, traces reach Tempo); the out-of-app push
|
||||||
caddy; prod VPN; rollback.
|
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
|
## Refinements logged during implementation
|
||||||
|
|
||||||
@@ -504,7 +664,10 @@ caddy; prod VPN; rollback.
|
|||||||
- **Margin** (interview): pick the candidate whose resulting margin (own+move−opp)
|
- **Margin** (interview): pick the candidate whose resulting margin (own+move−opp)
|
||||||
is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose,
|
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
|
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`)
|
- **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`)
|
||||||
substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`),
|
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
|
`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);
|
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
||||||
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
|
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
|
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/
|
**bonus-label style** (beginner/
|
||||||
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
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**
|
`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
|
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).
|
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 +
|
(Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
|
||||||
observability + the dual-bot idea split into Stages 15–17.)
|
observability + the dual-bot idea split into Stages 15–18.)
|
||||||
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
|
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
|
||||||
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
|
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
|
||||||
backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
|
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):
|
- **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,
|
- **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 +
|
so it was cut to the **solver/dictionary split** (the dependency foundation) and the deploy +
|
||||||
observability + the dual-bot idea were written into the plan as new **Stages 15–17**. The deploy
|
observability + the dual-bot idea were written into the plan as new **Stages 15–18**. The deploy
|
||||||
decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
|
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
|
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
|
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
|
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
|
||||||
already span the touched modules).
|
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 2–3, from live testing): the Grafana
|
||||||
|
double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is
|
||||||
|
disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the
|
||||||
|
move-duration panel was invisible because the deploy reseed (`rm -rf`) left the
|
||||||
|
config-only services on a stale bind mount — the deploy now **force-recreates**
|
||||||
|
caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 →
|
||||||
|
300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram
|
||||||
|
reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide
|
||||||
|
+ Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted —
|
||||||
|
chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native
|
||||||
|
**BackButton**, **HapticFeedback**, **closing confirmation** in a game,
|
||||||
|
**disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat
|
||||||
|
pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
|
||||||
|
button disables at zero hints; plus **board-only vertical scroll** (#9) and a
|
||||||
|
**keyboard-overlay** check-word dialog (#10).
|
||||||
|
- **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised
|
||||||
|
[1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel
|
||||||
|
toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a
|
||||||
|
single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap**
|
||||||
|
or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over
|
||||||
|
a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to
|
||||||
|
their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings
|
||||||
|
toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming
|
||||||
|
~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native
|
||||||
|
scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
|
||||||
|
**robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
|
||||||
|
across the game/robot package boundary, to be picked up when that seam is added.
|
||||||
|
- **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn**
|
||||||
|
now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit
|
||||||
|
the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op
|
||||||
|
(only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the
|
||||||
|
matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later
|
||||||
|
robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win —
|
||||||
|
`result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend
|
||||||
|
request to a robot** is accepted as pending and expires like a human ignore (robots no longer set
|
||||||
|
`BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the
|
||||||
|
chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish:
|
||||||
|
**even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no
|
||||||
|
lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch
|
||||||
|
zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop
|
||||||
|
capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known-
|
||||||
|
illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit
|
||||||
|
→ Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game
|
||||||
|
card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's
|
||||||
|
turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would
|
||||||
|
show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
|
||||||
|
- **Contour-verification follow-ups** (round 6, from live testing) — **shipped & deployed:** profile drops
|
||||||
|
the hint-balance line; no mobile tap-flash on a board cell (`-webkit-tap-highlight-color`); variant
|
||||||
|
display names keyed by the game's **alphabet**, not the UI language (english → "Scrabble",
|
||||||
|
russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game
|
||||||
|
title shows the variant name; **chat & nudge are mutually exclusive by turn** (message field on your
|
||||||
|
turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced
|
||||||
|
server-side to your own turn (`ErrChatNotYourTurn`); the **nudge cooldown resets** once the player has
|
||||||
|
moved or chatted since the last nudge (`game.LastMoveAt` + last chat vs last nudge; the UI mirrors it);
|
||||||
|
the **About** screen got localized titles + a rules link + the random/friends sections, and the app
|
||||||
|
**version comes from `git describe`** (Vite define `__APP_VERSION__` ← Docker build-arg in the deploy
|
||||||
|
step, default "dev"); the **quick-game buttons** became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a
|
||||||
|
bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences
|
||||||
|
from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when
|
||||||
|
available; redraw the USSR emblem as a thin schematic hammer & sickle); **#3 drag-reorder of rack tiles**
|
||||||
|
with a visual gap (the dragged tile lifts out, the rest slide to open a slot; `reorderIndices`
|
||||||
|
unit-tested; only with no pending tiles); and the **persistence backend foundation** (#4/#5/#6): a
|
||||||
|
`game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every
|
||||||
|
committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
|
||||||
|
overlapped — 5 integration tests.
|
||||||
|
- **Stage 17 round 6 — final pass (#4/#5/#6 + #16–20), shipped:**
|
||||||
|
1. **Draft persistence — gateway slice + UI (#4/#5/#6, PR #20).** FB `DraftRequest{game_id, json}`
|
||||||
|
(save) + `DraftView{json}` (get reuses `GameActionRequest`); the client serializes
|
||||||
|
`{rack_order, board_tiles}` itself (no FB tile array), the gateway forwards it as `json.RawMessage`
|
||||||
|
both ways (no double-encode), and `GET`/`PUT /games/:id/draft` (a server `draftDTO` ↔ `game.Draft`)
|
||||||
|
is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6)
|
||||||
|
and restore on load (`lib/draft.ts`, reconciling against the committed board); **#5** — tiles may be
|
||||||
|
arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only,
|
||||||
|
so an off-turn draft is position-only). Off-turn tiles keep the **existing pending highlight** — no
|
||||||
|
caption, no new style (owner's call). The backend draft endpoint is sub-ms.
|
||||||
|
2. **Landing + `/app/` move (#16–20, this PR).** One Vite build with **two HTML entries** — the game
|
||||||
|
SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.svelte`, reusing the
|
||||||
|
theme/i18n/`aboutContent` leaf modules, not the app store, so it stays small). The gateway serves the
|
||||||
|
**landing at `/`** and the **game SPA at `/app/` and `/telegram/`** (`webui.Handler(stripPrefix,
|
||||||
|
indexName)`); relative base keeps one build serving every mount with a shared `dist/assets/` (the
|
||||||
|
planned per-target `base` conditional proved unnecessary). **Correction to the original note:** the
|
||||||
|
Telegram **Mini App stays at `/telegram/`** — only the plain web app moved off `/` to `/app/`, so
|
||||||
|
BotFather is untouched. The landing's "Play in Telegram" link is **per-language** via two new build
|
||||||
|
vars `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` (test/prod bots differ → no hardcoding; the
|
||||||
|
button hides when unset). Logo copied `.claude/telegram-logo.svg` → `ui/public/` (source stays
|
||||||
|
untracked).
|
||||||
|
- **Edge robustness (folded into the landing PR).** (a) **Static cache headers** — the embedded
|
||||||
|
`http.FileServer` over `go:embed` has a zero modtime, so it emitted no validators → the client
|
||||||
|
re-downloaded the whole bundle every launch; now hash-named `/assets/*` are `immutable` (a relaunch
|
||||||
|
is a cache hit) and the HTML shells are `no-cache`. (b) **Live-stream 15 s abort** — the `Subscribe`
|
||||||
|
heartbeat only fired after the first 15 s tick, so the stream sat silent and raced a ~15 s edge idle
|
||||||
|
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
|
||||||
|
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
|
||||||
|
the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
|
||||||
|
- **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram
|
||||||
|
link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries
|
||||||
|
a channel **username**, the landing builds `https://t.me/<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)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
- ~~**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
|
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
|
discharged in Stage 14: the dict repo builds against the published solver + pinned
|
||||||
`dafsa`/`alphabet`, byte-identical to the fixtures.
|
`dafsa`/`alphabet`, byte-identical to the fixtures.
|
||||||
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
|
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9, extended later:*
|
||||||
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
|
the deep-link scheme exists (`f<code>`, shared Go ↔ TS), the bot redeems it on launch, and the UI
|
||||||
launch, and the UI shows a **share-to-Telegram** link for an issued code when
|
offers a real **Share** control for an issued code — Telegram's native share-to-chat picker inside
|
||||||
`VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a
|
the Mini App (`openTelegramLink` + `t.me/share/url`), the system share sheet (`navigator.share`) on
|
||||||
friend can add you by scanning rather than tapping/typing. The code semantics
|
the web, else copy-the-link. The shared link points at the **same bot the player signed in through**:
|
||||||
(12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes.
|
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).**
|
- **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
|
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
|
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
|
`account_stats` or a games query), falling back to their interface language
|
||||||
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
|
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
|
||||||
wrong.
|
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
@@ -0,0 +1,632 @@
|
|||||||
|
# Pre-release plan — hardening before Stage 18
|
||||||
|
|
||||||
|
Living tracker for the pre-release hardening pass that runs **before Stage 18** (the
|
||||||
|
prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session,
|
||||||
|
**interview the owner on the open details** at the start of each phase, bake every
|
||||||
|
decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in
|
||||||
|
the **same** PR, get CI green, then mark the phase done. Phases run as
|
||||||
|
`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges.
|
||||||
|
|
||||||
|
**Why now:** the system is feature-complete through Stage 17 and the test contour is
|
||||||
|
green, but there is **no prod data yet** — schema, wire labels and the dictionary
|
||||||
|
layout can still change for free. These phases spend that one-time freedom and harden
|
||||||
|
the edge before prod. Each phase maps back to the owner's raw pre-release TODO list
|
||||||
|
(numbers in the tracker).
|
||||||
|
|
||||||
|
## Phase tracker
|
||||||
|
|
||||||
|
| # | Phase | Raw TODOs | Status |
|
||||||
|
|---|-------|-----------|--------|
|
||||||
|
| R1 | Schema & naming reset | 1 + 10 | **done** |
|
||||||
|
| R2 | Stress harness + contour observability + early run | 9a | **done** |
|
||||||
|
| R3 | Edge hardening | 2 + 8 + 3 | **done** |
|
||||||
|
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
|
||||||
|
| R5 | Bundle slimming | 6 | **done** |
|
||||||
|
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
|
||||||
|
| R7 | Final stress run + tuning | 9b | **done** |
|
||||||
|
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
|
||||||
|
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
|
||||||
|
| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** |
|
||||||
|
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
|
||||||
|
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** |
|
||||||
|
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
|
||||||
|
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
|
||||||
|
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
||||||
|
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
|
||||||
|
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
|
||||||
|
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
|
||||||
|
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
|
||||||
|
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
|
||||||
|
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
|
||||||
|
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
|
||||||
|
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs, an SSH deploy of both hosts together — is **built in Stage 18** (the two-host registry rollout; first cutover pending the `erudit-game.ru` DNS). | owner ad-hoc | **done** (code + test contour; prod wiring built — Stage 18) |
|
||||||
|
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban on in prod via Stage 18 — machinery built, cutover pending DNS) |
|
||||||
|
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
|
||||||
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
|
|
||||||
|
- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is
|
||||||
|
safe precisely because there is no prod data and the contour DB is wiped. Folding the
|
||||||
|
new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline
|
||||||
|
makes the rename need **no data migration and no back-compat mapping**. Today's labels
|
||||||
|
(`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`,
|
||||||
|
`game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep
|
||||||
|
on a clean DB.
|
||||||
|
- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on
|
||||||
|
`your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only**
|
||||||
|
genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s,
|
||||||
|
`ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching
|
||||||
|
push events to carry payloads so the UI stops re-fetching after each signal.
|
||||||
|
- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from
|
||||||
|
the session (`Authorization: Bearer` → `X-User-ID`); the client cannot inject identity,
|
||||||
|
the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real
|
||||||
|
gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit
|
||||||
|
rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not**
|
||||||
|
covered by the gateway token bucket (it only guards `Execute`).
|
||||||
|
- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name
|
||||||
|
`backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe
|
||||||
|
current state; `PLAN.md` keeps the decision history.
|
||||||
|
|
||||||
|
## Locked decisions (owner interview)
|
||||||
|
|
||||||
|
- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB
|
||||||
|
through the gateway, moves generated by the solver) **plus a separate gateway-hammer**
|
||||||
|
saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**.
|
||||||
|
Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only
|
||||||
|
Go-runtime metrics exist). The harness stays in the repo for repeats.
|
||||||
|
- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep
|
||||||
|
poll as the ws-down fallback) **and** enrich push events with payloads.
|
||||||
|
- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list** —
|
||||||
|
behaviour-preserving, test-gated, contentious items surfaced to the owner before applying.
|
||||||
|
- **Landing (TODO 3):** **separate static container** behind the project caddy
|
||||||
|
(`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway
|
||||||
|
`go:embed`.
|
||||||
|
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** —
|
||||||
|
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
|
||||||
|
**no auto-ban**.
|
||||||
|
- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS*
|
||||||
|
defence — it detects/deceives but does not shed volumetric load, cannot cover the real
|
||||||
|
endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern,
|
||||||
|
out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot
|
||||||
|
and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision
|
||||||
|
governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed,
|
||||||
|
**prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the
|
||||||
|
existing `ratelimit` package (smallest surface); the decoy path list is a **single source of
|
||||||
|
truth in the caddy** (it tags requests with a header — the gateway keeps no second list);
|
||||||
|
bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin
|
||||||
|
console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice).
|
||||||
|
An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total`
|
||||||
|
counter cover it) to keep the diff focused.
|
||||||
|
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
|
||||||
|
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
|
||||||
|
joins it, or a robot fills it after a **90 s + random 0–90 s** wait, pushing the in-app
|
||||||
|
**opponent_joined** event. While open, the starter may move on their turn but resign, chat and
|
||||||
|
nudge are disabled, and the lobby + opponent card read "searching for opponent". Matchmaking is
|
||||||
|
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
|
||||||
|
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
|
||||||
|
empty seat.
|
||||||
|
- **Telegram egress off-host (TX, owner ad-hoc):** the driver is **removing VPN/Telegram
|
||||||
|
traffic from the main host** (OPSEC / one fewer analysis vector), not only notification
|
||||||
|
resilience. Login is local HMAC, so it stays up regardless of the bot — confirmed in the
|
||||||
|
code and made structural by the split. **Unified topology in code** (validator + bot, the
|
||||||
|
bot dialing the gateway) in **both** contours, differing only in deployment; the test bot
|
||||||
|
keeps its VPN sidecar. Transport = a **reverse gRPC bidi stream, mTLS, bot-dials-gateway**
|
||||||
|
(no inbound/static IP on the bot), reusing the push-stream pattern; **webhook rejected**
|
||||||
|
(one URL per token, adds inbound + a static address). Delivery **at-most-once** (a dropped
|
||||||
|
nudge beats a duplicate). **One bot now**, seams (registry + `owns_updates` + command ids)
|
||||||
|
for N later. **Cert rotation** by a scheduled CI job from a long-lived CA. **Prod deploy by
|
||||||
|
SSH** (pull excluded), the bot rolled **together** with the main app (the bot-link protocol
|
||||||
|
kept back-compatible by one version as the non-atomic-two-host-deploy safety net). The bot
|
||||||
|
is monitored **from the gateway** (connection + ack metrics). The bot-host token-at-rest is
|
||||||
|
**accepted**.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open
|
||||||
|
details below**, implement within scope, then update the tracker + docs/code and get CI
|
||||||
|
green before marking it done.
|
||||||
|
|
||||||
|
### R1 — Schema & naming reset *(TODO 1 + 10)* — first
|
||||||
|
Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql`
|
||||||
|
(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline →
|
||||||
|
prove a fresh migrate yields a schema identical to the 12-migration chain via the
|
||||||
|
integration suite → delete the old files; keep goose). Bake the new variant labels into the
|
||||||
|
baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend
|
||||||
|
(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire
|
||||||
|
(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`,
|
||||||
|
fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy
|
||||||
|
`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact
|
||||||
|
names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them
|
||||||
|
byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions
|
||||||
|
on the next deploy.
|
||||||
|
- Critical files: `backend/internal/postgres/migrations/`,
|
||||||
|
`backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`,
|
||||||
|
`ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`.
|
||||||
|
- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is
|
||||||
|
one PR or split; how to script the contour DB wipe in the deploy.
|
||||||
|
|
||||||
|
### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)*
|
||||||
|
Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`,
|
||||||
|
`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts
|
||||||
|
**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in
|
||||||
|
the DB and hands the plaintext tokens to the client; a driver that runs N virtual users,
|
||||||
|
each in 3–5 concurrent 2–4-player games, exercising submit-play / pass / exchange / nudge /
|
||||||
|
chat / check-word / draft-move / profile-save through the **edge protocol**, in
|
||||||
|
**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate
|
||||||
|
**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure
|
||||||
|
its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana
|
||||||
|
resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a
|
||||||
|
**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6.
|
||||||
|
- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`,
|
||||||
|
`docs/TESTING.md`.
|
||||||
|
- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move
|
||||||
|
for realistic game progress); run duration; the pass/fail bar.
|
||||||
|
|
||||||
|
### R3 — Edge hardening *(TODO 2 + 8 + 3)*
|
||||||
|
Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add
|
||||||
|
**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured
|
||||||
|
log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes
|
||||||
|
visible without per-user label cardinality, honouring the Stage 12/17 discipline); an
|
||||||
|
**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single-
|
||||||
|
instance, reset-on-restart, like the `active_users` gauge). Add the **conservative
|
||||||
|
auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft,
|
||||||
|
reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the
|
||||||
|
admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into
|
||||||
|
its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop
|
||||||
|
`landing.html` from the gateway `go:embed`.
|
||||||
|
- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`,
|
||||||
|
`gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`,
|
||||||
|
`deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`.
|
||||||
|
- Open details: the auto-flag threshold/window + whether the marker is persisted vs
|
||||||
|
in-memory; the landing image base (caddy vs nginx).
|
||||||
|
|
||||||
|
### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)*
|
||||||
|
Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down
|
||||||
|
fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI
|
||||||
|
renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency
|
||||||
|
the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit →
|
||||||
|
UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing
|
||||||
|
spot; regenerate FB.
|
||||||
|
- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`,
|
||||||
|
`ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`.
|
||||||
|
- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the
|
||||||
|
stream is down.
|
||||||
|
|
||||||
|
### R5 — Bundle slimming *(TODO 6)* — done
|
||||||
|
Analysed the bundle against the 100 KB-gzip budget; **no code slimming was warranted**, and the
|
||||||
|
budget metric was retargeted to measure the app correctly. The build already minifies +
|
||||||
|
tree-shakes; the dominant cost is the Connect/FlatBuffers transport runtime + generated bindings
|
||||||
|
+ the Svelte runtime (≈⅔ of `main`'s source is third-party/generated) — irreducible within scope.
|
||||||
|
**Lazy-loading was rejected**: `bundle-size.mjs` sums every emitted chunk, so code-splitting yields
|
||||||
|
no total-size win and adds request latency (+N gateway fetches on first navigation to a split
|
||||||
|
screen). i18n lazy-load was skipped (the catalogs are a sliver of a Svelte-runtime-dominated shared
|
||||||
|
chunk, and `en` must stay bundled as the `MessageKey` type source + fallback). Instead,
|
||||||
|
`bundle-size.mjs` now measures **per HTML entry**, with three independent gates on the natural chunk
|
||||||
|
boundaries — **app entry ≤ 100 KB, the Svelte+i18n shared chunk ≤ 30 KB, the landing's own chunk
|
||||||
|
≤ 5 KB** — since the app's real payload is its entry chunk plus the shared chunk (≈97 KB), while the
|
||||||
|
landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code contract, so the CI
|
||||||
|
step is unchanged.
|
||||||
|
- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed.
|
||||||
|
|
||||||
|
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — done
|
||||||
|
Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
|
||||||
|
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
|
||||||
|
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile
|
||||||
|
`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift
|
||||||
|
and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of
|
||||||
|
proposed optimizations / test-suite consolidations to the owner, apply only the approved,
|
||||||
|
behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the
|
||||||
|
regression gate. Incorporates the early-run (R2) bug fixes not already shipped.
|
||||||
|
- Open details: the structural-changes list itself (owner-approved before applying); the test
|
||||||
|
consolidation targets.
|
||||||
|
|
||||||
|
### R7 — Final stress run + tuning *(TODO 9, part 2)* — done
|
||||||
|
Re-run the R2 harness against the final, refactored system on a clean contour; analyse
|
||||||
|
resource consumption across **all** components (gateway, backend, Postgres, the
|
||||||
|
metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate
|
||||||
|
limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record
|
||||||
|
the methodology + results in the repo.
|
||||||
|
|
||||||
|
→ **Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md).
|
||||||
|
|
||||||
|
## Sequencing rationale
|
||||||
|
|
||||||
|
`R1` first (cheapest now; everything builds on the final schema/naming and the stress test
|
||||||
|
must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and
|
||||||
|
a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system.
|
||||||
|
`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and
|
||||||
|
benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it.
|
||||||
|
Then Stage 18.
|
||||||
|
|
||||||
|
## Regression-safety discipline (cross-cutting)
|
||||||
|
|
||||||
|
- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the
|
||||||
|
`CI / gate` check) must be green before the owner merges; watch the post-merge contour
|
||||||
|
deploy with `gitea-ci-watch.py`.
|
||||||
|
- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical
|
||||||
|
sweeps; contentious items are owner-approved first.
|
||||||
|
- The two stress runs (`R2` early, `R7` final) are the system-level regression gate.
|
||||||
|
|
||||||
|
## Verification (per phase)
|
||||||
|
|
||||||
|
- `go build ./<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 1–2 (harmless soft cap; the UI disables the button regardless). Strict atomicity was judged
|
||||||
|
a disproportionate diff across the two create paths.
|
||||||
|
- **No schema change → no contour DB wipe** (only a trailing FB field, no migration). Tests: backend integration
|
||||||
|
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
|
||||||
|
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
|
||||||
|
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
|
||||||
|
|
||||||
|
- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list):
|
||||||
|
- **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no
|
||||||
|
bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the
|
||||||
|
bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a
|
||||||
|
temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new
|
||||||
|
**`chat_muted` role** is a chat-only mute with the **game suspension dominating**
|
||||||
|
(`eligible = registered AND NOT suspended AND NOT chat_muted`).
|
||||||
|
- **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the
|
||||||
|
main bot's `t.me/<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.
|
||||||
@@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
|||||||
|
|
||||||
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
||||||
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
|
(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
|
- **`backend`** — internal-only service that owns every domain concern and
|
||||||
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
||||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
||||||
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
||||||
Capacitor. See [`ui/README.md`](ui/README.md).
|
Capacitor. See [`ui/README.md`](ui/README.md).
|
||||||
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
||||||
*(added in a later stage)*
|
|
||||||
|
|
||||||
## Documentation (sources of truth)
|
## 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
|
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
|
||||||
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
||||||
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
|
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.
|
||||||
|
|||||||
@@ -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
@@ -1,93 +1,141 @@
|
|||||||
# backend
|
# backend
|
||||||
|
|
||||||
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
||||||
It owns identity/sessions, accounts, and — in later stages — the lobby, game
|
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
|
||||||
runtime, robot, chat, history and administration. Its only network consumers are
|
and administration. Its only network consumers are the `gateway` and the platform
|
||||||
the `gateway` and the platform side-services; it is never exposed publicly.
|
side-services; it is never exposed publicly.
|
||||||
|
|
||||||
As of Stage 1 the backend provides the foundation: configuration, the HTTP
|
The backend provides the foundation: configuration, the HTTP listener with the
|
||||||
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
|
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
|
||||||
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
|
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
|
||||||
and the durable accounts / identities / sessions data model. The session and
|
accounts / identities / sessions data model. The session and account REST
|
||||||
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
|
endpoints live in the `gateway`; the backend ships the store/service layer they
|
||||||
store/service layer they will call.
|
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
|
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
||||||
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
||||||
detection) that emits dictionary-independent move records. It is a library only;
|
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
|
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
|
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
|
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
|
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
|
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
|
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||||||
Stages 1–2 it is a service/store layer; the HTTP surface lands with the
|
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
|
||||||
`gateway` (Stage 6).
|
|
||||||
|
|
||||||
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
|
The lobby and social fabric. `internal/lobby` runs **auto-match** — `Enqueue` opens a
|
||||||
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
|
real game seating the caller with an **empty opponent seat** (status `open`) or, when
|
||||||
|
another player already waits for the same variant and per-turn word rule, seats the
|
||||||
|
caller into that open game and starts it — and
|
||||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
|
||||||
|
player's active quick games — status `active`/`open`, excluding invitation-linked friend
|
||||||
|
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
|
||||||
|
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
|
||||||
|
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
|
||||||
|
`internal/social` owns the friend graph (request/accept),
|
||||||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
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,
|
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
|
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
|
development log mailer). The engine now also handles **multi-player drop-out**: in
|
||||||
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
||||||
(the tile disposition is a per-game setting), the game ending when one active seat
|
(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
|
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`
|
but their live delivery, and all REST endpoints, live in the `gateway`; the
|
||||||
(Stage 6); the services are exposed via `Server` accessors for those handlers.
|
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
|
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
|
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
|
`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
|
win (≈ 40%), targets a small score margin — with an occasional off-strategy move that tapers to
|
||||||
delay, a night-sleep window anchored to the opponent's timezone, and nudge
|
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
|
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||||
state. The matchmaker now substitutes a pooled robot after a 10-second wait and
|
state. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound
|
||||||
exposes `Poll` so a waiting player can collect the started game (the live
|
to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think
|
||||||
match-found notification arrives with the `gateway`).
|
time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal
|
||||||
|
delay, so a decided game is not dragged out while the robot never moves slower than usual. A background **reaper** seats a pooled robot (matching the game's language) in any open
|
||||||
|
game whose wait window — a fixed **90 s** plus a random **0–90 s** (so **90–180 s**) — has
|
||||||
|
elapsed, and the waiting starter is told an opponent took the seat by an in-app
|
||||||
|
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
|
||||||
|
re-enables resign and chat in place.
|
||||||
|
|
||||||
Stage 6 opens the backend to the edge. The route groups gain their first
|
The same robot also backs an **honest-AI quick game** (`games.vs_ai`), the alternative to the random
|
||||||
|
path that the player chooses on New Game. `Matchmaker.StartVsAI` picks a pooled robot and creates a
|
||||||
|
game **already seated and active** (random seat order) — it never enters the open pool. The robot
|
||||||
|
driver has a `vs_ai` branch (no sleep, no proactive nudge, zero delay) plus a focused
|
||||||
|
`DriveGame`/`TriggerMove` fast path wired from the game service's after-create/after-commit hook
|
||||||
|
(`SetAITrigger`), so the robot replies the instant the player moves. AI games keep the same strength
|
||||||
|
(`playToWin`), have **no per-move timeout** (`turn_timeout_secs = AIInactivityTimeout`, 7 days, so an
|
||||||
|
abandoned game is lost after a week of inactivity — only the human is ever on the clock), record **no
|
||||||
|
statistics** (skipped in `commit`), and disable chat/nudge (`social` `VsAI` → `ErrGameVsAI`).
|
||||||
|
|
||||||
|
The backend opens to the edge. The route groups gain their first
|
||||||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||||||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||||||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||||||
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
|
state, lobby enqueue, chat). The social/account/history operations under
|
||||||
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
(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
|
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
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
|
gateway. 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
|
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login
|
||||||
seed a new account's language and display name from the launch fields, and adds
|
seeds a new account's language and display name from the launch fields, and the
|
||||||
migration `00007` (`accounts.notifications_in_app_only`, default true).
|
`accounts.notifications_in_app_only` flag (default true).
|
||||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the
|
||||||
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
|
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`;
|
**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
|
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`/
|
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||||
`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change
|
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz` release
|
||||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` +
|
||||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the
|
||||||
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
|
`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a
|
||||||
migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram
|
backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's
|
||||||
user last signed in through, written on every login and returned by
|
**bot-link relay** — each broadcast renders through the bot in an operator-chosen language
|
||||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
and the relay awaits the bot's delivery ack. There is one bot,
|
||||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
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
|
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
|
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
|
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.
|
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
|
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.
|
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
|
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||||
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
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
|
## Package layout
|
||||||
|
|
||||||
@@ -110,17 +173,21 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
|||||||
migrations/ # embedded *.sql (goose), schema `backend`
|
migrations/ # embedded *.sql (goose), schema `backend`
|
||||||
jet/ # generated go-jet models + table builders (committed)
|
jet/ # generated go-jet models + table builders (committed)
|
||||||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
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/accountmerge/ # single-transaction merge of a secondary account into a primary
|
||||||
internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11)
|
internal/link/ # link/merge orchestrator over account + accountmerge + session
|
||||||
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
||||||
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||||||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||||||
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
|
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/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/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/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)
|
## 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_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_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
||||||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
| `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_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_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. |
|
| `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_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
| `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_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_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
|
## Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
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):
|
# 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_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||||
BACKEND_DICT_DIR=/tmp/dawg \
|
BACKEND_DICT_DIR=/tmp/dawg \
|
||||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||||
@@ -176,7 +245,10 @@ warmed.
|
|||||||
## Migrations & generated code
|
## Migrations & generated code
|
||||||
|
|
||||||
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
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):
|
regenerate the committed go-jet code (needs Docker):
|
||||||
|
|
||||||
```sh
|
```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**
|
(`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)
|
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
|
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
|
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||||
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14
|
(a missing dictionary aborts the boot). The flat directory is the seed version,
|
||||||
(TODO-1/TODO-2).
|
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
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -15,19 +15,24 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
"scrabble/backend/internal/accountmerge"
|
||||||
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/banview"
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
"scrabble/backend/internal/connector"
|
"scrabble/backend/internal/connector"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/feedback"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
"scrabble/backend/internal/session"
|
"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("dir", cfg.Game.DictDir),
|
||||||
zap.String("version", cfg.Game.DictVersion))
|
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)
|
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
||||||
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
||||||
var conn *connector.Client
|
var conn *connector.Client
|
||||||
@@ -132,14 +137,23 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
hub := notify.NewHub(0)
|
hub := notify.NewHub(0)
|
||||||
|
|
||||||
accounts := account.NewStore(db)
|
accounts := account.NewStore(db)
|
||||||
|
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
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.SetNotifier(hub)
|
||||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||||
logger.Info("game turn-timeout sweeper started",
|
logger.Info("game turn-timeout sweeper started",
|
||||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
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.
|
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
||||||
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
||||||
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
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("interval", cfg.GuestReapInterval),
|
||||||
zap.Duration("retention", cfg.GuestRetention))
|
zap.Duration("retention", cfg.GuestRetention))
|
||||||
|
|
||||||
// Stage 4 lobby & social domains. Their REST and stream surface is added with
|
// Re-evaluate moderated-chat write access when a temporary block self-expires:
|
||||||
// the gateway in Stage 6, so they are handed to the server (like the route
|
// no operator action fires then, so the sweeper emits the chat-access-changed
|
||||||
// groups) for the handlers to come.
|
// 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)
|
mailer := newMailer(cfg.SMTP, logger)
|
||||||
emails := account.NewEmailService(accounts, mailer)
|
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.
|
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||||
socialSvc.SetNotifier(hub)
|
socialSvc.SetNotifier(hub)
|
||||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
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
|
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||||
// substitutes a pooled robot for a missing human after the wait window.
|
// 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)
|
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
|
||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
return fmt.Errorf("provision robot pool: %w", err)
|
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)
|
go robots.Run(ctx, cfg.Robot.DriveInterval)
|
||||||
logger.Info("robot driver started", zap.Duration("interval", 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.SetNotifier(hub)
|
||||||
|
matchmaker.SetBlocker(socialSvc)
|
||||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||||
invitations.SetNotifier(hub)
|
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{
|
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
@@ -184,6 +238,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Accounts: accounts,
|
Accounts: accounts,
|
||||||
Games: games,
|
Games: games,
|
||||||
|
Feedback: feedbackSvc,
|
||||||
Social: socialSvc,
|
Social: socialSvc,
|
||||||
Matchmaker: matchmaker,
|
Matchmaker: matchmaker,
|
||||||
Invitations: invitations,
|
Invitations: invitations,
|
||||||
@@ -192,6 +247,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Registry: registry,
|
Registry: registry,
|
||||||
DictDir: cfg.Game.DictDir,
|
DictDir: cfg.Game.DictDir,
|
||||||
Connector: conn,
|
Connector: conn,
|
||||||
|
RateWatch: rateWatch,
|
||||||
|
BanView: banView,
|
||||||
|
Ads: adsSvc,
|
||||||
|
Notifier: hub,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ module scrabble/backend
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
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/XSAM/otelsql v0.42.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-jet/jet/v2 v2.14.1
|
github.com/go-jet/jet/v2 v2.14.1
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
@@ -25,8 +24,8 @@ import (
|
|||||||
|
|
||||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||||
// alongside platform identities; its confirmed flag is driven by the email
|
// 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
|
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||||
// robot opponent is a durable account bound to one robot identity (Stage 5).
|
// robot opponent is a durable account bound to one robot identity.
|
||||||
const (
|
const (
|
||||||
KindTelegram = "telegram"
|
KindTelegram = "telegram"
|
||||||
KindEmail = "email"
|
KindEmail = "email"
|
||||||
@@ -56,29 +55,34 @@ type Account struct {
|
|||||||
HintBalance int
|
HintBalance int
|
||||||
BlockChat bool
|
BlockChat bool
|
||||||
BlockFriendRequests bool
|
BlockFriendRequests bool
|
||||||
// ServiceLanguage is the language tag (en/ru) of the bot the account last
|
// VariantPreferences is the set of game variants (engine.Variant stable labels:
|
||||||
// authenticated through (its last Telegram ValidateInitData); it routes the
|
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
|
||||||
// account's out-of-app push back through the right bot. Empty when the account
|
// into. It gates the New Game picker, the matchmaker and the friend-invite the
|
||||||
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the
|
// player creates; an invited friend may still accept any variant. A new account
|
||||||
// interface language) and from a game's variant language.
|
// defaults to Erudit only. Never empty — enforced on update and by a DB check.
|
||||||
ServiceLanguage string
|
VariantPreferences []string
|
||||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||||
// excluded from statistics, friends and history.
|
// excluded from statistics, friends and history.
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||||
// true (the default): the platform side-service skips out-of-app push for the
|
// true (the default): the platform side-service skips out-of-app push for the
|
||||||
// account (Stage 9).
|
// account.
|
||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
// 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
|
// (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
|
PaidAccount bool
|
||||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
// 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
|
// 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
|
MergedInto uuid.UUID
|
||||||
CreatedAt time.Time
|
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||||
UpdatedAt time.Time
|
// 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
|
// 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.
|
// Store is the Postgres-backed query surface for accounts and identities.
|
||||||
type Store struct {
|
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 {
|
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
|
// 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{})
|
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||||
// identity. On first contact only, it seeds the new account's preferred language
|
// member: a KindRobot identity carrying displayName, with chat blocked but friend
|
||||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
// requests NOT blocked — a request to a robot is accepted as pending and, since the
|
||||||
// its display name from firstName (falling back to username); an already-existing
|
// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human
|
||||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
// who ignores the request. Robot names are system-generated, not player-edited, so they
|
||||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
// bypass the editable display-name validation and may carry forms the editor rejects (an
|
||||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
// 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,
|
// 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
|
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||||
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
|
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
||||||
// username (capped to maxDisplayName runes).
|
// 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 {
|
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||||
var seed provisionSeed
|
var seed provisionSeed
|
||||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
seed.preferredLanguage = lang
|
seed.preferredLanguage = lang
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(firstName)
|
name := sanitizeDisplayName(firstName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = strings.TrimSpace(username)
|
name = sanitizeDisplayName(username)
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
if name == "" {
|
||||||
name = string([]rune(name)[:maxDisplayName])
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
}
|
}
|
||||||
seed.displayName = name
|
seed.displayName = name
|
||||||
return seed
|
return seed
|
||||||
@@ -259,6 +315,14 @@ func (s *Store) CountAccounts(ctx context.Context) (int, error) {
|
|||||||
return int(dest.Count), nil
|
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,
|
// findByIdentity joins identities to accounts and returns the matching account,
|
||||||
// or ErrNotFound.
|
// or ErrNotFound.
|
||||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
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 {
|
if err != nil {
|
||||||
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
|
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
|
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 {
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
||||||
}
|
}
|
||||||
|
s.metrics.recordCreated(ctx, kindGuest)
|
||||||
return modelToAccount(row), nil
|
return modelToAccount(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,22 +450,63 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
|||||||
return n > 0, nil
|
return n > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
|
||||||
// user authenticated through. It is called on every Telegram login — new and
|
// positive: the additive update can only raise the balance, never lower it, so it enforces the
|
||||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
|
||||||
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
|
// It returns ErrNotFound when no account matches.
|
||||||
// (a non-Telegram login carries none) and does not bump updated_at (an infra
|
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
|
||||||
// routing field, not a user profile edit).
|
if n <= 0 {
|
||||||
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
|
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
|
||||||
if language == "" {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
stmt := table.Accounts.
|
stmt := table.Accounts.
|
||||||
UPDATE(table.Accounts.ServiceLanguage).
|
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||||
SET(postgres.String(language)).
|
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)))
|
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -406,15 +517,15 @@ func modelToAccount(row model.Accounts) Account {
|
|||||||
if row.MergedInto != nil {
|
if row.MergedInto != nil {
|
||||||
mergedInto = *row.MergedInto
|
mergedInto = *row.MergedInto
|
||||||
}
|
}
|
||||||
var serviceLanguage string
|
var flaggedHighRateAt time.Time
|
||||||
if row.ServiceLanguage != nil {
|
if row.FlaggedHighRateAt != nil {
|
||||||
serviceLanguage = *row.ServiceLanguage
|
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||||
}
|
}
|
||||||
return Account{
|
return Account{
|
||||||
ID: row.AccountID,
|
ID: row.AccountID,
|
||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
PreferredLanguage: row.PreferredLanguage,
|
PreferredLanguage: row.PreferredLanguage,
|
||||||
ServiceLanguage: serviceLanguage,
|
VariantPreferences: []string(row.VariantPreferences),
|
||||||
TimeZone: row.TimeZone,
|
TimeZone: row.TimeZone,
|
||||||
AwayStart: row.AwayStart,
|
AwayStart: row.AwayStart,
|
||||||
AwayEnd: row.AwayEnd,
|
AwayEnd: row.AwayEnd,
|
||||||
@@ -425,6 +536,7 @@ func modelToAccount(row model.Accounts) Account {
|
|||||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||||
PaidAccount: row.PaidAccount,
|
PaidAccount: row.PaidAccount,
|
||||||
MergedInto: mergedInto,
|
MergedInto: mergedInto,
|
||||||
|
FlaggedHighRateAt: flaggedHighRateAt,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ var (
|
|||||||
// ErrInvalidEmail is returned for an unparseable email address.
|
// ErrInvalidEmail is returned for an unparseable email address.
|
||||||
ErrInvalidEmail = errors.New("account: invalid email address")
|
ErrInvalidEmail = errors.New("account: invalid email address")
|
||||||
// ErrEmailTaken is returned when the email is already confirmed by another
|
// 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")
|
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
||||||
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
||||||
// requesting account.
|
// requesting account.
|
||||||
@@ -52,8 +52,8 @@ var (
|
|||||||
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
// 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),
|
// 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
|
// matching the session model. Binding an email already confirmed by a different
|
||||||
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
|
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||||
// using an email as a login is Stage 6, which reuses this mechanism.
|
// and using an email as a login reuses this mechanism.
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
store *Store
|
store *Store
|
||||||
mailer Mailer
|
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,
|
// 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
|
// 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
|
// 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
|
// 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.
|
// the login. It returns the target account id for the subsequent LoginWithCode.
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrIdentityTaken is returned when a platform identity being linked already
|
// 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")
|
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||||
|
|
||||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||||
// (taken or already-confirmed): possession of the address is the authorization for
|
// (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,
|
// 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 {
|
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
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
|
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
||||||
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
// (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) {
|
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
||||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||||
if errors.Is(err, ErrNotFound) {
|
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.
|
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
||||||
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
||||||
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
// 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 {
|
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
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
|
// 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.
|
// for an already-durable account.
|
||||||
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -4,14 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
@@ -21,14 +24,23 @@ import (
|
|||||||
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
||||||
const maxDisplayName = 32
|
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).
|
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
||||||
const maxAwayWindow = 12 * time.Hour
|
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
|
// 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,
|
// by a single space. No leading separator and no two adjacent separators (except
|
||||||
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
|
// "<dot|underscore> <space>"). The name may end with EITHER a single trailing "."
|
||||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
|
// (an initial, "Anna B.") OR a run of 1–5 digits (a handle's number or year,
|
||||||
|
// "Player2007"), but not both; digits never appear elsewhere. So "Name_P. Last",
|
||||||
|
// "Anna B." and "Аня2007" are valid, while "Name P._Last" and "Dark2Wolf" are not.
|
||||||
|
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*(?:\.|[0-9]{1,5})?$`)
|
||||||
|
|
||||||
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
||||||
// field (an unknown language, an invalid timezone, or an over-long display name).
|
// field (an unknown language, an invalid timezone, or an over-long display name).
|
||||||
@@ -47,6 +59,46 @@ type ProfileUpdate struct {
|
|||||||
BlockChat bool
|
BlockChat bool
|
||||||
BlockFriendRequests bool
|
BlockFriendRequests bool
|
||||||
NotificationsInAppOnly 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
|
// 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 {
|
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
|
||||||
return Account{}, err
|
return Account{}, err
|
||||||
}
|
}
|
||||||
|
prefs, err := validateVariantPreferences(p.VariantPreferences)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
|
||||||
stmt := table.Accounts.UPDATE(
|
stmt := table.Accounts.UPDATE(
|
||||||
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
||||||
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
||||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
||||||
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
|
table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences,
|
||||||
|
table.Accounts.UpdatedAt,
|
||||||
).SET(
|
).SET(
|
||||||
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
||||||
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
||||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
|
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))).
|
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||||
RETURNING(table.Accounts.AllColumns)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
@@ -107,9 +168,51 @@ func ValidateDisplayName(raw string) (string, error) {
|
|||||||
if !displayNameRe.MatchString(name) {
|
if !displayNameRe.MatchString(name) {
|
||||||
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
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
|
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
|
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
||||||
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
||||||
// "no away time" and is allowed.
|
// "no away time" and is allowed.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package account
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,11 +13,11 @@ import (
|
|||||||
|
|
||||||
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
||||||
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
// 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.
|
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
||||||
func TestUpdateProfileValidation(t *testing.T) {
|
func TestUpdateProfileValidation(t *testing.T) {
|
||||||
s := &Store{}
|
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) }
|
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) {
|
|||||||
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
|
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
|
||||||
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
|
{"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) }},
|
{"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 {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -8,21 +9,25 @@ import (
|
|||||||
|
|
||||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
// 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) {
|
func TestTelegramSeed(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
wantLang, wantName string
|
wantLang, wantName string
|
||||||
}{
|
}{
|
||||||
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
|
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
|
||||||
"en region-tagged": {"en-US", "user", "John", "en", "John"},
|
"en region-tagged": {"en-US", "user", "John", "en", "John"},
|
||||||
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
|
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
|
||||||
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
|
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
|
||||||
"empty language": {"", "neo", "Neo", "", "Neo"},
|
"empty language": {"", "neo", "Neo", "", "Neo"},
|
||||||
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
||||||
"username fallback": {"en", "handle", "", "en", "handle"},
|
"username fallback": {"en", "handle", "", "en", "handle"},
|
||||||
"both empty": {"en", "", "", "en", ""},
|
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
||||||
"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 {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
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
|
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
||||||
// maxDisplayName runes (counted in runes, not bytes).
|
// maxDisplayName runes (counted in runes, not bytes).
|
||||||
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -13,16 +14,43 @@ import (
|
|||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"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
|
// 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
|
// 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
|
// 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 {
|
type Stats struct {
|
||||||
Wins int
|
Wins int
|
||||||
Losses int
|
Losses int
|
||||||
Draws int
|
Draws int
|
||||||
MaxGamePoints int
|
MaxGamePoints int
|
||||||
MaxWordPoints 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
|
// 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)
|
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{
|
return Stats{
|
||||||
Wins: int(row.Wins),
|
Wins: int(row.Wins),
|
||||||
Losses: int(row.Losses),
|
Losses: int(row.Losses),
|
||||||
Draws: int(row.Draws),
|
Draws: int(row.Draws),
|
||||||
MaxGamePoints: int(row.MaxGamePoints),
|
MaxGamePoints: int(row.MaxGamePoints),
|
||||||
MaxWordPoints: int(row.MaxWordPoints),
|
MaxWordPoints: int(row.MaxWordPoints),
|
||||||
|
Moves: int(row.Moves),
|
||||||
|
HintsUsed: int(row.HintsUsed),
|
||||||
|
BestMoves: best,
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
// 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})$`)
|
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
||||||
|
|
||||||
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
||||||
|
|||||||
@@ -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, "?", "_")
|
||||||
|
}
|
||||||
@@ -12,19 +12,33 @@ func TestValidateDisplayName(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
ok bool
|
ok bool
|
||||||
}{
|
}{
|
||||||
"plain": {"Kaya", "Kaya", true},
|
"plain": {"Kaya", "Kaya", true},
|
||||||
"cyrillic": {"Кая", "Кая", true},
|
"cyrillic": {"Кая", "Кая", true},
|
||||||
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
|
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
|
||||||
"single dot": {"Mr.Smith", "Mr.Smith", true},
|
"single dot": {"Mr.Smith", "Mr.Smith", true},
|
||||||
"dot then space": {"Mr. Smith", "Mr. Smith", true},
|
"dot then space": {"Mr. Smith", "Mr. Smith", true},
|
||||||
"trim surrounding": {" Kaya ", "Kaya", true},
|
"trim surrounding": {" Kaya ", "Kaya", true},
|
||||||
"adjacent specials": {"Name P._Last", "", false},
|
"adjacent specials": {"Name P._Last", "", false},
|
||||||
"two spaces": {"Name Last", "", false},
|
"two spaces": {"Name Last", "", false},
|
||||||
"leading special": {"_Name", "", false},
|
"leading special": {"_Name", "", false},
|
||||||
"trailing special": {"Name.", "", false},
|
"trailing underscore": {"Name_", "", false},
|
||||||
"digit rejected": {"Name2", "", false},
|
"trailing dot ok": {"Anna B.", "Anna B.", true},
|
||||||
"blank": {" ", "", false},
|
"double trailing dot": {"Name..", "", false},
|
||||||
"too long": {strings.Repeat("a", 33), "", 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 {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Package accountmerge retires a secondary account into a primary one in a single
|
// 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,
|
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
// 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
|
// (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.
|
// one layer up (the link service), since the in-memory session cache lives there.
|
||||||
package accountmerge
|
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 {
|
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
|
||||||
// summed, max points kept) and deletes the secondary row.
|
// 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 {
|
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||||
var sec model.AccountStats
|
var sec model.AccountStats
|
||||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
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(
|
upd := table.AccountStats.UPDATE(
|
||||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
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(
|
).SET(
|
||||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
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),
|
postgres.TimestampzT(now),
|
||||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
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
|
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;
|
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||||
// all other profile fields stay the primary's.
|
// all other profile fields stay the primary's.
|
||||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
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); }
|
.subnav a.active { color: var(--ink); }
|
||||||
|
|
||||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
.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.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 label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||||
.form input, .form select, .form textarea {
|
.form input, .form select, .form textarea {
|
||||||
@@ -85,6 +86,22 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
.form textarea { min-height: 4rem; resize: vertical; }
|
.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 {
|
button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #06121f;
|
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 { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||||
.actions form { margin: 0; }
|
.actions form { margin: 0; }
|
||||||
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
.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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package adminconsole
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,14 +21,32 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
data any
|
data any
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
|
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", 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"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||||
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
{"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"},
|
{"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"},
|
{"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"},
|
||||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
{"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"},
|
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
{"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&page=1"`,
|
||||||
|
`href="/_gm/users?kind=robots&page=3"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("users pager: missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "kind%3drobots") {
|
||||||
|
t.Error("users pager: filter query was double-encoded (kind%3drobots)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("messages", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
view := MessagesView{FilterQuery: template.URL("game=abc&user=def"), Pager: NewPager(2, 50, 200)}
|
||||||
|
if err := r.Render(&buf, "messages", PageData{Title: "Messages", Data: view}); err != nil {
|
||||||
|
t.Fatalf("render messages: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`href="/_gm/messages.csv?game=abc&user=def"`,
|
||||||
|
`href="/_gm/messages?game=abc&user=def&page=1"`,
|
||||||
|
`href="/_gm/messages?game=abc&user=def&page=3"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("messages pager: missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "%3d") || strings.Contains(out, "%26") {
|
||||||
|
t.Error("messages pager: filter query was double-encoded (%3d / %26)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestRendererUnknownPage reports an error for a page that does not exist.
|
// TestRendererUnknownPage reports an error for a page that does not exist.
|
||||||
func TestRendererUnknownPage(t *testing.T) {
|
func TestRendererUnknownPage(t *testing.T) {
|
||||||
r := MustNewRenderer()
|
r := MustNewRenderer()
|
||||||
|
|||||||
@@ -16,8 +16,14 @@
|
|||||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
<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/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/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/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/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
|
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="content">
|
<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}}
|
{{if .ConnectorEnabled}}
|
||||||
<form class="form col" method="post" action="/_gm/broadcast">
|
<form class="form col" method="post" action="/_gm/broadcast">
|
||||||
<label>Message <textarea name="text" required></textarea></label>
|
<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>
|
<div><button type="submit">Post to channel</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
{{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">« 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"><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/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/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>
|
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
||||||
</div>
|
</div>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
<h1>Dictionary</h1>
|
<h1>Dictionary</h1>
|
||||||
{{with .Data}}
|
{{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>
|
<section class="panel"><h2>Resident versions</h2>
|
||||||
<table class="list">
|
<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>
|
<tbody>
|
||||||
{{range .Variants}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Hot-reload a version</h2>
|
<section class="panel"><h2>Update dictionaries</h2>
|
||||||
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.</p>
|
<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/reload">
|
<form class="form" method="post" action="/_gm/dictionary/upload" enctype="multipart/form-data">
|
||||||
<label>Version <input type="text" name="version" placeholder="v2" required></label>
|
<label>Release archive <input type="file" name="archive" accept=".gz,.tgz,application/gzip" required></label>
|
||||||
<div><button type="submit">Reload</button></div>
|
<div><button type="submit">Upload & preview</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Pending dictionary changes</h2>
|
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||||
@@ -30,12 +33,12 @@
|
|||||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||||
<label>Mark applied for variant
|
<label>Mark applied for variant
|
||||||
<select name="variant">
|
<select name="variant">
|
||||||
<option value="english">english</option>
|
<option value="scrabble_en">scrabble_en</option>
|
||||||
<option value="russian_scrabble">russian_scrabble</option>
|
<option value="scrabble_ru">scrabble_ru</option>
|
||||||
<option value="erudit">erudit</option>
|
<option value="erudit_ru">erudit_ru</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<div><button type="submit">Mark applied</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/feedback?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>Feedback</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/feedback">« 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" -}}
|
{{define "content" -}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<h1>Game {{.ID}}</h1>
|
<h1>Game {{.ID}}</h1>
|
||||||
<nav class="subnav"><a href="/_gm/games">« games</a></nav>
|
<nav class="subnav"><a href="/_gm/games">« games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
|
||||||
<section class="panel"><h2>Summary</h2>
|
<section class="panel"><h2>Summary</h2>
|
||||||
<ul class="kv">
|
<ul class="kv">
|
||||||
<li><b>Variant</b> {{.Variant}}</li>
|
<li><b>Variant</b> {{.Variant}}</li>
|
||||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</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>Players</b> {{.Players}}</li>
|
||||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||||
@@ -17,13 +18,96 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Seats</h2>
|
<section class="panel"><h2>Seats</h2>
|
||||||
<table class="list">
|
<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>
|
<tbody>
|
||||||
{{range .Seats}}
|
{{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 “A” 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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{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">◀ prev</button>
|
||||||
|
<span class="replay-pos" id="replay-pos"></span>
|
||||||
|
<button type="button" id="replay-next">next ▶</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?"★":(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+" · "+(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)?" · drew "+st.drawn.map(function(t){return t.l;}).join(""):"";
|
||||||
|
h+="<li class=\""+(s===step?"cur":"")+"\">"+esc(who)+" "+esc(desc)+drew+" · 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}}
|
{{- end}}
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<nav class="subnav">
|
<nav class="subnav">
|
||||||
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
|
<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=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>
|
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||||
</nav>
|
</nav>
|
||||||
<table class="list">
|
<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>
|
<tbody>
|
||||||
{{range .Items}}
|
{{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>
|
<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="5"><span class="note">no games</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<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}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/messages?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</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" -}}
|
{{define "content" -}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<h1>{{.DisplayName}}</h1>
|
<h1>{{.DisplayName}}</h1>
|
||||||
<nav class="subnav"><a href="/_gm/users">« users</a></nav>
|
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a> · <a href="/_gm/feedback?user={{.ID}}">feedback</a></nav>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<section class="panel"><h2>Account</h2>
|
<section class="panel"><h2>Account</h2>
|
||||||
<ul class="kv">
|
<ul class="kv">
|
||||||
@@ -13,8 +13,18 @@
|
|||||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
{{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>
|
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||||
</ul>
|
</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>
|
||||||
<section class="panel"><h2>Statistics</h2>
|
<section class="panel"><h2>Statistics</h2>
|
||||||
{{if .HasStats}}
|
{{if .HasStats}}
|
||||||
@@ -22,12 +32,66 @@
|
|||||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||||
<li><b>Draws</b> {{.Stats.Draws}}</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 game</b> {{.Stats.MaxGamePoints}}</li>
|
||||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}<p class="note">no statistics</p>{{end}}
|
{{else}}<p class="note">no statistics</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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="">— none —</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>
|
<section class="panel"><h2>Identities</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||||
@@ -38,12 +102,41 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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}}
|
{{if .TelegramID}}
|
||||||
<section class="panel"><h2>Send Telegram message</h2>
|
<section class="panel"><h2>Send Telegram message</h2>
|
||||||
{{if .ConnectorEnabled}}
|
{{if .ConnectorEnabled}}
|
||||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||||
<label>Message <textarea name="text" required></textarea></label>
|
<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>
|
<div><button type="submit">Send to user</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{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">
|
<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>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
<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>{{.Kind}}</td>
|
||||||
<td>{{.Language}}</td>
|
<td>{{.Language}}</td>
|
||||||
<td>{{.CreatedAt}}</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>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package adminconsole
|
package adminconsole
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
// The *View types are the display models the gin handlers fill and the templates
|
// 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
|
// render. Time values are pre-formatted to strings by the handlers so the
|
||||||
// templates stay logic-free.
|
// templates stay logic-free.
|
||||||
@@ -40,24 +42,100 @@ type DashboardView struct {
|
|||||||
Games int
|
Games int
|
||||||
ActiveGames int
|
ActiveGames int
|
||||||
OpenComplaints int
|
OpenComplaints int
|
||||||
|
OpenFeedback int
|
||||||
PendingChanges 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.
|
// UsersView is the paginated account list.
|
||||||
type UsersView struct {
|
type UsersView struct {
|
||||||
Items []UserRow
|
Items []UserRow
|
||||||
Pager Pager
|
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 {
|
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
|
DisplayName string
|
||||||
Kind string
|
Role string
|
||||||
Language string
|
|
||||||
Guest bool
|
|
||||||
CreatedAt string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserDetailView is one account with its stats, identities and recent games.
|
// UserDetailView is one account with its stats, identities and recent games.
|
||||||
@@ -70,9 +148,15 @@ type UserDetailView struct {
|
|||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
PaidAccount bool
|
PaidAccount bool
|
||||||
// MergedInto is the primary account id when this account has been retired by a
|
// MergedInto is the primary account id when this account has been retired by a
|
||||||
// merge (Stage 11), or empty for a live account.
|
// merge, or empty for a live account.
|
||||||
MergedInto string
|
MergedInto string
|
||||||
HintBalance int
|
// 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
|
CreatedAt string
|
||||||
HasStats bool
|
HasStats bool
|
||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
@@ -80,6 +164,51 @@ type UserDetailView struct {
|
|||||||
Games []GameRow
|
Games []GameRow
|
||||||
TelegramID string
|
TelegramID string
|
||||||
ConnectorEnabled bool
|
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.
|
// StatsRow is an account's lifetime statistics.
|
||||||
@@ -89,6 +218,8 @@ type StatsRow struct {
|
|||||||
Draws int
|
Draws int
|
||||||
MaxGamePoints int
|
MaxGamePoints int
|
||||||
MaxWordPoints int
|
MaxWordPoints int
|
||||||
|
Moves int
|
||||||
|
HintsUsed int
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityRow is one platform/email identity of an account.
|
// IdentityRow is one platform/email identity of an account.
|
||||||
@@ -106,6 +237,8 @@ type GameRow struct {
|
|||||||
Status string
|
Status string
|
||||||
Players int
|
Players int
|
||||||
UpdatedAt string
|
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.
|
// GamesView is the paginated games list, optionally filtered by status.
|
||||||
@@ -128,10 +261,39 @@ type GameDetailView struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt 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 {
|
type SeatRow struct {
|
||||||
Seat int
|
Seat int
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -139,6 +301,9 @@ type SeatRow struct {
|
|||||||
Score int
|
Score int
|
||||||
HintsUsed int
|
HintsUsed int
|
||||||
Winner bool
|
Winner bool
|
||||||
|
IsRobot bool
|
||||||
|
RobotIntent string
|
||||||
|
NextMove string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComplaintsView is the paginated complaint review queue.
|
// ComplaintsView is the paginated complaint review queue.
|
||||||
@@ -176,11 +341,13 @@ type ComplaintDetailView struct {
|
|||||||
Resolved bool
|
Resolved bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DictionaryView lists the resident versions per variant and the pending
|
// DictionaryView lists the resident versions per variant, the active version new
|
||||||
// wordlist changes from accepted complaints.
|
// games pin, and the pending wordlist changes from accepted complaints.
|
||||||
type DictionaryView struct {
|
type DictionaryView struct {
|
||||||
Variants []VariantVersions
|
// ActiveVersion is the dictionary version new games pin; the update form sets it.
|
||||||
Changes []DictChangeRow
|
ActiveVersion string
|
||||||
|
Variants []VariantVersions
|
||||||
|
Changes []DictChangeRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// DictChangeRow is one pending wordlist edit.
|
// DictChangeRow is one pending wordlist edit.
|
||||||
@@ -191,14 +358,201 @@ type DictChangeRow struct {
|
|||||||
ResolvedAt string
|
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.
|
// BroadcastView is the operator-broadcast form page.
|
||||||
type BroadcastView struct {
|
type BroadcastView struct {
|
||||||
ConnectorEnabled bool
|
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.
|
// MessageView is the result page shown after a POST action.
|
||||||
type MessageView struct {
|
type MessageView struct {
|
||||||
Heading string
|
Heading string
|
||||||
Body string
|
Body string
|
||||||
Back 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,9 @@ type Config struct {
|
|||||||
Lobby lobby.Config
|
Lobby lobby.Config
|
||||||
// Robot configures the robot opponent driver (scan cadence).
|
// Robot configures the robot opponent driver (scan cadence).
|
||||||
Robot robot.Config
|
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
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||||
// selects the development log mailer (the code is logged, not sent).
|
// selects the development log mailer (the code is logged, not sent).
|
||||||
SMTP account.SMTPConfig
|
SMTP account.SMTPConfig
|
||||||
@@ -96,6 +100,9 @@ func Load() (Config, error) {
|
|||||||
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
|
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
|
||||||
return Config{}, err
|
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 {
|
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,14 @@ func Load() (Config, error) {
|
|||||||
return Config{}, err
|
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)
|
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -131,6 +146,7 @@ func Load() (Config, error) {
|
|||||||
Game: gm,
|
Game: gm,
|
||||||
Lobby: lb,
|
Lobby: lb,
|
||||||
Robot: rb,
|
Robot: rb,
|
||||||
|
RateWatch: rw,
|
||||||
SMTP: smtp,
|
SMTP: smtp,
|
||||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||||
GuestReapInterval: guestReapInterval,
|
GuestReapInterval: guestReapInterval,
|
||||||
@@ -170,6 +186,9 @@ func (c Config) validate() error {
|
|||||||
if err := c.Robot.Validate(); err != nil {
|
if err := c.Robot.Validate(); err != nil {
|
||||||
return fmt.Errorf("config: %w", err)
|
return fmt.Errorf("config: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.RateWatch.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
if c.GuestReapInterval <= 0 {
|
if c.GuestReapInterval <= 0 {
|
||||||
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// Package connector is the backend's gRPC client for the Telegram platform
|
// Package connector is the backend's gRPC client for operator broadcasts: a direct
|
||||||
// connector side-service. The admin console uses it to send operator broadcasts:
|
// message to one user, or a post to the game channel. It calls the gateway's
|
||||||
// a direct message to one user, or a post to a game channel. Each broadcast
|
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS
|
||||||
// selects the delivering bot by language (an operator choice, since the connector
|
// link and reports back whether it was delivered). The relay lives on the trusted
|
||||||
// hosts one bot per service language). The connector lives on the trusted internal
|
// internal network, so the connection uses insecure (plaintext) transport
|
||||||
// network, so the connection uses insecure (plaintext) transport credentials
|
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
|
||||||
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
|
// narrowed to the two broadcast methods the admin surface needs.
|
||||||
// the two broadcast methods the admin surface needs.
|
|
||||||
package connector
|
package connector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -37,22 +36,21 @@ func New(addr string) (*Client, error) {
|
|||||||
func (c *Client) Close() error { return c.conn.Close() }
|
func (c *Client) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
// SendToUser sends an operator text message to one user, addressed by their
|
// SendToUser sends an operator text message to one user, addressed by their
|
||||||
// platform external_id, through the bot for the given language. delivered reports
|
// platform external_id, through the bot. delivered reports whether the connector
|
||||||
// whether the connector actually sent it (false when the user has not started that
|
// actually sent it (false when the user has not started the bot).
|
||||||
// bot).
|
func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
|
||||||
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})
|
||||||
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return resp.GetDelivered(), nil
|
return resp.GetDelivered(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendToGameChannel posts an operator text message to the game channel of the bot
|
// SendToGameChannel posts an operator text message to the bot's game channel.
|
||||||
// for the given language. delivered reports whether the connector sent it (false
|
// delivered reports whether the connector sent it (false when the bot has no
|
||||||
// when that bot has no channel configured).
|
// channel configured).
|
||||||
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
|
func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
|
||||||
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
|
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
|
// 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
|
// 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
|
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
|
||||||
// dictionary.
|
// dictionary.
|
||||||
type AlphabetEntry struct {
|
type AlphabetEntry struct {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
|
|
||||||
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
||||||
// contiguous indices, the concrete lower-case characters the solver emits and the standard
|
// 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) {
|
func TestAlphabetTableEnglish(t *testing.T) {
|
||||||
tab, err := AlphabetTable(VariantEnglish)
|
tab, err := AlphabetTable(VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(english): %v", err)
|
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
|
||||||
}
|
}
|
||||||
if len(tab) != 26 {
|
if len(tab) != 26 {
|
||||||
t.Fatalf("size = %d, want 26", len(tab))
|
t.Fatalf("size = %d, want 26", len(tab))
|
||||||
@@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) {
|
|||||||
func TestAlphabetTableRussianVariants(t *testing.T) {
|
func TestAlphabetTableRussianVariants(t *testing.T) {
|
||||||
ru, err := AlphabetTable(VariantRussianScrabble)
|
ru, err := AlphabetTable(VariantRussianScrabble)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(russian_scrabble): %v", err)
|
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
|
||||||
}
|
}
|
||||||
er, err := AlphabetTable(VariantErudit)
|
er, err := AlphabetTable(VariantErudit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(erudit): %v", err)
|
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
|
||||||
}
|
}
|
||||||
if len(ru) != 33 || len(er) != 33 {
|
if len(ru) != 33 || len(er) != 33 {
|
||||||
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
||||||
}
|
}
|
||||||
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
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 {
|
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 {
|
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 != "я" {
|
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
||||||
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -52,11 +52,25 @@ func fromScrabbleDir(d scrabble.Direction) Direction {
|
|||||||
|
|
||||||
// SubmitPlay validates and applies the current player's play described in decoded
|
// 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
|
// 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
|
// ruleset alphabet and delegates to Play, so it returns the same errors
|
||||||
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
||||||
// letter is outside the variant's alphabet.
|
// 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)
|
placements, err := g.placements(tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, err
|
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,
|
// EvaluatePlay scores and validates a tentative play without committing it,
|
||||||
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
// 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)
|
// It infers the play's orientation from the tiles and the board and applies the
|
||||||
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
|
// game's play options exactly as SubmitPlay does, so under the single-word rule
|
||||||
// are left untouched.
|
// perpendicular cross-words are ignored: the preview's legality and score then
|
||||||
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
// 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 {
|
if g.over {
|
||||||
return MoveRecord{}, ErrGameOver
|
return MoveRecord{}, ErrGameOver
|
||||||
}
|
}
|
||||||
@@ -89,7 +106,7 @@ func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, err
|
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 {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||||
}
|
}
|
||||||
@@ -152,6 +169,33 @@ func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
|
|||||||
return out, nil
|
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
|
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
|
||||||
// concrete letter) into the internal byte form, wrapping a bad letter as
|
// concrete letter) into the internal byte form, wrapping a bad letter as
|
||||||
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
|
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
rec, err := g.SubmitPlay(hint.Tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("submit play: %v", err)
|
t.Fatalf("submit play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
|||||||
boardBefore := g.BoardClone()
|
boardBefore := g.BoardClone()
|
||||||
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("evaluate play: %v", err)
|
t.Fatalf("evaluate play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
|||||||
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
||||||
g := newEnglishGame(t, 1)
|
g := newEnglishGame(t, 1)
|
||||||
letter := g.Hand(0)[0]
|
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) {
|
if !errors.Is(err, ErrIllegalPlay) {
|
||||||
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
||||||
}
|
}
|
||||||
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
|
|||||||
word string
|
word string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"english hit", VariantEnglish, "cat", true},
|
{"scrabble_en hit", VariantEnglish, "cat", true},
|
||||||
{"english miss", VariantEnglish, "zzzz", false},
|
{"scrabble_en miss", VariantEnglish, "zzzz", false},
|
||||||
{"russian hit", VariantRussianScrabble, "кот", true},
|
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
|
||||||
{"erudit hit", VariantErudit, "кот", true},
|
{"erudit_ru hit", VariantErudit, "кот", true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -38,15 +38,25 @@ const (
|
|||||||
func (v Variant) String() string {
|
func (v Variant) String() string {
|
||||||
switch v {
|
switch v {
|
||||||
case VariantEnglish:
|
case VariantEnglish:
|
||||||
return "english"
|
return "scrabble_en"
|
||||||
case VariantRussianScrabble:
|
case VariantRussianScrabble:
|
||||||
return "russian_scrabble"
|
return "scrabble_ru"
|
||||||
case VariantErudit:
|
case VariantErudit:
|
||||||
return "erudit"
|
return "erudit_ru"
|
||||||
}
|
}
|
||||||
return "unknown"
|
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
|
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
|
||||||
// (nil, false) for an unrecognised variant.
|
// (nil, false) for an unrecognised variant.
|
||||||
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const (
|
|||||||
EndScoreless
|
EndScoreless
|
||||||
// EndResign fires when a player resigns.
|
// EndResign fires when a player resigns.
|
||||||
EndResign
|
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.
|
// String renders the end reason for logs and diagnostics.
|
||||||
@@ -38,6 +42,8 @@ func (r EndReason) String() string {
|
|||||||
return "scoreless"
|
return "scoreless"
|
||||||
case EndResign:
|
case EndResign:
|
||||||
return "resign"
|
return "resign"
|
||||||
|
case EndAborted:
|
||||||
|
return "aborted"
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
@@ -92,6 +98,12 @@ type Options struct {
|
|||||||
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
|
// 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.
|
// with three or more seats; the zero value removes them from play.
|
||||||
DropoutTiles DropoutTiles
|
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
|
// 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
|
variant Variant
|
||||||
version string
|
version string
|
||||||
|
|
||||||
board *board.Board
|
board *board.Board
|
||||||
bag *Bag
|
bag *Bag
|
||||||
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
|
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
|
||||||
scores []int
|
scores []int
|
||||||
toMove int
|
toMove int
|
||||||
scorelessRun int
|
scorelessRun int
|
||||||
over bool
|
over bool
|
||||||
reason EndReason
|
reason EndReason
|
||||||
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
||||||
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
||||||
log []MoveRecord
|
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
|
// 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()
|
rs := solver.Rules()
|
||||||
g := &Game{
|
g := &Game{
|
||||||
solver: solver,
|
solver: solver,
|
||||||
rules: rs,
|
rules: rs,
|
||||||
variant: opts.Variant,
|
variant: opts.Variant,
|
||||||
version: version,
|
version: version,
|
||||||
board: board.New(rs.Rows, rs.Cols),
|
board: board.New(rs.Rows, rs.Cols),
|
||||||
bag: NewBag(rs, opts.Seed),
|
bag: NewBag(rs, opts.Seed),
|
||||||
hands: make([][]byte, opts.Players),
|
hands: make([][]byte, opts.Players),
|
||||||
scores: make([]int, opts.Players),
|
scores: make([]int, opts.Players),
|
||||||
resigned: make([]bool, opts.Players),
|
resigned: make([]bool, opts.Players),
|
||||||
dropoutTiles: opts.DropoutTiles,
|
dropoutTiles: opts.DropoutTiles,
|
||||||
|
multipleWords: opts.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
for i := range g.hands {
|
for i := range g.hands {
|
||||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||||
@@ -157,6 +171,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
|||||||
return g, nil
|
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
|
// 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,
|
// 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
|
// 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 {
|
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
|
||||||
return MoveRecord{}, err
|
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 {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
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
|
// winning regardless of score. A missed-turn timeout reuses Resign in the game
|
||||||
// domain, so it inherits this win/loss.
|
// domain, so it inherits this win/loss.
|
||||||
func (g *Game) Resign() (MoveRecord, error) {
|
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 {
|
if g.over {
|
||||||
return MoveRecord{}, ErrGameOver
|
return MoveRecord{}, ErrGameOver
|
||||||
}
|
}
|
||||||
player := g.toMove
|
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
|
||||||
g.resigned[player] = true
|
return MoveRecord{}, ErrGameOver
|
||||||
g.disposeHand(player)
|
}
|
||||||
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
|
g.resigned[seat] = true
|
||||||
|
g.disposeHand(seat)
|
||||||
|
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
|
||||||
g.log = append(g.log, rec)
|
g.log = append(g.log, rec)
|
||||||
if g.activeCount() <= 1 {
|
if g.activeCount() <= 1 {
|
||||||
g.finish(EndResign)
|
g.finish(EndResign)
|
||||||
} else {
|
} else if seat == g.toMove {
|
||||||
g.advance()
|
g.advance()
|
||||||
}
|
}
|
||||||
return rec, nil
|
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
|
// 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.
|
// by descending score. It is empty when the player has no legal play.
|
||||||
func (g *Game) GenerateMoves() []scrabble.Move {
|
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,
|
// 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)
|
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
|
// 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
|
// 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
|
// opponent loses their own. A scoreless stalemate forfeits each player's own
|
||||||
@@ -426,6 +470,9 @@ func (g *Game) winner() int {
|
|||||||
if !g.over {
|
if !g.over {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
if g.reason == EndAborted {
|
||||||
|
return -1 // an aborted game is a draw regardless of the running scores
|
||||||
|
}
|
||||||
best, tie := -1, false
|
best, tie := -1, false
|
||||||
for i := range g.scores {
|
for i := range g.scores {
|
||||||
if g.resigned[i] {
|
if g.resigned[i] {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
dawg "github.com/iliadenisov/dafsa"
|
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
|
// immediate subdirectory of dir: a subdirectory named V contributes, under
|
||||||
// version V, the variants whose committed DAWG it carries. This is the
|
// 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>/
|
// 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
|
// at runtime is resident again after a restart. The flat dir's version is resolved
|
||||||
// boot version is skipped (the flat dir already is the boot version). A partially
|
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records
|
||||||
// loaded registry is closed before any error is returned.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
|
|||||||
func TestRegistryUnknownLookups(t *testing.T) {
|
func TestRegistryUnknownLookups(t *testing.T) {
|
||||||
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open english-only registry: %v", err)
|
t.Fatalf("open scrabble_en-only registry: %v", err)
|
||||||
}
|
}
|
||||||
defer reg.Close()
|
defer reg.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,13 +46,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
|
|||||||
t.Fatalf("load available: %v", err)
|
t.Fatalf("load available: %v", err)
|
||||||
}
|
}
|
||||||
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
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 {
|
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) {
|
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 {
|
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)
|
latest, _, err := reg.Latest(VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("latest english: %v", err)
|
t.Fatalf("latest scrabble_en: %v", err)
|
||||||
}
|
}
|
||||||
if latest != "v2" {
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func TestResignLeadingPlayerStillLoses(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
played, err := g.SubmitPlay(hint.Tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("player 0 play: %v", err)
|
t.Fatalf("player 0 play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
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)
|
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.
|
// TestResignOnFinishedGame rejects a second transition.
|
||||||
func TestResignOnFinishedGame(t *testing.T) {
|
func TestResignOnFinishedGame(t *testing.T) {
|
||||||
g := newEnglishGame(t, 1)
|
g := newEnglishGame(t, 1)
|
||||||
@@ -132,7 +165,7 @@ func TestMultiplayerLastActiveWins(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("seat 0 play: %v", err)
|
t.Fatalf("seat 0 play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -212,7 +245,7 @@ func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("seat 0 play: %v", err)
|
t.Fatalf("seat 0 play: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ type gameCache struct {
|
|||||||
|
|
||||||
type cachedGame struct {
|
type cachedGame struct {
|
||||||
game *engine.Game
|
game *engine.Game
|
||||||
|
seats []Seat
|
||||||
variant string
|
variant string
|
||||||
lastAccess time.Time
|
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}
|
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).
|
// get returns the live game and its immutable seat list for id and refreshes its idle
|
||||||
func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
|
// 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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
e, ok := c.entries[id]
|
e, ok := c.entries[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
e.lastAccess = c.now()
|
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-
|
// put stores g as the live game for id together with its seat list. variant labels the
|
||||||
// games gauge can report counts by variant without inspecting engine internals.
|
// entry so the active-games gauge can report counts by variant without inspecting engine
|
||||||
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
|
// 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()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
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
|
// remove drops id from the cache (used on a finished game and after a failed
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
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()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
|
|||||||
"#character-encoding UTF-8",
|
"#character-encoding UTF-8",
|
||||||
"#player1 p1 Alice",
|
"#player1 p1 Alice",
|
||||||
"#player2 p2 Bob",
|
"#player2 p2 Bob",
|
||||||
"#lexicon english/v1",
|
"#lexicon scrabble_en/v1",
|
||||||
"#title game 00000000-0000-7000-8000-000000000001",
|
"#title game 00000000-0000-7000-8000-000000000001",
|
||||||
">p1: CATSER? 8H CAT +10 10",
|
">p1: CATSER? 8H CAT +10 10",
|
||||||
">p2: AS?E I8 .s +2 2",
|
">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) {
|
func TestGCGTilesUppercasesCyrillic(t *testing.T) {
|
||||||
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
|
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
|
||||||
t.Errorf("gcgTiles = %q, want КОТ?", got)
|
t.Errorf("gcgTiles = %q, want КОТ?", got)
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ func TestGameCacheEviction(t *testing.T) {
|
|||||||
cur := time.Unix(1_700_000_000, 0)
|
cur := time.Unix(1_700_000_000, 0)
|
||||||
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
cache.put(id, nil, "english")
|
cache.put(id, nil, "scrabble_en", nil)
|
||||||
if _, ok := cache.get(id); !ok {
|
if _, _, ok := cache.get(id); !ok {
|
||||||
t.Fatal("game must be resident after put")
|
t.Fatal("game must be resident after put")
|
||||||
}
|
}
|
||||||
cur = cur.Add(30 * time.Minute)
|
cur = cur.Add(30 * time.Minute)
|
||||||
@@ -104,7 +104,7 @@ func TestGameCacheEviction(t *testing.T) {
|
|||||||
if n := cache.sweep(); n != 1 {
|
if n := cache.sweep(); n != 1 {
|
||||||
t.Errorf("sweep evicted %d, want 1", n)
|
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")
|
t.Error("game must be evicted after idle TTL")
|
||||||
}
|
}
|
||||||
if cache.size() != 0 {
|
if cache.size() != 0 {
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import (
|
|||||||
const meterName = "scrabble/backend/game"
|
const meterName = "scrabble/backend/game"
|
||||||
|
|
||||||
// gameMetrics holds the game domain's operational instruments. Every game-scoped
|
// 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
|
// instruments default to no-ops (see defaultGameMetrics), so recording is always
|
||||||
// safe; SetMetrics installs the real meter during startup wiring.
|
// safe; SetMetrics installs the real meter during startup wiring.
|
||||||
type gameMetrics struct {
|
type gameMetrics struct {
|
||||||
replay metric.Float64Histogram
|
replay metric.Float64Histogram
|
||||||
validate metric.Float64Histogram
|
validate metric.Float64Histogram
|
||||||
|
moveDur metric.Float64Histogram
|
||||||
started metric.Int64Counter
|
started metric.Int64Counter
|
||||||
abandoned metric.Int64Counter
|
abandoned metric.Int64Counter
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics {
|
|||||||
return &gameMetrics{
|
return &gameMetrics{
|
||||||
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
|
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)."),
|
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."),
|
started: counter(meter, "games_started_total", "Games created and started."),
|
||||||
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
|
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))
|
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordStarted counts one started game of variant.
|
// recordMoveDuration records how long a seat spent on a committed move, attributed by
|
||||||
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
// variant and the game phase derived from moveCount. A non-positive duration (a clock
|
||||||
m.started.Add(ctx, 1, variantAttr(v))
|
// 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
|
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
|
||||||
// variant.
|
// 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) {
|
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant, vsAI bool) {
|
||||||
m.abandoned.Add(ctx, 1, variantAttr(v))
|
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
|
// variantAttr is the shared "variant" attribute option, usable for both Record and
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
m := newGameMetrics(meter)
|
m := newGameMetrics(meter)
|
||||||
|
|
||||||
m.recordStarted(ctx, engine.VariantEnglish)
|
m.recordStarted(ctx, engine.VariantEnglish, false)
|
||||||
m.recordStarted(ctx, engine.VariantEnglish)
|
m.recordStarted(ctx, engine.VariantEnglish, false)
|
||||||
m.recordStarted(ctx, engine.VariantRussianScrabble)
|
m.recordStarted(ctx, engine.VariantRussianScrabble, false)
|
||||||
m.recordAbandoned(ctx, engine.VariantErudit)
|
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.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
||||||
m.recordValidate(ctx, engine.VariantRussianScrabble, 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
|
var rm metricdata.ResourceMetrics
|
||||||
if err := reader.Collect(ctx, &rm); err != nil {
|
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")
|
started := counterByAttr(t, rm, "games_started_total", "variant")
|
||||||
if started["english"] != 2 || started["russian_scrabble"] != 1 {
|
if started["scrabble_en"] != 3 || started["scrabble_ru"] != 1 {
|
||||||
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
|
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 {
|
// The vs_ai attribute splits AI from human games for the per-kind Grafana panels.
|
||||||
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
|
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 {
|
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
||||||
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
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 {
|
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
||||||
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
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
|
// 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 {
|
for _, dp := range sum.DataPoints {
|
||||||
v, _ := dp.Attributes.Value(attribute.Key(attr))
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user