Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b6b7f2e3 | |||
| 225188e4b5 | |||
| 2a48df9b83 | |||
| f23da88028 | |||
| 8eee018728 | |||
| c16f27475f | |||
| 04263a17ca | |||
| 7210bed560 | |||
| 40ccfb9514 | |||
| c6e0dac940 | |||
| b47c47e969 | |||
| 1079878654 | |||
| c31ac7088c | |||
| 8881214213 | |||
| a372343797 | |||
| d4ef951db9 | |||
| 7ec17cdd53 | |||
| 41a642ef97 | |||
| e3b08461f0 | |||
| 7e75c32d07 | |||
| f20a4b49ff | |||
| ab58062565 | |||
| 8878711cf3 | |||
| c23ac94c4e | |||
| a2265a122e | |||
| 422bd14b53 | |||
| 0c55574ddd | |||
| aa137e3558 | |||
| bf3ee62711 | |||
| 8bfc44aad0 | |||
| bf07f77078 | |||
| 26aa154547 | |||
| 70e3fab512 | |||
| bf7dca0a09 | |||
| 265e442252 | |||
| d87c0fb10b | |||
| 84ecc85f51 | |||
| efa1d0bd22 | |||
| ef61b778fc | |||
| 844f26bbae | |||
| f166ff30fe | |||
| 6956dad354 | |||
| 13361c098c | |||
| 4999478ded | |||
| a7c566d2d1 | |||
| a84e9d8cb7 | |||
| 70110effd9 | |||
| 295e45486d | |||
| a132edd40a | |||
| 461e330bfc | |||
| c96d714fec | |||
| 7e34897d6d | |||
| 645df52c0b | |||
| f95a6cb9c8 | |||
| 5d677cb282 | |||
| c9a1eee510 | |||
| 83e9a90d40 | |||
| 356f490546 | |||
| 6b6baf5710 | |||
| b720907db2 | |||
| 34385240b9 | |||
| 3fd279cf8c | |||
| 5928be40b0 | |||
| e16076c89e | |||
| b8787a4123 | |||
| f5c2404123 | |||
| 353dff20c4 | |||
| 3632c2239f | |||
| 06c8039281 | |||
| 2b0b1c0035 | |||
| 35666e1705 | |||
| d3657fdf5c | |||
| 74683f294f | |||
| cdf616d6c4 | |||
| 2cb2b57cdb | |||
| 512ad4dfb9 | |||
| a420d6a2cd | |||
| f916d5e0ca | |||
| 29d1193a0a | |||
| 3899ffda0f | |||
| 10412fee8e | |||
| 3856b34f8a | |||
| 71b054227a | |||
| d0c1306d9b | |||
| 1bbf0bc654 | |||
| 4fd82335db | |||
| 54497374e4 | |||
| b15fd30c4f | |||
| f6bffd1f57 | |||
| 645a503532 | |||
| c94cd3c3bf | |||
| 09fec2b83c | |||
| 1d0bafaabb | |||
| c0b46a7ca6 | |||
| 635f2fd9fc | |||
| 6886efb6c0 | |||
| 831ecd0cab | |||
| 4a07d48a7b | |||
| dce3edacee | |||
| efbaf657c6 | |||
| 0ea35fe991 | |||
| ee8d4fd85e | |||
| 8700fbfae1 | |||
| 8c8f8c4d42 | |||
| e9f836db87 | |||
| 23b5c3b5cc | |||
| e7c9d301ba | |||
| ec435c0e7f | |||
| da6665b967 | |||
| 90eaf4964b | |||
| 6537082397 | |||
| d99705645f | |||
| dcd8de8b00 | |||
| 01485d8fc6 | |||
| 3a640a17a4 | |||
| 4c4beace85 | |||
| cf66ed7e26 |
@@ -0,0 +1,349 @@
|
||||
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]
|
||||
|
||||
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/*
|
||||
DICT_VERSION: v1.0.0
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch dictionary DAWGs
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
||||
fi
|
||||
|
||||
- name: vet
|
||||
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./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/*
|
||||
DICT_VERSION: v1.0.0
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch dictionary DAWGs
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Integration tests
|
||||
# -count=1 disables the cache; -p=1 -parallel=1 keeps the container-backed
|
||||
# tests serial; the 15-minute timeout bounds a stuck container pull.
|
||||
env:
|
||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
||||
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
|
||||
|
||||
ui:
|
||||
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_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
|
||||
TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
|
||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
||||
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
|
||||
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
|
||||
# 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_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
|
||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||
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"
|
||||
# App version for the About screen: the git tag if present, else the short SHA
|
||||
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
|
||||
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
|
||||
docker compose --ansi never build --progress plain
|
||||
docker compose --ansi never up -d --remove-orphans
|
||||
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||
# 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 and the gateway through caddy
|
||||
run: |
|
||||
set -u
|
||||
# Two probes through the contour caddy: "/" is the static
|
||||
# landing container, "/app/" is the gateway-served SPA shell.
|
||||
for i in $(seq 1 20); do
|
||||
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
|
||||
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then
|
||||
echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "probe failed; recent landing + gateway logs:"
|
||||
docker logs --tail 50 scrabble-landing || true
|
||||
docker logs --tail 50 scrabble-gateway || true
|
||||
exit 1
|
||||
|
||||
- name: Probe the Telegram connector liveness
|
||||
run: |
|
||||
set -u
|
||||
# The gateway probe cannot see a crash-looping connector (it long-polls and
|
||||
# egresses through the VPN sidecar, with no public ingress). Inspect the
|
||||
# container directly: it must be running, not restarting, with a stable
|
||||
# restart count. A grace period lets the VPN handshake settle (the connector
|
||||
# may restart a few times first).
|
||||
sleep 20
|
||||
for i in $(seq 1 20); do
|
||||
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
|
||||
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
|
||||
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
||||
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
sleep 5
|
||||
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
if [ "$c1" = "$c2" ]; then
|
||||
echo "connector healthy: status=$status restarts=$c2"
|
||||
exit 0
|
||||
fi
|
||||
echo "connector still restarting ($c1 -> $c2); waiting"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "connector not healthy; recent logs:"
|
||||
docker logs --tail 80 scrabble-telegram || true
|
||||
exit 1
|
||||
|
||||
- name: Prune dangling images
|
||||
if: always()
|
||||
run: docker image prune -f
|
||||
@@ -1,72 +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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch scrabble-solver (sibling)
|
||||
# The engine package consumes scrabble-solver in-process; go.work points
|
||||
# its bare module path at this sibling checkout. The repository is public,
|
||||
# so the clone needs no credentials. It tracks master HEAD (see PLAN.md
|
||||
# TODO-1 for the move to a published, versioned module).
|
||||
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
|
||||
|
||||
- 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 committed DAWGs in the sibling checkout.
|
||||
env:
|
||||
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
|
||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
||||
@@ -1,62 +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"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch scrabble-solver (sibling)
|
||||
# The backend now imports the engine package, which consumes
|
||||
# scrabble-solver in-process; go.work points its bare module path at this
|
||||
# sibling checkout. The repository is public, so the clone needs no
|
||||
# credentials. It tracks master HEAD (see PLAN.md TODO-1).
|
||||
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
|
||||
|
||||
- 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 committed DAWGs.
|
||||
env:
|
||||
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
|
||||
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
|
||||
@@ -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,6 @@
|
||||
# Local, unstaged env overrides
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
|
||||
# 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
|
||||
details to interview*.
|
||||
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7
|
||||
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
||||
security, the decision record. Always describes current state.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
||||
@@ -49,9 +51,20 @@ conversation memory — is the source of continuity. Keep it that way.
|
||||
|
||||
## Branching & CI
|
||||
|
||||
- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*`
|
||||
and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on
|
||||
`master` by necessity (an empty branch has nothing to PR into).
|
||||
- **Two long-lived branches** (Stage 16 onward): **`development`** is the
|
||||
integration branch; **`master`** is the production trunk. Cut `feature/*`
|
||||
branches **from `development`** and PR them back into it. (Stages 0–15 used
|
||||
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
|
||||
on `master` by necessity.)
|
||||
- A commit to a `feature/*` branch triggers **nothing**. The single workflow
|
||||
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
|
||||
on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls
|
||||
the **test contour** on a PR into — or a push to — `development`
|
||||
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into
|
||||
`master` is test-only.
|
||||
- Merge `development → master` only when CI is green; the **prod** deploy is then a
|
||||
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
|
||||
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
|
||||
- After any push, watch the run to green before declaring a stage done — use the
|
||||
ready-made watcher, never an inline poll loop:
|
||||
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
||||
@@ -113,6 +126,9 @@ backend/ # module scrabble/backend
|
||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||
gateway/ ui/ pkg/ # added by their stages
|
||||
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
||||
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 also has the `landing` target (R3)
|
||||
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
|
||||
```
|
||||
|
||||
## Build & test
|
||||
@@ -127,9 +143,15 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
||||
|
||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
||||
pnpm start # UI mock mode: lobby -> game, no backend
|
||||
|
||||
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA
|
||||
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing (R3)
|
||||
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
||||
```
|
||||
|
||||
The `ui` module is a Node project (pnpm), **not** in `go.work`; its CI is
|
||||
`.gitea/workflows/ui-test.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job
|
||||
of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit /
|
||||
integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
|
||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||
|
||||
@@ -43,9 +43,15 @@ independent (see ARCHITECTURE §9.1).
|
||||
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
|
||||
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
|
||||
| 9 | Telegram integration (bot side-service, deep-link, push) | **done** |
|
||||
| 10 | Admin & dictionary ops (complaint review, version reload) | todo |
|
||||
| 11 | Account linking & merge | todo |
|
||||
| 12 | Polish (observability, perf with evidence, deploy) | todo |
|
||||
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
|
||||
| 11 | Account linking & merge | **done** |
|
||||
| 12 | Observability & performance (telemetry, metrics, guest GC) | **done** |
|
||||
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
|
||||
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
|
||||
| 15 | Dual Telegram bots & language-gated variants | **done** |
|
||||
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
|
||||
| 17 | Test-contour verification & defect fixes | **done** |
|
||||
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
|
||||
|
||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||
adds the modules it needs.
|
||||
@@ -204,10 +210,214 @@ dedupe). High blast-radius — focused regression tests.
|
||||
Open details: conflict resolution (active games on both, duplicate friends,
|
||||
display-name collisions); irreversibility/audit; confirm-flow per platform.
|
||||
|
||||
### Stage 12 — Polish
|
||||
Scope: observability dashboards, evidence-based performance work, prod
|
||||
build/deploy.
|
||||
Open details: deployment target/host; dashboards; load expectations.
|
||||
### Stage 12 — Observability & performance
|
||||
Scope: wire a configurable **OTLP** exporter (alongside `none`/`stdout`), shared in a
|
||||
new `pkg/telemetry`; add telemetry to the **gateway** and the **Telegram connector**
|
||||
(providers + `otelgrpc` on the gRPC hops) for parity with the backend; add
|
||||
domain/operational **metrics** close to the business (game replay/validate timings,
|
||||
started/abandoned games, live-cache size, chat/nudge counts, the edge roundtrip, Go
|
||||
runtime metrics); discharge **TODO-3** (abandoned-guest GC). The OTLP collector and
|
||||
dashboards are stood up with the deploy (Stage 15); the default exporter stays `none`,
|
||||
so CI needs no collector. Performance is operational-metric instrumentation, not
|
||||
speculative optimisation (the standing "evidence first" rule — no measured hotspot yet).
|
||||
Open details: exporter default and whether a collector is stood up now; the metric set
|
||||
and its attributes; the guest-reaper trigger given revoke-only sessions.
|
||||
|
||||
### Stage 13 — Alphabet on the wire (TODO-4)
|
||||
Scope: make the UI **alphabet-agnostic**. On game-screen load the client receives the
|
||||
variant's alphabet table `(letter, index, value)` for **display only**, caches it in
|
||||
memory by variant (a request flag gates whether the table is included, so it is not
|
||||
resent on every state poll); live play then exchanges **letter indices** both ways, and
|
||||
**word-check** sends indices, constraining input to the variant's alphabet. The engine
|
||||
already works in alphabet-index bytes, so the wire does *less* decoding in live play; the
|
||||
durable journal / history / GCG stay decoded concrete characters (the §9.1
|
||||
dictionary-independent invariant is untouched). The alphabet comes from the **solver's
|
||||
rules** (not the DAWG), so the wire table is pinned by the solver version. **Index-drift
|
||||
caveat:** the running solver, the DAWGs (built against it — Stage 14 / TODO-2) and the
|
||||
wire table must agree, or letter indexing silently corrupts. Blast radius: `pkg/fbs`
|
||||
(a new Alphabet table; index fields in `StateView`/rack and in
|
||||
`SubmitPlay`/`Exchange`/`check_word`) → backend DTO encode/decode → UI
|
||||
`codec.ts`/`premiums.ts` → board/rack render, the move/exchange/word-check senders, the
|
||||
mock transport and the Vitest tests.
|
||||
Open details: the fbs shape and `include_alphabet` flag placement; whether to keep
|
||||
concrete-letter fields during the transition; whether tile exchange moves fully to
|
||||
indices; the premiums.ts parity-test rework.
|
||||
|
||||
### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
|
||||
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
|
||||
deploy + observability + the two-bots idea were split into **Stages 15–18** below and this
|
||||
stage took only the dependency/artifact split that everything else builds on. Scope: publish
|
||||
`scrabble-solver` as a versioned Gitea module and split the dictionary build into a new
|
||||
`scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume
|
||||
both — discharging **TODO-1** and **TODO-2**.
|
||||
|
||||
- **TODO-1 — solver published.** `scrabble-solver` renamed to module
|
||||
`gitea.iliadenisov.ru/developer/scrabble-solver`, tagged **v1.0.0**; `wordlist`/`dictdawg`
|
||||
de-internalised to public packages (the dict repo imports them); `cmd/builddict`/`dictprep`/the
|
||||
`dictionaries` submodule moved out; `internal/dict` repointed at the committed `dawg/*.dawg`
|
||||
fixtures. `backend/go.mod` pins `v1.0.0`; the `go.work` replace and the CI sibling-clone are
|
||||
gone; `GOPRIVATE=gitea.iliadenisov.ru/*` makes go fetch it directly (no public proxy/checksum DB).
|
||||
- **TODO-2 — dictionary artifacts.** New repo `developer/scrabble-dictionary` holds the word-list
|
||||
sources + `cmd/builddict` and builds the three DAWGs against the **published solver + pinned
|
||||
`dafsa`/`alphabet` v1.1.0**, so they are byte-identical to the solver's fixtures (no index drift).
|
||||
Released as `scrabble-dawg-vX.Y.Z.tar.gz` (flat, one semver per set); the Go workflows download it
|
||||
and point `BACKEND_DICT_DIR` at it. The runtime contract is unchanged (additive
|
||||
`BACKEND_DICT_DIR/<version>/`, `engine.OpenWithVersions`, per-game `dict_version` pin; a version is
|
||||
safe to retire once no active game pins it).
|
||||
|
||||
### Stage 15 — Dual Telegram bots & language-gated variants *(done)*
|
||||
Re-framed at its start to be **service-agnostic**: the sign-in service returns, with the user identity, a
|
||||
**set of supported game languages** (subset of `{en, ru}`, ≥ 1) that gates the New Game variant choice.
|
||||
Built: the connector hosts **two bots in one container** (one per service language, each its own token +
|
||||
game channel; the same Telegram user id spans both); `ValidateInitData` tries each token in turn and
|
||||
returns the validating bot's **`service_language`** + **`supported_languages`** set. The set rides the
|
||||
`Session` (FlatBuffers, session-scoped, not persisted) and the UI offers only the matching variants on New
|
||||
Game (en → English; ru → Russian + Эрудит) — gating **only** the start of a new game (auto-match + friend
|
||||
invite); existing games of any language are unrestricted and the backend does not enforce. The service
|
||||
language is persisted (`accounts.service_language`, migration `00010`, written on every login —
|
||||
last-login-wins) and routes the user-facing out-of-app push (`Notify`) back through the right bot (falls
|
||||
back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the gateway default set
|
||||
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
|
||||
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
|
||||
|
||||
### Stage 16 — Deploy infra & test contour *(done)*
|
||||
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
|
||||
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
|
||||
gains **static UI serving** — **embedded** via `go:embed` (a node build stage in the gateway image),
|
||||
SPA served at both `/` (web) and `/telegram/` (Mini App), the §13 single-origin model; prod UI build
|
||||
vars (`VITE_TELEGRAM_BOT_ID`, `VITE_TELEGRAM_LINK`, `VITE_GATEWAY_URL`) as image build-args; a root
|
||||
`deploy/docker-compose.yml` (backend + gateway + Postgres + connector + VPN sidecar + the **full
|
||||
observability stack** — OTel Collector + Prometheus + Tempo + Grafana with provisioned dashboards) on
|
||||
the external `edge` network behind the host caddy (VPN sidecar only for the connector); the backend
|
||||
image pulls the DAWG release artifact (Stage 14). **The test contour deploys automatically on push to
|
||||
a feature branch** (`docker compose up -d --build` on the local host where the gitea runner lives),
|
||||
with a post-deploy probe (`GET /` on the gateway). Test-contour secrets use the **`TEST_`** prefix
|
||||
(see Stage 16).
|
||||
Open details (re-interview at start): the dashboard set; the gateway static-serving hook (before the
|
||||
h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go build` works without a UI
|
||||
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
|
||||
collector/Tempo/Prometheus retention.
|
||||
|
||||
### Stage 17 — Test-contour verification & defect fixes *(done)*
|
||||
Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the
|
||||
"does it actually work in the contour" pass before prod. Bring up the `development` deploy, then
|
||||
verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin
|
||||
console and Grafana sit behind the single `/_gm` Basic-Auth; the Telegram **bots** start (test
|
||||
environment) and the Mini App launches/authenticates; a game can be created and played through (web
|
||||
+ Mini App); the **observability** stack receives data (Prometheus targets up, the dashboards
|
||||
populate incl. `accounts_created_total`/`active_users`, traces reach Tempo); the out-of-app push
|
||||
works. Fix the defects found and harden where the run exposes gaps — notably a CI **connector
|
||||
liveness check** (the deploy probe only hits the gateway today, so a crash-looping connector is
|
||||
invisible — that is how the Stage 16 test-env miss went unnoticed) and **path-conditional CI** (skip
|
||||
the jobs whose code did not change, behind a single always-running gate job so branch-protection
|
||||
required checks stay satisfiable — a skipped required check otherwise blocks the merge).
|
||||
Open details (interview at start): the verification checklist + pass bar; which discovered defects
|
||||
are in-scope vs deferred; the changed-paths design + the aggregate gate job; the connector
|
||||
liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before
|
||||
it settles).
|
||||
|
||||
#### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*)
|
||||
|
||||
The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss),
|
||||
discussed where they were forks, and resolved in one session with tests where practical. The
|
||||
per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the
|
||||
raw list is kept here as the record of what the first contour run surfaced.
|
||||
|
||||
- /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/
|
||||
Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth.
|
||||
|
||||
- нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход,
|
||||
скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести
|
||||
метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?),
|
||||
а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа
|
||||
способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров.
|
||||
|
||||
- регистрация пользователя из телеграм (как и других коннекторов):
|
||||
пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле.
|
||||
если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр),
|
||||
язык в зависимости от внешнего коннектора.
|
||||
|
||||
- game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход".
|
||||
Думаю, опечатка - "не" лишняя, проверь на всех языках.
|
||||
|
||||
- если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная",
|
||||
т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает
|
||||
на самих стилях)
|
||||
|
||||
- возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема.
|
||||
когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar
|
||||
рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка.
|
||||
Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в
|
||||
самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там
|
||||
системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним.
|
||||
|
||||
- не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios,
|
||||
то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби.
|
||||
интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли?
|
||||
если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого?
|
||||
нужен твой анализ и совет.
|
||||
|
||||
- надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода
|
||||
выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал
|
||||
сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно.
|
||||
На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону.
|
||||
А то я сейчас поиграл, роботы на первых ходах по 15 минут думают.
|
||||
Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры.
|
||||
Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя
|
||||
активного игрока. Этот пункт требует тщательного обсуждения, пожалуй.
|
||||
|
||||
- при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает
|
||||
каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то
|
||||
неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать
|
||||
каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь
|
||||
проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п.
|
||||
|
||||
- при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна.
|
||||
|
||||
- а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой),
|
||||
чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое?
|
||||
ну и для собственного дефолтного стиля тоже надо выбрать соответствующие.
|
||||
|
||||
- Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем".
|
||||
Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся.
|
||||
Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать
|
||||
плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry.
|
||||
|
||||
- при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка.
|
||||
|
||||
- В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён
|
||||
было не больше 20%.
|
||||
|
||||
- Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации.
|
||||
|
||||
- Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство
|
||||
поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы
|
||||
остальные игроки были как бы "утоплены" внутрь.
|
||||
|
||||
- В игре клик/тач по плашке с именами игроков открывает/закрывает историю.
|
||||
|
||||
- В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо.
|
||||
|
||||
- В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить)
|
||||
|
||||
- При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей.
|
||||
|
||||
- Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка
|
||||
"что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится.
|
||||
|
||||
### Stage 18 — Prod contour deploy
|
||||
Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
|
||||
(`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP
|
||||
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master`
|
||||
(the Stage 16 branch model: `feature/* → development → master`, merge gated green). Two-contour config
|
||||
uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no deployment environments (verified:
|
||||
the `environments` API 404s), so a flat prefixed namespace is the convention.
|
||||
Reuses the Stage 16 `deploy/docker-compose.yml` as-is, mapping the **`PROD_`** set onto the same
|
||||
unprefixed compose vars. **No host caddy on prod**, so the contour's own caddy terminates TLS — set
|
||||
`CADDY_SITE_ADDRESS` to the prod domain so caddy does its own ACME (the Caddyfile is already
|
||||
parameterised for this; the test contour leaves it `:80` behind the host caddy).
|
||||
Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
|
||||
provided cert) at the contour caddy; prod VPN; rollback.
|
||||
|
||||
## Refinements logged during implementation
|
||||
|
||||
@@ -682,39 +892,629 @@ Open details: deployment target/host; dashboards; load expectations.
|
||||
- **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/
|
||||
`SendToGameChannel` (backend gains its own connector client) for operator
|
||||
broadcasts to a user and the game channel.
|
||||
- **Verification-time fixes** (caught by the CI gate): (1) the gateway transcode
|
||||
dropped `notifications_in_app_only` in four places (`ProfileResp`, `encodeProfile`,
|
||||
`profileUpdateHandler`, the `UpdateProfile` body) so the toggle never reached the
|
||||
backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made
|
||||
**hermetic** (a shared `ui/e2e/fixtures.ts` blocks the real `telegram-web-app.js`):
|
||||
the render-blocking CDN `<script>` hung every page load on the CI runner, where
|
||||
telegram.org is unreachable, timing out all non-Telegram specs. (3) A pre-existing
|
||||
time-of-day flake in `TestTimeoutSweep` (the default 00:00–07:00 away window made
|
||||
the sweeper skip when CI ran with `now-1h` inside it) was made deterministic by
|
||||
clearing the test account's away window.
|
||||
|
||||
- **Stage 10** (interview + implementation):
|
||||
- **Admin console = backend-rendered `/_gm`, gateway Basic-Auth** (interview, two
|
||||
rounds): the owner chose a dedicated web console but, pointing at `../galaxy-game`
|
||||
and asking to keep it simple, the deliverable is **server-rendered Go
|
||||
`html/template` + one embedded CSS** (`backend/internal/adminconsole`: a
|
||||
framework-agnostic renderer + page view-models, `//go:embed` templates/assets, zero
|
||||
JS, no build step), **not** a SPA. It lives **in the backend** on its own route
|
||||
`/_gm/*`; the **gateway** (the project's built-in reverse proxy) gates `/_gm/*` with
|
||||
the existing `GATEWAY_ADMIN_USER/PASSWORD` Basic-Auth on its **public** listener and
|
||||
proxies **verbatim** to backend `/_gm/*` (mounted on the edge mux below the h2c wrap
|
||||
so Connect keeps working). This **supersedes Stage 6's** gateway-fronts-
|
||||
`/api/v1/admin` model: the separate admin port `GATEWAY_ADMIN_ADDR` is dropped (only
|
||||
the port — user/password stay), the backend `/api/v1/admin` group + `ping` are
|
||||
removed, and `gateway/internal/admin` is repurposed to the verbatim proxy. The
|
||||
backend keeps **no operator identity** and no `admin_accounts` table; CSRF on the
|
||||
console's POSTs is a **same-origin** check (`Origin`/`Referer` vs `Host`, the gateway
|
||||
preserves the inbound Host) — no token. Discharges Stage 1's "admin bootstrap" (it is
|
||||
config, not a DB seed).
|
||||
- **Complaint resolution + dictionary pipeline** (interview): migration **00008**
|
||||
(+ jetgen) adds `disposition`/`resolution_note`/`resolved_at`/`applied_in_version`
|
||||
to `complaints` and the deferred `status` CHECK (`open|resolved`) — **discharges
|
||||
Stage 3's** deferral (no `resolved_by`: operator identity is not tracked). Resolution
|
||||
sets a disposition (`reject`/`accept_add`/`accept_remove`); accepted complaints are
|
||||
**derived by query** into a pending dictionary-change list (no new table), stamped
|
||||
`applied_in_version` once a rebuilt version is loaded. New `game` reads
|
||||
`ListComplaints`/`GetComplaint`/`CountComplaints`/`ResolveComplaint`/
|
||||
`DictionaryChanges`/`MarkChangesApplied`; admin list/count reads
|
||||
`account.ListAccounts/CountAccounts/Identities` and `game.ListGames/CountGames/
|
||||
GameByID`.
|
||||
- **Dictionary hot-reload = per-version subdir** (interview): the launch version stays
|
||||
in the flat `BACKEND_DICT_DIR` (CI/dev untouched); a reloaded version `X` loads from
|
||||
`BACKEND_DICT_DIR/X/` via the new `Registry.LoadAvailable` (present variants only),
|
||||
and boot re-loads every subdirectory via `engine.OpenWithVersions` so reloaded
|
||||
versions survive a restart. **Partially addresses TODO-2** (the runtime reload
|
||||
contract; the offline DAWG generator stays future work).
|
||||
- **Operator broadcasts** (discharges Stage 9's forward-note): the backend gains its
|
||||
own connector gRPC client (`backend/internal/connector`, `BACKEND_CONNECTOR_ADDR`,
|
||||
nil when unset) over the existing `pkg/proto/telegram/v1`; the console messages a
|
||||
user by `account_id` (backend resolves the Telegram `external_id`) and posts to the
|
||||
game channel via `SendToUser`/`SendToGameChannel`.
|
||||
- **Config/CI**: backend adds `BACKEND_CONNECTOR_ADDR`; gateway drops
|
||||
`GATEWAY_ADMIN_ADDR` (keeps user/password). No new module and no fbs/proto/UI codegen
|
||||
(the console is server-rendered Go). The Go workflows already span
|
||||
`./backend/... ./gateway/... ./pkg/...`; integration stays `./backend/...`.
|
||||
|
||||
- **Stage 11** (interview + implementation):
|
||||
- **Scope = link-via-confirm + merge for email and Telegram** (interview): the
|
||||
current account is the merge **primary**; a linked identity that already has its
|
||||
own account is merged into the current one and the secondary is retired as an
|
||||
**audit tombstone** (`accounts.merged_into`/`merged_at`, migration `00009`
|
||||
+ jetgen). Linkable this stage: **email** (the existing confirm-code) and
|
||||
**Telegram via the Login Widget** (the web sign-in). New `internal/accountmerge`
|
||||
(the single-transaction data merge) and `internal/link` (the orchestrator over
|
||||
account + accountmerge + session).
|
||||
- **Tombstone, not delete** (interview): the secondary row is kept so a **shared
|
||||
finished game**'s no-cascade `game_players`/`chat`/`complaints` foreign keys stay
|
||||
valid; its seat in such a game is left in place. The merge is **refused**
|
||||
(`ErrActiveGameConflict`) only when the two share an **active** game.
|
||||
- **Merge algorithm** (one tx): stats summed (wins/losses/draws) + max kept;
|
||||
`hint_balance` summed; identities repointed; non-shared `game_players` transferred
|
||||
(shared kept); `chat_messages`/`complaints` reassigned; friendships/blocks repointed
|
||||
with self-edge drop and dedupe (friendships by status precedence
|
||||
accepted>pending>declined); invitations: secondary's as inviter deleted, invitee
|
||||
rows deduped; secondary's `email_confirmations`/`friend_codes` dropped; secondary
|
||||
tombstoned. Sessions are handled one layer up: `session.Service.RevokeAllForAccount`
|
||||
(+ `Cache.RemoveByAccount`) retires the secondary's sessions after the tx.
|
||||
- **Primary direction + guest inversion** (interview): primary = the current account,
|
||||
**except** when the initiator is a **guest** and the linked identity already has a
|
||||
**durable** owner — then the **durable account wins**, the guest's active games
|
||||
transfer into it, the guest is retired, and a **fresh session for the durable
|
||||
account is minted and returned** (the client adopts it). Binding a **free** identity
|
||||
to a guest is a plain upgrade (clear `is_guest`, same session). Discharges Stage 8's
|
||||
"guest email-binding is Stage 11".
|
||||
- **API/UX = dedicated ops; reveal only after the code** (interview): new edge ops
|
||||
`link.email.request/confirm/merge` (Email-rate-limited) and
|
||||
`link.telegram.confirm/merge`. `request` **always** mails a code (no pre-send
|
||||
"taken" signal, so a probe cannot enumerate registered addresses); a required merge
|
||||
is revealed **only after** the code is verified, gating an explicit irreversible
|
||||
merge step (the Profile screen's confirmation dialog). This **supersedes Stage 8's**
|
||||
`email.bind.*` ops (and their fbs `EmailBindRequest`/`EmailConfirmRequest` tables),
|
||||
which were retired from the gateway/UI for that reason; the backend
|
||||
`EmailService.RequestCode`/`ConfirmCode` primitives stay (still covered by inttest).
|
||||
- **Field policy** (interview): `display_name` = primary's; profile prefs/flags
|
||||
(language, timezone, away window, block toggles, `notifications_in_app_only`) =
|
||||
primary's; `hint_balance` = **sum**. A new service column **`paid_account`**
|
||||
(`bool`, default false; lifetime one-time-payment marker, no purchase flow yet) is
|
||||
added in `00009` and **ORed** on merge (`true` always wins). It is not user-editable
|
||||
and is shown read-only on the admin account-detail page.
|
||||
- **Telegram Login Widget** (interview, owner chose the broader scope): the connector
|
||||
validates it (`internal/loginwidget`, secret = `SHA-256(bot_token)`, distinct from
|
||||
initData) via a new `Telegram.ValidateLoginWidget` RPC; the gateway validates the
|
||||
widget payload and passes the **trusted** `external_id` to the backend link route
|
||||
(same trust model as `auth.telegram`). The UI offers "Link Telegram" only in a plain
|
||||
web context (`loginWidgetAvailable`), driving the popup `Telegram.Login.auth`; it is
|
||||
**inert in production until BotFather `/setdomain`** registers the site domain and
|
||||
`VITE_TELEGRAM_BOT_ID` is configured (a deploy concern, Stage 12). e2e mocks the
|
||||
widget (telegram.org is blocked on CI).
|
||||
- **Wire/CI**: new fbs `LinkEmailRequest`/`LinkEmailConfirm`/`LinkTelegramRequest`/
|
||||
`LinkResult` (committed Go + TS); new proto RPC (committed Go); new REST routes under
|
||||
`/api/v1/user/link/*`. The Go workflows already span `./backend/... ./gateway/...
|
||||
./pkg/... ./platform/telegram/...`; integration stays `./backend/...`. UI ~90 KB gzip
|
||||
JS (budget 100 KB). New error code `merge_active_game_conflict`.
|
||||
|
||||
- **Stage 12** (interview + implementation):
|
||||
- **Re-scoped & split** (interview): the original "Polish (observability + perf +
|
||||
deploy)" was too large for one session, so it was split — **Stage 12** = observability
|
||||
+ performance + guest GC; **Stage 13** = alphabet-on-the-wire (TODO-4); **Stage 14** =
|
||||
CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written
|
||||
into the plan now as the agreed baseline (each still re-interviews at its own start).
|
||||
(Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
|
||||
observability + the dual-bot idea split into Stages 15–18.)
|
||||
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
|
||||
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
|
||||
backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
|
||||
and the gateway and connector gained telemetry runtimes. A configurable **`otlp`**
|
||||
exporter was added alongside `none`/`stdout`; the **default stays `none`**, the OTLP
|
||||
endpoint comes from the standard `OTEL_EXPORTER_OTLP_*` env, and the collector +
|
||||
dashboards are Stage 15 (so CI needs none). `otelgrpc` instruments the backend push
|
||||
server, the gateway's backend + connector clients, and the connector's gRPC server.
|
||||
New config `GATEWAY_SERVICE_NAME`/`GATEWAY_OTEL_*` and `TELEGRAM_SERVICE_NAME`/
|
||||
`TELEGRAM_OTEL_*`; the backend's existing `BACKEND_OTEL_*` gained the `otlp` value.
|
||||
- **Metrics = operational, business-near** (interview): histograms
|
||||
`game_replay_duration` and `game_move_validate_duration`; counters
|
||||
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop) and
|
||||
`chat_messages_total` (`kind`=message/nudge); an observable gauge `game_cache_active`;
|
||||
the gateway `edge_request_duration` (`message_type`/`result`); plus Go runtime/heap
|
||||
metrics. Game-scoped metrics carry a **`variant`** attribute
|
||||
(english/russian_scrabble/erudit — chosen over a coarser `language`, which it
|
||||
subsumes); the gateway edge metric is variant-agnostic. Optional wiring uses the
|
||||
established `SetMetrics`/`SetNotifier` setter pattern (default no-op meter), so existing
|
||||
constructors and tests are untouched. **No speculative optimisation** — there is no
|
||||
measured hotspot; the deliverable is the instrumentation (the standing "performance only
|
||||
with evidence" rule). pprof was not added (reframed away by the owner).
|
||||
- **Guest GC** (interview, TODO-3): age-based, no-seat-only — see the discharged TODO-3
|
||||
below; new config `BACKEND_GUEST_REAP_INTERVAL`/`BACKEND_GUEST_RETENTION`.
|
||||
- **Deps/CI**: new OTel modules (the OTLP exporters,
|
||||
`contrib/instrumentation/runtime`, `otelgrpc`) added with the no-tidy pattern
|
||||
(`go mod edit` + `go mod download` + `go work sync`; `pkg` carries no bare-path dep, so
|
||||
it tidies cleanly). No workflow change — the Go workflows already span
|
||||
`./backend/... ./gateway/... ./pkg/... ./platform/telegram/...`, integration stays
|
||||
`./backend/...`, and the default `none` exporter keeps CI collector-free.
|
||||
|
||||
- **Stage 13** (interview + implementation, discharges TODO-4):
|
||||
- **Scope = live play only** (interview): indices ride the wire for `StateView.rack`
|
||||
(out) and `SubmitPlay`/`Evaluate`/`Exchange`/`CheckWord` (in). The **board path is
|
||||
untouched** — `MoveRecord` (history, move results, hint), formed `words`,
|
||||
`ComplaintRequest.word` (durable, admin-reviewed) and `WordCheckResult.word` (echo) stay
|
||||
decoded concrete characters, so the durable journal / GCG and the §9.1 invariant are
|
||||
unchanged. **Hard cutover**, no dual letter/index fields (single client; the fbs Go + TS
|
||||
regenerate in one PR; no external wire consumer). Exchange moved fully to indices, a
|
||||
blank = the shared sentinel index **255** (`engine.BlankIndex`).
|
||||
- **Edge-mapping layering** (engineering): the engine gained a cached per-variant codec —
|
||||
`AlphabetTable` (the `(index, letter, value)` table from the solver ruleset),
|
||||
`LetterForIndex`, `EncodeRack`, `DecodeTiles`, `DecodeWord` — and the backend **server
|
||||
edge** owns the index↔letter mapping. `game.Service`'s domain methods, `engine.Game` and
|
||||
the **robot** keep a single **letter-based** play path (untouched); a new thin
|
||||
`game.Service.GameVariant` (a single-column `SELECT variant`, cheaper than `GetGame`)
|
||||
lets the inbound handlers resolve the variant without doubling the play-path read. The
|
||||
**gateway carries no alphabet table** — it passes indices through verbatim; `check_word`
|
||||
rides as repeated `?idx=` query params.
|
||||
- **`include_alphabet` flag** (interview): `StateRequest.include_alphabet` gates the table
|
||||
so it is not resent on every poll; the client sets it only on a **per-variant cache
|
||||
miss** (first open of a variant), and the table then arrives with the index rack so the
|
||||
rack is always decodable. The client caches the table in memory by variant
|
||||
(`ui/src/lib/alphabet.ts`).
|
||||
- **Letter case** (discovered): the solver emits **lower-case** letters and the rest of
|
||||
the UI works in **upper case**. The wire and the journal stay lower case; the **UI
|
||||
normalises display to upper case** (the codec upper-cases decoded board tiles and words,
|
||||
and the alphabet cache upper-cases on ingest), so `placement.ts` / `board.ts` /
|
||||
`checkword.ts` are unchanged and the latent real-backend lower-case display is fixed.
|
||||
- **Parity rework** (interview): the real value/alphabet parity moved to a **Go engine
|
||||
test** (`engine.AlphabetTable`: EN/RU/Эрудит sizes, EN a=1/q=10, **Эрудит ё=index 6,
|
||||
value 0**); `ui/src/lib/premiums.ts` is now **geometry only** (its value tables,
|
||||
`tileValue` and `alphabet` were removed, its parity test trimmed to the premium grid);
|
||||
the codec test round-trips the index tiles + the alphabet table; the **mock keeps a
|
||||
fixture table** (relocated from `premiums.ts`) seeded into the client cache, so the
|
||||
mock-driven UI is alphabet-agnostic too.
|
||||
- **Wire/codegen/CI**: new fbs `AlphabetEntry` + `PlayTile`; `StateView.rack`→`[ubyte]` +
|
||||
`alphabet`; `StateRequest.include_alphabet`; `SubmitPlay`/`Eval` tiles→`[PlayTile]`;
|
||||
`Exchange` tiles→`[ubyte]`; `CheckWord.word`→`[ubyte]` (committed Go + TS regenerated).
|
||||
UI ~90 KB gzip JS (budget 100 KB). **No CI workflow change** — the Go workflows already
|
||||
span `./backend/... ./gateway/... ./pkg/...` and the UI workflow runs check/unit/build +
|
||||
a chromium/webkit e2e. `docs/FUNCTIONAL.md` is **untouched** (no user-visible behaviour
|
||||
change — the UI looks and plays the same; like Stage 2). The index-drift caveat is
|
||||
handled by construction (the running backend produces the table, so client↔server cannot
|
||||
drift); the DAWG/solver build-time agreement remains **Stage 14 / 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,
|
||||
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–18**. The deploy
|
||||
decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
|
||||
full Collector+Prometheus+Tempo+Grafana stack; **two contours** — test = auto on feature-branch
|
||||
push on the local host, prod = manual SSH `docker save`/`load` after merge; `TEST_`/`PROD_` secret
|
||||
prefixes since Gitea 1.26 has no environments — verified).
|
||||
- **TODO-1 — publish solver** (interview: "опубликовать и запинить"): `scrabble-solver` renamed to
|
||||
module `gitea.iliadenisov.ru/developer/scrabble-solver`, `internal/{wordlist,dictdawg}`
|
||||
**de-internalised** to public packages (so the dict repo imports one builder — no drift), the build
|
||||
pipeline (`cmd/builddict`, `dictprep`, the `dictionaries` submodule) moved out, `internal/dict`
|
||||
repointed at the committed `dawg/*.dawg` fixtures, tagged **v1.0.0**. scrabble-game pins it in
|
||||
`backend/go.mod`, drops the `go.work` replace + the CI clone, and sets `GOPRIVATE=gitea.iliadenisov.ru/*`
|
||||
(go fetches the module directly from Gitea — verified end-to-end). The solver hash lives in
|
||||
`go.work.sum` (workspace mode; the bare-path `scrabble/pkg` replace still blocks `go mod tidy`).
|
||||
- **TODO-2 — dictionary repo** (interview: "полный TODO-2, новый репо"): `developer/scrabble-dictionary`
|
||||
builds the three DAWGs against the published solver + pinned `dafsa`/`alphabet` v1.1.0,
|
||||
**byte-identical** to the solver fixtures; published as the release artifact
|
||||
`scrabble-dawg-v1.0.0.tar.gz`; both Go workflows download it for `BACKEND_DICT_DIR` instead of
|
||||
cloning the solver. English source vendored from `kamilmielnik/scrabble-dictionaries`; the Эрудит
|
||||
fold is committed as `dictprep/russian/erudit.txt`, so the build needs no `python`.
|
||||
- **Bootstrap nuances** (encountered): the dict repo was created empty with a protected `master`, so
|
||||
it was seeded once via an owner-authorised protection lift→push→restore (a subsequent CI-fix push
|
||||
correctly went through a PR, not another lift); it was made **public** (like the solver) so the Go
|
||||
workflows fetch the artifact anonymously. Its CI is a **build-only** validation gate — the
|
||||
auto-release step's `${{ github.* }}` contexts failed the Gitea workflow compile, so releases are
|
||||
published manually for now (a logged follow-up).
|
||||
|
||||
- **Stage 15** (interview + implementation):
|
||||
- **Re-framed service-agnostic** (interview): the owner kept the two-bots-in-one-container model but
|
||||
generalised the language signal — the sign-in service returns a **set** of supported game languages
|
||||
(subset of `{en, ru}`, ≥ 1) on the validate response, and the **UI gates** the New Game variant choice
|
||||
by it. Two distinct scopes, deliberately not conflated: the **gating set** is per-session (rides the
|
||||
`Session` fbs, never persisted — so the same `telegram_id` logged in through the en- and ru-bot gates
|
||||
differently, which is correct), and the **routing language** is per-account.
|
||||
- **Push routing resolved** (interview, the original "which bot delivers" open detail): only the
|
||||
**user-facing `Notify`** carries the `en`/`ru` language from the user's **last `ValidateInitData`**,
|
||||
persisted as `accounts.service_language` (migration `00010`, written every login — new and existing —
|
||||
last-login-wins, read by `/internal/push-target` with a `preferred_language` fallback). It is NOT the
|
||||
game's variant language. **Correction mid-interview:** the admin broadcasts `SendToUser` /
|
||||
`SendToGameChannel` are admin-panel-only and unrelated to `ValidateInitData`; they pick the bot by an
|
||||
**operator-chosen** language (a console `<select>`), so a `language` field was added to those two RPCs
|
||||
sourced from the form, not from `service_language`.
|
||||
- **Gating = UI-only, creation-only** (interview): the backend does not enforce (a valid game is
|
||||
harmless, not a trust boundary); only the New Game pickers (auto-match + friend invite) filter — there
|
||||
is no variant picker on accept/open/play, so those are inherently ungated. Non-Telegram logins
|
||||
(web/email/guest) carry the gateway default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, default all).
|
||||
- **Wire/connector**: `ValidateInitDataResponse` gained `service_language` + `supported_languages`; the
|
||||
fbs `Session` gained `supported_languages:[string]`; `SendToUser`/`SendToGameChannel` gained
|
||||
`language` (committed Go + TS regenerated via `make -C pkg gen` + `pnpm -C ui codegen`). The connector
|
||||
config moved to **per-language** bots (`TELEGRAM_BOT_TOKEN_EN/_RU`, `TELEGRAM_GAME_CHANNEL_ID_EN/_RU`;
|
||||
`TELEGRAM_MINIAPP_URL` shared; ≥ 1 token required — a breaking config change, no prod yet); the
|
||||
server hosts a bot map and routes by language. The push template language now follows the routing bot
|
||||
(was `preferred_language`) — a documented change. The deploy compose/Dockerfile env was updated to the
|
||||
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
|
||||
already span the touched modules).
|
||||
|
||||
- **Stage 16** (interview + implementation):
|
||||
- **Branch model reshaped** (interview, supersedes the Stage 0 `feature/* → master`): a long-lived
|
||||
**`development`** integration branch + **`master`** as the prod trunk. Feature branches are cut from
|
||||
`development`; a feature-branch commit triggers nothing. A single consolidated
|
||||
`.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs `unit`+`integration`+`ui` on a PR
|
||||
into `development`/`master` and a **gated `deploy`** job (`needs` the three) that auto-rolls the test
|
||||
contour **on a PR into — or a push to — `development`** (owner's "и PR, и push"). A PR into `master` is
|
||||
test-only; prod is the manual Stage 18. The former `go-unit`/`integration`/`ui-test` workflows were
|
||||
folded in (no path filters — full CI on every PR, per the owner). Console kept plain (`NO_COLOR`,
|
||||
`docker compose --ansi never`, `--progress plain`).
|
||||
- **Gateway serves the UI** (interview, the §13 single-origin): a new `gateway/internal/webui` embeds
|
||||
`dist` via `go:embed` (a committed placeholder index so `go build`/CI compile without a UI build) and
|
||||
serves the SPA at `/` and `/telegram/` (a path-stripping SPA handler, index.html fallback for the hash
|
||||
router), mounted in the edge mux **below** the h2c wrap; `/_gm` stays an explicit 404 when the local
|
||||
admin proxy is off so the catch-all does not leak the shell. The `gateway/Dockerfile` node stage builds
|
||||
the UI with the `VITE_*` build-args and copies it into the embed dir before `go build`.
|
||||
- **Images** (interview): multi-stage distroless `backend/Dockerfile` (a DAWG stage `curl`s the
|
||||
`scrabble-dawg` release pinned to `DICT_VERSION`, `GOPRIVATE` fetches the solver) and `gateway/Dockerfile`
|
||||
(node UI stage + Go stage), both trimming `go.work` like `platform/telegram/Dockerfile`. Built and
|
||||
verified locally.
|
||||
- **Contour = caddy-fronted** (interview, "caddy всё равно нужен для https"): a new `caddy` service owns
|
||||
a **single `/_gm` Basic-Auth** and routes `/_gm/grafana/*` → Grafana (anonymous-admin + sub-path, no
|
||||
own accounts) and the rest of `/_gm/*` → the backend console; everything else → the gateway. This
|
||||
**supersedes Stage 10's** gateway-fronts-`/_gm` model **in the deploy topology** (the gateway's own
|
||||
`/_gm` proxy stays for a local non-caddy run). TLS: the **host caddy** terminates it for the test
|
||||
contour and forwards to `scrabble:80`; the in-compose caddy is parameterised (`CADDY_SITE_ADDRESS`) to
|
||||
own ACME on prod (Stage 18) where there is no host caddy.
|
||||
- **Networks** (engineering): inter-service traffic on a private `internal` network (project-scoped DNS,
|
||||
no name collisions on the shared `edge`); only caddy joins the external `edge` (alias `scrabble`). The
|
||||
connector keeps its VPN sidecar (the only egress that needs the tunnel). The connector-scoped
|
||||
`platform/telegram/deploy/docker-compose.yml` was **retired** (the root `deploy/docker-compose.yml`
|
||||
supersedes it; the connector Dockerfile stays).
|
||||
- **Observability stack** (interview): OTel Collector (OTLP/gRPC → a Prometheus scrape endpoint +
|
||||
Tempo OTLP) + Prometheus (**15d**) + Tempo (**72h**) + Grafana (provisioned Prometheus+Tempo datasources
|
||||
+ four dashboards: Service overview, Edge/UX, Game domain, Users; Traces via the Tempo datasource +
|
||||
Explore, no fixed panels). The collector's prometheus exporter uses `add_metric_suffixes:false` +
|
||||
`resource_to_telemetry_conversion` so the dashboards' PromQL matches the in-code metric names and carries
|
||||
`service_name`. The three services export `otlp` in the contour (default stays `none`, so CI needs no
|
||||
collector). Loki/logs were left out of scope (container stdout / zap JSON).
|
||||
- **User metrics** (interview): a backend `accounts_created_total{kind}` counter (telegram/email/guest;
|
||||
robots excluded — they are a provisioned pool, not users) via the Stage-12 `SetMetrics` no-op pattern,
|
||||
and a gateway **in-memory** `active_users{window=24h,7d}` observable gauge (distinct authenticated edge
|
||||
actors). The owner chose the in-memory gauge over a DB `last_seen_at` (overkill); its single-instance /
|
||||
reset-on-restart limits are documented (a live gauge, not billing).
|
||||
- **Owner actions before the contour is green** (surfaced, not blockers): set the **`TEST_`** Gitea
|
||||
secrets/variables (see `deploy/.env.example`) and add a host-caddy route `<test domain> → scrabble:80`
|
||||
on the runner host. CI bootstrap nuance: the first PR introducing `ci.yaml` may first deploy on the
|
||||
post-merge push to `development` (depending on whether Gitea runs head/base workflows for a PR), after
|
||||
which PR-time deploys work.
|
||||
- **Telegram test environment** (post-deploy fix): the connector now selects Telegram's test env with the
|
||||
library's native `tgbot.UseTestEnvironment()` (was a `token += "/test"` hack — functionally identical,
|
||||
verified, but the option is idiomatic and now has a `bot` test asserting the `/bot<token>/test/getMe`
|
||||
path). The test contour **pins `TELEGRAM_TEST_ENV=true` in `ci.yaml`** (the contour is the test
|
||||
environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
|
||||
knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`.
|
||||
|
||||
- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's
|
||||
collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session.
|
||||
- **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical
|
||||
string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in
|
||||
the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`,
|
||||
`Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged
|
||||
(persisted games, GCG and the `variant` metric attribute keep it).
|
||||
- **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with
|
||||
`game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct
|
||||
`nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn.
|
||||
- **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the
|
||||
editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by
|
||||
language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor
|
||||
validation (e.g. "Peter J.").
|
||||
- **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names
|
||||
paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only /
|
||||
first + surname initial / first + full surname), composed deterministically per pool slot (stable
|
||||
across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20%
|
||||
Latin, an English game the Latin pool. Robot identities are keyed `robot-<lang>-<index>`.
|
||||
- **Robot 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** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte`
|
||||
— the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod
|
||||
bundle (code kept for post-release polish).
|
||||
- **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption).
|
||||
- **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger
|
||||
right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's
|
||||
own controls.
|
||||
- **Edge-swipe back** (`Screen.svelte`): a left-edge rightward drag navigates to `back`
|
||||
(touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped
|
||||
inside Telegram, which has its own back).
|
||||
- **Chat + 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.
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
|
||||
give it a real module URL and switch `backend` to a versioned dependency,
|
||||
dropping the `go.work` replace and the CI clone. Removes the floating
|
||||
`master` dependency accepted for now (Stage 2 interview).
|
||||
- **TODO-2 — split the solver into engine vs dictionary generator + versioned
|
||||
dictionary artifacts.** Owner's idea, with the caveats agreed at the Stage 2
|
||||
interview: the split is sound (build-time wordlist→DAWG vs runtime load have
|
||||
different lifecycles and shrink the runtime dependency surface), **but** the
|
||||
generator must pin the **same** `dafsa`/`alphabet` versions and alphabet
|
||||
definitions as the runtime engine or the on-disk format / letter indexing
|
||||
drifts and silently corrupts validation. For delivery prefer **Git LFS or an
|
||||
artifact store** (Gitea releases / OCI artifact / object storage) over a raw
|
||||
git submodule (the ~0.5–0.7 MB DAWGs are regenerated wholesale and bloat git
|
||||
history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull
|
||||
is a **deploy-time** way to populate the directory, **not** the runtime
|
||||
dynamic-reload mechanism (Stage 10) — keep the `BACKEND_DICT_DIR` directory as
|
||||
the runtime contract: a new `.dawg` appears in it and is loaded with
|
||||
`dawg.Load`.
|
||||
- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a
|
||||
durable `accounts` row (no identity, `is_guest`), so an ephemeral guest leaves a
|
||||
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
|
||||
guest accounts with no active games once their last session is gone; the
|
||||
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
|
||||
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
|
||||
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
|
||||
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
|
||||
letters. Consider extending `game.state` to carry the variant's `(letter, index,
|
||||
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
|
||||
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
|
||||
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
|
||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
||||
published as module `gitea.iliadenisov.ru/developer/scrabble-solver` (tagged `v1.0.0`, with
|
||||
`wordlist`/`dictdawg` de-internalised to public packages); `backend/go.mod` pins it, the `go.work`
|
||||
replace and the CI sibling-clone are gone, and `GOPRIVATE=gitea.iliadenisov.ru/*` fetches it directly
|
||||
(no public proxy/checksum DB). Removes the floating `master` dependency accepted since Stage 2.
|
||||
- ~~**TODO-2 — split the solver into engine vs dictionary generator + versioned dictionary
|
||||
artifacts.**~~ **Done in Stage 14.** A new repo `developer/scrabble-dictionary` holds the word-list
|
||||
sources + `cmd/builddict` (moved out of the solver, with `dictprep` and the `dictionaries` submodule)
|
||||
and builds the three DAWGs against the **published solver + pinned `dafsa`/`alphabet` v1.1.0** — the
|
||||
output is **byte-identical** to the solver's committed fixtures, so the index-drift caveat is handled
|
||||
by construction. Delivered as a Gitea **release artifact** `scrabble-dawg-vX.Y.Z.tar.gz` (not
|
||||
`go get`; DAWGs are data; **one semver label for the whole set**); the Go workflows download it for
|
||||
`BACKEND_DICT_DIR`. The runtime dynamic-reload contract (per-version `BACKEND_DICT_DIR/<version>/` via
|
||||
`Registry.LoadAvailable` / `engine.OpenWithVersions`, Stage 10) is unchanged — a deploy drops a new
|
||||
set into the directory; a version is safe to retire once no active game pins it.
|
||||
- ~~**TODO-3 — garbage-collect abandoned guest accounts.**~~ **Done in Stage 12.**
|
||||
A periodic `account.GuestReaper` deletes guests (`is_guest`) **with no game seat at
|
||||
all** whose account age exceeds `BACKEND_GUEST_RETENTION` (default 30 d, swept every
|
||||
`BACKEND_GUEST_REAP_INTERVAL`, default 1 h). Two schema facts shaped this, narrowing
|
||||
the original sketch: (1) `game_players`/`chat_messages`/`complaints` reference accounts
|
||||
**without** `ON DELETE CASCADE`, and a finished game belongs to the other players'
|
||||
history, so a guest with any seat is retained (a delete would be blocked anyway) — hence
|
||||
"no seat", not "no active game"; (2) sessions are revoke-only with no maintained
|
||||
`last_seen_at`, so a lingering session never expires and **account age** is the
|
||||
abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/
|
||||
`account_stats` fall away via their own `ON DELETE CASCADE`.
|
||||
- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in
|
||||
Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter,
|
||||
value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant
|
||||
cache miss — and live play exchanges **letter indices** both ways (rack, submit-play,
|
||||
evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is
|
||||
produced from the solver ruleset (`engine.AlphabetTable`), so it is pinned by the solver
|
||||
version and cannot drift from the running backend, and `ui/src/lib/premiums.ts` is now
|
||||
geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1,
|
||||
unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was
|
||||
discharged in Stage 14: the dict repo builds against the published solver + pinned
|
||||
`dafsa`/`alphabet`, byte-identical to the fixtures.
|
||||
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
|
||||
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
|
||||
launch, and the UI shows a **share-to-Telegram** link for an issued code when
|
||||
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
# 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** |
|
||||
| → | 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**.
|
||||
|
||||
## 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-R2.md`](../loadtest/REPORT-R2.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-R7.md`](../loadtest/REPORT-R7.md).
|
||||
- **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-R7.md` (new), `loadtest/README.md`,
|
||||
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
|
||||
@@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
||||
|
||||
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
||||
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
|
||||
admin surface behind Basic Auth. *(added in a later stage)*
|
||||
admin surface behind Basic Auth.
|
||||
- **`backend`** — internal-only service that owns every domain concern and
|
||||
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
||||
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
||||
Capacitor. See [`ui/README.md`](ui/README.md).
|
||||
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
||||
*(added in a later stage)*
|
||||
|
||||
## Documentation (sources of truth)
|
||||
|
||||
@@ -80,3 +79,24 @@ pnpm dev # against a running gateway (Vite proxies the RPC path to :8081)
|
||||
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
|
||||
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
||||
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
|
||||
|
||||
## Deploy (`deploy/`)
|
||||
|
||||
The full contour is [`deploy/docker-compose.yml`](deploy/docker-compose.yml):
|
||||
`backend` + `gateway` (with the UI embedded via `go:embed`, baked in by its node
|
||||
build stage) + Postgres + the Telegram connector (with a VPN sidecar) + an
|
||||
observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a front
|
||||
**caddy** that owns a single `/_gm` Basic-Auth (admin console + Grafana). The Go
|
||||
services build from multi-stage distroless `*/Dockerfile`.
|
||||
|
||||
```sh
|
||||
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
|
||||
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
|
||||
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
||||
```
|
||||
|
||||
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
||||
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
||||
`development → master`. Env reference: [`deploy/.env.example`](deploy/.env.example);
|
||||
the topology and the two-contour model are in
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.0.0
|
||||
RUN apk add --no-cache curl tar
|
||||
RUN mkdir -p /dawg \
|
||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||
"https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \
|
||||
&& tar xzf /tmp/dawg.tar.gz -C /dawg
|
||||
|
||||
# --- build -------------------------------------------------------------------
|
||||
FROM golang:1.26.3-alpine AS build
|
||||
WORKDIR /src
|
||||
# git: the published solver module is fetched from Gitea directly (GOPRIVATE).
|
||||
RUN apk add --no-cache git
|
||||
ENV GOPRIVATE=gitea.iliadenisov.ru/*
|
||||
|
||||
COPY go.work go.work.sum ./
|
||||
COPY pkg ./pkg
|
||||
COPY backend ./backend
|
||||
|
||||
# Reduce the workspace to what the backend needs: backend + pkg. 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
|
||||
COPY --from=build /out/backend /usr/local/bin/backend
|
||||
COPY --from=dawg /dawg /opt/dawg
|
||||
ENV BACKEND_DICT_DIR=/opt/dawg
|
||||
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||
+105
-58
@@ -1,24 +1,24 @@
|
||||
# backend
|
||||
|
||||
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
||||
It owns identity/sessions, accounts, and — in later stages — the lobby, game
|
||||
runtime, robot, chat, history and administration. Its only network consumers are
|
||||
the `gateway` and the platform side-services; it is never exposed publicly.
|
||||
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
|
||||
and administration. Its only network consumers are the `gateway` and the platform
|
||||
side-services; it is never exposed publicly.
|
||||
|
||||
As of Stage 1 the backend provides the foundation: configuration, the HTTP
|
||||
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
|
||||
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
|
||||
and the durable accounts / identities / sessions data model. The session and
|
||||
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
|
||||
store/service layer they will call.
|
||||
The backend provides the foundation: configuration, the HTTP listener with the
|
||||
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
|
||||
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
|
||||
accounts / identities / sessions data model. The session and account REST
|
||||
endpoints live in the `gateway`; the backend ships the store/service layer they
|
||||
call.
|
||||
|
||||
Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver`
|
||||
`internal/engine` is the in-process bridge to the `scrabble-solver`
|
||||
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
||||
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
||||
detection) that emits dictionary-independent move records. It is a library only;
|
||||
the game domain wires it into the process in Stage 3.
|
||||
the game domain wires it into the process.
|
||||
|
||||
Stage 3 adds `internal/game`, the game domain over the engine. Active games are
|
||||
`internal/game` is the game domain over the engine. Active games are
|
||||
event-sourced: a `games` row plus an append-only decoded move journal, with the
|
||||
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
||||
provides create, the play/pass/exchange/resign transitions, an unlimited
|
||||
@@ -26,10 +26,9 @@ score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
||||
word-check tool with complaint capture, per-player game state, history and GCG
|
||||
export, per-account statistics on finish, and a background turn-timeout sweeper
|
||||
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||||
Stages 1–2 it is a service/store layer; the HTTP surface lands with the
|
||||
`gateway` (Stage 6).
|
||||
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
|
||||
|
||||
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
|
||||
The lobby and social fabric. `internal/lobby` holds an in-memory
|
||||
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
|
||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
||||
@@ -41,40 +40,72 @@ development log mailer). The engine now also handles **multi-player drop-out**:
|
||||
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
||||
(the tile disposition is a per-game setting), the game ending when one active seat
|
||||
remains. As before this is a service/store layer — chat and nudges are persisted
|
||||
but their live delivery, and all REST endpoints, arrive with the `gateway`
|
||||
(Stage 6); the services are exposed via `Server` accessors for those handlers.
|
||||
but their live delivery, and all REST endpoints, live in the `gateway`; the
|
||||
services are exposed via `Server` accessors for those handlers.
|
||||
|
||||
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
|
||||
The robot opponent (`internal/robot`). A pool of durable accounts —
|
||||
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
||||
requests blocked — backs a human-like name pool. A background driver plays the
|
||||
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
||||
robot's moves through the public game API as an ordinary seated player (so only
|
||||
`internal/engine` imports the solver): it decides once per game whether to play to
|
||||
win (≈ 40%), targets a small score margin, and times its moves with a right-skewed
|
||||
delay, a night-sleep window anchored to the opponent's timezone, and nudge
|
||||
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
|
||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||
state. The matchmaker now substitutes a pooled robot after a 10-second wait and
|
||||
exposes `Poll` so a waiting player can collect the started game (the live
|
||||
match-found notification arrives with the `gateway`).
|
||||
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
||||
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
|
||||
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
|
||||
initial game state) drives it instead.
|
||||
|
||||
Stage 6 opens the backend to the edge. The route groups gain their first
|
||||
The backend opens to the edge. The route groups gain their first
|
||||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||||
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
|
||||
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||
state, lobby enqueue/poll, chat). The social/account/history operations under
|
||||
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
||||
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
|
||||
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
|
||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||
gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
|
||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
||||
seed a new account's language and display name from the launch fields, and adds
|
||||
migration `00007` (`accounts.notifications_in_app_only`, default true).
|
||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
||||
with no identity, excluded from statistics. The shared wire contracts live in the
|
||||
sibling [`../pkg`](../pkg) module.
|
||||
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||||
route out-of-app push to the Telegram connector; the Telegram login
|
||||
seeds a new account's language and display name from the launch fields, and the
|
||||
`accounts.notifications_in_app_only` flag (default true).
|
||||
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||||
with no identity, excluded from statistics. The server-rendered
|
||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||||
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||||
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
|
||||
holds the language tag of the bot a Telegram
|
||||
user last signed in through, written on every login and returned by
|
||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||
attached to the current account, and when the identity already has its own account
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
|
||||
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
|
||||
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
||||
The current account is primary, except a guest initiator whose linked identity has a
|
||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||
|
||||
Rate-limit observability: the gateway posts its periodic rejection
|
||||
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
|
||||
bounded in-memory episode window for the console's **Throttled** page and applies the
|
||||
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
|
||||
rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
|
||||
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
|
||||
**Clear** action on the user card; never an automatic ban).
|
||||
|
||||
## Package layout
|
||||
|
||||
@@ -86,14 +117,19 @@ internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
|
||||
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
||||
migrations/ # embedded *.sql (goose), schema `backend`
|
||||
jet/ # generated go-jet models + table builders (committed)
|
||||
internal/account/ # durable accounts + platform/email identities (store)
|
||||
internal/session/ # opaque tokens, sessions store, write-through cache, service
|
||||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
||||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
|
||||
internal/link/ # link/merge orchestrator over account + accountmerge + session
|
||||
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
||||
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||||
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
|
||||
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
|
||||
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
|
||||
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
||||
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
||||
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
||||
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||||
```
|
||||
|
||||
## Configuration (environment)
|
||||
@@ -109,8 +145,8 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
|
||||
| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. |
|
||||
| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. |
|
||||
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
|
||||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none` or `stdout` (OTLP arrives later). |
|
||||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none` or `stdout`. |
|
||||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
||||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
||||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
||||
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
|
||||
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
||||
@@ -123,13 +159,21 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
|
||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
||||
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
|
||||
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
|
||||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||
BACKEND_DICT_DIR=../../scrabble-solver/dawg \
|
||||
BACKEND_DICT_DIR=/tmp/dawg \
|
||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||
go run ./cmd/backend
|
||||
```
|
||||
|
||||
@@ -143,7 +187,10 @@ warmed.
|
||||
## Migrations & generated code
|
||||
|
||||
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
||||
`NNNNN_name.sql`), embedded and applied at startup. After changing the schema,
|
||||
`NNNNN_name.sql`), embedded and applied at startup. The incremental history was
|
||||
squashed into a single `00001_baseline.sql` before the first production deploy
|
||||
(there was no production data); new schema changes append as `00002_*` onward.
|
||||
After changing the schema,
|
||||
regenerate the committed go-jet code (needs Docker):
|
||||
|
||||
```sh
|
||||
@@ -152,19 +199,17 @@ go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp containe
|
||||
|
||||
## Engine & dictionaries
|
||||
|
||||
`internal/engine` consumes the sibling `scrabble-solver` module in-process. Its
|
||||
bare module path (`scrabble-solver`, not a URL) cannot be fetched via VCS, so the
|
||||
workspace `go.work` carries `replace scrabble-solver => ../scrabble-solver` and
|
||||
the build must run from the repository root (the workspace), not from this module
|
||||
in isolation. `github.com/iliadenisov/dafsa` (the DAWG loader) is a direct
|
||||
dependency. CI clones the public solver repository into `../scrabble-solver`
|
||||
before building (see `.gitea/workflows/`); locally, check it out next to this
|
||||
repository. Committed dictionaries (`en_sowpods.dawg`, `ru_scrabble.dawg`,
|
||||
`ru_erudit.dawg`) live in the solver's `dawg/` directory; the engine loads them
|
||||
by `(variant, dict_version)` from a directory path. Since Stage 3 the backend
|
||||
loads them at startup from the **required** `BACKEND_DICT_DIR` (a missing
|
||||
dictionary aborts the boot); the future versioned-artifact direction is recorded
|
||||
in [`../PLAN.md`](../PLAN.md) TODO-2.
|
||||
`internal/engine` consumes `scrabble-solver` in-process as a **published, versioned
|
||||
module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `go.mod`). Set
|
||||
`GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea (skipping
|
||||
the public proxy/checksum DB); no sibling checkout or `go.work` replace is needed (for
|
||||
local solver co-development you may add a temporary replace — see `go.work`).
|
||||
`github.com/iliadenisov/dafsa` (the DAWG loader) is a direct dependency. The dictionaries
|
||||
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
||||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||||
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||
(a missing dictionary aborts the boot).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -175,6 +220,8 @@ go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker)
|
||||
|
||||
Integration tests are guarded by the `integration` build tag and run against a
|
||||
throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent
|
||||
rather than skipping. The `internal/engine` tests load the committed DAWGs from
|
||||
`BACKEND_DICT_DIR` (defaulting to the sibling `../scrabble-solver/dawg`) and fail
|
||||
loudly when that directory is absent.
|
||||
rather than skipping. The `internal/engine` tests load the DAWGs from
|
||||
`BACKEND_DICT_DIR` (CI sets it to the extracted dictionary release artifact; locally it
|
||||
defaults to a `scrabble-solver/dawg` sibling checkout) and fail loudly when that directory
|
||||
is absent. `GOPRIVATE=gitea.iliadenisov.ru/*` is needed for go to fetch the pinned solver
|
||||
module.
|
||||
|
||||
@@ -18,13 +18,17 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/config"
|
||||
"scrabble/backend/internal/connector"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -77,6 +81,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Warn("telemetry shutdown", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
if err := tel.StartRuntimeMetrics(); err != nil {
|
||||
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
|
||||
}
|
||||
|
||||
db, err := postgres.Open(ctx, cfg.Postgres,
|
||||
postgres.WithTracerProvider(tel.TracerProvider()),
|
||||
@@ -92,7 +99,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
logger.Info("database migrations applied")
|
||||
|
||||
registry, err := engine.Open(cfg.Game.DictDir, cfg.Game.DictVersion)
|
||||
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dictionaries: %w", err)
|
||||
}
|
||||
@@ -101,6 +108,19 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
zap.String("dir", cfg.Game.DictDir),
|
||||
zap.String("version", cfg.Game.DictVersion))
|
||||
|
||||
// Admin console: an optional backend client to the Telegram connector
|
||||
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
||||
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
||||
var conn *connector.Client
|
||||
if cfg.ConnectorAddr != "" {
|
||||
conn, err = connector.New(cfg.ConnectorAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial connector: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
logger.Info("connector client ready", zap.String("addr", cfg.ConnectorAddr))
|
||||
}
|
||||
|
||||
sessions := session.NewService(session.NewStore(db), session.NewCache())
|
||||
if err := sessions.Warm(ctx); err != nil {
|
||||
return fmt.Errorf("warm session cache: %w", err)
|
||||
@@ -113,21 +133,34 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
hub := notify.NewHub(0)
|
||||
|
||||
accounts := account.NewStore(db)
|
||||
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||
games.SetNotifier(hub)
|
||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||
logger.Info("game turn-timeout sweeper started",
|
||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||
|
||||
// Stage 4 lobby & social domains. Their REST and stream surface is added with
|
||||
// the gateway in Stage 6, so they are handed to the server (like the route
|
||||
// groups) for the handlers to come.
|
||||
// Reap abandoned guest accounts (no game seat, account age past
|
||||
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
||||
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
||||
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
||||
logger.Info("guest reaper started",
|
||||
zap.Duration("interval", cfg.GuestReapInterval),
|
||||
zap.Duration("retention", cfg.GuestRetention))
|
||||
|
||||
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
||||
// so they are handed to the server (like the route groups) for the handlers.
|
||||
mailer := newMailer(cfg.SMTP, logger)
|
||||
emails := account.NewEmailService(accounts, mailer)
|
||||
// Account linking & merge: the orchestrator over the account, merge and
|
||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||
socialSvc.SetNotifier(hub)
|
||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||
|
||||
// Stage 5 robot opponent: provision its durable account pool (a hard startup
|
||||
// Robot opponent: provision its durable account pool (a hard startup
|
||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||
// substitutes a pooled robot for a missing human after the wait window.
|
||||
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
|
||||
@@ -144,6 +177,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
invitations.SetNotifier(hub)
|
||||
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
|
||||
|
||||
// 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))
|
||||
|
||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||
Logger: logger,
|
||||
DB: db,
|
||||
@@ -156,6 +196,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
Matchmaker: matchmaker,
|
||||
Invitations: invitations,
|
||||
Emails: emails,
|
||||
Links: links,
|
||||
Registry: registry,
|
||||
DictDir: cfg.Game.DictDir,
|
||||
Connector: conn,
|
||||
RateWatch: rateWatch,
|
||||
})
|
||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ module scrabble/backend
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0
|
||||
github.com/XSAM/otelsql v0.42.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-jet/jet/v2 v2.14.1
|
||||
@@ -20,7 +21,6 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/zap v1.27.1
|
||||
scrabble-solver v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -101,6 +101,7 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
@@ -25,8 +24,8 @@ import (
|
||||
|
||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||
// alongside platform identities; its confirmed flag is driven by the email
|
||||
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
|
||||
// robot opponent is a durable account bound to one robot identity (Stage 5).
|
||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||
// robot opponent is a durable account bound to one robot identity.
|
||||
const (
|
||||
KindTelegram = "telegram"
|
||||
KindEmail = "email"
|
||||
@@ -56,25 +55,56 @@ type Account struct {
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
// ServiceLanguage is the language tag (en/ru) of the bot the account last
|
||||
// authenticated through (its last Telegram ValidateInitData); it routes the
|
||||
// account's out-of-app push back through the right bot. Empty when the account
|
||||
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the
|
||||
// interface language) and from a game's variant language.
|
||||
ServiceLanguage string
|
||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||
// excluded from statistics, friends and history.
|
||||
IsGuest bool
|
||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account (Stage 9).
|
||||
// account.
|
||||
NotificationsInAppOnly bool
|
||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
||||
// never lost when accounts are consolidated.
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// foreign keys of a shared finished game stay valid.
|
||||
MergedInto uuid.UUID
|
||||
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||
// zero time for an unflagged account, otherwise when the gateway-reported
|
||||
// rate-limiter rejections first crossed the sustained threshold. An
|
||||
// operator clears it in the admin console; it never gates any request.
|
||||
FlaggedHighRateAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Identity is one of an account's platform/email identities, surfaced on the
|
||||
// admin account-detail view. ExternalID is the platform user id (or the email
|
||||
// address for an email identity); Confirmed tracks the email confirm-code flow.
|
||||
type Identity struct {
|
||||
Kind string
|
||||
ExternalID string
|
||||
Confirmed bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the Postgres-backed query surface for accounts and identities.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
metrics *accountMetrics
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
||||
// SetMetrics installs the real one during startup wiring.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
return &Store{db: db, metrics: defaultAccountMetrics()}
|
||||
}
|
||||
|
||||
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
||||
@@ -86,10 +116,43 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||
}
|
||||
|
||||
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||
// member: a KindRobot identity carrying displayName, with chat blocked but friend
|
||||
// requests NOT blocked — a request to a robot is accepted as pending and, since the
|
||||
// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human
|
||||
// who ignores the request. Robot names are system-generated, not player-edited, so they
|
||||
// bypass the editable display-name validation and may carry forms the editor rejects (an
|
||||
// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the
|
||||
// display name and both block flags.
|
||||
func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) {
|
||||
acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName})
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests {
|
||||
return acc, nil
|
||||
}
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.DisplayName, table.Accounts.BlockChat,
|
||||
table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(displayName), postgres.Bool(true),
|
||||
postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
||||
// identity. On first contact only, it seeds the new account's preferred language
|
||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
||||
// its display name from firstName (falling back to username); an already-existing
|
||||
// its display name sanitized from firstName (falling back to username, then to a
|
||||
// generated placeholder when neither yields any letters); an already-existing
|
||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
||||
@@ -129,19 +192,21 @@ type provisionSeed struct {
|
||||
|
||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
|
||||
// username (capped to maxDisplayName runes).
|
||||
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
||||
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
||||
// editable format). When neither yields any letters, it falls back to a generated
|
||||
// placeholder in the seeded language (placeholderDisplayName).
|
||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
seed.preferredLanguage = lang
|
||||
}
|
||||
name := strings.TrimSpace(firstName)
|
||||
name := sanitizeDisplayName(firstName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(username)
|
||||
name = sanitizeDisplayName(username)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
name = string([]rune(name)[:maxDisplayName])
|
||||
if name == "" {
|
||||
name = placeholderDisplayName(seed.preferredLanguage)
|
||||
}
|
||||
seed.displayName = name
|
||||
return seed
|
||||
@@ -187,6 +252,54 @@ func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kin
|
||||
return row.ExternalID, nil
|
||||
}
|
||||
|
||||
// Identities returns the account's platform/email identities, oldest first, for
|
||||
// the admin account-detail view.
|
||||
func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity, error) {
|
||||
stmt := postgres.SELECT(table.Identities.AllColumns).
|
||||
FROM(table.Identities).
|
||||
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
|
||||
ORDER_BY(table.Identities.CreatedAt.ASC())
|
||||
var rows []model.Identities
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("account: list identities %s: %w", accountID, err)
|
||||
}
|
||||
out := make([]Identity, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, Identity{Kind: r.Kind, ExternalID: r.ExternalID, Confirmed: r.Confirmed, CreatedAt: r.CreatedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
||||
// by limit and offset.
|
||||
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
ORDER_BY(table.Accounts.CreatedAt.DESC()).
|
||||
LIMIT(int64(limit)).
|
||||
OFFSET(int64(offset))
|
||||
var rows []model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("account: list accounts: %w", err)
|
||||
}
|
||||
out := make([]Account, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, modelToAccount(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountAccounts returns the total number of accounts, for admin-list pagination.
|
||||
func (s *Store) CountAccounts(ctx context.Context) (int, error) {
|
||||
stmt := postgres.SELECT(postgres.COUNT(table.Accounts.AccountID).AS("count")).
|
||||
FROM(table.Accounts)
|
||||
var dest struct{ Count int64 }
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return 0, fmt.Errorf("account: count accounts: %w", err)
|
||||
}
|
||||
return int(dest.Count), nil
|
||||
}
|
||||
|
||||
// findByIdentity joins identities to accounts and returns the matching account,
|
||||
// or ErrNotFound.
|
||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
@@ -259,6 +372,11 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
|
||||
}
|
||||
// Count genuinely new durable accounts; robots are a fixed provisioned pool,
|
||||
// not users, so they are excluded.
|
||||
if kind != KindRobot {
|
||||
s.metrics.recordCreated(ctx, kind)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
@@ -283,6 +401,7 @@ func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
||||
}
|
||||
s.metrics.recordCreated(ctx, kindGuest)
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
@@ -308,12 +427,82 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
|
||||
// the account is not already flagged — the first sustained episode wins, and a
|
||||
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
|
||||
// a profile edit, so updated_at is untouched; it never gates any request.
|
||||
// It reports whether the flag was newly set.
|
||||
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||
SET(postgres.TimestampzT(at.UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(id)).
|
||||
AND(table.Accounts.FlaggedHighRateAt.IS_NULL()),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: flag high rate %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: flag high rate rows %s: %w", id, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ClearHighRateFlag removes the high-rate marker — the operator's reversible
|
||||
// action in the admin console. Clearing an unflagged account is a no-op.
|
||||
func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||
SET(postgres.NULL).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: clear high-rate flag %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
||||
// user authenticated through. It is called on every Telegram login — new and
|
||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
||||
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
|
||||
// (a non-Telegram login carries none) and does not bump updated_at (an infra
|
||||
// routing field, not a user profile edit).
|
||||
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
|
||||
if language == "" {
|
||||
return nil
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.ServiceLanguage).
|
||||
SET(postgres.String(language)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: set service language %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelToAccount projects a generated model row into the public Account struct.
|
||||
func modelToAccount(row model.Accounts) Account {
|
||||
var mergedInto uuid.UUID
|
||||
if row.MergedInto != nil {
|
||||
mergedInto = *row.MergedInto
|
||||
}
|
||||
var serviceLanguage string
|
||||
if row.ServiceLanguage != nil {
|
||||
serviceLanguage = *row.ServiceLanguage
|
||||
}
|
||||
var flaggedHighRateAt time.Time
|
||||
if row.FlaggedHighRateAt != nil {
|
||||
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||
}
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
ServiceLanguage: serviceLanguage,
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
@@ -322,6 +511,9 @@ func modelToAccount(row model.Accounts) Account {
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
FlaggedHighRateAt: flaggedHighRateAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ var (
|
||||
// ErrInvalidEmail is returned for an unparseable email address.
|
||||
ErrInvalidEmail = errors.New("account: invalid email address")
|
||||
// ErrEmailTaken is returned when the email is already confirmed by another
|
||||
// account; binding it would be a merge, which Stage 11 owns.
|
||||
// account; binding it would be a merge, which the link/merge flow owns.
|
||||
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
||||
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
||||
// requesting account.
|
||||
@@ -52,8 +52,8 @@ var (
|
||||
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
||||
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
||||
// matching the session model. Binding an email already confirmed by a different
|
||||
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
|
||||
// using an email as a login is Stage 6, which reuses this mechanism.
|
||||
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||
// and using an email as a login reuses this mechanism.
|
||||
type EmailService struct {
|
||||
store *Store
|
||||
mailer Mailer
|
||||
@@ -128,7 +128,7 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
|
||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
||||
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
|
||||
// the unauthenticated email-login entry point and, unlike RequestCode,
|
||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||
// login. The code is mailed to the address, so only its real owner can complete
|
||||
// the login. It returns the target account id for the subsequent LoginWithCode.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// ErrIdentityTaken is returned when a platform identity being linked already
|
||||
// belongs to another account; the caller turns it into a merge.
|
||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||
|
||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||
// (taken or already-confirmed): possession of the address is the authorization for
|
||||
// a later link or merge, and the merge is only revealed once the code is verified,
|
||||
// so a probe cannot learn whether an address is registered.
|
||||
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
|
||||
return err
|
||||
}
|
||||
subject := "Your Scrabble confirmation code"
|
||||
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
|
||||
return s.mailer.Send(ctx, addr, subject, body)
|
||||
}
|
||||
|
||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||
// current owner. When the address is free it binds a confirmed email identity to
|
||||
// accountID and returns (accountID, true, nil). When accountID already owns it,
|
||||
// it returns (accountID, true, nil) unchanged. When another account owns it, it
|
||||
// returns (owner, false, nil) without consuming the code, so the explicit merge
|
||||
// step can re-verify the same live code. It returns the usual confirm-code errors
|
||||
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch).
|
||||
func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
if ok {
|
||||
if owner == accountID {
|
||||
return accountID, true, nil
|
||||
}
|
||||
return owner, false, nil
|
||||
}
|
||||
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return accountID, true, nil
|
||||
}
|
||||
|
||||
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
|
||||
// addr), counting a wrong attempt. It returns the confirmation on success.
|
||||
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
|
||||
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
|
||||
if err != nil {
|
||||
return emailConfirmation{}, err
|
||||
}
|
||||
if s.now().After(conf.expiresAt) {
|
||||
return emailConfirmation{}, ErrCodeExpired
|
||||
}
|
||||
if conf.attempts >= emailCodeMaxAttempts {
|
||||
return emailConfirmation{}, ErrTooManyAttempts
|
||||
}
|
||||
if hashCode(code) != conf.codeHash {
|
||||
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
|
||||
return emailConfirmation{}, err
|
||||
}
|
||||
return emailConfirmation{}, ErrCodeMismatch
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
||||
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
||||
// flow.
|
||||
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return acc.ID, true, nil
|
||||
}
|
||||
|
||||
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
||||
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
||||
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
||||
// to the current account during linking.
|
||||
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("account: new identity id: %w", err)
|
||||
}
|
||||
ins := table.Identities.INSERT(
|
||||
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
|
||||
table.Identities.ExternalID, table.Identities.Confirmed,
|
||||
).VALUES(id, accountID, kind, externalID, confirmed)
|
||||
if _, err := ins.ExecContext(ctx, s.db); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrIdentityTaken
|
||||
}
|
||||
return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
||||
// to a durable account once it gains its first identity. It is a no-op
|
||||
// for an already-durable account.
|
||||
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
||||
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
|
||||
)
|
||||
if _, err := upd.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: clear guest %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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,9 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
@@ -21,14 +23,20 @@ import (
|
||||
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
||||
const maxDisplayName = 32
|
||||
|
||||
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
|
||||
// every name rune that is neither a letter nor a space) an editable display name may
|
||||
// carry, so a still-well-formed name cannot be made of mostly punctuation.
|
||||
const maxDisplayNameSpecials = 5
|
||||
|
||||
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
||||
const maxAwayWindow = 12 * time.Hour
|
||||
|
||||
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
||||
// displayNameRe enforces the editable display-name format: Unicode letters
|
||||
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
||||
// by a single space. No leading or trailing separator and no two adjacent separators,
|
||||
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
|
||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
|
||||
// by a single space. No leading separator and no two adjacent separators (except
|
||||
// "<dot|underscore> <space>"); a single trailing "." is allowed, so
|
||||
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
|
||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
|
||||
|
||||
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
||||
// field (an unknown language, an invalid timezone, or an over-long display name).
|
||||
@@ -107,9 +115,51 @@ func ValidateDisplayName(raw string) (string, error) {
|
||||
if !displayNameRe.MatchString(name) {
|
||||
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
||||
}
|
||||
specials := 0
|
||||
for _, r := range name {
|
||||
if r != ' ' && !unicode.IsLetter(r) {
|
||||
specials++
|
||||
}
|
||||
}
|
||||
if specials > maxDisplayNameSpecials {
|
||||
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram
|
||||
// first name) to the editable display-name format: it keeps the maximal runs of
|
||||
// Unicode letters and joins them with a single space, dropping every other rune
|
||||
// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The
|
||||
// result therefore always satisfies ValidateDisplayName, or is empty when the input
|
||||
// carries no letters — in which case the caller substitutes placeholderDisplayName.
|
||||
// Mirroring the profile editor's rule means a connector-provisioned name is editable
|
||||
// later without first failing validation.
|
||||
func sanitizeDisplayName(raw string) string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) })
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
name := strings.Join(fields, " ")
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// placeholderDisplayName builds a fallback display name for a platform account whose
|
||||
// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or
|
||||
// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally
|
||||
// carries digits and a hyphen, so it lies outside the editable format and the player
|
||||
// is expected to rename it; provisioned names bypass that editor validation.
|
||||
func placeholderDisplayName(lang string) string {
|
||||
prefix := "Player"
|
||||
if lang == "ru" {
|
||||
prefix = "Игрок"
|
||||
}
|
||||
return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000))
|
||||
}
|
||||
|
||||
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
||||
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
||||
// "no away time" and is allowed.
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
||||
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
||||
// confirms UpdateProfile wires the Stage 8 validators (name format, away window,
|
||||
// confirms UpdateProfile wires the validators (name format, away window,
|
||||
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
||||
func TestUpdateProfileValidation(t *testing.T) {
|
||||
s := &Store{}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
@@ -8,7 +9,8 @@ import (
|
||||
|
||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||
// the first-name / username display-name precedence, and trimming.
|
||||
// the first-name / username display-name precedence, and the sanitization that
|
||||
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
|
||||
func TestTelegramSeed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, username, firstName string
|
||||
@@ -21,8 +23,11 @@ func TestTelegramSeed(t *testing.T) {
|
||||
"empty language": {"", "neo", "Neo", "", "Neo"},
|
||||
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
||||
"username fallback": {"en", "handle", "", "en", "handle"},
|
||||
"both empty": {"en", "", "", "en", ""},
|
||||
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
||||
"emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"},
|
||||
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -37,6 +42,28 @@ func TestTelegramSeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to
|
||||
// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
|
||||
func TestTelegramSeedPlaceholder(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, username, firstName string
|
||||
wantRe string
|
||||
}{
|
||||
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName
|
||||
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
|
||||
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
||||
// maxDisplayName runes (counted in runes, not bytes).
|
||||
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// ReapAbandonedGuests deletes guest accounts created before olderThan that are
|
||||
// not seated in any game. It returns the number deleted.
|
||||
//
|
||||
// Scope is deliberately "no game seat at all", not merely "no active game": a
|
||||
// finished game belongs to the other players' history, and game_players carries no
|
||||
// ON DELETE CASCADE to accounts (docs/ARCHITECTURE.md §4), so a guest with any seat
|
||||
// is retained (and a delete would be blocked by that foreign key regardless). The
|
||||
// dependent rows of a reaped guest — sessions, identities, account_stats — fall
|
||||
// away through their own ON DELETE CASCADE foreign keys. Account age is the
|
||||
// abandonment signal because sessions are revoke-only with no maintained
|
||||
// last_seen_at, so a lingering session never expires on its own.
|
||||
func (s *Store) ReapAbandonedGuests(ctx context.Context, olderThan time.Time) (int64, error) {
|
||||
stmt := table.Accounts.DELETE().WHERE(
|
||||
table.Accounts.IsGuest.EQ(postgres.Bool(true)).
|
||||
AND(table.Accounts.CreatedAt.LT(postgres.TimestampzT(olderThan))).
|
||||
AND(postgres.NOT(postgres.EXISTS(
|
||||
postgres.SELECT(table.GamePlayers.AccountID).
|
||||
FROM(table.GamePlayers).
|
||||
WHERE(table.GamePlayers.AccountID.EQ(table.Accounts.AccountID)),
|
||||
))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("account: reap guests: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("account: reap guests rows affected: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GuestReaper periodically deletes abandoned guest accounts via
|
||||
// Store.ReapAbandonedGuests. It mirrors the game turn-timeout sweeper and the
|
||||
// matchmaker reaper: one background goroutine, started once from main.
|
||||
type GuestReaper struct {
|
||||
store *Store
|
||||
retention time.Duration
|
||||
clock func() time.Time
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewGuestReaper constructs a reaper deleting guests whose account age exceeds
|
||||
// retention. log may be nil.
|
||||
func NewGuestReaper(store *Store, retention time.Duration, log *zap.Logger) *GuestReaper {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &GuestReaper{
|
||||
store: store,
|
||||
retention: retention,
|
||||
clock: func() time.Time { return time.Now().UTC() },
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Run reaps abandoned guests on each tick until ctx is cancelled.
|
||||
func (r *GuestReaper) Run(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := r.store.ReapAbandonedGuests(ctx, r.clock().Add(-r.retention))
|
||||
if err != nil {
|
||||
r.log.Warn("guest reap failed", zap.Error(err))
|
||||
} else if n > 0 {
|
||||
r.log.Info("reaped abandoned guests", zap.Int64("count", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
||||
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
|
||||
// profile editor stores (an offset dropdown rather than an IANA name).
|
||||
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
||||
|
||||
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
||||
|
||||
@@ -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, "?", "_")
|
||||
}
|
||||
@@ -21,10 +21,15 @@ func TestValidateDisplayName(t *testing.T) {
|
||||
"adjacent specials": {"Name P._Last", "", false},
|
||||
"two spaces": {"Name Last", "", false},
|
||||
"leading special": {"_Name", "", false},
|
||||
"trailing special": {"Name.", "", false},
|
||||
"trailing underscore": {"Name_", "", false},
|
||||
"trailing dot ok": {"Anna B.", "Anna B.", true},
|
||||
"double trailing dot": {"Name..", "", false},
|
||||
"digit rejected": {"Name2", "", false},
|
||||
"blank": {" ", "", false},
|
||||
"too long": {strings.Repeat("a", 33), "", false},
|
||||
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
|
||||
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
|
||||
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// 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
|
||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||
// (accounts.merged_into). It is the data core of account linking & merge
|
||||
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
||||
// one layer up (the link service), since the in-memory session cache lives there.
|
||||
package accountmerge
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// statusActive mirrors game.StatusActive; the active-shared-game guard reads it
|
||||
// without taking a dependency on the game package.
|
||||
const statusActive = "active"
|
||||
|
||||
// Friendship statuses, highest precedence first, mirroring internal/social.
|
||||
const (
|
||||
friendAccepted = "accepted"
|
||||
friendPending = "pending"
|
||||
friendDeclined = "declined"
|
||||
)
|
||||
|
||||
// ErrActiveGameConflict is returned when the primary and secondary accounts share
|
||||
// an active game: merging would seat one player against themselves, so the caller
|
||||
// must wait for the game to finish.
|
||||
var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game")
|
||||
|
||||
// ErrSameAccount is returned when primary and secondary are the same account.
|
||||
var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account")
|
||||
|
||||
// Merger merges accounts over a Postgres handle.
|
||||
type Merger struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewMerger constructs a Merger over db.
|
||||
func NewMerger(db *sql.DB) *Merger {
|
||||
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// Merge retires secondary into primary atomically. The secondary is kept as a
|
||||
// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared
|
||||
// finished game stay valid; its seat in such a game is left untouched. The merge
|
||||
// is refused with ErrActiveGameConflict when the two share an active game.
|
||||
func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error {
|
||||
if primary == secondary {
|
||||
return ErrSameAccount
|
||||
}
|
||||
now := m.now()
|
||||
return withTx(ctx, m.db, func(tx *sql.Tx) error {
|
||||
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: identities: %w", err)
|
||||
}
|
||||
if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: chat: %w", err)
|
||||
}
|
||||
if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: complaints: %w", err)
|
||||
}
|
||||
if err := mergeFriendships(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeBlocks(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeInvitations(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
return tombstone(ctx, tx, primary, secondary, now)
|
||||
})
|
||||
}
|
||||
|
||||
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
|
||||
// are both seated in the same active game.
|
||||
func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
pri, err := activeGameIDs(ctx, tx, primary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pri) == 0 {
|
||||
return nil
|
||||
}
|
||||
sec, err := activeGameIDs(ctx, tx, secondary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := make(map[uuid.UUID]struct{}, len(pri))
|
||||
for _, id := range pri {
|
||||
have[id] = struct{}{}
|
||||
}
|
||||
for _, id := range sec {
|
||||
if _, ok := have[id]; ok {
|
||||
return ErrActiveGameConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeGameIDs lists the active games accountID is seated in.
|
||||
func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.GamePlayers.GameID).
|
||||
FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))).
|
||||
WHERE(
|
||||
table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Games.Status.EQ(postgres.String(statusActive))),
|
||||
)
|
||||
var rows []model.GamePlayers
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.GameID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
||||
// summed, max points kept) and deletes the secondary row.
|
||||
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.AccountStats
|
||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("accountmerge: load secondary stats: %w", err)
|
||||
}
|
||||
|
||||
ensure := table.AccountStats.INSERT(table.AccountStats.AccountID).
|
||||
VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING()
|
||||
if _, err := ensure.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: ensure primary stats: %w", err)
|
||||
}
|
||||
var pri model.AccountStats
|
||||
if err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
FROM(table.AccountStats).
|
||||
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))).
|
||||
FOR(postgres.UPDATE()).
|
||||
QueryContext(ctx, tx, &pri); err != nil {
|
||||
return fmt.Errorf("accountmerge: lock primary stats: %w", err)
|
||||
}
|
||||
|
||||
upd := table.AccountStats.UPDATE(
|
||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
||||
).SET(
|
||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: update primary stats: %w", err)
|
||||
}
|
||||
|
||||
del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary stats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||
// all other profile fields stay the primary's.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.Accounts
|
||||
if err := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec); err != nil {
|
||||
return fmt.Errorf("accountmerge: load secondary account: %w", err)
|
||||
}
|
||||
upd := table.Accounts.UPDATE(
|
||||
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
|
||||
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: update primary account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// transferGamePlayers moves secondary's seats to primary, except in a game primary
|
||||
// already sits in (a shared finished game — active is barred by the guard), where
|
||||
// the secondary seat is left as the tombstone so the no-cascade FK stays valid.
|
||||
func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
var prows []model.GamePlayers
|
||||
if err := postgres.SELECT(table.GamePlayers.GameID).
|
||||
FROM(table.GamePlayers).
|
||||
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))).
|
||||
QueryContext(ctx, tx, &prows); err != nil {
|
||||
if !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: primary seats: %w", err)
|
||||
}
|
||||
}
|
||||
cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary))
|
||||
if len(prows) > 0 {
|
||||
ids := make([]postgres.Expression, len(prows))
|
||||
for i, r := range prows {
|
||||
ids[i] = postgres.UUID(r.GameID)
|
||||
}
|
||||
cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...))
|
||||
}
|
||||
upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond)
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: transfer seats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reassignColumn blanket-reassigns a no-collision account column from secondary to
|
||||
// primary (identities, chat sender, complaint complainant).
|
||||
func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error {
|
||||
upd := tbl.UPDATE(col).SET(postgres.UUID(primary)).
|
||||
WHERE(col.EQ(postgres.UUID(secondary)))
|
||||
_, err := upd.ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// friendRank ranks a friendship status for dedupe precedence (higher wins).
|
||||
func friendRank(status string) int {
|
||||
switch status {
|
||||
case friendAccepted:
|
||||
return 3
|
||||
case friendPending:
|
||||
return 2
|
||||
case friendDeclined:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// mergeFriendships repoints secondary's friendships to primary, dropping the direct
|
||||
// primary-secondary edge (it would become a self-edge) and de-duplicating a shared
|
||||
// counterparty by keeping the higher-precedence status (accepted > pending >
|
||||
// declined). Each account has at most one edge per unordered pair, so the per-other
|
||||
// decision is unambiguous.
|
||||
func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop self-friendship: %w", err)
|
||||
}
|
||||
|
||||
priByOther := map[uuid.UUID]string{}
|
||||
var prows []model.Friendships
|
||||
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil {
|
||||
return fmt.Errorf("accountmerge: primary friendships: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status
|
||||
}
|
||||
|
||||
var srows []model.Friendships
|
||||
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil {
|
||||
return fmt.Errorf("accountmerge: secondary friendships: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
other := otherOf(r.RequesterID, r.AddresseeID, secondary)
|
||||
if priStatus, ok := priByOther[other]; ok {
|
||||
if friendRank(r.Status) <= friendRank(priStatus) {
|
||||
if err := deleteEdge(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dominated friendship: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
|
||||
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop superseded friendship: %w", err)
|
||||
}
|
||||
}
|
||||
if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID,
|
||||
r.RequesterID, r.AddresseeID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint friendship: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBlocks repoints secondary's blocks to primary, dropping the direct
|
||||
// primary-secondary block (a self-block) and de-duplicating a counterparty already
|
||||
// blocked by primary in either direction (a block is undirected for suppression).
|
||||
func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
if err := deletePair(ctx, tx, table.Blocks.DELETE(),
|
||||
table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop self-block: %w", err)
|
||||
}
|
||||
|
||||
priOthers := map[uuid.UUID]struct{}{}
|
||||
var prows []model.Blocks
|
||||
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil {
|
||||
return fmt.Errorf("accountmerge: primary blocks: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{}
|
||||
}
|
||||
|
||||
var srows []model.Blocks
|
||||
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil {
|
||||
return fmt.Errorf("accountmerge: secondary blocks: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok {
|
||||
if err := deleteEdge(ctx, tx, table.Blocks.DELETE(),
|
||||
table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dup block: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID,
|
||||
r.BlockerID, r.BlockedID, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint block: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeInvitations deletes secondary's pending invitations as inviter (cascading to
|
||||
// their invitees) and repoints its invitee rows to primary, dropping a row where
|
||||
// primary is already an invitee of the same invitation.
|
||||
func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
|
||||
delInv := table.GameInvitations.DELETE().
|
||||
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := delInv.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary invitations: %w", err)
|
||||
}
|
||||
|
||||
priInv := map[uuid.UUID]struct{}{}
|
||||
var prows []model.GameInvitationInvitees
|
||||
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))).
|
||||
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: primary invitees: %w", err)
|
||||
}
|
||||
for _, r := range prows {
|
||||
priInv[r.InvitationID] = struct{}{}
|
||||
}
|
||||
|
||||
var srows []model.GameInvitationInvitees
|
||||
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
|
||||
FROM(table.GameInvitationInvitees).
|
||||
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: secondary invitees: %w", err)
|
||||
}
|
||||
for _, r := range srows {
|
||||
where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)).
|
||||
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, dup := priInv[r.InvitationID]; dup {
|
||||
if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: drop dup invitee: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID).
|
||||
SET(postgres.UUID(primary)).WHERE(where)
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: repoint invitee: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteEphemerals drops the secondary's pending email confirmations and friend
|
||||
// codes (short-lived, single-use; not worth carrying over).
|
||||
func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error {
|
||||
if _, err := table.EmailConfirmations.DELETE().
|
||||
WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))).
|
||||
ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete confirmations: %w", err)
|
||||
}
|
||||
if _, err := table.FriendCodes.DELETE().
|
||||
WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))).
|
||||
ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete friend codes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tombstone marks secondary retired, pointing at primary for audit.
|
||||
func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt).
|
||||
SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: tombstone secondary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// otherOf returns the endpoint of a two-account edge that is not self.
|
||||
func otherOf(a, b, self uuid.UUID) uuid.UUID {
|
||||
if a == self {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// selectEdges loads the rows of a symmetric two-column edge table touching account.
|
||||
func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error {
|
||||
err := postgres.SELECT(cols).
|
||||
FROM(tbl).
|
||||
WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))).
|
||||
QueryContext(ctx, tx, dest)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// deletePair deletes the directed-or-reverse edge between a and b.
|
||||
func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error {
|
||||
cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))).
|
||||
OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a))))
|
||||
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteEdge deletes the single edge identified by its (left, right) primary key.
|
||||
func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error {
|
||||
cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r)))
|
||||
_, err := del.WHERE(cond).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping
|
||||
// the edge's direction.
|
||||
func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error {
|
||||
var col postgres.ColumnString
|
||||
var where postgres.BoolExpression
|
||||
if l == secondary {
|
||||
col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r)))
|
||||
} else {
|
||||
col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary)))
|
||||
}
|
||||
_, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("accountmerge: begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("accountmerge: commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Admin console stylesheet. Deliberately small and dependency-free: the console
|
||||
is an internal operator tool served under /_gm, not a public surface. */
|
||||
:root {
|
||||
--bg: #11151c;
|
||||
--panel: #1b2230;
|
||||
--panel-hi: #232c3d;
|
||||
--ink: #e6ebf2;
|
||||
--ink-dim: #9aa7ba;
|
||||
--line: #2c3850;
|
||||
--accent: #5aa9ff;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #4ecb8d;
|
||||
--warn: #f1c453;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
|
||||
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
|
||||
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
|
||||
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
||||
.lede { color: var(--ink-dim); margin-top: 0; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1.2rem 0; }
|
||||
.card {
|
||||
display: block;
|
||||
padding: 1rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.card:hover { background: var(--panel-hi); text-decoration: none; }
|
||||
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
|
||||
.card .bignum { font-size: 1.8rem; margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.panel {
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
|
||||
.kv { list-style: none; margin: 0; padding: 0; }
|
||||
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
|
||||
.kv li b { color: var(--ink); font-weight: 600; }
|
||||
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
|
||||
.ok { color: var(--ok); }
|
||||
.bad { color: var(--danger); }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
|
||||
.list th { color: var(--ink-dim); font-weight: 600; }
|
||||
.list tr:hover td { background: var(--panel-hi); }
|
||||
.list td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
|
||||
.subnav { color: var(--ink-dim); margin: -0.2rem 0 1rem; font-size: 0.9rem; }
|
||||
.subnav a.active { color: var(--ink); }
|
||||
|
||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
||||
.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; }
|
||||
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
||||
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||
.form input, .form select, .form textarea {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font: inherit;
|
||||
}
|
||||
.form textarea { min-height: 4rem; resize: vertical; }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #06121f;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.danger { background: var(--danger); color: #1a0606; }
|
||||
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||
.actions form { margin: 0; }
|
||||
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
||||
|
||||
/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */
|
||||
.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; }
|
||||
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; }
|
||||
.chart .lbl { fill: var(--ink-dim); font-size: 11px; }
|
||||
.chart .ln { fill: none; stroke-width: 1.5; }
|
||||
.chart .ln-min { stroke: var(--ok); }
|
||||
.chart .ln-avg { stroke: var(--accent); }
|
||||
.chart .ln-max { stroke: var(--danger); }
|
||||
.lg { font-weight: 600; }
|
||||
.lg-min { color: var(--ok); }
|
||||
.lg-avg { color: var(--accent); }
|
||||
.lg-max { color: var(--danger); }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package adminconsole renders the backend's server-side admin console: a small,
|
||||
// dependency-free set of Go html/template pages plus one embedded stylesheet,
|
||||
// served under /_gm. It owns the rendering and the page view models only; the gin
|
||||
// handlers (internal/server) fetch the domain data, populate the view models and
|
||||
// gate the surface — the gateway puts HTTP Basic-Auth in front of /_gm and a
|
||||
// same-origin check guards the POST actions (docs/ARCHITECTURE.md §12). It mirrors
|
||||
// the shape of galaxy-game's adminconsole package, minus the per-operator CSRF
|
||||
// token and operator name (this console tracks no operator identity).
|
||||
package adminconsole
|
||||
@@ -0,0 +1,101 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Renderer holds the parsed admin console templates. It composes one template set
|
||||
// per content page, each combining the shared layout (the page chrome and the
|
||||
// "layout" entry template) with that page's "content" block, so rendering a page
|
||||
// is a single ExecuteTemplate call against "layout".
|
||||
type Renderer struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// PageData is the view model passed to every admin console page. Title is the
|
||||
// document title; ActiveNav marks the highlighted navigation entry; Data carries
|
||||
// the page-specific payload (one of the *View types in views.go).
|
||||
type PageData struct {
|
||||
Title string
|
||||
ActiveNav string
|
||||
Data any
|
||||
}
|
||||
|
||||
// NewRenderer parses the embedded layout and every content page under
|
||||
// templates/pages. It fails when a template cannot be parsed.
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
||||
}
|
||||
|
||||
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
||||
}
|
||||
if len(pageFiles) == 0 {
|
||||
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
||||
}
|
||||
|
||||
pages := make(map[string]*template.Template, len(pageFiles))
|
||||
for _, file := range pageFiles {
|
||||
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
||||
clone, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
||||
}
|
||||
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
||||
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
||||
}
|
||||
pages[name] = clone
|
||||
}
|
||||
|
||||
return &Renderer{pages: pages}, nil
|
||||
}
|
||||
|
||||
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
||||
// embedded at build time, so a parse failure is a programmer error.
|
||||
func MustNewRenderer() *Renderer {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
// Render writes the named page, wrapped in the shared layout, to w using data. It
|
||||
// renders into an intermediate buffer first, so a mid-render failure never emits
|
||||
// a partial document. It returns an error for an unknown page or a failed render.
|
||||
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
||||
tmpl, ok := r.pages[page]
|
||||
if !ok {
|
||||
return fmt.Errorf("admin console: unknown page %q", page)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
return fmt.Errorf("render admin console page %q: %w", page, err)
|
||||
}
|
||||
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// Assets returns the embedded static asset tree rooted at the assets directory,
|
||||
// suitable for serving under /_gm/assets/.
|
||||
func Assets() (fs.FS, error) {
|
||||
return fs.Sub(assetsFS, "assets")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRendererRendersEveryPage parses the embedded templates and renders each
|
||||
// page with a representative view, asserting the page executes, carries the
|
||||
// shared layout chrome and shows a distinctive value.
|
||||
func TestRendererRendersEveryPage(t *testing.T) {
|
||||
r, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("new renderer: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
page string
|
||||
data any
|
||||
want string
|
||||
}{
|
||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"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"},
|
||||
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
||||
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.page, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := r.Render(&buf, tc.page, PageData{Title: tc.page, Data: tc.data}); err != nil {
|
||||
t.Fatalf("render %s: %v", tc.page, err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Errorf("render %s: missing %q in output", tc.page, tc.want)
|
||||
}
|
||||
if !strings.Contains(out, "Scrabble · admin") {
|
||||
t.Errorf("render %s: missing layout chrome", tc.page)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRendererUnknownPage reports an error for a page that does not exist.
|
||||
func TestRendererUnknownPage(t *testing.T) {
|
||||
r := MustNewRenderer()
|
||||
if err := r.Render(&bytes.Buffer{}, "nope", PageData{}); err == nil {
|
||||
t.Fatal("expected an error rendering an unknown page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssets confirms the stylesheet is embedded and reachable under the assets
|
||||
// root.
|
||||
func TestAssets(t *testing.T) {
|
||||
fsys, err := Assets()
|
||||
if err != nil {
|
||||
t.Fatalf("assets: %v", err)
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "console.css"); err != nil {
|
||||
t.Errorf("console.css not embedded: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{{define "layout" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{.Title}} · Scrabble admin</title>
|
||||
<link rel="stylesheet" href="/_gm/assets/console.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">Scrabble · admin</span>
|
||||
<nav class="mainnav">
|
||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</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/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</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/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="content">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "content" -}}
|
||||
<h1>Broadcast</h1>
|
||||
{{with .Data}}
|
||||
<section class="panel"><h2>Post to the game channel</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/broadcast">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
||||
<div><button type="submit">Post to channel</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
<p class="note">To message a single user, open their <a href="/_gm/users">user page</a>.</p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Complaint: {{.Word}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/complaints">« complaints</a></nav>
|
||||
<section class="panel"><h2>Details</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Word</b> <code>{{.Word}}</code></li>
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Lookup at filing</b> {{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</li>
|
||||
<li><b>Filer note</b> {{if .Note}}{{.Note}}{{else}}<span class="note">none</span>{{end}}</li>
|
||||
<li><b>Game</b> <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a></li>
|
||||
<li><b>Filed</b> {{.CreatedAt}}</li>
|
||||
<li><b>Status</b> {{.Status}}</li>
|
||||
{{if .Resolved}}<li><b>Disposition</b> {{.Disposition}}</li><li><b>Resolution note</b> {{.ResolutionNote}}</li><li><b>Resolved</b> {{.ResolvedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}</h2>
|
||||
<form class="form col" method="post" action="/_gm/complaints/{{.ID}}/resolve">
|
||||
<label>Disposition
|
||||
<select name="disposition">
|
||||
<option value="reject">reject — dictionary is correct</option>
|
||||
<option value="accept_add">accept — add word to the dictionary</option>
|
||||
<option value="accept_remove">accept — remove word from the dictionary</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Note <textarea name="note"></textarea></label>
|
||||
<div><button type="submit">Resolve</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content" -}}
|
||||
<h1>Complaints</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/complaints?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
|
||||
<a href="/_gm/complaints?status=resolved"{{if eq .Status "resolved"}} class="active"{{end}}>resolved</a> ·
|
||||
<a href="/_gm/complaints"{{if eq .Status ""}} class="active"{{end}}>all</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Word</th><th>Variant</th><th>Was valid</th><th>Status</th><th>Disposition</th><th>Filed</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/complaints/{{.ID}}">{{.Word}}</a></td>
|
||||
<td>{{.Variant}}</td>
|
||||
<td>{{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
<td>{{.Disposition}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no complaints</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="lede">Operator console for users, games, complaints and dictionaries.</p>
|
||||
{{with .Data}}
|
||||
<div class="cards">
|
||||
<a class="card" href="/_gm/users"><h2>Users</h2><p class="bignum">{{.Accounts}}</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/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
|
||||
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h2>Dictionaries</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident versions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dictionary</h1>
|
||||
{{with .Data}}
|
||||
<section class="panel"><h2>Resident versions</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Hot-reload a version</h2>
|
||||
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.</p>
|
||||
<form class="form" method="post" action="/_gm/dictionary/reload">
|
||||
<label>Version <input type="text" name="version" placeholder="v2" required></label>
|
||||
<div><button type="submit">Reload</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Action</th><th>Word</th><th>Resolved</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Changes}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Action}}</td><td><code>{{.Word}}</code></td><td>{{.ResolvedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no pending changes</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||
<label>Mark applied for variant
|
||||
<select name="variant">
|
||||
<option value="scrabble_en">scrabble_en</option>
|
||||
<option value="scrabble_ru">scrabble_ru</option>
|
||||
<option value="erudit_ru">erudit_ru</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
||||
<div><button type="submit">Mark applied</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Game {{.ID}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/games">« games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
|
||||
<section class="panel"><h2>Summary</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||
<li><b>Players</b> {{.Players}}</li>
|
||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
<li><b>Updated</b> {{.UpdatedAt}}</li>
|
||||
{{if .FinishedAt}}<li><b>Finished</b> {{.FinishedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Seats</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Seats}}
|
||||
<tr><td>{{.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>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "content" -}}
|
||||
<h1>Games</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
|
||||
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
|
||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.Heading}}</h1>
|
||||
<p>{{.Body}}</p>
|
||||
<p><a href="{{.Back}}">« back</a></p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{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 (* ?)">
|
||||
<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>Game</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.Source}}</td>
|
||||
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
|
||||
<td>{{.IP}}</td>
|
||||
<td>{{.Body}}</td>
|
||||
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6"><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,39 @@
|
||||
{{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>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}}
|
||||
@@ -0,0 +1,75 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.DisplayName}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
|
||||
<div class="cards">
|
||||
<section class="panel"><h2>Account</h2>
|
||||
<ul class="kv">
|
||||
<li><b>ID</b> {{.ID}}</li>
|
||||
<li><b>Language</b> {{.Language}}</li>
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
||||
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
</ul>
|
||||
{{if .FlaggedHighRateAt}}
|
||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/clear-high-rate-flag">
|
||||
<button type="submit">Clear high-rate flag</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
<ul class="kv">
|
||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||
</ul>
|
||||
{{else}}<p class="note">no statistics</p>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{if .MoveChart}}
|
||||
<section class="panel"><h2>Move timing</h2>
|
||||
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||
{{.MoveChart}}
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Identities</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Identities}}
|
||||
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{if .TelegramID}}
|
||||
<section class="panel"><h2>Send Telegram message</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
||||
<div><button type="submit">Send to user</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Games}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "content" -}}
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
||||
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||
</nav>
|
||||
<form class="form" method="get" action="/_gm/users">
|
||||
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
||||
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Language}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="8"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,289 @@
|
||||
package adminconsole
|
||||
|
||||
import "html/template"
|
||||
|
||||
// The *View types are the display models the gin handlers fill and the templates
|
||||
// render. Time values are pre-formatted to strings by the handlers so the
|
||||
// templates stay logic-free.
|
||||
|
||||
// Pager is the shared list pagination state.
|
||||
type Pager struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// NewPager builds the pagination state for a 1-based page of pageSize over total
|
||||
// items.
|
||||
func NewPager(page, pageSize, total int) Pager {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1}
|
||||
p.HasPrev = page > 1
|
||||
p.HasNext = page*pageSize < total
|
||||
return p
|
||||
}
|
||||
|
||||
// VariantVersions lists the dictionary versions resident for one variant.
|
||||
type VariantVersions struct {
|
||||
Variant string
|
||||
Latest string
|
||||
Versions []string
|
||||
}
|
||||
|
||||
// DashboardView is the landing-page summary.
|
||||
type DashboardView struct {
|
||||
Accounts int
|
||||
Games int
|
||||
ActiveGames int
|
||||
OpenComplaints int
|
||||
PendingChanges int
|
||||
Variants []VariantVersions
|
||||
}
|
||||
|
||||
// UsersView is the paginated account list.
|
||||
type UsersView struct {
|
||||
Items []UserRow
|
||||
Pager Pager
|
||||
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
||||
// glob filters; FilterQuery is those encoded for pager/toggle links.
|
||||
Robots bool
|
||||
NameMask string
|
||||
ExternalIDMask string
|
||||
FilterQuery string
|
||||
}
|
||||
|
||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||
// pre-formatted move-duration summary (empty when it has no timed move);
|
||||
// FlaggedHighRate marks the soft high-rate badge.
|
||||
type UserRow struct {
|
||||
ID string
|
||||
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 encoded for the pager links.
|
||||
type MessagesView struct {
|
||||
Items []MessageRow
|
||||
Pager Pager
|
||||
NameMask string
|
||||
ExtMask string
|
||||
GameID string
|
||||
UserID string
|
||||
FilterQuery string
|
||||
}
|
||||
|
||||
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
||||
// card), source, IP, body, game (linked to the game card) and time.
|
||||
type MessageRow struct {
|
||||
ID string
|
||||
SenderID string
|
||||
SenderName string
|
||||
Source string
|
||||
IP string
|
||||
Body string
|
||||
GameID string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// UserDetailView is one account with its stats, identities and recent games.
|
||||
type UserDetailView struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
Language string
|
||||
TimeZone string
|
||||
Guest bool
|
||||
NotificationsInAppOnly bool
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge, or empty for a live account.
|
||||
MergedInto string
|
||||
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||
// empty for an unflagged account; the card shows it with the Clear action.
|
||||
FlaggedHighRateAt string
|
||||
HintBalance int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||
// time (min/mean/max), empty when the account has no timed move.
|
||||
MoveChart template.HTML
|
||||
}
|
||||
|
||||
// StatsRow is an account's lifetime statistics.
|
||||
type StatsRow struct {
|
||||
Wins int
|
||||
Losses int
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
}
|
||||
|
||||
// IdentityRow is one platform/email identity of an account.
|
||||
type IdentityRow struct {
|
||||
Kind string
|
||||
ExternalID string
|
||||
Confirmed bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// GameRow is one game row in a list.
|
||||
type GameRow struct {
|
||||
ID string
|
||||
Variant string
|
||||
Status string
|
||||
Players int
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// GamesView is the paginated games list, optionally filtered by status.
|
||||
type GamesView struct {
|
||||
Items []GameRow
|
||||
Status string
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// GameDetailView is one game with its seats.
|
||||
type GameDetailView struct {
|
||||
ID string
|
||||
Variant string
|
||||
DictVersion string
|
||||
Status string
|
||||
Players int
|
||||
ToMove int
|
||||
EndReason string
|
||||
MoveCount int
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
FinishedAt string
|
||||
Seats []SeatRow
|
||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||
HasRobot bool
|
||||
RobotTargetPct int
|
||||
}
|
||||
|
||||
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
||||
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
|
||||
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
|
||||
type SeatRow struct {
|
||||
Seat int
|
||||
DisplayName string
|
||||
AccountID string
|
||||
Score int
|
||||
HintsUsed int
|
||||
Winner bool
|
||||
IsRobot bool
|
||||
RobotIntent string
|
||||
NextMove string
|
||||
}
|
||||
|
||||
// ComplaintsView is the paginated complaint review queue.
|
||||
type ComplaintsView struct {
|
||||
Items []ComplaintRow
|
||||
Status string
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// ComplaintRow is one complaint row in the queue.
|
||||
type ComplaintRow struct {
|
||||
ID string
|
||||
Word string
|
||||
Variant string
|
||||
WasValid bool
|
||||
Status string
|
||||
Disposition string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// ComplaintDetailView is one complaint with its resolution state and form.
|
||||
type ComplaintDetailView struct {
|
||||
ID string
|
||||
Word string
|
||||
Variant string
|
||||
DictVersion string
|
||||
WasValid bool
|
||||
Note string
|
||||
Status string
|
||||
Disposition string
|
||||
ResolutionNote string
|
||||
CreatedAt string
|
||||
ResolvedAt string
|
||||
GameID string
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// DictionaryView lists the resident versions per variant and the pending
|
||||
// wordlist changes from accepted complaints.
|
||||
type DictionaryView struct {
|
||||
Variants []VariantVersions
|
||||
Changes []DictChangeRow
|
||||
}
|
||||
|
||||
// DictChangeRow is one pending wordlist edit.
|
||||
type DictChangeRow struct {
|
||||
Variant string
|
||||
Word string
|
||||
Action string
|
||||
ResolvedAt string
|
||||
}
|
||||
|
||||
// BroadcastView is the operator-broadcast form page.
|
||||
type BroadcastView struct {
|
||||
ConnectorEnabled bool
|
||||
}
|
||||
|
||||
// ThrottledView is the rate-limit observability page: the recent gateway-reported
|
||||
// throttle episodes (in-memory, reset on restart) and the accounts currently
|
||||
// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
|
||||
// auto-flag tuning.
|
||||
type ThrottledView struct {
|
||||
Episodes []ThrottleEpisodeRow
|
||||
Flagged []FlaggedAccountRow
|
||||
FlagThreshold int
|
||||
FlagWindow string
|
||||
}
|
||||
|
||||
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
|
||||
// user card and is set only for the user class (the other classes key by IP).
|
||||
type ThrottleEpisodeRow struct {
|
||||
Class string
|
||||
Key string
|
||||
UserID string
|
||||
Rejected int
|
||||
FirstSeen string
|
||||
LastSeen string
|
||||
}
|
||||
|
||||
// FlaggedAccountRow is one account carrying the high-rate flag.
|
||||
type FlaggedAccountRow struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
FlaggedAt string
|
||||
}
|
||||
|
||||
// MessageView is the result page shown after a POST action.
|
||||
type MessageView struct {
|
||||
Heading string
|
||||
Body string
|
||||
Back string
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
)
|
||||
@@ -35,9 +36,21 @@ type Config struct {
|
||||
Lobby lobby.Config
|
||||
// Robot configures the robot opponent driver (scan cadence).
|
||||
Robot robot.Config
|
||||
// RateWatch tunes the conservative high-rate auto-flag applied to the
|
||||
// gateway's rate-limiter rejection reports.
|
||||
RateWatch ratewatch.Config
|
||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||
// selects the development log mailer (the code is logged, not sent).
|
||||
SMTP account.SMTPConfig
|
||||
// ConnectorAddr is the gRPC address of the Telegram platform connector
|
||||
// side-service, used by the admin console to send operator broadcasts. Empty
|
||||
// disables broadcasts (the admin broadcast actions report "not configured").
|
||||
ConnectorAddr string
|
||||
// GuestReapInterval is the cadence of the abandoned-guest reaper sweep.
|
||||
GuestReapInterval time.Duration
|
||||
// GuestRetention is the account age past which an unused guest (no game seat)
|
||||
// is eligible for deletion by the reaper.
|
||||
GuestRetention time.Duration
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -45,6 +58,8 @@ const (
|
||||
defaultHTTPAddr = ":8080"
|
||||
defaultGRPCAddr = ":9090"
|
||||
defaultLogLevel = "info"
|
||||
defaultGuestReapInterval = time.Hour
|
||||
defaultGuestRetention = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Load reads the configuration from the environment, applies defaults for unset
|
||||
@@ -94,6 +109,23 @@ func Load() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
rw := ratewatch.DefaultConfig()
|
||||
if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
smtp := account.SMTPConfig{
|
||||
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
||||
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
||||
@@ -111,7 +143,11 @@ func Load() (Config, error) {
|
||||
Game: gm,
|
||||
Lobby: lb,
|
||||
Robot: rb,
|
||||
RateWatch: rw,
|
||||
SMTP: smtp,
|
||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||
GuestReapInterval: guestReapInterval,
|
||||
GuestRetention: guestRetention,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -147,6 +183,15 @@ func (c Config) validate() error {
|
||||
if err := c.Robot.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if err := c.RateWatch.Validate(); err != nil {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if c.GuestReapInterval <= 0 {
|
||||
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
||||
}
|
||||
if c.GuestRetention <= 0 {
|
||||
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -151,12 +151,54 @@ func TestLoadRejectsMalformedDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the MVP
|
||||
// set is rejected.
|
||||
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the
|
||||
// supported set is rejected.
|
||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "prometheus")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAcceptsOTLPExporter verifies that the otlp exporter is now accepted
|
||||
// (the collector is stood up with the deploy; the default stays none).
|
||||
func TestLoadAcceptsOTLPExporter(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_DICT_DIR", "/dict")
|
||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
|
||||
t.Setenv("BACKEND_OTEL_METRICS_EXPORTER", "otlp")
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("Load with otlp exporters: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadGuestReaperDefaultsAndOverride covers the guest-reaper knobs: defaults
|
||||
// when unset, an override, and rejection of a non-positive value.
|
||||
func TestLoadGuestReaperDefaultsAndOverride(t *testing.T) {
|
||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||
t.Setenv("BACKEND_DICT_DIR", "/dict")
|
||||
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.GuestReapInterval != defaultGuestReapInterval {
|
||||
t.Errorf("GuestReapInterval = %s, want %s", c.GuestReapInterval, defaultGuestReapInterval)
|
||||
}
|
||||
if c.GuestRetention != defaultGuestRetention {
|
||||
t.Errorf("GuestRetention = %s, want %s", c.GuestRetention, defaultGuestRetention)
|
||||
}
|
||||
|
||||
t.Setenv("BACKEND_GUEST_RETENTION", "168h")
|
||||
if c, err = Load(); err != nil {
|
||||
t.Fatalf("Load (override): %v", err)
|
||||
} else if c.GuestRetention != 168*time.Hour {
|
||||
t.Errorf("GuestRetention = %s, want 168h", c.GuestRetention)
|
||||
}
|
||||
|
||||
t.Setenv("BACKEND_GUEST_REAP_INTERVAL", "0s")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a non-positive reap interval, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package connector is the backend's gRPC client for the Telegram platform
|
||||
// connector side-service. The admin console uses it to send operator broadcasts:
|
||||
// a direct message to one user, or a post to a game channel. Each broadcast
|
||||
// selects the delivering bot by language (an operator choice, since the connector
|
||||
// hosts one bot per service language). The connector lives on the trusted internal
|
||||
// network, so the connection uses insecure (plaintext) transport credentials
|
||||
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
|
||||
// the two broadcast methods the admin surface needs.
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
)
|
||||
|
||||
// Client wraps the connector's Telegram gRPC service.
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
c telegramv1.TelegramClient
|
||||
}
|
||||
|
||||
// New dials the connector gRPC endpoint at addr.
|
||||
func New(addr string) (*Client, error) {
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connector: dial %s: %w", addr, err)
|
||||
}
|
||||
return &Client{conn: conn, c: telegramv1.NewTelegramClient(conn)}, nil
|
||||
}
|
||||
|
||||
// Close releases the gRPC connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// SendToUser sends an operator text message to one user, addressed by their
|
||||
// platform external_id, through the bot for the given language. delivered reports
|
||||
// whether the connector actually sent it (false when the user has not started that
|
||||
// bot).
|
||||
func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) {
|
||||
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetDelivered(), nil
|
||||
}
|
||||
|
||||
// SendToGameChannel posts an operator text message to the game channel of the bot
|
||||
// for the given language. delivered reports whether the connector sent it (false
|
||||
// when that bot has no channel configured).
|
||||
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
|
||||
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetDelivered(), nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
|
||||
// concrete character and its tile point value. It is the dictionary-independent display
|
||||
// table the edge sends to the client, produced from the variant's solver
|
||||
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
|
||||
// dictionary.
|
||||
type AlphabetEntry struct {
|
||||
// Index is the alphabet-index byte the wire uses for this letter (0..Size-1).
|
||||
Index byte
|
||||
// Letter is the concrete character, in the case the solver ruleset emits (lower).
|
||||
Letter string
|
||||
// Value is the tile's point score.
|
||||
Value int
|
||||
}
|
||||
|
||||
// BlankIndex is the wire sentinel for a blank tile inside an alphabet-index sequence (a
|
||||
// rack or an exchange list). It is out of range of every offered variant's alphabet (the
|
||||
// largest has 33 letters), so it never collides with a real letter index. A placed blank
|
||||
// instead travels as an ordinary tile carrying its designated letter's index alongside a
|
||||
// separate blank flag. The constant is untyped so it serves both byte (FlatBuffers ubyte)
|
||||
// and int (the gateway/backend JSON edge) call sites.
|
||||
const BlankIndex = 0xFF
|
||||
|
||||
// variantCodec is the cached per-variant alphabet data backing the wire helpers: the
|
||||
// ordered display table and a case-insensitive letter→index lookup. Both are derived once
|
||||
// from the solver ruleset (see variantCodecs).
|
||||
type variantCodec struct {
|
||||
table []AlphabetEntry
|
||||
letterToIndex map[string]byte
|
||||
}
|
||||
|
||||
// variantCodecs holds one codec per offered variant, built once at package load from each
|
||||
// ruleset's alphabet and value table. The rulesets are needed only here (not per request),
|
||||
// so the hot path never rebuilds them.
|
||||
var variantCodecs = buildVariantCodecs()
|
||||
|
||||
func buildVariantCodecs() map[Variant]*variantCodec {
|
||||
m := make(map[Variant]*variantCodec, len(Variants()))
|
||||
for _, v := range Variants() {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
size := rs.Alphabet.Size()
|
||||
table := make([]AlphabetEntry, size)
|
||||
lut := make(map[string]byte, size)
|
||||
for i := range size {
|
||||
ch, err := rs.Alphabet.Character(byte(i))
|
||||
if err != nil {
|
||||
// An offered variant's alphabet never yields a bad index; skip defensively.
|
||||
continue
|
||||
}
|
||||
table[i] = AlphabetEntry{Index: byte(i), Letter: ch, Value: rs.Values[i]}
|
||||
lut[strings.ToLower(ch)] = byte(i)
|
||||
}
|
||||
m[v] = &variantCodec{table: table, letterToIndex: lut}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// AlphabetTable returns a copy of variant's full alphabet as an ordered (index, letter,
|
||||
// value) table, or ErrUnknownVariant. Entry i has Index i, so the slice doubles as an
|
||||
// index→(letter, value) lookup. It needs no dictionary — the data comes from the solver
|
||||
// ruleset alone — so it is safe to build for any offered variant and is the same table the
|
||||
// client caches for display while live play exchanges bare indices.
|
||||
func AlphabetTable(v Variant) ([]AlphabetEntry, error) {
|
||||
c, ok := variantCodecs[v]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
out := make([]AlphabetEntry, len(c.table))
|
||||
copy(out, c.table)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LetterForIndex maps one alphabet index to its concrete letter for variant. It is the
|
||||
// wire-decode primitive for a placed tile (a blank carries its designated letter's index).
|
||||
// An out-of-range index is an illegal play.
|
||||
func LetterForIndex(v Variant, idx int) (string, error) {
|
||||
c, ok := variantCodecs[v]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
if idx < 0 || idx >= len(c.table) {
|
||||
return "", fmt.Errorf("%w: alphabet index %d for %s", ErrIllegalPlay, idx, v)
|
||||
}
|
||||
return c.table[idx].Letter, nil
|
||||
}
|
||||
|
||||
// EncodeRack maps a decoded rack (the Game.Hand form: concrete letters with "?" for an
|
||||
// undesignated blank) to wire alphabet indices, using BlankIndex for each blank. It backs
|
||||
// the per-player state view, whose rack the client renders via the cached table.
|
||||
func EncodeRack(v Variant, letters []string) ([]int, error) {
|
||||
c, ok := variantCodecs[v]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
out := make([]int, len(letters))
|
||||
for i, l := range letters {
|
||||
if l == blankLetter {
|
||||
out[i] = BlankIndex
|
||||
continue
|
||||
}
|
||||
idx, ok := c.letterToIndex[strings.ToLower(l)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: rack letter %q for %s", ErrTilesNotOnRack, l, v)
|
||||
}
|
||||
out[i] = int(idx)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecodeTiles maps a wire rack/exchange index list back to the decoded letter form ("?"
|
||||
// for a blank, BlankIndex), for handing to the existing letter-based exchange path.
|
||||
func DecodeTiles(v Variant, idx []int) ([]string, error) {
|
||||
out := make([]string, len(idx))
|
||||
for i, x := range idx {
|
||||
if x == BlankIndex {
|
||||
out[i] = blankLetter
|
||||
continue
|
||||
}
|
||||
l, err := LetterForIndex(v, x)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (exchange)", err)
|
||||
}
|
||||
out[i] = l
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecodeWord maps a sequence of alphabet indices to a concrete word (word-check carries no
|
||||
// blanks). The client constrains input to the variant's alphabet, so every index is a real
|
||||
// letter.
|
||||
func DecodeWord(v Variant, idx []int) (string, error) {
|
||||
var sb strings.Builder
|
||||
for _, x := range idx {
|
||||
l, err := LetterForIndex(v, x)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w (word check)", err)
|
||||
}
|
||||
sb.WriteString(l)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
||||
// contiguous indices, the concrete lower-case characters the solver emits and the standard
|
||||
// tile values. This is the real parity check the UI no longer carries.
|
||||
func TestAlphabetTableEnglish(t *testing.T) {
|
||||
tab, err := AlphabetTable(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
|
||||
}
|
||||
if len(tab) != 26 {
|
||||
t.Fatalf("size = %d, want 26", len(tab))
|
||||
}
|
||||
for i, e := range tab {
|
||||
if int(e.Index) != i {
|
||||
t.Errorf("entry %d has Index %d, want %d (index must equal position)", i, e.Index, i)
|
||||
}
|
||||
}
|
||||
// a=index0/value1, q=index16/value10, z=index25/value10.
|
||||
if tab[0].Letter != "a" || tab[0].Value != 1 {
|
||||
t.Errorf("entry 0 = %q/%d, want a/1", tab[0].Letter, tab[0].Value)
|
||||
}
|
||||
if tab[16].Letter != "q" || tab[16].Value != 10 {
|
||||
t.Errorf("entry 16 = %q/%d, want q/10", tab[16].Letter, tab[16].Value)
|
||||
}
|
||||
if tab[25].Letter != "z" || tab[25].Value != 10 {
|
||||
t.Errorf("entry 25 = %q/%d, want z/10", tab[25].Letter, tab[25].Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlphabetTableRussianVariants pins both Russian variants: they share the 33-letter
|
||||
// alphabet but differ in tile values — most visibly ё (index 6), worth 3 in Russian
|
||||
// Scrabble and 0 in Эрудит.
|
||||
func TestAlphabetTableRussianVariants(t *testing.T) {
|
||||
ru, err := AlphabetTable(VariantRussianScrabble)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
|
||||
}
|
||||
er, err := AlphabetTable(VariantErudit)
|
||||
if err != nil {
|
||||
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
|
||||
}
|
||||
if len(ru) != 33 || len(er) != 33 {
|
||||
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
||||
}
|
||||
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
||||
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
||||
}
|
||||
if ru[6].Letter != "ё" || ru[6].Value != 3 {
|
||||
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
||||
}
|
||||
if er[6].Letter != "ё" || er[6].Value != 0 {
|
||||
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
||||
}
|
||||
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
||||
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlphabetTableUnknownVariant rejects a variant outside the catalogue.
|
||||
func TestAlphabetTableUnknownVariant(t *testing.T) {
|
||||
if _, err := AlphabetTable(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Fatalf("got %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRackCodecRoundTrip pins the rack/exchange index codec the edge uses: EncodeRack maps
|
||||
// concrete letters (with "?" for a blank) to indices (BlankIndex for the blank) and
|
||||
// DecodeTiles inverts it. EncodeRack is case-insensitive so it accepts the lower-case
|
||||
// Hand form and an upper-case letter alike.
|
||||
func TestRackCodecRoundTrip(t *testing.T) {
|
||||
letters := []string{"c", "a", "t", "?"}
|
||||
idx, err := EncodeRack(VariantEnglish, letters)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeRack: %v", err)
|
||||
}
|
||||
if want := []int{2, 0, 19, BlankIndex}; !slices.Equal(idx, want) {
|
||||
t.Fatalf("EncodeRack = %v, want %v", idx, want)
|
||||
}
|
||||
back, err := DecodeTiles(VariantEnglish, idx)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeTiles: %v", err)
|
||||
}
|
||||
if !slices.Equal(back, letters) {
|
||||
t.Fatalf("DecodeTiles = %v, want %v", back, letters)
|
||||
}
|
||||
if up, err := EncodeRack(VariantEnglish, []string{"C"}); err != nil || !slices.Equal(up, []int{2}) {
|
||||
t.Errorf("EncodeRack upper-case = %v,%v; want [2],nil", up, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeWordAndBounds covers the word-check decode and the out-of-range guard.
|
||||
func TestDecodeWordAndBounds(t *testing.T) {
|
||||
w, err := DecodeWord(VariantEnglish, []int{2, 0, 19})
|
||||
if err != nil || w != "cat" {
|
||||
t.Fatalf("DecodeWord = %q,%v; want cat,nil", w, err)
|
||||
}
|
||||
if _, err := LetterForIndex(VariantEnglish, 26); !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("out-of-range index: got %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
if _, err := DecodeWord(VariantEnglish, []int{BlankIndex}); !errors.Is(err, ErrIllegalPlay) {
|
||||
t.Errorf("blank in word: got %v, want ErrIllegalPlay", err)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package engine
|
||||
import (
|
||||
"math/rand"
|
||||
|
||||
"scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// blankTile marks a blank tile in a hand or in the bag, matching the
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// allTiles returns the full multiset of tiles a bag is filled from, in ruleset
|
||||
|
||||
@@ -3,9 +3,9 @@ package engine
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/rules"
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// ActionKind classifies a turn in the move log.
|
||||
|
||||
@@ -3,7 +3,7 @@ package engine
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// blankCellFlag is the bit board cells set for a blank tile (board.go encoding).
|
||||
|
||||
@@ -3,7 +3,7 @@ package engine
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
|
||||
|
||||
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
|
||||
word string
|
||||
want bool
|
||||
}{
|
||||
{"english hit", VariantEnglish, "cat", true},
|
||||
{"english miss", VariantEnglish, "zzzz", false},
|
||||
{"russian hit", VariantRussianScrabble, "кот", true},
|
||||
{"erudit hit", VariantErudit, "кот", true},
|
||||
{"scrabble_en hit", VariantEnglish, "cat", true},
|
||||
{"scrabble_en miss", VariantEnglish, "zzzz", false},
|
||||
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
|
||||
{"erudit_ru hit", VariantErudit, "кот", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// Variant identifies a Scrabble variant the backend offers. Each maps to a
|
||||
@@ -38,15 +38,25 @@ const (
|
||||
func (v Variant) String() string {
|
||||
switch v {
|
||||
case VariantEnglish:
|
||||
return "english"
|
||||
return "scrabble_en"
|
||||
case VariantRussianScrabble:
|
||||
return "russian_scrabble"
|
||||
return "scrabble_ru"
|
||||
case VariantErudit:
|
||||
return "erudit"
|
||||
return "erudit_ru"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
|
||||
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
|
||||
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
|
||||
func (v Variant) Language() string {
|
||||
if v == VariantEnglish {
|
||||
return "en"
|
||||
}
|
||||
return "ru"
|
||||
}
|
||||
|
||||
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
|
||||
// (nil, false) for an unrecognised variant.
|
||||
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
||||
|
||||
@@ -3,10 +3,10 @@ package engine
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/rack"
|
||||
"scrabble-solver/rules"
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// scorelessLimit is the number of consecutive scoreless turns (passes and
|
||||
@@ -248,17 +248,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
|
||||
// winning regardless of score. A missed-turn timeout reuses Resign in the game
|
||||
// domain, so it inherits this win/loss.
|
||||
func (g *Game) Resign() (MoveRecord, error) {
|
||||
return g.ResignSeat(g.toMove)
|
||||
}
|
||||
|
||||
// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
|
||||
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
|
||||
// skips resigned seats). The turn cursor only advances when the seat that resigned
|
||||
// was the one to move; resigning an off-turn seat leaves the current player's turn
|
||||
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
|
||||
// already-resigned seat.
|
||||
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
|
||||
if g.over {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
player := g.toMove
|
||||
g.resigned[player] = true
|
||||
g.disposeHand(player)
|
||||
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
|
||||
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
|
||||
return MoveRecord{}, ErrGameOver
|
||||
}
|
||||
g.resigned[seat] = true
|
||||
g.disposeHand(seat)
|
||||
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
|
||||
g.log = append(g.log, rec)
|
||||
if g.activeCount() <= 1 {
|
||||
g.finish(EndResign)
|
||||
} else {
|
||||
} else if seat == g.toMove {
|
||||
g.advance()
|
||||
}
|
||||
return rec, nil
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// newEnglishGame starts a two-player English game with the given seed.
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"scrabble-solver/rules"
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// testVersion labels the single dictionary version the tests register.
|
||||
|
||||
@@ -2,13 +2,14 @@ package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// dictFiles maps each variant to its committed DAWG filename, as built by
|
||||
@@ -63,6 +64,36 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// OpenWithVersions builds a Registry by loading the boot version from the flat
|
||||
// dir (every variant, as Open) and then every additional version held in an
|
||||
// immediate subdirectory of dir: a subdirectory named V contributes, under
|
||||
// version V, the variants whose committed DAWG it carries. This is the
|
||||
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
|
||||
// at runtime is resident again after a restart. A subdirectory named like the
|
||||
// boot version is skipped (the flat dir already is the boot version). A partially
|
||||
// loaded registry is closed before any error is returned.
|
||||
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
||||
r, err := Open(dir, bootVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
_ = r.Close()
|
||||
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Name() == bootVersion {
|
||||
continue
|
||||
}
|
||||
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
||||
_ = r.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Load reads the committed DAWG of variant from dir, builds a solver over it and
|
||||
// registers it under version. Reloading the same (variant, version) replaces the
|
||||
// previous entry, closing its finder. The most recently loaded version of a
|
||||
@@ -91,6 +122,29 @@ func (r *Registry) Load(v Variant, version, dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAvailable loads, under version, every variant whose committed DAWG is
|
||||
// present in dir, skipping a variant whose file is absent. It backs the admin
|
||||
// dictionary reload (a version subdirectory may carry only the variants that were
|
||||
// rebuilt) and OpenWithVersions' boot-time scan. It returns the variants it
|
||||
// loaded, in catalogue order, or the first load error.
|
||||
func (r *Registry) LoadAvailable(dir, version string) ([]Variant, error) {
|
||||
var loaded []Variant
|
||||
for _, v := range Variants() {
|
||||
path := filepath.Join(dir, dictFiles[v])
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return loaded, fmt.Errorf("engine: stat %s dictionary %q in %s: %w", v, version, dir, err)
|
||||
}
|
||||
if err := r.Load(v, version, dir); err != nil {
|
||||
return loaded, err
|
||||
}
|
||||
loaded = append(loaded, v)
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
|
||||
// when the variant is absent and ErrUnknownVersion when only the version is.
|
||||
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"scrabble-solver/board"
|
||||
"scrabble-solver/scrabble"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
)
|
||||
|
||||
// TestRegistryOpensEveryVariant checks that Open loads all three variants at the
|
||||
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
|
||||
func TestRegistryUnknownLookups(t *testing.T) {
|
||||
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("open english-only registry: %v", err)
|
||||
t.Fatalf("open scrabble_en-only registry: %v", err)
|
||||
}
|
||||
defer reg.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// copyDawg copies the committed DAWG for v from srcDir into dstDir (creating
|
||||
// dstDir). It is the fixture builder for the dictionary-reload tests, which need
|
||||
// real DAWG files laid out in temporary version directories.
|
||||
func copyDawg(t *testing.T, srcDir, dstDir string, v Variant) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dstDir, err)
|
||||
}
|
||||
name := dictFiles[v]
|
||||
src, err := os.Open(filepath.Join(srcDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open source dawg %s: %v", name, err)
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
dst, err := os.Create(filepath.Join(dstDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("create dest dawg %s: %v", name, err)
|
||||
}
|
||||
defer func() { _ = dst.Close() }()
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
t.Fatalf("copy dawg %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAvailableLoadsPresentSkipsAbsent verifies LoadAvailable registers only
|
||||
// the variants whose DAWG is present in the directory, under the given version.
|
||||
func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
copyDawg(t, testDictDir(), dir, VariantEnglish) // only English present
|
||||
|
||||
reg := NewRegistry()
|
||||
defer func() { _ = reg.Close() }()
|
||||
loaded, err := reg.LoadAvailable(dir, "v2")
|
||||
if err != nil {
|
||||
t.Fatalf("load available: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
||||
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
|
||||
}
|
||||
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
||||
t.Errorf("scrabble_en v2 solver: %v", err)
|
||||
}
|
||||
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenWithVersionsScansSubdirs verifies the boot helper loads the flat boot
|
||||
// version plus every version subdirectory, the subdir version becoming the
|
||||
// variant's latest while the boot version stays resident.
|
||||
func TestOpenWithVersionsScansSubdirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, v := range Variants() { // flat boot version: all three variants
|
||||
copyDawg(t, testDictDir(), dir, v)
|
||||
}
|
||||
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // v2 subdir: English only
|
||||
|
||||
reg, err := OpenWithVersions(dir, "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("open with versions: %v", err)
|
||||
}
|
||||
defer func() { _ = reg.Close() }()
|
||||
|
||||
for _, v := range Variants() {
|
||||
if _, err := reg.Solver(v, "v1"); err != nil {
|
||||
t.Errorf("boot solver %s/v1: %v", v, err)
|
||||
}
|
||||
}
|
||||
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
||||
t.Errorf("scrabble_en versions = %v, want two", got)
|
||||
}
|
||||
latest, _, err := reg.Latest(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("latest scrabble_en: %v", err)
|
||||
}
|
||||
if latest != "v2" {
|
||||
t.Errorf("latest scrabble_en = %q, want v2", latest)
|
||||
}
|
||||
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
|
||||
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
|
||||
// already resident, moves the latest pointer and keeps the earlier version.
|
||||
func TestReloadRegistersNewVersion(t *testing.T) {
|
||||
reg, err := Open(testDictDir(), "v1", VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = reg.Close() }()
|
||||
|
||||
if err := reg.Load(VariantEnglish, "v2", testDictDir()); err != nil {
|
||||
t.Fatalf("reload v2: %v", err)
|
||||
}
|
||||
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
||||
t.Fatalf("versions = %v, want two", got)
|
||||
}
|
||||
latest, _, err := reg.Latest(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("latest: %v", err)
|
||||
}
|
||||
if latest != "v2" {
|
||||
t.Errorf("latest = %q, want v2", latest)
|
||||
}
|
||||
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
|
||||
t.Errorf("v1 still resident: %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.Dir, hint.Tiles); err != nil { // player 0 moves
|
||||
t.Fatalf("player 0 play: %v", err)
|
||||
}
|
||||
if g.ToMove() != 1 {
|
||||
t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove())
|
||||
}
|
||||
|
||||
// Player 0 resigns although it is player 1's turn.
|
||||
rec, err := g.ResignSeat(0)
|
||||
if err != nil {
|
||||
t.Fatalf("player 0 off-turn resign: %v", err)
|
||||
}
|
||||
if rec.Player != 0 || rec.Action != ActionResign {
|
||||
t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action)
|
||||
}
|
||||
if !g.Over() || g.Reason() != EndResign {
|
||||
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
||||
}
|
||||
if res := g.Result(); res.Winner != 1 {
|
||||
t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignOnFinishedGame rejects a second transition.
|
||||
func TestResignOnFinishedGame(t *testing.T) {
|
||||
g := newEnglishGame(t, 1)
|
||||
|
||||
@@ -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,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 {
|
||||
game *engine.Game
|
||||
variant string
|
||||
lastAccess time.Time
|
||||
}
|
||||
|
||||
@@ -82,11 +83,12 @@ func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
|
||||
return e.game, true
|
||||
}
|
||||
|
||||
// put stores g as the live game for id.
|
||||
func (c *gameCache) put(id uuid.UUID, g *engine.Game) {
|
||||
// put stores g as the live game for id. variant labels the entry so the active-
|
||||
// games gauge can report counts by variant without inspecting engine internals.
|
||||
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries[id] = &cachedGame{game: g, lastAccess: c.now()}
|
||||
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
|
||||
}
|
||||
|
||||
// remove drops id from the cache (used on a finished game and after a failed
|
||||
@@ -119,3 +121,16 @@ func (c *gameCache) size() int {
|
||||
defer c.mu.Unlock()
|
||||
return len(c.entries)
|
||||
}
|
||||
|
||||
// countByVariant tallies the resident games by their variant label. It backs the
|
||||
// game_cache_active observable gauge; the resident set is the bounded number of
|
||||
// live (active) games, so the scan under the lock is cheap.
|
||||
func (c *gameCache) countByVariant() map[string]int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make(map[string]int, len(c.entries))
|
||||
for _, e := range c.entries {
|
||||
out[e.variant]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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,112 @@
|
||||
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)
|
||||
}
|
||||
// Routed out-of-app by the game's language (the default Variant is English).
|
||||
if yourTurn.Language != "en" {
|
||||
t.Errorf("your_turn language = %q, want en", yourTurn.Language)
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
if over[winner].Language != "en" || over[loser].Language != "en" {
|
||||
t.Errorf("game_over languages = %q/%q, want en/en", over[winner].Language, over[loser].Language)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// The mappers below project the game domain into the wire-agnostic notify.* input
|
||||
// structs the enriched live events carry. They keep the wire schema out of the
|
||||
// game package: notify owns the FlatBuffers encoding, this file only resolves the
|
||||
// values (seat display names, last-activity sort key) into its input shapes.
|
||||
|
||||
// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched
|
||||
// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix
|
||||
// mirrors the gateway view (the current turn's start while active, the finish time once
|
||||
// finished).
|
||||
func gameSummary(g Game, names []string) notify.GameSummary {
|
||||
seats := make([]notify.SeatStanding, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
name := ""
|
||||
if s.Seat >= 0 && s.Seat < len(names) {
|
||||
name = names[s.Seat]
|
||||
}
|
||||
seats = append(seats, notify.SeatStanding{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID.String(),
|
||||
DisplayName: name,
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
})
|
||||
}
|
||||
last := g.TurnStartedAt
|
||||
if g.FinishedAt != nil {
|
||||
last = *g.FinishedAt
|
||||
}
|
||||
return notify.GameSummary{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
LastActivityUnix: last.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// playerState projects a StateView into the notify.PlayerState carried by the
|
||||
// match_found / game_started events. The rack is re-encoded to wire alphabet indices;
|
||||
// the variant alphabet display table is embedded when includeAlphabet is set (an
|
||||
// initial view whose recipient may not have cached the variant yet).
|
||||
func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) {
|
||||
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
||||
if err != nil {
|
||||
return notify.PlayerState{}, err
|
||||
}
|
||||
ps := notify.PlayerState{
|
||||
Game: gameSummary(v.Game, names),
|
||||
Seat: v.Seat,
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
if err != nil {
|
||||
return notify.PlayerState{}, err
|
||||
}
|
||||
ps.Alphabet = make([]notify.AlphabetLetter, len(tab))
|
||||
for i, e := range tab {
|
||||
ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
|
||||
}
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
|
||||
"#character-encoding UTF-8",
|
||||
"#player1 p1 Alice",
|
||||
"#player2 p2 Bob",
|
||||
"#lexicon english/v1",
|
||||
"#lexicon scrabble_en/v1",
|
||||
"#title game 00000000-0000-7000-8000-000000000001",
|
||||
">p1: CATSER? 8H CAT +10 10",
|
||||
">p2: AS?E I8 .s +2 2",
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
|
||||
cur := time.Unix(1_700_000_000, 0)
|
||||
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
||||
id := uuid.New()
|
||||
cache.put(id, nil)
|
||||
cache.put(id, nil, "scrabble_en")
|
||||
if _, ok := cache.get(id); !ok {
|
||||
t.Fatal("game must be resident after put")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// meterName scopes the game domain's OpenTelemetry instruments.
|
||||
const meterName = "scrabble/backend/game"
|
||||
|
||||
// gameMetrics holds the game domain's operational instruments. Every game-scoped
|
||||
// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The
|
||||
// instruments default to no-ops (see defaultGameMetrics), so recording is always
|
||||
// safe; SetMetrics installs the real meter during startup wiring.
|
||||
type gameMetrics struct {
|
||||
replay metric.Float64Histogram
|
||||
validate metric.Float64Histogram
|
||||
moveDur metric.Float64Histogram
|
||||
started metric.Int64Counter
|
||||
abandoned metric.Int64Counter
|
||||
}
|
||||
|
||||
// defaultGameMetrics returns instruments backed by a no-op meter, recording
|
||||
// nothing until SetMetrics installs a real one.
|
||||
func defaultGameMetrics() *gameMetrics {
|
||||
return newGameMetrics(noop.NewMeterProvider().Meter(meterName))
|
||||
}
|
||||
|
||||
// newGameMetrics builds the instruments on meter, falling back to no-op
|
||||
// instruments on the (rare) construction error so the game domain never fails to
|
||||
// start over telemetry.
|
||||
func newGameMetrics(meter metric.Meter) *gameMetrics {
|
||||
return &gameMetrics{
|
||||
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
|
||||
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
|
||||
moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."),
|
||||
started: counter(meter, "games_started_total", "Games created and started."),
|
||||
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
|
||||
}
|
||||
}
|
||||
|
||||
// SetMetrics installs the meter the game domain records to and registers the
|
||||
// observable gauge reporting the live games resident in the cache by variant. It
|
||||
// must be called during startup wiring; the default is a no-op meter.
|
||||
func (svc *Service) SetMetrics(meter metric.Meter) {
|
||||
if meter == nil {
|
||||
return
|
||||
}
|
||||
svc.metrics = newGameMetrics(meter)
|
||||
if _, err := meter.Int64ObservableGauge("game_cache_active",
|
||||
metric.WithDescription("Live games currently resident in the in-memory cache, by variant."),
|
||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||
for variant, n := range svc.cache.countByVariant() {
|
||||
o.Observe(int64(n), metric.WithAttributes(attribute.String("variant", variant)))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
); err != nil {
|
||||
svc.log.Warn("game: register cache gauge", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// recordReplay records the duration of a cache-miss journal replay for variant.
|
||||
func (m *gameMetrics) recordReplay(ctx context.Context, v engine.Variant, start time.Time) {
|
||||
m.replay.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
|
||||
}
|
||||
|
||||
// recordValidate records the duration of one play validation for variant.
|
||||
func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, start time.Time) {
|
||||
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
|
||||
}
|
||||
|
||||
// recordMoveDuration records how long a seat spent on a committed move, attributed by
|
||||
// variant and the game phase derived from moveCount. A non-positive duration (a clock
|
||||
// skew or a move with no recorded turn start) is dropped.
|
||||
func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
m.moveDur.Record(ctx, d.Seconds(),
|
||||
metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount))))
|
||||
}
|
||||
|
||||
// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The
|
||||
// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7).
|
||||
func phaseOf(moveCount int) string {
|
||||
switch {
|
||||
case moveCount <= 8:
|
||||
return "opening"
|
||||
case moveCount <= 20:
|
||||
return "middle"
|
||||
default:
|
||||
return "endgame"
|
||||
}
|
||||
}
|
||||
|
||||
// recordStarted counts one started game of variant.
|
||||
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
||||
m.started.Add(ctx, 1, variantAttr(v))
|
||||
}
|
||||
|
||||
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
|
||||
// variant.
|
||||
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant) {
|
||||
m.abandoned.Add(ctx, 1, variantAttr(v))
|
||||
}
|
||||
|
||||
// variantAttr is the shared "variant" attribute option, usable for both Record and
|
||||
// Add measurements.
|
||||
func variantAttr(v engine.Variant) metric.MeasurementOption {
|
||||
return metric.WithAttributes(attribute.String("variant", v.String()))
|
||||
}
|
||||
|
||||
func histogram(m metric.Meter, name, desc string) metric.Float64Histogram {
|
||||
h, err := m.Float64Histogram(name, metric.WithUnit("s"), metric.WithDescription(desc))
|
||||
if err != nil {
|
||||
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram(name)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func counter(m metric.Meter, name, desc string) metric.Int64Counter {
|
||||
c, err := m.Int64Counter(name, metric.WithDescription(desc))
|
||||
if err != nil {
|
||||
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter(name)
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// TestGameMetrics records each game instrument through a manual reader and asserts
|
||||
// the counters carry the right "variant" attribute and the histograms observe.
|
||||
func TestGameMetrics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
m := newGameMetrics(meter)
|
||||
|
||||
m.recordStarted(ctx, engine.VariantEnglish)
|
||||
m.recordStarted(ctx, engine.VariantEnglish)
|
||||
m.recordStarted(ctx, engine.VariantRussianScrabble)
|
||||
m.recordAbandoned(ctx, engine.VariantErudit)
|
||||
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
||||
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
|
||||
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
|
||||
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped
|
||||
|
||||
var rm metricdata.ResourceMetrics
|
||||
if err := reader.Collect(ctx, &rm); err != nil {
|
||||
t.Fatalf("collect: %v", err)
|
||||
}
|
||||
|
||||
started := counterByAttr(t, rm, "games_started_total", "variant")
|
||||
if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 {
|
||||
t.Errorf("games_started_total = %v, want scrabble_en:2 scrabble_ru:1", started)
|
||||
}
|
||||
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit_ru"] != 1 {
|
||||
t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned)
|
||||
}
|
||||
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
||||
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
||||
}
|
||||
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
||||
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
||||
}
|
||||
if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
|
||||
t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhaseOf checks the move-ordinal to phase bucketing.
|
||||
func TestPhaseOf(t *testing.T) {
|
||||
cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
|
||||
for mc, want := range cases {
|
||||
if got := phaseOf(mc); got != want {
|
||||
t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// counterByAttr sums the int64 counter named name, grouped by the value of the
|
||||
// attribute key attr.
|
||||
func counterByAttr(t *testing.T, rm metricdata.ResourceMetrics, name, attr string) map[string]int64 {
|
||||
t.Helper()
|
||||
out := map[string]int64{}
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, md := range sm.Metrics {
|
||||
if md.Name != name {
|
||||
continue
|
||||
}
|
||||
sum, ok := md.Data.(metricdata.Sum[int64])
|
||||
if !ok {
|
||||
t.Fatalf("%s is not an int64 sum", name)
|
||||
}
|
||||
for _, dp := range sum.DataPoints {
|
||||
v, _ := dp.Attributes.Value(attribute.Key(attr))
|
||||
out[v.AsString()] += dp.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// histogramCount returns the total observation count of the float64 histogram
|
||||
// named name.
|
||||
func histogramCount(t *testing.T, rm metricdata.ResourceMetrics, name string) uint64 {
|
||||
t.Helper()
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, md := range sm.Metrics {
|
||||
if md.Name != name {
|
||||
continue
|
||||
}
|
||||
h, ok := md.Data.(metricdata.Histogram[float64])
|
||||
if !ok {
|
||||
t.Fatalf("%s is not a float64 histogram", name)
|
||||
}
|
||||
var n uint64
|
||||
for _, dp := range h.DataPoints {
|
||||
n += dp.Count
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s not found", name)
|
||||
return 0
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +34,7 @@ type Service struct {
|
||||
clock func() time.Time
|
||||
rng func() int64
|
||||
pub notify.Publisher
|
||||
metrics *gameMetrics
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
|
||||
clock: clock,
|
||||
rng: randomSeed,
|
||||
pub: notify.Nop{},
|
||||
metrics: defaultGameMetrics(),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@@ -135,7 +138,8 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.cache.put(id, g)
|
||||
svc.cache.put(id, g, params.Variant.String())
|
||||
svc.metrics.recordStarted(ctx, params.Variant)
|
||||
return svc.store.GetGame(ctx, id)
|
||||
}
|
||||
|
||||
@@ -168,11 +172,77 @@ func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, t
|
||||
}
|
||||
|
||||
// Resign ends the game on the player's turn; the remaining player wins.
|
||||
// Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is
|
||||
// allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does
|
||||
// not go through transition's turn check: it resigns the actor's own seat, whoever is to
|
||||
// move. The resigning seat always loses (docs/ARCHITECTURE.md §7).
|
||||
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
|
||||
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
|
||||
rec, err := g.Resign()
|
||||
return rec, nil, err
|
||||
})
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
seat, ok := pre.seatOf(accountID)
|
||||
if !ok {
|
||||
return MoveResult{}, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
return MoveResult{}, ErrFinished
|
||||
}
|
||||
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
|
||||
g, err := svc.liveGame(ctx, pre)
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
if g.Over() {
|
||||
return MoveResult{}, ErrFinished
|
||||
}
|
||||
|
||||
rackBefore := g.Hand(seat)
|
||||
rec, err := g.ResignSeat(seat)
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats)
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
|
||||
// A resignation carries no think time (it can happen on the opponent's turn), so it
|
||||
// is intentionally excluded from the move-duration metric.
|
||||
return MoveResult{Move: rec, Game: post}, nil
|
||||
}
|
||||
|
||||
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
||||
// indices to concrete letters before delegating to the letter-based play, exchange and
|
||||
// word-check methods, keeping a single domain path shared with the robot.
|
||||
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
|
||||
return svc.store.GetGameVariant(ctx, gameID)
|
||||
}
|
||||
|
||||
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
|
||||
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
|
||||
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
v, err := svc.GameVariant(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v.Language(), nil
|
||||
}
|
||||
|
||||
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
|
||||
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
|
||||
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
|
||||
return svc.store.RobotSchedule(ctx, gameID)
|
||||
}
|
||||
|
||||
// LastMoveAt returns the time of an account's most recent move in a game (and whether it
|
||||
// has moved). The social service uses it to reset the nudge cooldown once a player has
|
||||
// taken a turn.
|
||||
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||
return svc.store.LastMoveAt(ctx, gameID, accountID)
|
||||
}
|
||||
|
||||
// transition validates the actor and turn, applies op under the per-game lock and
|
||||
@@ -216,7 +286,26 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
if err != nil {
|
||||
return MoveResult{}, err
|
||||
}
|
||||
return MoveResult{Move: rec, Game: post}, nil
|
||||
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
|
||||
// Record the seat's think time (turn start to commit) for the move-duration
|
||||
// metric; the timeout path commits separately and is excluded by design.
|
||||
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
||||
return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil
|
||||
}
|
||||
|
||||
// afterCommitDrafts maintains the drafts after a committed move: the actor's own
|
||||
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
|
||||
// draft, which is then reset. Best-effort — the move is already committed, so a draft
|
||||
// cleanup failure is logged rather than failing the move.
|
||||
func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) {
|
||||
if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil {
|
||||
svc.log.Warn("clear actor draft", zap.Error(err))
|
||||
}
|
||||
if rec.Action == engine.ActionPlay {
|
||||
if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil {
|
||||
svc.log.Warn("reset conflicting board drafts", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commit persists a just-applied transition: the journal row, the post-move turn
|
||||
@@ -272,30 +361,113 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
||||
if err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.emitMove(post, rec)
|
||||
svc.emitMove(ctx, post, rec, g.BagLen())
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// emitMove publishes the live events for a just-committed move: opponent_moved to
|
||||
// every seat other than the actor, and your_turn to the next mover while the game
|
||||
// is still active. Delivery is best-effort (notify.Publisher never blocks).
|
||||
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
||||
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
||||
// every seat — including the actor's own account, so the mover's other devices (and
|
||||
// their lobby) refresh too — and your_turn to the next mover while the game is still
|
||||
// active. opponent_moved is in-app only (the gateway never turns it into an
|
||||
// out-of-app push), so the actor is not notified out of band about their own move.
|
||||
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
|
||||
// event out to all of the recipient's live streams.
|
||||
func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) {
|
||||
// Resolve the seat names once and reuse them for every recipient's enriched summary.
|
||||
names := svc.seatNames(ctx, post)
|
||||
summary := gameSummary(post, names)
|
||||
intents := make([]notify.Intent, 0, 2*len(post.Seats))
|
||||
for _, s := range post.Seats {
|
||||
if s.Seat == rec.Player {
|
||||
continue
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
|
||||
}
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
||||
}
|
||||
if post.Status == StatusActive {
|
||||
// Game pushes are routed out-of-app by the game's own language, not the recipient's
|
||||
// last-login bot.
|
||||
lang := post.Variant.Language()
|
||||
switch post.Status {
|
||||
case StatusActive:
|
||||
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
|
||||
deadline := post.TurnStartedAt.Add(post.TurnTimeout)
|
||||
intents = append(intents, notify.YourTurn(next, post.ID, deadline))
|
||||
action := rec.Action.String()
|
||||
word := ""
|
||||
if action == "play" && len(rec.Words) > 0 {
|
||||
word = rec.Words[0]
|
||||
}
|
||||
opponent := svc.displayName(ctx, post.Seats, rec.Player)
|
||||
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
|
||||
yourTurn.Language = lang
|
||||
intents = append(intents, yourTurn)
|
||||
}
|
||||
case StatusFinished:
|
||||
// The game just ended (any path: a closing play, all-pass, resign or timeout). Tell every
|
||||
// seat, each with their own perspective + recipient-first score, so an offline player gets
|
||||
// an out-of-app "game over" push (online players take it from the in-app refresh).
|
||||
for _, s := range post.Seats {
|
||||
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
|
||||
over.Language = lang
|
||||
intents = append(intents, over)
|
||||
}
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// displayName resolves the display name of the account at the given seat, or "" when the seat
|
||||
// is absent or the lookup fails (the enriched push then falls back to its plain text).
|
||||
func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string {
|
||||
if svc.accounts == nil {
|
||||
return ""
|
||||
}
|
||||
id, ok := seatAccount(seats, seat)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return acc.DisplayName
|
||||
}
|
||||
|
||||
// scoreLine formats the running scores with recipientSeat's score first, then the remaining
|
||||
// seats in seat order, colon-joined (e.g. "120:95:80") — the recipient-first form used in the
|
||||
// out-of-app notifications.
|
||||
func scoreLine(g Game, recipientSeat int) string {
|
||||
n := len(g.Seats)
|
||||
bySeat := make([]int, n)
|
||||
for _, s := range g.Seats {
|
||||
if s.Seat >= 0 && s.Seat < n {
|
||||
bySeat[s.Seat] = s.Score
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, n)
|
||||
if recipientSeat >= 0 && recipientSeat < n {
|
||||
parts = append(parts, strconv.Itoa(bySeat[recipientSeat]))
|
||||
}
|
||||
for seat := 0; seat < n; seat++ {
|
||||
if seat != recipientSeat {
|
||||
parts = append(parts, strconv.Itoa(bySeat[seat]))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// seatResult reports the finished-game outcome from recipientSeat's perspective: "draw" when no
|
||||
// seat is flagged the winner, "won" when recipientSeat is, otherwise "lost".
|
||||
func seatResult(seats []Seat, recipientSeat int) string {
|
||||
winner := false
|
||||
for _, s := range seats {
|
||||
if s.IsWinner {
|
||||
winner = true
|
||||
if s.Seat == recipientSeat {
|
||||
return "won"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !winner {
|
||||
return "draw"
|
||||
}
|
||||
return "lost"
|
||||
}
|
||||
|
||||
// seatAccount returns the account seated at the given seat index, or false when
|
||||
// no seat matches (the slice is not assumed to be ordered by seat).
|
||||
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
|
||||
@@ -350,6 +522,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
|
||||
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
|
||||
return false, err
|
||||
}
|
||||
svc.metrics.recordAbandoned(ctx, cur.Variant)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -373,7 +546,9 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
|
||||
if err != nil {
|
||||
return EvalResult{}, err
|
||||
}
|
||||
validateStart := time.Now()
|
||||
rec, err := g.EvaluatePlay(dir, tiles)
|
||||
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
|
||||
if err != nil {
|
||||
if errors.Is(err, engine.ErrIllegalPlay) {
|
||||
return EvalResult{Valid: false}, nil
|
||||
@@ -420,6 +595,68 @@ func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UU
|
||||
})
|
||||
}
|
||||
|
||||
// ListComplaints returns word-check complaints for the admin review queue,
|
||||
// newest first. status filters by lifecycle state ("" = all); limit is clamped
|
||||
// to a sane page size and offset is floored at zero.
|
||||
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
|
||||
return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
|
||||
}
|
||||
|
||||
// GetComplaint loads a single complaint for the admin detail view.
|
||||
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
|
||||
return svc.store.GetComplaint(ctx, id)
|
||||
}
|
||||
|
||||
// CountComplaints returns the number of complaints, optionally restricted to a
|
||||
// status, for the admin queue pager and the dashboard counts.
|
||||
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
|
||||
return svc.store.CountComplaints(ctx, status)
|
||||
}
|
||||
|
||||
// ResolveComplaint closes a complaint with an operator disposition (reject /
|
||||
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
||||
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
||||
// change is marked applied. It returns ErrInvalidConfig for an unknown
|
||||
// disposition and ErrNotFound when no complaint matches.
|
||||
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
|
||||
if !validDisposition(disposition) {
|
||||
return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
|
||||
}
|
||||
return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
|
||||
}
|
||||
|
||||
// DictionaryChanges returns the pending wordlist edits implied by resolved,
|
||||
// accepted complaints not yet marked applied — the input to the offline DAWG
|
||||
// rebuild.
|
||||
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
|
||||
rows, err := svc.store.ListDictionaryChanges(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]DictionaryChange, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
ch := DictionaryChange{
|
||||
ComplaintID: c.ID,
|
||||
Variant: c.Variant,
|
||||
Word: c.Word,
|
||||
Add: c.Disposition == DispositionAcceptAdd,
|
||||
Note: c.Note,
|
||||
}
|
||||
if c.ResolvedAt != nil {
|
||||
ch.ResolvedAt = *c.ResolvedAt
|
||||
}
|
||||
out = append(out, ch)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkChangesApplied records that every pending accepted change for variant has
|
||||
// been folded into the dictionary version that was just hot-reloaded, removing
|
||||
// them from DictionaryChanges. It returns the number of changes marked.
|
||||
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
|
||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||||
// turn, spending one hint from their per-game allowance and then their profile
|
||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||
@@ -550,6 +787,20 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitialState returns accountID's full initial view of game gameID as the notify
|
||||
// PlayerState carried by the match_found / game_started events, so a client can
|
||||
// render a freshly started game from the event without a follow-up fetch. The variant
|
||||
// alphabet table is always embedded (the recipient may be seeing the variant for the
|
||||
// first time). It satisfies lobby.GameCreator.
|
||||
func (svc *Service) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) {
|
||||
v, err := svc.GameState(ctx, gameID, accountID)
|
||||
if err != nil {
|
||||
return notify.PlayerState{}, err
|
||||
}
|
||||
names := svc.seatNames(ctx, v.Game)
|
||||
return playerState(v, names, true)
|
||||
}
|
||||
|
||||
// Participants returns the seated account IDs in seat order, the seat index whose
|
||||
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
|
||||
// lets the social package gate per-game chat and nudges without importing the
|
||||
@@ -583,6 +834,47 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
|
||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
||||
}
|
||||
|
||||
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||
func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seated := false
|
||||
for _, s := range g.Seats {
|
||||
if s.AccountID == accountID {
|
||||
seated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seated {
|
||||
return ErrNotAPlayer
|
||||
}
|
||||
if g.Status != StatusFinished {
|
||||
return ErrGameActive
|
||||
}
|
||||
return svc.store.HideGame(ctx, accountID, gameID)
|
||||
}
|
||||
|
||||
// GameByID returns a game with its seats for the admin console detail view.
|
||||
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||
return svc.store.GetGame(ctx, id)
|
||||
}
|
||||
|
||||
// ListGames returns games for the admin list, newest-updated first, paginated,
|
||||
// optionally filtered by status.
|
||||
func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
|
||||
return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset))
|
||||
}
|
||||
|
||||
// CountGames returns the game count, optionally filtered by status, for the admin
|
||||
// list pager and dashboard.
|
||||
func (svc *Service) CountGames(ctx context.Context, status string) (int, error) {
|
||||
return svc.store.CountGames(ctx, status)
|
||||
}
|
||||
|
||||
// History returns a game's full, dictionary-independent move journal.
|
||||
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
@@ -625,7 +917,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
|
||||
return nil, err
|
||||
}
|
||||
if !g.Over() {
|
||||
svc.cache.put(pre.ID, g)
|
||||
svc.cache.put(pre.ID, g, pre.Variant.String())
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -634,6 +926,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
|
||||
// re-applying every journalled move in order. The deterministic bag makes the
|
||||
// reconstruction exact.
|
||||
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
|
||||
defer svc.metrics.recordReplay(ctx, pre.Variant, time.Now())
|
||||
seed, err := svc.store.GameSeed(ctx, pre.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -732,6 +1025,9 @@ func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, er
|
||||
// seatNames resolves each seat's display name for GCG export.
|
||||
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
|
||||
names := make([]string, g.Players)
|
||||
if svc.accounts == nil {
|
||||
return names
|
||||
}
|
||||
for _, s := range g.Seats {
|
||||
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
|
||||
names[s.Seat] = acc.DisplayName
|
||||
@@ -770,6 +1066,29 @@ func normalizeWord(word string) string {
|
||||
return strings.ToLower(strings.TrimSpace(word))
|
||||
}
|
||||
|
||||
// validDisposition reports whether d is an accepted complaint disposition.
|
||||
func validDisposition(d string) bool {
|
||||
switch d {
|
||||
case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset
|
||||
// (non-positive) request to 50.
|
||||
func clampPageSize(limit int) int {
|
||||
switch {
|
||||
case limit <= 0:
|
||||
return 50
|
||||
case limit > 200:
|
||||
return 200
|
||||
default:
|
||||
return limit
|
||||
}
|
||||
}
|
||||
|
||||
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
|
||||
// system source fails.
|
||||
func randomSeed() int64 {
|
||||
|
||||
@@ -135,6 +135,24 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||
return projectGame(grow, srows)
|
||||
}
|
||||
|
||||
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
|
||||
// to map wire alphabet indices to concrete letters without loading the whole
|
||||
// game and its seats.
|
||||
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
|
||||
stmt := postgres.SELECT(table.Games.Variant).
|
||||
FROM(table.Games).
|
||||
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
var row model.Games
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return 0, fmt.Errorf("game: get variant %s: %w", id, err)
|
||||
}
|
||||
return engine.ParseVariant(row.Variant)
|
||||
}
|
||||
|
||||
// SharedGameExists reports whether accounts a and b are both seated in at least
|
||||
// one game (active or finished). It backs the social package's "befriend an
|
||||
// opponent" gate via a self-join on game_players.
|
||||
@@ -168,6 +186,23 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
||||
if len(grows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Drop games the account has hidden from its own lobby.
|
||||
hidden, err := s.hiddenGameIDs(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(hidden) > 0 {
|
||||
kept := grows[:0]
|
||||
for _, g := range grows {
|
||||
if !hidden[g.GameID] {
|
||||
kept = append(kept, g)
|
||||
}
|
||||
}
|
||||
grows = kept
|
||||
if len(grows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]postgres.Expression, len(grows))
|
||||
for i, g := range grows {
|
||||
@@ -197,6 +232,83 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||
// game is finished and the account is a player.
|
||||
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
accountID, gameID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("game: hide game: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hiddenGameIDs returns the set of games the account has hidden from its lobby.
|
||||
func (s *Store) hiddenGameIDs(ctx context.Context, accountID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM backend.game_hidden WHERE account_id = $1`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game: hidden ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uuid.UUID]bool{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("game: scan hidden id: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListGames returns games for the admin games list, most-recently-updated first,
|
||||
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
|
||||
// The seats are not loaded — the list shows summaries; the detail view uses
|
||||
// GetGame.
|
||||
func (s *Store) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
|
||||
where := postgres.Bool(true)
|
||||
if status != "" {
|
||||
where = table.Games.Status.EQ(postgres.String(status))
|
||||
}
|
||||
stmt := postgres.SELECT(table.Games.AllColumns).
|
||||
FROM(table.Games).
|
||||
WHERE(where).
|
||||
ORDER_BY(table.Games.UpdatedAt.DESC()).
|
||||
LIMIT(int64(limit)).
|
||||
OFFSET(int64(offset))
|
||||
var rows []model.Games
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("game: list games: %w", err)
|
||||
}
|
||||
out := make([]Game, 0, len(rows))
|
||||
for _, g := range rows {
|
||||
pg, err := projectGame(g, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountGames returns the number of games, optionally restricted to a status, for
|
||||
// admin-list pagination.
|
||||
func (s *Store) CountGames(ctx context.Context, status string) (int, error) {
|
||||
where := postgres.Bool(true)
|
||||
if status != "" {
|
||||
where = table.Games.Status.EQ(postgres.String(status))
|
||||
}
|
||||
stmt := postgres.SELECT(postgres.COUNT(table.Games.GameID).AS("count")).
|
||||
FROM(table.Games).
|
||||
WHERE(where)
|
||||
var dest struct{ Count int64 }
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return 0, fmt.Errorf("game: count games: %w", err)
|
||||
}
|
||||
return int(dest.Count), nil
|
||||
}
|
||||
|
||||
// GetJournal loads the ordered, decoded move journal for a game.
|
||||
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
|
||||
stmt := postgres.SELECT(table.GameMoves.AllColumns).
|
||||
@@ -384,6 +496,122 @@ func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, erro
|
||||
return projectComplaint(row)
|
||||
}
|
||||
|
||||
// ListComplaints returns complaints for the admin review queue, newest first.
|
||||
// status filters by lifecycle state when non-empty; limit and offset paginate.
|
||||
func (s *Store) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
|
||||
where := postgres.Bool(true)
|
||||
if status != "" {
|
||||
where = table.Complaints.Status.EQ(postgres.String(status))
|
||||
}
|
||||
stmt := postgres.SELECT(table.Complaints.AllColumns).
|
||||
FROM(table.Complaints).
|
||||
WHERE(where).
|
||||
ORDER_BY(table.Complaints.CreatedAt.DESC()).
|
||||
LIMIT(int64(limit)).
|
||||
OFFSET(int64(offset))
|
||||
var rows []model.Complaints
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("game: list complaints: %w", err)
|
||||
}
|
||||
return projectComplaints(rows)
|
||||
}
|
||||
|
||||
// GetComplaint loads one complaint by id, or ErrNotFound.
|
||||
func (s *Store) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
|
||||
stmt := postgres.SELECT(table.Complaints.AllColumns).
|
||||
FROM(table.Complaints).
|
||||
WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
var row model.Complaints
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Complaint{}, ErrNotFound
|
||||
}
|
||||
return Complaint{}, fmt.Errorf("game: get complaint %s: %w", id, err)
|
||||
}
|
||||
return projectComplaint(row)
|
||||
}
|
||||
|
||||
// ResolveComplaint closes a complaint with a disposition and note, stamping
|
||||
// resolved_at, and returns the updated row (ErrNotFound when none matches). It
|
||||
// leaves applied_in_version untouched.
|
||||
func (s *Store) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string, now time.Time) (Complaint, error) {
|
||||
stmt := table.Complaints.UPDATE(
|
||||
table.Complaints.Status, table.Complaints.Disposition,
|
||||
table.Complaints.ResolutionNote, table.Complaints.ResolvedAt,
|
||||
).SET(
|
||||
postgres.String(StatusComplaintResolved), postgres.String(disposition),
|
||||
postgres.String(note), postgres.TimestampzT(now),
|
||||
).WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Complaints.AllColumns)
|
||||
var row model.Complaints
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Complaint{}, ErrNotFound
|
||||
}
|
||||
return Complaint{}, fmt.Errorf("game: resolve complaint %s: %w", id, err)
|
||||
}
|
||||
return projectComplaint(row)
|
||||
}
|
||||
|
||||
// ListDictionaryChanges returns the resolved, accepted complaints not yet marked
|
||||
// applied (the pending wordlist edits), ordered by variant then resolution time.
|
||||
func (s *Store) ListDictionaryChanges(ctx context.Context) ([]Complaint, error) {
|
||||
stmt := postgres.SELECT(table.Complaints.AllColumns).
|
||||
FROM(table.Complaints).
|
||||
WHERE(
|
||||
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
|
||||
AND(table.Complaints.Disposition.IN(
|
||||
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
|
||||
)).
|
||||
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
|
||||
).
|
||||
ORDER_BY(table.Complaints.Variant.ASC(), table.Complaints.ResolvedAt.ASC())
|
||||
var rows []model.Complaints
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("game: list dictionary changes: %w", err)
|
||||
}
|
||||
return projectComplaints(rows)
|
||||
}
|
||||
|
||||
// MarkChangesApplied stamps every pending accepted change for variant with
|
||||
// version (so it drops out of ListDictionaryChanges) and returns the count.
|
||||
func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) (int64, error) {
|
||||
stmt := table.Complaints.UPDATE(table.Complaints.AppliedInVersion).
|
||||
SET(postgres.String(version)).
|
||||
WHERE(
|
||||
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
|
||||
AND(table.Complaints.Variant.EQ(postgres.String(variant))).
|
||||
AND(table.Complaints.Disposition.IN(
|
||||
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
|
||||
)).
|
||||
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("game: mark changes applied: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountComplaints returns the number of complaints, optionally restricted to a
|
||||
// status, for the admin queue pager and the dashboard counts.
|
||||
func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) {
|
||||
where := postgres.Bool(true)
|
||||
if status != "" {
|
||||
where = table.Complaints.Status.EQ(postgres.String(status))
|
||||
}
|
||||
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
|
||||
FROM(table.Complaints).
|
||||
WHERE(where)
|
||||
var dest struct{ Count int64 }
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return 0, fmt.Errorf("game: count complaints: %w", err)
|
||||
}
|
||||
return int(dest.Count), nil
|
||||
}
|
||||
|
||||
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
|
||||
// filters them against the per-move deadline and the player's away window.
|
||||
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
||||
@@ -470,6 +698,43 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||
return row.Seed, nil
|
||||
}
|
||||
|
||||
// LastMoveAt returns the time of the account's most recent move in the game and true, or
|
||||
// the zero time and false when it has not moved. The social service uses it to reset the
|
||||
// nudge cooldown once the player has taken a turn.
|
||||
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||
var at sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MAX(m.created_at) FROM backend.game_moves m
|
||||
JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat
|
||||
WHERE m.game_id = $1 AND p.account_id = $2`,
|
||||
gameID, accountID).Scan(&at)
|
||||
if err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err)
|
||||
}
|
||||
if !at.Valid {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return at.Time, true, nil
|
||||
}
|
||||
|
||||
// RobotSchedule returns a game's bag seed and current turn-start time. The admin console
|
||||
// combines them with the robot strategy to show a robot seat's play-to-win intent and its
|
||||
// next-move ETA. Both are server-only state, never part of the public game view.
|
||||
func (s *Store) RobotSchedule(ctx context.Context, id uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
|
||||
stmt := postgres.SELECT(table.Games.Seed, table.Games.TurnStartedAt).
|
||||
FROM(table.Games).
|
||||
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
|
||||
LIMIT(1)
|
||||
var row model.Games
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return 0, time.Time{}, ErrNotFound
|
||||
}
|
||||
return 0, time.Time{}, fmt.Errorf("game: get schedule %s: %w", id, err)
|
||||
}
|
||||
return row.Seed, row.TurnStartedAt, nil
|
||||
}
|
||||
|
||||
// projectGame builds a Game from a games row and its ordered seat rows.
|
||||
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
variant, err := engine.ParseVariant(g.Variant)
|
||||
@@ -533,9 +798,26 @@ func projectComplaint(row model.Complaints) (Complaint, error) {
|
||||
Note: row.Note,
|
||||
Status: row.Status,
|
||||
CreatedAt: row.CreatedAt,
|
||||
Disposition: row.Disposition,
|
||||
ResolutionNote: row.ResolutionNote,
|
||||
ResolvedAt: row.ResolvedAt,
|
||||
AppliedInVersion: row.AppliedInVersion,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// projectComplaints projects a slice of complaint rows, preserving order.
|
||||
func projectComplaints(rows []model.Complaints) ([]Complaint, error) {
|
||||
out := make([]Complaint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
c, err := projectComplaint(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
|
||||
@@ -15,9 +15,23 @@ const (
|
||||
StatusFinished = "finished"
|
||||
)
|
||||
|
||||
// ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only
|
||||
// ever writes StatusComplaintOpen.
|
||||
const StatusComplaintOpen = "open"
|
||||
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
|
||||
// and closed StatusComplaintResolved by the admin review queue with a
|
||||
// Disposition. The CHECK constraints live in migration 00008.
|
||||
const (
|
||||
StatusComplaintOpen = "open"
|
||||
StatusComplaintResolved = "resolved"
|
||||
)
|
||||
|
||||
// Complaint dispositions chosen at resolution. DispositionReject keeps the
|
||||
// dictionary as-is; DispositionAcceptAdd / DispositionAcceptRemove mark the word
|
||||
// for addition to / removal from the variant's wordlist and feed the offline
|
||||
// dictionary-rebuild pipeline (see DictionaryChange).
|
||||
const (
|
||||
DispositionReject = "reject"
|
||||
DispositionAcceptAdd = "accept_add"
|
||||
DispositionAcceptRemove = "accept_remove"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
|
||||
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
|
||||
@@ -110,10 +124,14 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
|
||||
}
|
||||
|
||||
// MoveResult is the outcome of a committed transition: the decoded move and the
|
||||
// post-move game.
|
||||
// post-move game, plus the actor's own refilled rack and the bag size after the draw
|
||||
// (Rack/BagLen), so the mover renders the next state from the response without a
|
||||
// follow-up game.state.
|
||||
type MoveResult struct {
|
||||
Move engine.MoveRecord
|
||||
Game Game
|
||||
Rack []string
|
||||
BagLen int
|
||||
}
|
||||
|
||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
||||
@@ -179,7 +197,9 @@ type RobotTurn struct {
|
||||
Seed int64
|
||||
}
|
||||
|
||||
// Complaint is a word-check complaint awaiting admin review (Stage 10).
|
||||
// Complaint is a word-check complaint in the admin review queue. It is filed
|
||||
// against a game's pinned (Variant, DictVersion) with the lookup result at filing
|
||||
// time (WasValid); the resolution fields stay empty until an operator resolves it.
|
||||
type Complaint struct {
|
||||
ID uuid.UUID
|
||||
ComplainantID uuid.UUID
|
||||
@@ -191,4 +211,24 @@ type Complaint struct {
|
||||
Note string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
|
||||
// Resolution fields, set when Status == StatusComplaintResolved.
|
||||
Disposition string // "" while open; otherwise a Disposition* value
|
||||
ResolutionNote string // operator note recorded at resolution
|
||||
ResolvedAt *time.Time // nil while open
|
||||
AppliedInVersion string // dict version an accepted change was folded into ("" = pending)
|
||||
}
|
||||
|
||||
// DictionaryChange is the wordlist edit implied by one resolved, accepted
|
||||
// complaint: Add reports whether Word should be added (DispositionAcceptAdd) or
|
||||
// removed (DispositionAcceptRemove) for Variant. The admin console lists the
|
||||
// pending changes as the input to the offline DAWG rebuild; once a rebuilt
|
||||
// dictionary version is hot-reloaded they are marked applied.
|
||||
type DictionaryChange struct {
|
||||
ComplaintID uuid.UUID
|
||||
Variant engine.Variant
|
||||
Word string
|
||||
Add bool
|
||||
ResolvedAt time.Time
|
||||
Note string
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
|
||||
@@ -77,7 +80,7 @@ func TestAccountProvisionByIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
|
||||
// reads back the zero statistics rather than an error (the Stage 8 stats screen).
|
||||
// reads back the zero statistics rather than an error (the stats screen).
|
||||
func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -108,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||
// seeds the new account's language and display name from the launch fields,
|
||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||
// later login (Stage 9 language seeding).
|
||||
// later login (language seeding).
|
||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -155,6 +158,102 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceLanguageRoundTrip checks SetServiceLanguage persists the push-routing
|
||||
// language (the bot a Telegram user last signed in through): a fresh account has
|
||||
// none, a set value reads back, a later login overwrites it (last-login-wins), and
|
||||
// an empty value is a no-op. The push-target route coalesces it with the preferred
|
||||
// language.
|
||||
func TestServiceLanguageRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.ServiceLanguage != "" {
|
||||
t.Errorf("fresh ServiceLanguage = %q, want empty", acc.ServiceLanguage)
|
||||
}
|
||||
|
||||
if err := store.SetServiceLanguage(ctx, acc.ID, "ru"); err != nil {
|
||||
t.Fatalf("set service language: %v", err)
|
||||
}
|
||||
if got, err := store.GetByID(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
} else if got.ServiceLanguage != "ru" {
|
||||
t.Errorf("ServiceLanguage = %q, want ru", got.ServiceLanguage)
|
||||
}
|
||||
|
||||
// A later login through the other bot updates it; a subsequent empty value
|
||||
// (a non-Telegram login) leaves it unchanged.
|
||||
if err := store.SetServiceLanguage(ctx, acc.ID, "en"); err != nil {
|
||||
t.Fatalf("update service language: %v", err)
|
||||
}
|
||||
if err := store.SetServiceLanguage(ctx, acc.ID, ""); err != nil {
|
||||
t.Fatalf("noop service language: %v", err)
|
||||
}
|
||||
if got, err := store.GetByID(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
} else if got.ServiceLanguage != "en" {
|
||||
t.Errorf("ServiceLanguage after update+noop = %q, want en", got.ServiceLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
|
||||
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
|
||||
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
|
||||
// the operator clear takes a fresh timestamp.
|
||||
func TestHighRateFlagRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if !acc.FlaggedHighRateAt.IsZero() {
|
||||
t.Fatalf("fresh FlaggedHighRateAt = %v, want zero", acc.FlaggedHighRateAt)
|
||||
}
|
||||
|
||||
first := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
set, err := store.FlagHighRate(ctx, acc.ID, first)
|
||||
if err != nil {
|
||||
t.Fatalf("flag: %v", err)
|
||||
}
|
||||
if !set {
|
||||
t.Fatal("first FlagHighRate reported not set")
|
||||
}
|
||||
if set, err = store.FlagHighRate(ctx, acc.ID, first.Add(time.Hour)); err != nil {
|
||||
t.Fatalf("re-flag: %v", err)
|
||||
} else if set {
|
||||
t.Fatal("second FlagHighRate must not overwrite the marker")
|
||||
}
|
||||
got, err := store.GetByID(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if !got.FlaggedHighRateAt.Equal(first) {
|
||||
t.Errorf("FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, first)
|
||||
}
|
||||
|
||||
if err := store.ClearHighRateFlag(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if got, err = store.GetByID(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
} else if !got.FlaggedHighRateAt.IsZero() {
|
||||
t.Errorf("cleared FlaggedHighRateAt = %v, want zero", got.FlaggedHighRateAt)
|
||||
}
|
||||
|
||||
second := first.Add(24 * time.Hour)
|
||||
if set, err = store.FlagHighRate(ctx, acc.ID, second); err != nil || !set {
|
||||
t.Fatalf("re-flag after clear = (%v, %v), want (true, nil)", set, err)
|
||||
}
|
||||
if got, err = store.GetByID(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
} else if !got.FlaggedHighRateAt.Equal(second) {
|
||||
t.Errorf("re-flagged FlaggedHighRateAt = %v, want %v", got.FlaggedHighRateAt, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentityExternalID covers the reverse identity lookup the push-target route
|
||||
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
||||
// including for a guest that carries no identity.
|
||||
@@ -182,7 +281,7 @@ func TestIdentityExternalID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the profile flag persists
|
||||
// through UpdateProfile and reads back through GetByID.
|
||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -214,3 +313,59 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
t.Error("GetByID still reports in-app-only after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||
// against a robot to a natural end and checks the guest holds a seat (the
|
||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||
// durable robot opponent does.
|
||||
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
robots := newRobotService(t, svc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
seed := openingSeed(t)
|
||||
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
||||
TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
const robotSeat = 1 // seats = [guest, robot]
|
||||
|
||||
finished := false
|
||||
for i := 0; i < 400 && !finished; i++ {
|
||||
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != game.StatusActive {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
if toMove == robotSeat {
|
||||
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
||||
robots.Drive(ctx, daytime)
|
||||
continue
|
||||
}
|
||||
playHuman(t, ctx, svc, g.ID, guest)
|
||||
}
|
||||
if !finished {
|
||||
t.Fatal("guest game did not finish within the move budget")
|
||||
}
|
||||
|
||||
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
||||
t.Error("a guest must not accrue a statistics row")
|
||||
}
|
||||
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
||||
t.Error("the durable robot opponent should have a statistics row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/server"
|
||||
)
|
||||
|
||||
// TestComplaintResolutionPipeline drives a complaint from filing through
|
||||
// resolution into the dictionary-change pipeline and on to "applied".
|
||||
func TestComplaintResolutionPipeline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
word := "zzzzzz" // a non-word the filer thinks should be valid → an accept_add candidate
|
||||
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], word, "please add")
|
||||
if err != nil {
|
||||
t.Fatalf("file: %v", err)
|
||||
}
|
||||
|
||||
if open, _ := svc.CountComplaints(ctx, game.StatusComplaintOpen); open < 1 {
|
||||
t.Fatalf("open complaints = %d, want >= 1", open)
|
||||
}
|
||||
list, err := svc.ListComplaints(ctx, game.StatusComplaintOpen, 100, 0)
|
||||
if err != nil || !containsComplaint(list, filed.ID) {
|
||||
t.Fatalf("open list missing filed complaint (err %v)", err)
|
||||
}
|
||||
|
||||
resolved, err := svc.ResolveComplaint(ctx, filed.ID, game.DispositionAcceptAdd, "agreed")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if resolved.Status != game.StatusComplaintResolved || resolved.Disposition != game.DispositionAcceptAdd || resolved.ResolvedAt == nil {
|
||||
t.Fatalf("unexpected resolved complaint: %+v", resolved)
|
||||
}
|
||||
|
||||
changes, err := svc.DictionaryChanges(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("changes: %v", err)
|
||||
}
|
||||
if !changeFor(changes, word, true) {
|
||||
t.Fatalf("dictionary changes missing add %q: %+v", word, changes)
|
||||
}
|
||||
|
||||
n, err := svc.MarkChangesApplied(ctx, engine.VariantEnglish, "v2")
|
||||
if err != nil || n < 1 {
|
||||
t.Fatalf("mark applied n=%d err=%v", n, err)
|
||||
}
|
||||
if after, err := svc.DictionaryChanges(ctx); err != nil || changeFor(after, word, true) {
|
||||
t.Fatalf("change still pending after apply (err %v): %+v", err, after)
|
||||
}
|
||||
}
|
||||
|
||||
func containsComplaint(list []game.Complaint, id uuid.UUID) bool {
|
||||
for _, c := range list {
|
||||
if c.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func changeFor(changes []game.DictionaryChange, word string, add bool) bool {
|
||||
for _, c := range changes {
|
||||
if c.Word == word && c.Add == add {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestAdminListsAndCounts checks the admin read queries and their COUNT scans.
|
||||
func TestAdminListsAndCounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
svc := newGameService()
|
||||
|
||||
accBefore, err := store.CountAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("count accounts: %v", err)
|
||||
}
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
accAfter, err := store.CountAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("count accounts: %v", err)
|
||||
}
|
||||
if accAfter < accBefore+2 {
|
||||
t.Errorf("account count did not grow by 2: %d -> %d", accBefore, accAfter)
|
||||
}
|
||||
if page, err := store.ListAccounts(ctx, 1, 0); err != nil || len(page) != 1 {
|
||||
t.Fatalf("list accounts page size 1 = %d (err %v)", len(page), err)
|
||||
}
|
||||
if ids, err := store.Identities(ctx, a); err != nil || len(ids) != 1 || ids[0].Kind != account.KindTelegram {
|
||||
t.Fatalf("identities for a = %+v (err %v)", ids, err)
|
||||
}
|
||||
|
||||
gBefore, _ := svc.CountGames(ctx, "")
|
||||
if _, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 2}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if gAfter, _ := svc.CountGames(ctx, ""); gAfter != gBefore+1 {
|
||||
t.Errorf("game count %d -> %d, want +1", gBefore, gAfter)
|
||||
}
|
||||
if active, err := svc.ListGames(ctx, game.StatusActive, 100, 0); err != nil || len(active) == 0 {
|
||||
t.Fatalf("list active games = %d (err %v)", len(active), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleServesAndGuardsCSRF drives the /_gm console over HTTP against real
|
||||
// stores: pages render, and a state-changing POST needs a same-origin header.
|
||||
func TestConsoleServesAndGuardsCSRF(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], "qwxz", "review")
|
||||
if err != nil {
|
||||
t.Fatalf("file: %v", err)
|
||||
}
|
||||
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(),
|
||||
Accounts: account.NewStore(testDB),
|
||||
Games: svc,
|
||||
Registry: testRegistry,
|
||||
DictDir: dictDir(),
|
||||
})
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm"
|
||||
|
||||
if code, body := consoleDo(h, http.MethodGet, base+"/", "", ""); code != http.StatusOK || !strings.Contains(body, "Dashboard") {
|
||||
t.Fatalf("dashboard = %d, has Dashboard=%v", code, strings.Contains(body, "Dashboard"))
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodGet, base+"/complaints/"+filed.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "qwxz") {
|
||||
t.Fatalf("complaint detail = %d, has word=%v", code, strings.Contains(body, "qwxz"))
|
||||
}
|
||||
// A resolve POST without a same-origin header is rejected by the CSRF guard.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("resolve without origin = %d, want 403", code)
|
||||
}
|
||||
// With a matching Origin it succeeds and persists.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject¬e=ok", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Resolved") {
|
||||
t.Fatalf("resolve with origin = %d, has Resolved=%v", code, strings.Contains(body, "Resolved"))
|
||||
}
|
||||
if got, err := svc.GetComplaint(ctx, filed.ID); err != nil || got.Status != game.StatusComplaintResolved {
|
||||
t.Fatalf("complaint not resolved: %+v (err %v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
|
||||
// play-to-win intent and, while it is the robot's turn, its next-move ETA.
|
||||
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
|
||||
if err != nil {
|
||||
t.Fatalf("provision robot: %v", err)
|
||||
}
|
||||
human := provisionAccount(t)
|
||||
// Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
|
||||
})
|
||||
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("game detail = %d, want 200", code)
|
||||
}
|
||||
if !strings.Contains(body, "🤖") {
|
||||
t.Error("robot seat is not marked in the game detail")
|
||||
}
|
||||
if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
|
||||
t.Error("robot play-to-win intent missing")
|
||||
}
|
||||
if !strings.Contains(body, "next move") {
|
||||
t.Error("robot is to move but the next-move ETA is missing")
|
||||
}
|
||||
if !strings.Contains(body, "~40%") {
|
||||
t.Error("robot play-to-win target caption missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
|
||||
// end against real stores: a gateway report past the threshold auto-flags the
|
||||
// account, the throttled view shows the episode and the flagged account, the
|
||||
// user card carries the marker, and the operator clear (a same-origin POST)
|
||||
// reverses it.
|
||||
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
|
||||
watch := ratewatch.New(ratewatch.Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, accounts, zap.NewNop())
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(),
|
||||
Accounts: accounts,
|
||||
Games: newGameService(),
|
||||
Registry: testRegistry,
|
||||
DictDir: dictDir(),
|
||||
RateWatch: watch,
|
||||
})
|
||||
h := srv.Handler()
|
||||
|
||||
report := `{"window_seconds":30,"entries":[` +
|
||||
`{"class":"user","key":"` + acc.ID.String() + `","rejected":150},` +
|
||||
`{"class":"public","key":"10.1.2.3","rejected":7}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "http://admin.test/api/v1/internal/ratelimit/report", strings.NewReader(report))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("report = %d, want 204", rec.Code)
|
||||
}
|
||||
|
||||
got, err := accounts.GetByID(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.FlaggedHighRateAt.IsZero() {
|
||||
t.Fatal("account not auto-flagged past the threshold")
|
||||
}
|
||||
|
||||
base := "http://admin.test/_gm"
|
||||
code, body := consoleDo(h, http.MethodGet, base+"/throttled", "", "")
|
||||
if code != http.StatusOK || !strings.Contains(body, acc.ID.String()) ||
|
||||
!strings.Contains(body, "10.1.2.3") || !strings.Contains(body, "Throttled Player") {
|
||||
t.Fatalf("throttled view = %d, episode/flag shown = %v/%v",
|
||||
code, strings.Contains(body, "10.1.2.3"), strings.Contains(body, "Throttled Player"))
|
||||
}
|
||||
if code, body = consoleDo(h, http.MethodGet, base+"/users/"+acc.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "Clear high-rate flag") {
|
||||
t.Fatalf("user card = %d, has clear action = %v", code, strings.Contains(body, "Clear high-rate flag"))
|
||||
}
|
||||
|
||||
// The clear POST is CSRF-guarded like every console action.
|
||||
if code, _ = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("clear without origin = %d, want 403", code)
|
||||
}
|
||||
if code, body = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "x=1", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Cleared") {
|
||||
t.Fatalf("clear with origin = %d, has Cleared = %v", code, strings.Contains(body, "Cleared"))
|
||||
}
|
||||
if got, err = accounts.GetByID(ctx, acc.ID); err != nil || !got.FlaggedHighRateAt.IsZero() {
|
||||
t.Fatalf("flag survived the clear: %v (err %v)", got.FlaggedHighRateAt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleDo issues a request to h, optionally with an Origin header, and returns
|
||||
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
|
||||
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestMoveDurationAnalytics seeds a game with crafted move timestamps and checks the
|
||||
// admin-console move-duration reports compute the think time (gap to the previous
|
||||
// move, the first move measured from game creation) correctly, per account and per
|
||||
// the account's move ordinal.
|
||||
func TestMoveDurationAnalytics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
a, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("provision A: %v", err)
|
||||
}
|
||||
b, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("provision B: %v", err)
|
||||
}
|
||||
|
||||
gid := uuid.New()
|
||||
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
|
||||
VALUES ($1,'scrabble_en','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
||||
t.Fatalf("insert game: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil {
|
||||
t.Fatalf("insert seats: %v", err)
|
||||
}
|
||||
// seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200.
|
||||
moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}}
|
||||
for _, m := range moves {
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`,
|
||||
gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil {
|
||||
t.Fatalf("insert move %d: %v", m.seq, err)
|
||||
}
|
||||
}
|
||||
|
||||
store := game.NewStore(testDB)
|
||||
stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 {
|
||||
t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa)
|
||||
}
|
||||
if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 {
|
||||
t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb)
|
||||
}
|
||||
|
||||
byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("by ordinal: %v", err)
|
||||
}
|
||||
want := []game.OrdinalDuration{
|
||||
{Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60},
|
||||
{Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50},
|
||||
}
|
||||
if len(byOrd) != len(want) {
|
||||
t.Fatalf("by ordinal = %+v, want %+v", byOrd, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if byOrd[i] != w {
|
||||
t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the
|
||||
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
|
||||
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
|
||||
func TestDraftPersistAndConflictReset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, gameID, seats, hint := newDraftGame(t)
|
||||
|
||||
// Round-trip seat 0's rack order + a board draft.
|
||||
d0 := game.Draft{RackOrder: "QANIWE?", BoardTiles: []game.DraftTile{{Row: 1, Col: 1, Letter: "Q"}}}
|
||||
if err := svc.SaveDraft(ctx, gameID, seats[0], d0); err != nil {
|
||||
t.Fatalf("save draft 0: %v", err)
|
||||
}
|
||||
if got, err := svc.GetDraft(ctx, gameID, seats[0]); err != nil ||
|
||||
got.RackOrder != "QANIWE?" || len(got.BoardTiles) != 1 || got.BoardTiles[0].Letter != "Q" {
|
||||
t.Fatalf("get draft 0 = %+v (err %v)", got, err)
|
||||
}
|
||||
|
||||
// Seat 1 drafts a board tile on a cell the opening play will commit.
|
||||
overlap := hint.Tiles[0]
|
||||
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
|
||||
RackOrder: "ABCDEFG",
|
||||
BoardTiles: []game.DraftTile{{Row: overlap.Row, Col: overlap.Col, Letter: "X"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("save draft 1: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
|
||||
t.Fatalf("seat0 play: %v", err)
|
||||
}
|
||||
|
||||
// Seat 0's own draft is cleared by their move.
|
||||
if d, _ := svc.GetDraft(ctx, gameID, seats[0]); d.RackOrder != "" || len(d.BoardTiles) != 0 {
|
||||
t.Errorf("actor draft not cleared: %+v", d)
|
||||
}
|
||||
// Seat 1's board draft overlapped the play and is reset; the rack order is kept.
|
||||
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 0 || d.RackOrder != "ABCDEFG" {
|
||||
t.Errorf("conflicting draft not reset (or rack order lost): %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraftSurvivesNonConflictingMove checks an opponent's board draft is kept when a
|
||||
// committed play does not touch any of its cells.
|
||||
func TestDraftSurvivesNonConflictingMove(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, gameID, seats, hint := newDraftGame(t)
|
||||
|
||||
// Seat 1 drafts a far corner tile the central opening play cannot reach.
|
||||
if err := svc.SaveDraft(ctx, gameID, seats[1], game.Draft{
|
||||
BoardTiles: []game.DraftTile{{Row: 0, Col: 0, Letter: "Z"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("save draft 1: %v", err)
|
||||
}
|
||||
if _, err := svc.SubmitPlay(ctx, gameID, seats[0], hint.Dir, hint.Tiles); err != nil {
|
||||
t.Fatalf("seat0 play: %v", err)
|
||||
}
|
||||
if d, _ := svc.GetDraft(ctx, gameID, seats[1]); len(d.BoardTiles) != 1 || d.BoardTiles[0].Letter != "Z" {
|
||||
t.Errorf("non-conflicting draft should survive: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveDraftRejectsOutsider checks only a seated player may save a draft.
|
||||
func TestSaveDraftRejectsOutsider(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, gameID, _, _ := newDraftGame(t)
|
||||
if err := svc.SaveDraft(ctx, gameID, provisionAccount(t), game.Draft{RackOrder: "X"}); err == nil {
|
||||
t.Fatal("outsider SaveDraft should fail")
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func TestUpdateProfilePersists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is
|
||||
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
|
||||
// accepted, persisted verbatim, and resolved to the right fixed offset.
|
||||
func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
t.Fatalf("ResolveZone offset = %d, want 10800", off)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailLoginFlow covers the email-as-login path: a code is mailed to
|
||||
// a new address, verifying it provisions and returns the owning account, and a
|
||||
// second login for the same address resolves to that same account (a returning
|
||||
// user), with the identity confirmed.
|
||||
func TestEmailLoginFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if code == "" {
|
||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
acc, err := svc.LoginWithCode(ctx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("login with code: %v", err)
|
||||
}
|
||||
if acc.ID != accountID {
|
||||
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
||||
}
|
||||
if acc.IsGuest {
|
||||
t.Error("an email account must be durable, not a guest")
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("the email identity must be confirmed after login")
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("second login: %v", err)
|
||||
}
|
||||
if acc2.ID != accountID {
|
||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,88 +4,17 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// newGameService builds a game service over the shared pool and registry.
|
||||
func newGameService() *game.Service {
|
||||
return game.NewService(
|
||||
game.NewStore(testDB),
|
||||
account.NewStore(testDB),
|
||||
testRegistry,
|
||||
game.Config{
|
||||
DictDir: dictDir(),
|
||||
DictVersion: testDictVersion,
|
||||
TimeoutSweepInterval: time.Minute,
|
||||
CacheTTL: time.Hour,
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
}
|
||||
|
||||
// provisionAccount creates a fresh durable account and returns its id.
|
||||
func provisionAccount(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("provision account: %v", err)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// openingSeed returns a seed whose fresh two-player English opening rack has a
|
||||
// legal move, so a greedy mirror can drive a game.
|
||||
func openingSeed(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 200; seed++ {
|
||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("engine new: %v", err)
|
||||
}
|
||||
if _, ok := g.HintView(); ok {
|
||||
return seed
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening seed found")
|
||||
return 0
|
||||
}
|
||||
|
||||
// newMirror builds a parallel engine game with the same seed, used to compute
|
||||
// legal moves to feed the service under test.
|
||||
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
|
||||
t.Helper()
|
||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("mirror new: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// readStats reads an account's statistics row.
|
||||
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
|
||||
t.Helper()
|
||||
row := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
|
||||
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
t.Fatalf("read stats: %v", err)
|
||||
}
|
||||
return wins, losses, draws, maxGame, maxWord, true
|
||||
}
|
||||
|
||||
// TestListForAccount checks the lobby "my games" query: it returns exactly the
|
||||
// games the account is seated in (each with its seats), and nothing for an outsider.
|
||||
func TestListForAccount(t *testing.T) {
|
||||
@@ -299,6 +228,42 @@ func TestResignWinnerAndStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResignOnOpponentTurn checks a player can forfeit on the
|
||||
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
||||
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
||||
// despite leading on score.
|
||||
func TestResignOnOpponentTurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
seed := openingSeed(t)
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
hint, ok := newMirror(t, seed, 2).HintView()
|
||||
if !ok {
|
||||
t.Fatal("no opening move")
|
||||
}
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
|
||||
t.Fatalf("p0 play: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
|
||||
if err != nil {
|
||||
t.Fatalf("off-turn resign = %v, want nil", err)
|
||||
}
|
||||
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
|
||||
t.Fatalf("after off-turn resign: %+v", res.Game)
|
||||
}
|
||||
if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
|
||||
t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
|
||||
func TestTimeoutSweep(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -312,6 +277,13 @@ func TestTimeoutSweep(t *testing.T) {
|
||||
}
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
|
||||
// Disable the to-move account's away window: with the default 00:00–07:00
|
||||
// window the sweeper (correctly) declines to time out a player whose deadline
|
||||
// fell while they were asleep, which made this test fail whenever CI ran with
|
||||
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
|
||||
// deterministic regardless of the time of day.
|
||||
setAway(t, seats[0], "UTC", "00:00", "00:00")
|
||||
|
||||
// The sweep is global over the shared pool; assert the target game itself,
|
||||
// not the count, since other tests leave active games behind.
|
||||
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||
@@ -421,6 +393,26 @@ func TestHintPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameVariant covers the edge's lightweight variant lookup: it returns the
|
||||
// created game's variant and ErrNotFound for an unknown id.
|
||||
func TestGameVariant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
|
||||
t.Fatalf("GameVariant = %v, %v; want scrabble_en, nil", v, err)
|
||||
}
|
||||
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
|
||||
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
|
||||
func TestCheckWordAndComplaint(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -556,7 +548,7 @@ func equalStrings(a, b []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
|
||||
// TestExportGCGRefusesActiveGame checks the finished-only gate: a GCG export
|
||||
// is allowed only once the game is over, so an active game leaks nothing mid-play.
|
||||
func TestExportGCGRefusesActiveGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestGuestReaper verifies the abandoned-guest reaper: it deletes guests with no
|
||||
// game seat once their account age is past the cutoff, while sparing guests that
|
||||
// are too young, guests seated in a game (the FK-protected opponent history), and
|
||||
// durable accounts.
|
||||
func TestGuestReaper(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
|
||||
guestA := provisionGuest(t) // guest, no seat → reaped on a future cutoff
|
||||
guestB := provisionGuest(t) // guest, no seat → reaped on a future cutoff
|
||||
seated := provisionGuest(t) // guest seated in a game → kept
|
||||
durable := provisionAccount(t)
|
||||
|
||||
// Seat the third guest in a game with a durable opponent (Create needs 2-4).
|
||||
opp := provisionAccount(t)
|
||||
if _, err := newGameService().Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{seated, opp}, TurnTimeout: 24 * time.Hour, Seed: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("create game: %v", err)
|
||||
}
|
||||
|
||||
// A cutoff in the past: every account is younger than the window, so the age
|
||||
// gate spares them all.
|
||||
if n, err := store.ReapAbandonedGuests(ctx, time.Now().Add(-time.Hour)); err != nil {
|
||||
t.Fatalf("reap (past cutoff): %v", err)
|
||||
} else if n != 0 {
|
||||
t.Fatalf("reap with a past cutoff deleted %d, want 0", n)
|
||||
}
|
||||
assertAccount(t, store, guestA, true)
|
||||
|
||||
// A cutoff in the future: every account predates it, so the no-seat guests are
|
||||
// reaped and the seated guest and the durable account survive.
|
||||
if _, err := store.ReapAbandonedGuests(ctx, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("reap (future cutoff): %v", err)
|
||||
}
|
||||
assertAccount(t, store, guestA, false)
|
||||
assertAccount(t, store, guestB, false)
|
||||
assertAccount(t, store, seated, true)
|
||||
assertAccount(t, store, durable, true)
|
||||
}
|
||||
|
||||
// assertAccount checks whether the account with id is present, failing the test
|
||||
// when its presence differs from want.
|
||||
func assertAccount(t *testing.T, store *account.Store, id uuid.UUID, want bool) {
|
||||
t.Helper()
|
||||
_, err := store.GetByID(context.Background(), id)
|
||||
switch {
|
||||
case err == nil:
|
||||
if !want {
|
||||
t.Errorf("account %s still exists, want reaped", id)
|
||||
}
|
||||
case errors.Is(err, account.ErrNotFound):
|
||||
if want {
|
||||
t.Errorf("account %s was reaped, want kept", id)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("get account %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// Shared fixtures for the Postgres-backed integration suite: the service
|
||||
// constructors over the shared pool/registry, account provisioning, game
|
||||
// assembly, and the stats reader. Helpers used by a single test file stay in
|
||||
// that file; everything reused across files lives here.
|
||||
|
||||
// newGameService builds a game service over the shared pool and registry.
|
||||
func newGameService() *game.Service {
|
||||
return game.NewService(
|
||||
game.NewStore(testDB),
|
||||
account.NewStore(testDB),
|
||||
testRegistry,
|
||||
game.Config{
|
||||
DictDir: dictDir(),
|
||||
DictVersion: testDictVersion,
|
||||
TimeoutSweepInterval: time.Minute,
|
||||
CacheTTL: time.Hour,
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
}
|
||||
|
||||
// newSocialService builds a social service over the shared pool, reading game
|
||||
// state through a real game service.
|
||||
func newSocialService() *social.Service {
|
||||
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
||||
}
|
||||
|
||||
// newRobotService builds a robot service over games (shared so its moves and the
|
||||
// test's human moves use the same live-game cache and per-game locks), a fresh
|
||||
// social service for nudges, and a no-op meter.
|
||||
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
||||
t.Helper()
|
||||
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
||||
}
|
||||
|
||||
// newMatchmaker builds a matchmaker starting real games and substituting from
|
||||
// robots after wait.
|
||||
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
||||
t.Helper()
|
||||
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
|
||||
}
|
||||
|
||||
// provisionAccount creates a fresh durable account and returns its id.
|
||||
func provisionAccount(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionByIdentity(context.Background(), account.KindTelegram, "tg-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("provision account: %v", err)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||
func provisionGuest(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
if !acc.IsGuest {
|
||||
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// openingSeed returns a seed whose fresh two-player English opening rack has a
|
||||
// legal move, so a greedy mirror can drive a game.
|
||||
func openingSeed(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
for seed := int64(1); seed <= 200; seed++ {
|
||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("engine new: %v", err)
|
||||
}
|
||||
if _, ok := g.HintView(); ok {
|
||||
return seed
|
||||
}
|
||||
}
|
||||
t.Fatal("no opening seed found")
|
||||
return 0
|
||||
}
|
||||
|
||||
// newMirror builds a parallel engine game with the same seed, used to compute
|
||||
// legal moves to feed the service under test.
|
||||
func newMirror(t *testing.T, seed int64, players int) *engine.Game {
|
||||
t.Helper()
|
||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: players, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("mirror new: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
||||
// game id and the seated account ids in seat order.
|
||||
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||
t.Helper()
|
||||
seats := make([]uuid.UUID, n)
|
||||
for i := range seats {
|
||||
seats[i] = provisionAccount(t)
|
||||
}
|
||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create game: %v", err)
|
||||
}
|
||||
return g.ID, seats
|
||||
}
|
||||
|
||||
// newDraftGame creates a started two-player English game on an opening seed and returns the
|
||||
// service, game id, seats, and the opening play (from a mirror) used to drive a real commit.
|
||||
func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.MoveRecord) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
seed := openingSeed(t)
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
hint, ok := newMirror(t, seed, 2).HintView()
|
||||
if !ok || len(hint.Tiles) == 0 {
|
||||
t.Fatal("no opening move")
|
||||
}
|
||||
return svc, g.ID, seats, hint
|
||||
}
|
||||
|
||||
// readStats reads an account's statistics row.
|
||||
func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWord int, found bool) {
|
||||
t.Helper()
|
||||
row := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT wins, losses, draws, max_game_points, max_word_points FROM backend.account_stats WHERE account_id = $1`, id)
|
||||
if err := row.Scan(&wins, &losses, &draws, &maxGame, &maxWord); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
t.Fatalf("read stats: %v", err)
|
||||
}
|
||||
return wins, losses, draws, maxGame, maxWord, true
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestHideFinishedGame covers per-account game hiding: an active game cannot be
|
||||
// hidden, a finished game is removed from the hider's own list while staying visible to the
|
||||
// other player, an outsider cannot hide it, and the action is idempotent.
|
||||
func TestHideFinishedGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, gameID, seats, _ := newDraftGame(t)
|
||||
|
||||
// Hiding while the game is still active is refused.
|
||||
if err := svc.HideGame(ctx, seats[0], gameID); !errors.Is(err, game.ErrGameActive) {
|
||||
t.Fatalf("hide active = %v, want ErrGameActive", err)
|
||||
}
|
||||
|
||||
// Finish the game by seat 0 resigning.
|
||||
if _, err := svc.Resign(ctx, gameID, seats[0]); err != nil {
|
||||
t.Fatalf("resign: %v", err)
|
||||
}
|
||||
|
||||
// A non-player cannot hide it.
|
||||
if err := svc.HideGame(ctx, provisionAccount(t), gameID); !errors.Is(err, game.ErrNotAPlayer) {
|
||||
t.Fatalf("hide by outsider = %v, want ErrNotAPlayer", err)
|
||||
}
|
||||
|
||||
// Seat 0 hides the finished game; hiding again is a no-op success.
|
||||
if err := svc.HideGame(ctx, seats[0], gameID); err != nil {
|
||||
t.Fatalf("hide: %v", err)
|
||||
}
|
||||
if err := svc.HideGame(ctx, seats[0], gameID); err != nil {
|
||||
t.Fatalf("hide twice: %v", err)
|
||||
}
|
||||
|
||||
// It is gone from seat 0's list but still in seat 1's (hiding is per-account).
|
||||
if containsGame(t, svc, seats[0], gameID) {
|
||||
t.Error("hidden game still listed for the hider")
|
||||
}
|
||||
if !containsGame(t, svc, seats[1], gameID) {
|
||||
t.Error("hidden game should remain listed for the other player")
|
||||
}
|
||||
}
|
||||
|
||||
// containsGame reports whether the account's lobby list includes gameID.
|
||||
func containsGame(t *testing.T, svc *game.Service, accountID, gameID uuid.UUID) bool {
|
||||
t.Helper()
|
||||
games, err := svc.ListForAccount(context.Background(), accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("list for account: %v", err)
|
||||
}
|
||||
for _, g := range games {
|
||||
if g.ID == gameID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// --- merge test helpers ---
|
||||
|
||||
func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.account_stats (account_id, wins, losses, draws, max_game_points, max_word_points)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (account_id) DO UPDATE SET wins=$2, losses=$3, draws=$4, max_game_points=$5, max_word_points=$6`,
|
||||
id, w, l, d, mg, mw); err != nil {
|
||||
t.Fatalf("set stats: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil {
|
||||
t.Fatalf("set wallet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
|
||||
uuid.New(), acc, email); err != nil {
|
||||
t.Fatalf("bind email identity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertFriendship(t *testing.T, a, b uuid.UUID, status string) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`INSERT INTO backend.friendships (requester_id, addressee_id, status) VALUES ($1,$2,$3)`, a, b, status); err != nil {
|
||||
t.Fatalf("insert friendship: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mergedInto(t *testing.T, id uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
var into *uuid.UUID
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT merged_into FROM backend.accounts WHERE account_id=$1`, id).Scan(&into); err != nil {
|
||||
t.Fatalf("read merged_into: %v", err)
|
||||
}
|
||||
if into == nil {
|
||||
return uuid.Nil
|
||||
}
|
||||
return *into
|
||||
}
|
||||
|
||||
func seatCount(t *testing.T, gameID, accountID uuid.UUID) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT count(*) FROM backend.game_players WHERE game_id=$1 AND account_id=$2`, gameID, accountID).Scan(&n); err != nil {
|
||||
t.Fatalf("seat count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID {
|
||||
t.Helper()
|
||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: timeout, Seed: openingSeed(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seat game: %v", err)
|
||||
}
|
||||
return g.ID
|
||||
}
|
||||
|
||||
func newLinkService(mailer account.Mailer) *link.Service {
|
||||
store := account.NewStore(testDB)
|
||||
emails := account.NewEmailService(store, mailer)
|
||||
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
|
||||
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
|
||||
}
|
||||
|
||||
// TestAccountMergeCore folds every account-scoped artifact of a secondary into a
|
||||
// primary: stats summed, wallet summed, paid flag ORed, identity repointed, a
|
||||
// non-shared game transferred, a friend carried over, and the secondary tombstoned.
|
||||
func TestAccountMergeCore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
friend := provisionAccount(t)
|
||||
|
||||
setStats(t, primary, 1, 0, 0, 100, 90)
|
||||
setStats(t, secondary, 3, 1, 2, 400, 80)
|
||||
setWallet(t, primary, 2, false)
|
||||
setWallet(t, secondary, 5, true)
|
||||
|
||||
email := "merge-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, secondary, email)
|
||||
insertFriendship(t, secondary, friend, "accepted")
|
||||
gameID := seatGame(t, []uuid.UUID{secondary, friend}, 24*time.Hour)
|
||||
|
||||
if err := merger.Merge(ctx, primary, secondary); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
|
||||
w, l, d, mg, mw, found := readStats(t, primary)
|
||||
if !found || w != 4 || l != 1 || d != 2 || mg != 400 || mw != 90 {
|
||||
t.Errorf("primary stats = (%d,%d,%d,%d,%d found=%v), want (4,1,2,400,90 true)", w, l, d, mg, mw, found)
|
||||
}
|
||||
if _, _, _, _, _, found := readStats(t, secondary); found {
|
||||
t.Error("secondary stats row should be deleted after merge")
|
||||
}
|
||||
|
||||
acc, err := store.GetByID(ctx, primary)
|
||||
if err != nil {
|
||||
t.Fatalf("get primary: %v", err)
|
||||
}
|
||||
if acc.HintBalance != 7 {
|
||||
t.Errorf("hint balance = %d, want 7", acc.HintBalance)
|
||||
}
|
||||
if !acc.PaidAccount {
|
||||
t.Error("paid_account should be true (ORed from secondary)")
|
||||
}
|
||||
|
||||
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
|
||||
t.Errorf("email owner = %s ok=%v, want primary %s", owner, ok, primary)
|
||||
}
|
||||
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 0 {
|
||||
t.Error("non-shared game seat should transfer to primary")
|
||||
}
|
||||
if friends, _ := newSocialService().ListFriends(ctx, primary); len(friends) != 1 || friends[0] != friend {
|
||||
t.Errorf("primary friends = %v, want [%s]", friends, friend)
|
||||
}
|
||||
if mergedInto(t, secondary) != primary {
|
||||
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
|
||||
// game (one player cannot be merged against themselves).
|
||||
func TestAccountMergeActiveGameConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
|
||||
|
||||
if err := merger.Merge(ctx, primary, secondary); err != accountmerge.ErrActiveGameConflict {
|
||||
t.Fatalf("merge = %v, want ErrActiveGameConflict", err)
|
||||
}
|
||||
if mergedInto(t, secondary) != uuid.Nil {
|
||||
t.Error("a refused merge must not tombstone the secondary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountMergeFinishedSharedGameKept allows a merge when the shared game is
|
||||
// finished and leaves the secondary's seat in place (the tombstone keeps the
|
||||
// no-cascade foreign key valid).
|
||||
func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
merger := accountmerge.NewMerger(testDB)
|
||||
primary := provisionAccount(t)
|
||||
secondary := provisionAccount(t)
|
||||
gameID := seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
|
||||
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, gameID); err != nil {
|
||||
t.Fatalf("finish game: %v", err)
|
||||
}
|
||||
|
||||
if err := merger.Merge(ctx, primary, secondary); err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 1 {
|
||||
t.Error("a shared finished game must keep both seats (secondary as tombstone)")
|
||||
}
|
||||
if mergedInto(t, secondary) != primary {
|
||||
t.Error("secondary should be tombstoned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
|
||||
func TestAccountLinkFreeEmail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
links := newLinkService(mailer)
|
||||
|
||||
guest := provisionGuest(t)
|
||||
email := "fresh-" + uuid.NewString() + "@example.com"
|
||||
if err := links.RequestEmail(ctx, guest, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
res, err := links.ConfirmEmail(ctx, guest, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !res.Linked || res.MergeRequired {
|
||||
t.Fatalf("confirm = %+v, want linked", res)
|
||||
}
|
||||
acc, _ := store.GetByID(ctx, guest)
|
||||
if acc.IsGuest {
|
||||
t.Error("guest flag should clear once an identity is linked")
|
||||
}
|
||||
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != guest {
|
||||
t.Errorf("email owner = %s, want the promoted guest %s", owner, guest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkEmailMergeIntoCaller merges the email's owner into the current
|
||||
// (durable) account: the caller stays primary and keeps its session.
|
||||
func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
links := newLinkService(mailer)
|
||||
|
||||
caller := provisionAccount(t)
|
||||
other := provisionAccount(t)
|
||||
email := "owned-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, other, email)
|
||||
|
||||
if err := links.RequestEmail(ctx, caller, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
confirm, err := links.ConfirmEmail(ctx, caller, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !confirm.MergeRequired || confirm.SecondaryID != other {
|
||||
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
|
||||
}
|
||||
merge, err := links.MergeEmail(ctx, caller, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
|
||||
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
|
||||
}
|
||||
if mergedInto(t, other) != caller {
|
||||
t.Error("other should be tombstoned into caller")
|
||||
}
|
||||
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != caller {
|
||||
t.Errorf("email owner = %s, want caller", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
|
||||
// that owns the email: the durable account wins and a fresh session is minted.
|
||||
func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
links := newLinkService(mailer)
|
||||
|
||||
durable := provisionAccount(t)
|
||||
email := "durable-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, durable, email)
|
||||
guest := provisionGuest(t)
|
||||
|
||||
if err := links.RequestEmail(ctx, guest, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
merge, err := links.MergeEmail(ctx, guest, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if merge.PrimaryID != durable {
|
||||
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
|
||||
}
|
||||
if merge.SwitchedToken == "" {
|
||||
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
|
||||
}
|
||||
if mergedInto(t, guest) != durable {
|
||||
t.Error("the guest should be tombstoned into the durable account")
|
||||
}
|
||||
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != durable {
|
||||
t.Errorf("email owner = %s, want durable", owner)
|
||||
}
|
||||
}
|
||||
@@ -8,31 +8,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/robot"
|
||||
)
|
||||
|
||||
// newRobotService builds a robot service over games (shared so its moves and the
|
||||
// test's human moves use the same live-game cache and per-game locks), a fresh
|
||||
// social service for nudges, and a no-op meter.
|
||||
func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
||||
t.Helper()
|
||||
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
||||
}
|
||||
|
||||
// newMatchmaker builds a matchmaker starting real games and substituting from
|
||||
// robots after wait.
|
||||
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
||||
t.Helper()
|
||||
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
|
||||
}
|
||||
|
||||
// setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or
|
||||
// idle) at a chosen instant, independent of wall time.
|
||||
func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) {
|
||||
@@ -82,19 +63,25 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
||||
if err := r.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool (idempotent): %v", err)
|
||||
}
|
||||
id, err := r.Pick()
|
||||
id, err := r.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
if !isRobotAccount(t, id) {
|
||||
t.Errorf("picked account %s is not a robot identity", id)
|
||||
}
|
||||
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
|
||||
t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
|
||||
}
|
||||
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get robot account: %v", err)
|
||||
}
|
||||
if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests {
|
||||
t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
||||
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
|
||||
// expires, mirroring a human who ignores it.
|
||||
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
|
||||
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
|
||||
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +96,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick()
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
@@ -201,8 +188,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotProactiveNudge checks the robot nudges the human after the idle
|
||||
// threshold on the human's turn.
|
||||
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
|
||||
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
|
||||
func TestRobotProactiveNudge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
@@ -210,7 +197,7 @@ func TestRobotProactiveNudge(t *testing.T) {
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick()
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
@@ -226,14 +213,18 @@ func TestRobotProactiveNudge(t *testing.T) {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for
|
||||
// every drift.
|
||||
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
|
||||
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
setTurnStarted(t, g.ID, start)
|
||||
robots.Drive(ctx, start.Add(13*time.Hour))
|
||||
|
||||
robots.Drive(ctx, start.Add(30*time.Minute))
|
||||
if n := countNudges(t, g.ID, robotID); n != 0 {
|
||||
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
|
||||
}
|
||||
robots.Drive(ctx, start.Add(2*time.Hour))
|
||||
if n := countNudges(t, g.ID, robotID); n != 1 {
|
||||
t.Errorf("robot nudges = %d, want 1 after 13h idle on the human's turn", n)
|
||||
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,30 +15,66 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/social"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// newSocialService builds a social service over the shared pool, reading game
|
||||
// state through a real game service.
|
||||
func newSocialService() *social.Service {
|
||||
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
|
||||
// capturePublisher records every published intent for assertions on live events.
|
||||
type capturePublisher struct {
|
||||
mu sync.Mutex
|
||||
intents []notify.Intent
|
||||
}
|
||||
|
||||
// newGameWithSeats creates a started game seating n fresh accounts and returns the
|
||||
// game id and the seated account ids in seat order.
|
||||
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
||||
t.Helper()
|
||||
seats := make([]uuid.UUID, n)
|
||||
for i := range seats {
|
||||
seats[i] = provisionAccount(t)
|
||||
func (c *capturePublisher) Publish(in ...notify.Intent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.intents = append(c.intents, in...)
|
||||
}
|
||||
g, err := newGameService().Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||
})
|
||||
|
||||
// notified reports whether a Notification with the given sub-kind was published to user.
|
||||
func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, in := range c.intents {
|
||||
if in.UserID == user && in.Kind == notify.KindNotification &&
|
||||
string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||
// just sits unanswered and later expires — mirroring a human who ignores it.
|
||||
func TestFriendRequestToRobotStaysPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
accs := account.NewStore(testDB)
|
||||
|
||||
human := provisionAccount(t)
|
||||
robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie")
|
||||
if err != nil {
|
||||
t.Fatalf("provision robot: %v", err)
|
||||
}
|
||||
if robot.BlockFriendRequests {
|
||||
t.Fatal("robot must not block friend requests")
|
||||
}
|
||||
// A request is only allowed between players who share a game.
|
||||
if _, err := newGameService().Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
|
||||
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||
}); err != nil {
|
||||
t.Fatalf("create game: %v", err)
|
||||
}
|
||||
return g.ID, seats
|
||||
|
||||
if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil {
|
||||
t.Fatalf("request to robot = %v, want nil (accepted as pending)", err)
|
||||
}
|
||||
if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human {
|
||||
t.Fatalf("robot incoming = %v, want [human]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendRequestLifecycle(t *testing.T) {
|
||||
@@ -282,6 +319,20 @@ func TestChatRejectsBadContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn:
|
||||
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
|
||||
func TestChatOnlyOnYourTurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening
|
||||
if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) {
|
||||
t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil {
|
||||
t.Fatalf("on-turn chat = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeRulesAndRateLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -307,3 +358,200 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
|
||||
t.Fatalf("nudge after window: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
|
||||
// acted (moved or chatted) since their last nudge, even within the hour.
|
||||
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gsvc := newGameService()
|
||||
gameID, seats := newGameWithSeats(t, 2) // seat 0 to move
|
||||
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
|
||||
t.Fatalf("nudge: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
|
||||
t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
|
||||
}
|
||||
// Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes.
|
||||
if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil {
|
||||
t.Fatalf("seat0 pass: %v", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil {
|
||||
t.Fatalf("seat1 chat: %v", err)
|
||||
}
|
||||
if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil {
|
||||
t.Fatalf("seat1 pass: %v", err)
|
||||
}
|
||||
// Back on the opponent's turn, the cooldown is reset by the action since the nudge.
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||
t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
||||
// friends" item: a pending request shows for the requester only; an accepted one
|
||||
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
||||
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
||||
func TestListOutgoingRequests(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
|
||||
// Pending: outgoing for the requester, not the addressee.
|
||||
_, s1 := newGameWithSeats(t, 2)
|
||||
a, b := s1[0], s1[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b {
|
||||
t.Fatalf("outgoing pending = %v, want [b]", got)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 {
|
||||
t.Fatalf("addressee outgoing = %v, want none", got)
|
||||
}
|
||||
// Accepted: a friendship, no longer an outgoing request.
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 {
|
||||
t.Fatalf("outgoing after accept = %v, want none", got)
|
||||
}
|
||||
|
||||
// Declined: stays outgoing (reads as sent; cannot re-send).
|
||||
_, s2 := newGameWithSeats(t, 2)
|
||||
c, d := s2[0], s2[1]
|
||||
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||
t.Fatalf("send2: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||
t.Fatalf("decline: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d {
|
||||
t.Fatalf("outgoing after decline = %v, want [d]", got)
|
||||
}
|
||||
|
||||
// Lazily expired pending: omitted (may be re-sent).
|
||||
_, s3 := newGameWithSeats(t, 2)
|
||||
e, f := s3[0], s3[1]
|
||||
if err := svc.SendFriendRequest(ctx, e, f); err != nil {
|
||||
t.Fatalf("send3: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 {
|
||||
t.Fatalf("expired outgoing = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
||||
// requester over the live channel: accept -> friend_added, decline ->
|
||||
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
||||
func TestRespondPublishesToRequester(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
|
||||
_, s1 := newGameWithSeats(t, 2)
|
||||
a, b := s1[0], s1[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if !pub.notified(a, notify.NotifyFriendAdded) {
|
||||
t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded)
|
||||
}
|
||||
|
||||
_, s2 := newGameWithSeats(t, 2)
|
||||
c, d := s2[0], s2[1]
|
||||
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||
t.Fatalf("send2: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||
t.Fatalf("decline: %v", err)
|
||||
}
|
||||
if !pub.notified(c, notify.NotifyFriendDeclined) {
|
||||
t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
|
||||
// it is delivered by the game's bot rather than the recipient's last-login bot.
|
||||
func TestNudgeRoutedByGameLanguage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
|
||||
gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||
t.Fatalf("nudge: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, in := range pub.intents {
|
||||
if in.Kind == notify.KindNudge {
|
||||
found = true
|
||||
if in.Language != "en" {
|
||||
t.Errorf("nudge language = %q, want en (the game's language)", in.Language)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no nudge intent published")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminListMessages checks the admin moderation list: real messages only
|
||||
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||
func TestAdminListMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
|
||||
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.9"); err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
|
||||
t.Fatalf("nudge: %v", err)
|
||||
}
|
||||
|
||||
// Pinned to the game: the message is listed; the nudge (kind=nudge) is excluded.
|
||||
msgs, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID}, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("admin list: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("game messages = %d, want 1 (nudge excluded)", len(msgs))
|
||||
}
|
||||
if m := msgs[0]; m.Body != "good luck" || m.SenderID != seats[0] || m.SenderIP != "203.0.113.9" {
|
||||
t.Fatalf("message = %+v, want body=good luck sender=seat0 ip=203.0.113.9", m)
|
||||
}
|
||||
if msgs[0].Source != "telegram" { // provisionAccount provisions a telegram identity
|
||||
t.Errorf("source = %q, want telegram", msgs[0].Source)
|
||||
}
|
||||
if n, _ := svc.AdminCountMessages(ctx, social.AdminMessageFilter{GameID: gameID}); n != 1 {
|
||||
t.Errorf("count = %d, want 1", n)
|
||||
}
|
||||
|
||||
// Sender pin: seat 0 has the message; seat 1 has only a nudge.
|
||||
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{SenderID: seats[0]}, 50, 0); len(got) == 0 {
|
||||
t.Error("sender=seat0 returned nothing")
|
||||
}
|
||||
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, SenderID: seats[1]}, 50, 0); len(got) != 0 {
|
||||
t.Errorf("sender=seat1 has only a nudge, got %d messages", len(got))
|
||||
}
|
||||
|
||||
// Sender glob masks: the telegram external id matches "tg-*"; bogus masks exclude.
|
||||
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "tg-*"}, 50, 0); len(got) != 1 {
|
||||
t.Errorf("ext mask tg-* = %d, want 1", len(got))
|
||||
}
|
||||
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "zzz-*"}, 50, 0); len(got) != 0 {
|
||||
t.Errorf("ext mask zzz-* = %d, want 0", len(got))
|
||||
}
|
||||
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, NameMask: "zzz-no-such-*"}, 50, 0); len(got) != 0 {
|
||||
t.Errorf("name mask miss = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||
func provisionGuest(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
if !acc.IsGuest {
|
||||
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
|
||||
// against a robot to a natural end and checks the guest holds a seat (the
|
||||
// game_players foreign key is satisfied) yet accrues no statistics, while the
|
||||
// durable robot opponent does.
|
||||
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
robots := newRobotService(t, svc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
robotID, err := robots.Pick()
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
seed := openingSeed(t)
|
||||
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
|
||||
TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
const robotSeat = 1 // seats = [guest, robot]
|
||||
|
||||
finished := false
|
||||
for i := 0; i < 400 && !finished; i++ {
|
||||
_, toMove, status, err := svc.Participants(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != game.StatusActive {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
if toMove == robotSeat {
|
||||
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
|
||||
robots.Drive(ctx, daytime)
|
||||
continue
|
||||
}
|
||||
playHuman(t, ctx, svc, g.ID, guest)
|
||||
}
|
||||
if !finished {
|
||||
t.Fatal("guest game did not finish within the move budget")
|
||||
}
|
||||
|
||||
if _, _, _, _, _, ok := readStats(t, guest); ok {
|
||||
t.Error("a guest must not accrue a statistics row")
|
||||
}
|
||||
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
|
||||
t.Error("the durable robot opponent should have a statistics row")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailLoginFlow covers the Stage 6 email-as-login path: a code is mailed to
|
||||
// a new address, verifying it provisions and returns the owning account, and a
|
||||
// second login for the same address resolves to that same account (a returning
|
||||
// user), with the identity confirmed.
|
||||
func TestEmailLoginFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if code == "" {
|
||||
t.Fatalf("no code in mail body %q", mailer.lastBody)
|
||||
}
|
||||
|
||||
acc, err := svc.LoginWithCode(ctx, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("login with code: %v", err)
|
||||
}
|
||||
if acc.ID != accountID {
|
||||
t.Errorf("login account = %s, want %s", acc.ID, accountID)
|
||||
}
|
||||
if acc.IsGuest {
|
||||
t.Error("an email account must be durable, not a guest")
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("the email identity must be confirmed after login")
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
if err != nil {
|
||||
t.Fatalf("second login: %v", err)
|
||||
}
|
||||
if acc2.ID != accountID {
|
||||
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
|
||||
// TestUserListFilter checks the admin user-list filter: the people/robots split (by a
|
||||
// robot identity) and the case-insensitive glob masks on display name and external id.
|
||||
func TestUserListFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := account.NewStore(testDB)
|
||||
uniq := uuid.NewString()
|
||||
|
||||
human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
|
||||
if err != nil {
|
||||
t.Fatalf("provision human: %v", err)
|
||||
}
|
||||
robot, err := st.ProvisionRobot(ctx, "robot-uxz-"+uniq, "Zzqxbot")
|
||||
if err != nil {
|
||||
t.Fatalf("provision robot: %v", err)
|
||||
}
|
||||
guest, err := st.ProvisionGuest(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
|
||||
collect := func(f account.UserFilter) map[uuid.UUID]account.UserListItem {
|
||||
items, err := st.ListUsers(ctx, f, 5000, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list users %+v: %v", f, err)
|
||||
}
|
||||
m := make(map[uuid.UUID]account.UserListItem, len(items))
|
||||
for _, it := range items {
|
||||
m[it.ID] = it
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
people := collect(account.UserFilter{})
|
||||
if _, ok := people[human.ID]; !ok {
|
||||
t.Error("human missing from people")
|
||||
}
|
||||
if _, ok := people[guest.ID]; !ok {
|
||||
t.Error("guest missing from people")
|
||||
}
|
||||
if _, ok := people[robot.ID]; ok {
|
||||
t.Error("robot must not appear in people")
|
||||
}
|
||||
if it := people[human.ID]; it.IsRobot || it.IsGuest {
|
||||
t.Errorf("human flags wrong: robot=%v guest=%v (want both false)", it.IsRobot, it.IsGuest)
|
||||
}
|
||||
|
||||
robots := collect(account.UserFilter{Robots: true})
|
||||
if it, ok := robots[robot.ID]; !ok || !it.IsRobot {
|
||||
t.Errorf("robot missing from robots or IsRobot=false (ok=%v)", ok)
|
||||
}
|
||||
if _, ok := robots[human.ID]; ok {
|
||||
t.Error("human must not appear in robots")
|
||||
}
|
||||
|
||||
// Name mask (people).
|
||||
if _, ok := collect(account.UserFilter{NameMask: "Zzqx*"})[human.ID]; !ok {
|
||||
t.Error("name mask Zzqx* should match the human")
|
||||
}
|
||||
if _, ok := collect(account.UserFilter{NameMask: "nomatch*"})[human.ID]; ok {
|
||||
t.Error("name mask nomatch* should not match the human")
|
||||
}
|
||||
// External-id mask (robots).
|
||||
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"})[robot.ID]; !ok {
|
||||
t.Error("external-id mask robot-uxz-* should match the robot")
|
||||
}
|
||||
if _, ok := collect(account.UserFilter{Robots: true, ExternalIDMask: "robot-zzz-*"})[robot.ID]; ok {
|
||||
t.Error("external-id mask robot-zzz-* should not match the robot")
|
||||
}
|
||||
// CountUsers agrees that robots exist.
|
||||
if n, err := st.CountUsers(ctx, account.UserFilter{Robots: true, ExternalIDMask: "robot-uxz-*"}); err != nil || n != 1 {
|
||||
t.Errorf("count robots robot-uxz-* = (%d, %v), want 1", n, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package link orchestrates account linking & merge (ARCHITECTURE.md §4).
|
||||
// It sits above the account, accountmerge and session layers: it verifies the
|
||||
// caller's control of an identity (an email confirm-code or a gateway-validated
|
||||
// platform identity), binds a free identity to the current account, and — when the
|
||||
// identity already has its own account — merges the two. The current account is the
|
||||
// merge primary, except when the initiator is a guest and the other account is
|
||||
// durable, in which case the durable account wins and a fresh session is minted for
|
||||
// it (the client switches to it).
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// Service drives the link/merge flow.
|
||||
type Service struct {
|
||||
emails *account.EmailService
|
||||
accounts *account.Store
|
||||
merger *accountmerge.Merger
|
||||
sessions *session.Service
|
||||
}
|
||||
|
||||
// NewService constructs a Service over its collaborators.
|
||||
func NewService(emails *account.EmailService, accounts *account.Store, merger *accountmerge.Merger, sessions *session.Service) *Service {
|
||||
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
|
||||
}
|
||||
|
||||
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
|
||||
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
|
||||
// required (the caller renders an irreversible-merge confirmation from it).
|
||||
type ConfirmResult struct {
|
||||
Linked bool
|
||||
MergeRequired bool
|
||||
SecondaryID uuid.UUID
|
||||
}
|
||||
|
||||
// MergeResult reports a completed merge. PrimaryID is the surviving account.
|
||||
// SwitchedToken is a fresh session token for the primary when the active account
|
||||
// changed (a guest initiator whose durable counterpart won); empty otherwise, in
|
||||
// which case the caller keeps its current session.
|
||||
type MergeResult struct {
|
||||
PrimaryID uuid.UUID
|
||||
SwitchedToken string
|
||||
}
|
||||
|
||||
// RequestEmail mails a confirm-code for email to the caller (always sent).
|
||||
func (s *Service) RequestEmail(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||
return s.emails.RequestLinkCode(ctx, accountID, email)
|
||||
}
|
||||
|
||||
// ConfirmEmail verifies the code and either binds the free address to the caller
|
||||
// (Linked) or reports that the address belongs to another account (MergeRequired).
|
||||
func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email, code string) (ConfirmResult, error) {
|
||||
owner, linked, err := s.emails.ConfirmLink(ctx, accountID, email, code)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if linked {
|
||||
if err := s.accounts.ClearGuest(ctx, accountID); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeEmail re-verifies the code and merges the address's account into the
|
||||
// caller's (subject to the guest-primary rule).
|
||||
func (s *Service) MergeEmail(ctx context.Context, callerID uuid.UUID, email, code string) (MergeResult, error) {
|
||||
owner, linked, err := s.emails.ConfirmLink(ctx, callerID, email, code)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
if linked {
|
||||
// Raced to free/self between confirm and merge: it is now simply linked.
|
||||
if err := s.accounts.ClearGuest(ctx, callerID); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
return s.merge(ctx, callerID, owner)
|
||||
}
|
||||
|
||||
// ConfirmTelegram attaches a gateway-validated Telegram identity to the caller
|
||||
// (Linked) or reports that it belongs to another account (MergeRequired).
|
||||
func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
|
||||
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
if owner == callerID {
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeTelegram merges the account owning a gateway-validated Telegram identity
|
||||
// into the caller's (subject to the guest-primary rule).
|
||||
func (s *Service) MergeTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
|
||||
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
if owner == callerID {
|
||||
return MergeResult{PrimaryID: callerID}, nil
|
||||
}
|
||||
return s.merge(ctx, callerID, owner)
|
||||
}
|
||||
|
||||
// attachTelegram links the identity to the caller and promotes a guest.
|
||||
func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, externalID string) error {
|
||||
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindTelegram, externalID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.accounts.ClearGuest(ctx, callerID)
|
||||
}
|
||||
|
||||
// merge decides the primary (the caller, unless it is a guest and the other is
|
||||
// durable), runs the data merge, retires the secondary's sessions and mints a new
|
||||
// session when the active account switches.
|
||||
func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (MergeResult, error) {
|
||||
caller, err := s.accounts.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
primary, secondary := callerID, otherID
|
||||
if caller.IsGuest {
|
||||
primary, secondary = otherID, callerID
|
||||
}
|
||||
if err := s.merger.Merge(ctx, primary, secondary); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
if err := s.sessions.RevokeAllForAccount(ctx, secondary); err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
res := MergeResult{PrimaryID: primary}
|
||||
if primary != callerID {
|
||||
token, _, err := s.sessions.Create(ctx, primary)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
res.SwitchedToken = token
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -105,18 +105,72 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
// notify publishes a re-poll Notification of the given sub-kind to each user.
|
||||
func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) {
|
||||
if len(userIDs) == 0 {
|
||||
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
|
||||
// itself so the client adds it to its lobby list without a refetch.
|
||||
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
|
||||
if len(inviteeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
intents := make([]notify.Intent, 0, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
intents = append(intents, notify.Notification(id, kind))
|
||||
summary := svc.invitationSummary(ctx, inv)
|
||||
intents := make([]notify.Intent, 0, len(inviteeIDs))
|
||||
for _, id := range inviteeIDs {
|
||||
intents = append(intents, notify.NotificationInvitation(id, summary))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// emitGameStarted publishes the game_started notification to each seated player, carrying their
|
||||
// initial view of the started game so the client seeds its game cache without a refetch. A
|
||||
// seat whose state cannot be read is skipped (it still sees the game on the next lobby load).
|
||||
func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
|
||||
intents := make([]notify.Intent, 0, len(seats))
|
||||
for _, id := range seats {
|
||||
state, err := svc.games.InitialState(ctx, g.ID, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
intents = append(intents, notify.NotificationGameStarted(id, state))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
|
||||
// resolving the inviter's and invitees' display names from the account store.
|
||||
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
|
||||
name := func(id uuid.UUID) string {
|
||||
if acc, err := svc.accounts.GetByID(ctx, id); err == nil {
|
||||
return acc.DisplayName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
invitees := make([]notify.InvitationInvitee, 0, len(inv.Invitees))
|
||||
for _, iv := range inv.Invitees {
|
||||
invitees = append(invitees, notify.InvitationInvitee{
|
||||
AccountID: iv.AccountID.String(),
|
||||
DisplayName: name(iv.AccountID),
|
||||
Seat: iv.Seat,
|
||||
Response: iv.Response,
|
||||
})
|
||||
}
|
||||
gameID := ""
|
||||
if inv.GameID != nil {
|
||||
gameID = inv.GameID.String()
|
||||
}
|
||||
return notify.InvitationSummary{
|
||||
ID: inv.ID.String(),
|
||||
Inviter: notify.AccountRef{AccountID: inv.InviterID.String(), DisplayName: name(inv.InviterID)},
|
||||
Invitees: invitees,
|
||||
Variant: inv.Settings.Variant.String(),
|
||||
TurnTimeoutSecs: int(inv.Settings.TurnTimeout / time.Second),
|
||||
HintsAllowed: inv.Settings.HintsAllowed,
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles.String(),
|
||||
Status: inv.Status,
|
||||
GameID: gameID,
|
||||
ExpiresAtUnix: inv.ExpiresAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
|
||||
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
|
||||
// invitees distinct and not the inviter, every invitee an existing account with no
|
||||
@@ -176,7 +230,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
svc.notify(notify.NotifyInvitation, inviteeIDs...)
|
||||
svc.emitInvitation(ctx, inv, inviteeIDs)
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
@@ -224,7 +278,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
||||
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.notify(notify.NotifyGameStarted, seats...)
|
||||
svc.emitGameStarted(ctx, g, seats)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,20 +12,26 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||
// game. game.Service satisfies it.
|
||||
// game and reading a player's initial view of it. game.Service satisfies it.
|
||||
type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
// InitialState returns a seated player's full initial view of a started game, used
|
||||
// to enrich the match_found / game_started events so the client renders the new game
|
||||
// without a follow-up fetch.
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
}
|
||||
|
||||
// RobotProvider supplies a robot account to substitute for a missing human in
|
||||
// auto-match. robot.Service satisfies it; it returns an error when no robot is
|
||||
// available so the matchmaker can defer substitution.
|
||||
type RobotProvider interface {
|
||||
Pick() (uuid.UUID, error)
|
||||
Pick(variant engine.Variant) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// Blocker reports whether two accounts have a block between them (either
|
||||
|
||||
@@ -75,10 +75,21 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
|
||||
// emitMatchFound pushes match_found to every seat of a freshly started game.
|
||||
// Emitting to a robot seat is harmless (no client subscription exists for it).
|
||||
func (m *Matchmaker) emitMatchFound(g game.Game) {
|
||||
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) {
|
||||
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot
|
||||
intents := make([]notify.Intent, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
intents = append(intents, notify.MatchFound(s.AccountID, g.ID))
|
||||
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
|
||||
if err != nil {
|
||||
// A waiter still discovers the game through Poll (the ws-down fallback), so skip the
|
||||
// enriched push for this seat rather than failing the match.
|
||||
m.log.Warn("match_found initial state",
|
||||
zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
mf := notify.MatchFound(s.AccountID, g.ID, state)
|
||||
mf.Language = lang
|
||||
intents = append(intents, mf)
|
||||
}
|
||||
m.pub.Publish(intents...)
|
||||
}
|
||||
@@ -125,7 +136,7 @@ func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant e
|
||||
m.mu.Lock()
|
||||
m.results[opponent] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(g)
|
||||
m.emitMatchFound(ctx, g)
|
||||
return EnqueueResult{Matched: true, Game: g}, nil
|
||||
}
|
||||
|
||||
@@ -142,11 +153,14 @@ func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult
|
||||
return EnqueueResult{}, nil
|
||||
}
|
||||
|
||||
// Cancel removes accountID from whatever pool it waits in, reporting whether it
|
||||
// was queued.
|
||||
// Cancel removes accountID from whatever pool it waits in and drops any pending
|
||||
// matched result, reporting whether it was queued. Clearing the result closes the
|
||||
// race where the reaper substituted a robot just before the player cancelled: the
|
||||
// stale game must not later surface through Poll as a game the player did not want.
|
||||
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.results, accountID)
|
||||
variant, ok := m.queued[accountID]
|
||||
if !ok {
|
||||
return false
|
||||
@@ -197,12 +211,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||
}
|
||||
var subs []sub
|
||||
for _, acc := range due {
|
||||
robotID, err := m.robots.Pick()
|
||||
variant := m.queued[acc]
|
||||
robotID, err := m.robots.Pick(variant)
|
||||
if err != nil {
|
||||
m.log.Warn("robot substitution deferred", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
variant := m.queued[acc]
|
||||
m.removeLocked(acc, variant)
|
||||
seats := []uuid.UUID{acc, robotID}
|
||||
if m.rng.Intn(2) == 0 {
|
||||
@@ -221,7 +235,7 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
||||
m.mu.Lock()
|
||||
m.results[s.human] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(g)
|
||||
m.emitMatchFound(ctx, g)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// fakeCreator records the games a matchmaker asks it to start.
|
||||
@@ -27,14 +28,22 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
|
||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||
}
|
||||
|
||||
// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing
|
||||
// tests assert on matching behaviour, not the payload, so an empty state is enough.
|
||||
func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
||||
return notify.PlayerState{}, nil
|
||||
}
|
||||
|
||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
||||
// an empty pool.
|
||||
// an empty pool. It records the variant of the last substitution request.
|
||||
type fakeRobots struct {
|
||||
id uuid.UUID
|
||||
err error
|
||||
lastVariant engine.Variant
|
||||
}
|
||||
|
||||
func (f *fakeRobots) Pick() (uuid.UUID, error) {
|
||||
func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
|
||||
f.lastVariant = variant
|
||||
if f.err != nil {
|
||||
return uuid.Nil, f.err
|
||||
}
|
||||
@@ -238,6 +247,27 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
|
||||
// robot just before the player cancels: Cancel must drop the pending result so the
|
||||
// abandoned game never surfaces through Poll.
|
||||
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
|
||||
mm.Cancel(ctx, a) // ... then the player cancels
|
||||
if got, _ := mm.Poll(ctx, a); got.Matched {
|
||||
t.Error("cancel must drop the pending substituted game; Poll still matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/pkg/wire"
|
||||
)
|
||||
|
||||
// The builders below encode the nested wire tables embedded in enriched event
|
||||
// payloads. They map the domain's already-resolved values (notify.* payload structs
|
||||
// and the decoded engine.MoveRecord) to the neutral scrabble/pkg/wire structs and
|
||||
// delegate the FlatBuffers construction to package wire — the single definition of the
|
||||
// nested-table layout shared with the gateway transcoder. Each returns the offset of
|
||||
// the table it built; callers must build every nested table before opening the parent.
|
||||
|
||||
// toWireGame maps a GameSummary to the shared wire.GameView.
|
||||
func toWireGame(g GameSummary) wire.GameView {
|
||||
seats := make([]wire.SeatView, len(g.Seats))
|
||||
for i, s := range g.Seats {
|
||||
seats[i] = wire.SeatView{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID,
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
DisplayName: s.DisplayName,
|
||||
}
|
||||
}
|
||||
return wire.GameView{
|
||||
ID: g.ID,
|
||||
Variant: g.Variant,
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: g.TurnTimeoutSecs,
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
LastActivityUnix: g.LastActivityUnix,
|
||||
}
|
||||
}
|
||||
|
||||
// buildGameView builds a GameView table from a GameSummary and returns its offset.
|
||||
func buildGameView(b *flatbuffers.Builder, g GameSummary) flatbuffers.UOffsetT {
|
||||
return wire.BuildGameView(b, toWireGame(g))
|
||||
}
|
||||
|
||||
// buildMoveRecord builds a MoveRecord table from a decoded engine move and returns its
|
||||
// offset (Count is the engine count: the number of tiles swapped on an exchange, zero
|
||||
// otherwise).
|
||||
func buildMoveRecord(b *flatbuffers.Builder, m engine.MoveRecord) flatbuffers.UOffsetT {
|
||||
tiles := make([]wire.TileRecord, len(m.Tiles))
|
||||
for i, t := range m.Tiles {
|
||||
tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
|
||||
}
|
||||
return wire.BuildMoveRecord(b, wire.MoveRecord{
|
||||
Player: m.Player,
|
||||
Action: m.Action.String(),
|
||||
Dir: m.Dir.String(),
|
||||
MainRow: m.MainRow,
|
||||
MainCol: m.MainCol,
|
||||
Tiles: tiles,
|
||||
Words: m.Words,
|
||||
Count: m.Count,
|
||||
Score: m.Score,
|
||||
Total: m.Total,
|
||||
})
|
||||
}
|
||||
|
||||
// buildStateView builds a StateView table from a PlayerState and returns its offset.
|
||||
func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT {
|
||||
alphabet := make([]wire.AlphabetEntry, len(s.Alphabet))
|
||||
for i, e := range s.Alphabet {
|
||||
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||
}
|
||||
return wire.BuildStateView(b, wire.StateView{
|
||||
Game: toWireGame(s.Game),
|
||||
Seat: s.Seat,
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
Alphabet: alphabet,
|
||||
})
|
||||
}
|
||||
|
||||
// buildAccountRef builds an AccountRef table and returns its offset.
|
||||
func buildAccountRef(b *flatbuffers.Builder, a AccountRef) flatbuffers.UOffsetT {
|
||||
return wire.BuildAccountRef(b, wire.AccountRef{AccountID: a.AccountID, DisplayName: a.DisplayName})
|
||||
}
|
||||
|
||||
// buildInvitation builds an Invitation table from an InvitationSummary and returns its offset.
|
||||
func buildInvitation(b *flatbuffers.Builder, inv InvitationSummary) flatbuffers.UOffsetT {
|
||||
invitees := make([]wire.InvitationInvitee, len(inv.Invitees))
|
||||
for i, iv := range inv.Invitees {
|
||||
invitees[i] = wire.InvitationInvitee{
|
||||
AccountID: iv.AccountID,
|
||||
DisplayName: iv.DisplayName,
|
||||
Seat: iv.Seat,
|
||||
Response: iv.Response,
|
||||
}
|
||||
}
|
||||
return wire.BuildInvitation(b, wire.Invitation{
|
||||
ID: inv.ID,
|
||||
Inviter: wire.AccountRef{AccountID: inv.Inviter.AccountID, DisplayName: inv.Inviter.DisplayName},
|
||||
Invitees: invitees,
|
||||
Variant: inv.Variant,
|
||||
TurnTimeoutSecs: inv.TurnTimeoutSecs,
|
||||
HintsAllowed: inv.HintsAllowed,
|
||||
HintsPerPlayer: inv.HintsPerPlayer,
|
||||
DropoutTiles: inv.DropoutTiles,
|
||||
Status: inv.Status,
|
||||
GameID: inv.GameID,
|
||||
ExpiresAtUnix: inv.ExpiresAtUnix,
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
@@ -13,30 +14,65 @@ import (
|
||||
// the payload with the shared scrabblefb schema. Keeping the encoding here lets
|
||||
// the game/social/lobby services emit events without importing the wire schema.
|
||||
|
||||
// YourTurn announces to userID that it is their turn in game gameID, with the
|
||||
// turn's nominal deadline.
|
||||
func YourTurn(userID, gameID uuid.UUID, deadline time.Time) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
// YourTurn announces to userID that it is their turn in game gameID, with the turn's nominal
|
||||
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push:
|
||||
// the player who just moved, their move kind, the main word of a scoring play (empty
|
||||
// otherwise) and the recipient-first running score line. Empty strings render the plain "your
|
||||
// turn" text. moveCount is the post-move count, which the client compares against its cached
|
||||
// game to detect a missed in-app move and fall back to a refetch.
|
||||
func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
gid := b.CreateString(gameID.String())
|
||||
name := b.CreateString(opponentName)
|
||||
action := b.CreateString(lastAction)
|
||||
word := b.CreateString(lastWord)
|
||||
score := b.CreateString(scoreLine)
|
||||
fb.YourTurnEventStart(b)
|
||||
fb.YourTurnEventAddGameId(b, gid)
|
||||
fb.YourTurnEventAddDeadlineUnix(b, deadline.Unix())
|
||||
fb.YourTurnEventAddOpponentName(b, name)
|
||||
fb.YourTurnEventAddLastAction(b, action)
|
||||
fb.YourTurnEventAddLastWord(b, word)
|
||||
fb.YourTurnEventAddScoreLine(b, score)
|
||||
fb.YourTurnEventAddMoveCount(b, int32(moveCount))
|
||||
b.Finish(fb.YourTurnEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindYourTurn, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// OpponentMoved tells userID that seat just committed a move in game gameID,
|
||||
// summarising it (the client refetches the full state).
|
||||
func OpponentMoved(userID, gameID uuid.UUID, seat int, action string, score, total int) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
// GameOver announces to userID that game gameID finished. result is the outcome from userID's
|
||||
// own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both
|
||||
// feed the out-of-app "game over" push. game is the final post-game summary (the
|
||||
// adjusted scores after rack penalties and the winner flag), so an in-app client settles the
|
||||
// finished game from the event without a refetch.
|
||||
func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
act := b.CreateString(action)
|
||||
res := b.CreateString(result)
|
||||
score := b.CreateString(scoreLine)
|
||||
gameOff := buildGameView(b, game)
|
||||
fb.GameOverEventStart(b)
|
||||
fb.GameOverEventAddGameId(b, gid)
|
||||
fb.GameOverEventAddResult(b, res)
|
||||
fb.GameOverEventAddScoreLine(b, score)
|
||||
fb.GameOverEventAddGame(b, gameOff)
|
||||
b.Finish(fb.GameOverEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindGameOver, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta
|
||||
// the client applies to its cached game without a refetch: move is the decoded play/pass/
|
||||
// exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and
|
||||
// bagLen is the bag size after the draw.
|
||||
func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
moveOff := buildMoveRecord(b, move)
|
||||
gameOff := buildGameView(b, game)
|
||||
fb.OpponentMovedEventStart(b)
|
||||
fb.OpponentMovedEventAddGameId(b, gid)
|
||||
fb.OpponentMovedEventAddSeat(b, int32(seat))
|
||||
fb.OpponentMovedEventAddAction(b, act)
|
||||
fb.OpponentMovedEventAddScore(b, int32(score))
|
||||
fb.OpponentMovedEventAddTotal(b, int32(total))
|
||||
fb.OpponentMovedEventAddMove(b, moveOff)
|
||||
fb.OpponentMovedEventAddGame(b, gameOff)
|
||||
fb.OpponentMovedEventAddBagLen(b, int32(bagLen))
|
||||
b.Finish(fb.OpponentMovedEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindOpponentMoved, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
@@ -72,21 +108,24 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
|
||||
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// MatchFound tells userID that game gameID, which they are seated in, has
|
||||
// started (an auto-match pairing or a robot substitution).
|
||||
func MatchFound(userID, gameID uuid.UUID) Intent {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match
|
||||
// pairing or a robot substitution). state is the recipient's full initial view of the new game,
|
||||
// so the client navigates straight in from the event with no follow-up fetch.
|
||||
func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
gid := b.CreateString(gameID.String())
|
||||
stateOff := buildStateView(b, state)
|
||||
fb.MatchFoundEventStart(b)
|
||||
fb.MatchFoundEventAddGameId(b, gid)
|
||||
fb.MatchFoundEventAddState(b, stateOff)
|
||||
b.Finish(fb.MatchFoundEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
|
||||
// scope its refresh.
|
||||
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
|
||||
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
|
||||
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
|
||||
// enriched constructors below, which let the client update its lobby without a refetch.
|
||||
func Notification(userID uuid.UUID, kind string) Intent {
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
k := b.CreateString(kind)
|
||||
@@ -96,6 +135,47 @@ func Notification(userID uuid.UUID, kind string) Intent {
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
|
||||
// account it concerns (the requester, the new friend or the decliner), so the client updates its
|
||||
// requests/friends lists and the in-game "add friend" state without a refetch.
|
||||
func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
|
||||
b := flatbuffers.NewBuilder(128)
|
||||
k := b.CreateString(kind)
|
||||
accOff := buildAccountRef(b, acc)
|
||||
fb.NotificationEventStart(b)
|
||||
fb.NotificationEventAddKind(b, k)
|
||||
fb.NotificationEventAddAccount(b, accOff)
|
||||
b.Finish(fb.NotificationEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's
|
||||
// initial view of the just-started invited game, so the client seeds its game cache and the
|
||||
// lobby list without a refetch.
|
||||
func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
k := b.CreateString(NotifyGameStarted)
|
||||
stateOff := buildStateView(b, state)
|
||||
fb.NotificationEventStart(b)
|
||||
fb.NotificationEventAddKind(b, k)
|
||||
fb.NotificationEventAddState(b, stateOff)
|
||||
b.Finish(fb.NotificationEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
|
||||
// so the client adds it to its lobby invitations list without a refetch.
|
||||
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
|
||||
b := flatbuffers.NewBuilder(512)
|
||||
k := b.CreateString(NotifyInvitation)
|
||||
invOff := buildInvitation(b, inv)
|
||||
fb.NotificationEventStart(b)
|
||||
fb.NotificationEventAddKind(b, k)
|
||||
fb.NotificationEventAddInvitation(b, invOff)
|
||||
b.Finish(fb.NotificationEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
// eventID returns a best-effort correlation id for one emitted event.
|
||||
func eventID() string {
|
||||
if id, err := uuid.NewV7(); err == nil {
|
||||
|
||||
@@ -27,6 +27,9 @@ const (
|
||||
// KindNotification is a lightweight "re-poll your lobby counters" signal
|
||||
// (incoming friend requests, invitations) that drives the lobby badge.
|
||||
KindNotification = "notify"
|
||||
// KindGameOver announces a finished game to each seated player, driving the
|
||||
// out-of-app "game over" push.
|
||||
KindGameOver = "game_over"
|
||||
)
|
||||
|
||||
// Notification sub-kinds carried in a KindNotification event payload; the client
|
||||
@@ -34,6 +37,9 @@ const (
|
||||
const (
|
||||
NotifyFriendRequest = "friend_request"
|
||||
NotifyFriendAdded = "friend_added"
|
||||
// NotifyFriendDeclined tells the original requester their request was declined, so a
|
||||
// game screen watching that opponent re-derives its "add to friends" state.
|
||||
NotifyFriendDeclined = "friend_declined"
|
||||
NotifyInvitation = "invitation"
|
||||
NotifyGameStarted = "game_started"
|
||||
)
|
||||
@@ -46,6 +52,11 @@ type Intent struct {
|
||||
Kind string
|
||||
Payload []byte
|
||||
EventID string
|
||||
// Language routes an out-of-app push to a specific per-language bot: for a
|
||||
// game event it is the game's language ("en"/"ru"), so the notification comes from the
|
||||
// game's bot rather than the recipient's last-login bot. Empty falls back to the
|
||||
// recipient's service language at the gateway.
|
||||
Language string
|
||||
}
|
||||
|
||||
// Publisher accepts live-event intents. Implementations must be safe for
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
@@ -61,7 +62,7 @@ func TestNopPublisherDiscards(t *testing.T) {
|
||||
|
||||
func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0))
|
||||
in := notify.YourTurn(uid, gid, time.Unix(1717000000, 0), "Ann", "play", "STOOL", "120:95", 7)
|
||||
if in.UserID != uid || in.Kind != notify.KindYourTurn || in.EventID == "" {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
@@ -72,18 +73,124 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) {
|
||||
if got := ev.DeadlineUnix(); got != 1717000000 {
|
||||
t.Fatalf("deadline = %d, want 1717000000", got)
|
||||
}
|
||||
if got := ev.MoveCount(); got != 7 {
|
||||
t.Fatalf("move_count = %d, want 7", got)
|
||||
}
|
||||
if string(ev.OpponentName()) != "Ann" || string(ev.LastAction()) != "play" ||
|
||||
string(ev.LastWord()) != "STOOL" || string(ev.ScoreLine()) != "120:95" {
|
||||
t.Fatalf("enriched fields wrong: name=%q action=%q word=%q score=%q",
|
||||
ev.OpponentName(), ev.LastAction(), ev.LastWord(), ev.ScoreLine())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameOverPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}}
|
||||
in := notify.GameOver(uid, gid, "won", "120:95:80", summary)
|
||||
if in.UserID != uid || in.Kind != notify.KindGameOver || in.EventID == "" {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
ev := fb.GetRootAsGameOverEvent(in.Payload, 0)
|
||||
if string(ev.GameId()) != gid.String() || string(ev.Result()) != "won" || string(ev.ScoreLine()) != "120:95:80" {
|
||||
t.Fatalf("game_over fields wrong: game=%q result=%q score=%q", ev.GameId(), ev.Result(), ev.ScoreLine())
|
||||
}
|
||||
g := ev.Game(nil)
|
||||
if g == nil || string(g.Id()) != gid.String() || g.MoveCount() != 18 || g.SeatsLength() != 1 {
|
||||
t.Fatalf("final game summary wrong: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
in := notify.OpponentMoved(uid, gid, 1, "play", 24, 130)
|
||||
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
|
||||
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
|
||||
in := notify.OpponentMoved(uid, gid, move, summary, 42)
|
||||
if in.Kind != notify.KindOpponentMoved {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
}
|
||||
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
|
||||
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
|
||||
t.Fatalf("decoded wrong: game=%q seat=%d action=%q score=%d total=%d",
|
||||
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
|
||||
if string(ev.GameId()) != gid.String() {
|
||||
t.Fatalf("game id = %q", ev.GameId())
|
||||
}
|
||||
// The delta: the move, the post-move summary and the bag size.
|
||||
if ev.BagLen() != 42 {
|
||||
t.Fatalf("bag_len = %d, want 42", ev.BagLen())
|
||||
}
|
||||
m := ev.Move(nil)
|
||||
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
|
||||
t.Fatalf("move wrong: %+v", m)
|
||||
}
|
||||
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
|
||||
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchFoundCarriesInitialState(t *testing.T) {
|
||||
uid, gid := uuid.New(), uuid.New()
|
||||
state := notify.PlayerState{
|
||||
Game: notify.GameSummary{ID: gid.String(), Variant: "scrabble_en", Seats: []notify.SeatStanding{{Seat: 0, DisplayName: "Ann"}}},
|
||||
Seat: 0,
|
||||
Rack: []int{0, 1, 2, 255},
|
||||
BagLen: 86,
|
||||
}
|
||||
in := notify.MatchFound(uid, gid, state)
|
||||
if in.UserID != uid || in.Kind != notify.KindMatchFound {
|
||||
t.Fatalf("intent metadata wrong: %+v", in)
|
||||
}
|
||||
ev := fb.GetRootAsMatchFoundEvent(in.Payload, 0)
|
||||
if string(ev.GameId()) != gid.String() {
|
||||
t.Fatalf("game id = %q", ev.GameId())
|
||||
}
|
||||
st := ev.State(nil)
|
||||
if st == nil || st.Seat() != 0 || st.BagLen() != 86 || st.RackLength() != 4 || st.Rack(3) != 255 {
|
||||
t.Fatalf("initial state wrong: %+v", st)
|
||||
}
|
||||
if g := st.Game(nil); g == nil || string(g.Variant()) != "scrabble_en" {
|
||||
t.Fatalf("state game wrong: %+v", st.Game(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationInvitationCarriesInvitation(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
inv := notify.InvitationSummary{
|
||||
ID: "inv-1",
|
||||
Inviter: notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"},
|
||||
Invitees: []notify.InvitationInvitee{{AccountID: "b-1", DisplayName: "Bob", Seat: 1, Response: "pending"}},
|
||||
Variant: "erudit_ru",
|
||||
TurnTimeoutSecs: 86400,
|
||||
Status: "pending",
|
||||
}
|
||||
in := notify.NotificationInvitation(uid, inv)
|
||||
if in.Kind != notify.KindNotification {
|
||||
t.Fatalf("kind = %q", in.Kind)
|
||||
}
|
||||
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
|
||||
if string(ev.Kind()) != notify.NotifyInvitation {
|
||||
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitation)
|
||||
}
|
||||
got := ev.Invitation(nil)
|
||||
if got == nil || string(got.Id()) != "inv-1" || string(got.Variant()) != "erudit_ru" || got.InviteesLength() != 1 {
|
||||
t.Fatalf("invitation wrong: %+v", got)
|
||||
}
|
||||
var iv fb.InvitationInvitee
|
||||
if !got.Invitees(&iv, 0) || string(iv.DisplayName()) != "Bob" || iv.Seat() != 1 {
|
||||
t.Fatalf("invitee wrong")
|
||||
}
|
||||
if inviter := got.Inviter(nil); inviter == nil || string(inviter.DisplayName()) != "Ann" {
|
||||
t.Fatalf("inviter wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationAccountCarriesAccount(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"})
|
||||
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
|
||||
if string(ev.Kind()) != notify.NotifyFriendRequest {
|
||||
t.Fatalf("sub-kind = %q", ev.Kind())
|
||||
}
|
||||
acc := ev.Account(nil)
|
||||
if acc == nil || string(acc.AccountId()) != "a-1" || string(acc.DisplayName()) != "Ann" {
|
||||
t.Fatalf("account wrong: %+v", acc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user