Compare commits
46 Commits
master
..
356f490546
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,345 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express
|
||||||
|
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
|
||||||
|
# one workflow.
|
||||||
|
#
|
||||||
|
# Branch model (CLAUDE.md): feature branches are cut from `development`; a commit
|
||||||
|
# to a feature branch triggers nothing. The pipeline runs on a PR into
|
||||||
|
# `development` or `master` (the full test suite — the merge gate) and on a push
|
||||||
|
# to `development` (after a merge). The deploy job runs only for `development`
|
||||||
|
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
|
||||||
|
# workflow (Stage 18).
|
||||||
|
#
|
||||||
|
# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their
|
||||||
|
# code changed (the `changes` job decides). Because a skipped required check would
|
||||||
|
# block a merge under branch protection, the always-running `gate` job aggregates
|
||||||
|
# their results and is the ONLY required status check; it passes when every
|
||||||
|
# upstream job either succeeded or was skipped.
|
||||||
|
#
|
||||||
|
# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
|
||||||
|
# `--progress plain`) so the Gitea logs stay readable.
|
||||||
|
|
||||||
|
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/|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/...
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
env:
|
||||||
|
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
||||||
|
|
||||||
|
integration:
|
||||||
|
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. Stage 18's prod workflow leaves it false.
|
||||||
|
TELEGRAM_TEST_ENV: "true"
|
||||||
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
|
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 gateway through caddy
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/; then
|
||||||
|
echo "healthy: GET http://scrabble/"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "probe failed; recent gateway logs:"
|
||||||
|
docker logs --tail 50 scrabble-gateway || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: 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,81 +0,0 @@
|
|||||||
name: Tests · Go
|
|
||||||
|
|
||||||
# Fast unit tests for the Go side of the monorepo. Runs on every push and pull
|
|
||||||
# request whose path filter matches a Go source directory. The module list
|
|
||||||
# grows as new go.work modules (gateway, pkg/*, platform/*) are added by later
|
|
||||||
# stages.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'gateway/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'platform/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/go-unit.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'gateway/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'platform/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/go-unit.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
# The engine consumes the published scrabble-solver module from this Gitea;
|
|
||||||
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
|
|
||||||
# DICT_VERSION selects the dictionary DAWG release the engine tests load.
|
|
||||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
|
||||||
DICT_VERSION: v1.0.0
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fetch dictionary DAWGs
|
|
||||||
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
|
|
||||||
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
|
|
||||||
# sibling clone). They ship as a release artifact, one semver per set.
|
|
||||||
run: |
|
|
||||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
|
||||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.work
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: gofmt
|
|
||||||
run: |
|
|
||||||
unformatted="$(gofmt -l .)"
|
|
||||||
if [ -n "$unformatted" ]; then
|
|
||||||
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: vet
|
|
||||||
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
# -count=1 disables the test cache so a green run never depends on a
|
|
||||||
# previous runner's cached state. BACKEND_DICT_DIR points the engine
|
|
||||||
# tests at the DAWGs fetched from the dictionary release.
|
|
||||||
env:
|
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
|
||||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
name: Tests · Integration
|
|
||||||
|
|
||||||
# Postgres-backed integration tests for the Go backend, gated behind the
|
|
||||||
# `integration` build tag. They spin a throwaway postgres:17-alpine container via
|
|
||||||
# testcontainers-go, which reaches the host Docker daemon through the socket the
|
|
||||||
# Gitea runner exposes. Slower than the unit job (go-unit.yaml); run serially
|
|
||||||
# (-p=1) with Ryuk disabled — TestMain terminates its own container. The module
|
|
||||||
# list grows as new go.work modules are added by later stages.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/integration.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/integration.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
integration:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
# Ryuk (testcontainers' reaper) does not start cleanly on every runner;
|
|
||||||
# the suite's TestMain terminates its own container, so disable it.
|
|
||||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
|
||||||
# The engine consumes the published scrabble-solver module from this Gitea
|
|
||||||
# (GOPRIVATE -> direct fetch, skipping the public proxy/checksum DB);
|
|
||||||
# DICT_VERSION selects the dictionary DAWG release the engine tests load.
|
|
||||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
|
||||||
DICT_VERSION: v1.0.0
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fetch dictionary DAWGs
|
|
||||||
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
|
|
||||||
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
|
|
||||||
# sibling clone). They ship as a release artifact; the engine's untagged
|
|
||||||
# tests (compiled here too) load them.
|
|
||||||
run: |
|
|
||||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
|
||||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.work
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Integration tests
|
|
||||||
# -count=1 disables the test cache; -p=1 -parallel=1 keeps the
|
|
||||||
# container-backed tests serial; the 15-minute timeout bounds a stuck
|
|
||||||
# container pull. The engine package's (untagged) tests also compile and
|
|
||||||
# run here, so BACKEND_DICT_DIR points them at the DAWGs from the release.
|
|
||||||
env:
|
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
|
||||||
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
|
|
||||||
@@ -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
|
|
||||||
@@ -49,9 +49,20 @@ conversation memory — is the source of continuity. Keep it that way.
|
|||||||
|
|
||||||
## Branching & CI
|
## Branching & CI
|
||||||
|
|
||||||
- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*`
|
- **Two long-lived branches** (Stage 16 onward): **`development`** is the
|
||||||
and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on
|
integration branch; **`master`** is the production trunk. Cut `feature/*`
|
||||||
`master` by necessity (an empty branch has nothing to PR into).
|
branches **from `development`** and PR them back into it. (Stages 0–15 used
|
||||||
|
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
|
||||||
|
on `master` by necessity.)
|
||||||
|
- A commit to a `feature/*` branch triggers **nothing**. The single workflow
|
||||||
|
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
|
||||||
|
on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls
|
||||||
|
the **test contour** on a PR into — or a push to — `development`
|
||||||
|
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into
|
||||||
|
`master` is test-only.
|
||||||
|
- Merge `development → master` only when CI is green; the **prod** deploy is then a
|
||||||
|
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
|
||||||
|
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
|
||||||
- After any push, watch the run to green before declaring a stage done — use the
|
- After any push, watch the run to green before declaring a stage done — use the
|
||||||
ready-made watcher, never an inline poll loop:
|
ready-made watcher, never an inline poll loop:
|
||||||
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
||||||
@@ -113,6 +124,8 @@ backend/ # module scrabble/backend
|
|||||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||||
gateway/ ui/ pkg/ # added by their stages
|
gateway/ ui/ pkg/ # added by their stages
|
||||||
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
||||||
|
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile # multi-stage distroless (Stage 16)
|
||||||
|
deploy/ # docker-compose + caddy + otelcol/prometheus/tempo/grafana (Stage 16)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
@@ -127,9 +140,14 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
|||||||
|
|
||||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
pnpm start # UI mock mode: lobby -> game, no backend
|
||||||
|
|
||||||
|
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the UI
|
||||||
|
docker build -f gateway/Dockerfile -t scrabble-gateway .
|
||||||
|
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
||||||
```
|
```
|
||||||
|
|
||||||
The `ui` module is a Node project (pnpm), **not** in `go.work`; its CI is
|
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job
|
||||||
`.gitea/workflows/ui-test.yaml`. Committed edge codegen under `ui/src/gen/`
|
of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit /
|
||||||
|
integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
|
||||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ independent (see ARCHITECTURE §9.1).
|
|||||||
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
|
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
|
||||||
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
|
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
|
||||||
| 15 | Dual Telegram bots & language-gated variants | **done** |
|
| 15 | Dual Telegram bots & language-gated variants | **done** |
|
||||||
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | todo |
|
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
|
||||||
| 17 | Prod contour deploy (SSH export/import, manual after merge) | todo |
|
| 17 | Test-contour verification & defect fixes | **done** |
|
||||||
|
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
|
||||||
|
|
||||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||||
adds the modules it needs.
|
adds the modules it needs.
|
||||||
@@ -244,7 +245,7 @@ indices; the premiums.ts parity-test rework.
|
|||||||
|
|
||||||
### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
|
### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
|
||||||
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
|
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
|
||||||
deploy + observability + the two-bots idea were split into **Stages 15–17** below and this
|
deploy + observability + the two-bots idea were split into **Stages 15–18** below and this
|
||||||
stage took only the dependency/artifact split that everything else builds on. Scope: publish
|
stage took only the dependency/artifact split that everything else builds on. Scope: publish
|
||||||
`scrabble-solver` as a versioned Gitea module and split the dictionary build into a new
|
`scrabble-solver` as a versioned Gitea module and split the dictionary build into a new
|
||||||
`scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume
|
`scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume
|
||||||
@@ -279,7 +280,7 @@ back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the g
|
|||||||
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
|
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
|
||||||
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
|
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
|
||||||
|
|
||||||
### Stage 16 — Deploy infra & test contour
|
### Stage 16 — Deploy infra & test contour *(done)*
|
||||||
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
|
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
|
||||||
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
|
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
|
||||||
gains **static UI serving** — **embedded** via `go:embed` (a node build stage in the gateway image),
|
gains **static UI serving** — **embedded** via `go:embed` (a node build stage in the gateway image),
|
||||||
@@ -297,15 +298,126 @@ h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go bu
|
|||||||
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
|
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
|
||||||
collector/Tempo/Prometheus retention.
|
collector/Tempo/Prometheus retention.
|
||||||
|
|
||||||
### Stage 17 — Prod contour deploy
|
### Stage 17 — Test-contour verification & defect fixes *(done)*
|
||||||
|
Scope: 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**
|
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
|
(`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP
|
||||||
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after a feature branch is merged to
|
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master`
|
||||||
`master`. Two-contour config uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no
|
(the Stage 16 branch model: `feature/* → development → master`, merge gated green). Two-contour config
|
||||||
deployment environments (verified: the `environments` API 404s), so a flat prefixed namespace is the
|
uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no deployment environments (verified:
|
||||||
convention.
|
the `environments` API 404s), so a flat prefixed namespace is the convention.
|
||||||
Open details (re-interview): export/import vs a registry trade-off; prod domain/TLS at the remote
|
Reuses the Stage 16 `deploy/docker-compose.yml` as-is, mapping the **`PROD_`** set onto the same
|
||||||
caddy; prod VPN; rollback.
|
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
|
## Refinements logged during implementation
|
||||||
|
|
||||||
@@ -901,7 +1013,7 @@ caddy; prod VPN; rollback.
|
|||||||
CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written
|
CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written
|
||||||
into the plan now as the agreed baseline (each still re-interviews at its own start).
|
into the plan now as the agreed baseline (each still re-interviews at its own start).
|
||||||
(Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
|
(Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
|
||||||
observability + the dual-bot idea split into Stages 15–17.)
|
observability + the dual-bot idea split into Stages 15–18.)
|
||||||
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
|
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
|
||||||
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
|
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
|
||||||
backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
|
backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
|
||||||
@@ -981,7 +1093,7 @@ caddy; prod VPN; rollback.
|
|||||||
- **Stage 14** (interview + implementation, re-scoped + discharges TODO-1/TODO-2):
|
- **Stage 14** (interview + implementation, re-scoped + discharges TODO-1/TODO-2):
|
||||||
- **Re-scoped to the split** (interview): the original "CI & deploy" was several sessions of work,
|
- **Re-scoped to the split** (interview): the original "CI & deploy" was several sessions of work,
|
||||||
so it was cut to the **solver/dictionary split** (the dependency foundation) and the deploy +
|
so it was cut to the **solver/dictionary split** (the dependency foundation) and the deploy +
|
||||||
observability + the dual-bot idea were written into the plan as new **Stages 15–17**. The deploy
|
observability + the dual-bot idea were written into the plan as new **Stages 15–18**. The deploy
|
||||||
decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
|
decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
|
||||||
full Collector+Prometheus+Tempo+Grafana stack; **two contours** — test = auto on feature-branch
|
full Collector+Prometheus+Tempo+Grafana stack; **two contours** — test = auto on feature-branch
|
||||||
push on the local host, prod = manual SSH `docker save`/`load` after merge; `TEST_`/`PROD_` secret
|
push on the local host, prod = manual SSH `docker save`/`load` after merge; `TEST_`/`PROD_` secret
|
||||||
@@ -1036,6 +1148,246 @@ caddy; prod VPN; rollback.
|
|||||||
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
|
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
|
||||||
already span the touched modules).
|
already span the touched modules).
|
||||||
|
|
||||||
|
- **Stage 16** (interview + implementation):
|
||||||
|
- **Branch model reshaped** (interview, supersedes the Stage 0 `feature/* → master`): a long-lived
|
||||||
|
**`development`** integration branch + **`master`** as the prod trunk. Feature branches are cut from
|
||||||
|
`development`; a feature-branch commit triggers nothing. A single consolidated
|
||||||
|
`.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs `unit`+`integration`+`ui` on a PR
|
||||||
|
into `development`/`master` and a **gated `deploy`** job (`needs` the three) that auto-rolls the test
|
||||||
|
contour **on a PR into — or a push to — `development`** (owner's "и PR, и push"). A PR into `master` is
|
||||||
|
test-only; prod is the manual Stage 18. The former `go-unit`/`integration`/`ui-test` workflows were
|
||||||
|
folded in (no path filters — full CI on every PR, per the owner). Console kept plain (`NO_COLOR`,
|
||||||
|
`docker compose --ansi never`, `--progress plain`).
|
||||||
|
- **Gateway serves the UI** (interview, the §13 single-origin): a new `gateway/internal/webui` embeds
|
||||||
|
`dist` via `go:embed` (a committed placeholder index so `go build`/CI compile without a UI build) and
|
||||||
|
serves the SPA at `/` and `/telegram/` (a path-stripping SPA handler, index.html fallback for the hash
|
||||||
|
router), mounted in the edge mux **below** the h2c wrap; `/_gm` stays an explicit 404 when the local
|
||||||
|
admin proxy is off so the catch-all does not leak the shell. The `gateway/Dockerfile` node stage builds
|
||||||
|
the UI with the `VITE_*` build-args and copies it into the embed dir before `go build`.
|
||||||
|
- **Images** (interview): multi-stage distroless `backend/Dockerfile` (a DAWG stage `curl`s the
|
||||||
|
`scrabble-dawg` release pinned to `DICT_VERSION`, `GOPRIVATE` fetches the solver) and `gateway/Dockerfile`
|
||||||
|
(node UI stage + Go stage), both trimming `go.work` like `platform/telegram/Dockerfile`. Built and
|
||||||
|
verified locally.
|
||||||
|
- **Contour = caddy-fronted** (interview, "caddy всё равно нужен для https"): a new `caddy` service owns
|
||||||
|
a **single `/_gm` Basic-Auth** and routes `/_gm/grafana/*` → Grafana (anonymous-admin + sub-path, no
|
||||||
|
own accounts) and the rest of `/_gm/*` → the backend console; everything else → the gateway. This
|
||||||
|
**supersedes Stage 10's** gateway-fronts-`/_gm` model **in the deploy topology** (the gateway's own
|
||||||
|
`/_gm` proxy stays for a local non-caddy run). TLS: the **host caddy** terminates it for the test
|
||||||
|
contour and forwards to `scrabble:80`; the in-compose caddy is parameterised (`CADDY_SITE_ADDRESS`) to
|
||||||
|
own ACME on prod (Stage 18) where there is no host caddy.
|
||||||
|
- **Networks** (engineering): inter-service traffic on a private `internal` network (project-scoped DNS,
|
||||||
|
no name collisions on the shared `edge`); only caddy joins the external `edge` (alias `scrabble`). The
|
||||||
|
connector keeps its VPN sidecar (the only egress that needs the tunnel). The connector-scoped
|
||||||
|
`platform/telegram/deploy/docker-compose.yml` was **retired** (the root `deploy/docker-compose.yml`
|
||||||
|
supersedes it; the connector Dockerfile stays).
|
||||||
|
- **Observability stack** (interview): OTel Collector (OTLP/gRPC → a Prometheus scrape endpoint +
|
||||||
|
Tempo OTLP) + Prometheus (**15d**) + Tempo (**72h**) + Grafana (provisioned Prometheus+Tempo datasources
|
||||||
|
+ four dashboards: Service overview, Edge/UX, Game domain, Users; Traces via the Tempo datasource +
|
||||||
|
Explore, no fixed panels). The collector's prometheus exporter uses `add_metric_suffixes:false` +
|
||||||
|
`resource_to_telemetry_conversion` so the dashboards' PromQL matches the in-code metric names and carries
|
||||||
|
`service_name`. The three services export `otlp` in the contour (default stays `none`, so CI needs no
|
||||||
|
collector). Loki/logs were left out of scope (container stdout / zap JSON).
|
||||||
|
- **User metrics** (interview): a backend `accounts_created_total{kind}` counter (telegram/email/guest;
|
||||||
|
robots excluded — they are a provisioned pool, not users) via the Stage-12 `SetMetrics` no-op pattern,
|
||||||
|
and a gateway **in-memory** `active_users{window=24h,7d}` observable gauge (distinct authenticated edge
|
||||||
|
actors). The owner chose the in-memory gauge over a DB `last_seen_at` (overkill); its single-instance /
|
||||||
|
reset-on-restart limits are documented (a live gauge, not billing).
|
||||||
|
- **Owner actions before the contour is green** (surfaced, not blockers): set the **`TEST_`** Gitea
|
||||||
|
secrets/variables (see `deploy/.env.example`) and add a host-caddy route `<test domain> → scrabble:80`
|
||||||
|
on the runner host. CI bootstrap nuance: the first PR introducing `ci.yaml` may first deploy on the
|
||||||
|
post-merge push to `development` (depending on whether Gitea runs head/base workflows for a PR), after
|
||||||
|
which PR-time deploys work.
|
||||||
|
- **Telegram test environment** (post-deploy fix): the connector now selects Telegram's test env with the
|
||||||
|
library's native `tgbot.UseTestEnvironment()` (was a `token += "/test"` hack — functionally identical,
|
||||||
|
verified, but the option is idiomatic and now has a `bot` test asserting the `/bot<token>/test/getMe`
|
||||||
|
path). The test contour **pins `TELEGRAM_TEST_ENV=true` in `ci.yaml`** (the contour is the test
|
||||||
|
environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
|
||||||
|
knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`.
|
||||||
|
|
||||||
|
- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's
|
||||||
|
collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session.
|
||||||
|
- **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical
|
||||||
|
string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in
|
||||||
|
the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`,
|
||||||
|
`Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged
|
||||||
|
(persisted games, GCG and the `variant` metric attribute keep it).
|
||||||
|
- **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with
|
||||||
|
`game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct
|
||||||
|
`nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn.
|
||||||
|
- **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the
|
||||||
|
editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by
|
||||||
|
language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor
|
||||||
|
validation (e.g. "Peter J.").
|
||||||
|
- **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names
|
||||||
|
paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only /
|
||||||
|
first + surname initial / first + full surname), composed deterministically per pool slot (stable
|
||||||
|
across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20%
|
||||||
|
Latin, an English game the Latin pool. Robot identities are keyed `robot-<lang>-<index>`.
|
||||||
|
- **Robot 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.
|
||||||
|
|
||||||
## Deferred TODOs (cross-stage)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
||||||
|
|||||||
@@ -80,3 +80,24 @@ pnpm dev # against a running gateway (Vite proxies the RPC path to :8081)
|
|||||||
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
|
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
|
||||||
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
||||||
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
|
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
|
||||||
|
|
||||||
|
## Deploy (`deploy/`)
|
||||||
|
|
||||||
|
The full contour is [`deploy/docker-compose.yml`](deploy/docker-compose.yml):
|
||||||
|
`backend` + `gateway` (with the UI embedded via `go:embed`, baked in by its node
|
||||||
|
build stage) + Postgres + the Telegram connector (with a VPN sidecar) + an
|
||||||
|
observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a front
|
||||||
|
**caddy** that owns a single `/_gm` Basic-Auth (admin console + Grafana). The Go
|
||||||
|
services build from multi-stage distroless `*/Dockerfile`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
|
||||||
|
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
|
||||||
|
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
||||||
|
```
|
||||||
|
|
||||||
|
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
||||||
|
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
||||||
|
`development → master` (Stage 18). Env reference: [`deploy/.env.example`](deploy/.env.example);
|
||||||
|
the topology and the two-contour model are in
|
||||||
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Multi-stage build for the backend service. Mirrors platform/telegram/Dockerfile:
|
||||||
|
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
||||||
|
#
|
||||||
|
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
||||||
|
# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
||||||
|
# binary at them. The published solver module is fetched directly from Gitea
|
||||||
|
# (GOPRIVATE), so the build stage needs git and network.
|
||||||
|
#
|
||||||
|
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||||
|
# in the Docker context:
|
||||||
|
# docker build -f backend/Dockerfile -t scrabble-backend .
|
||||||
|
|
||||||
|
# --- dictionary artifact -----------------------------------------------------
|
||||||
|
FROM alpine:3.20 AS dawg
|
||||||
|
ARG DICT_VERSION=v1.0.0
|
||||||
|
RUN apk add --no-cache curl tar
|
||||||
|
RUN mkdir -p /dawg \
|
||||||
|
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||||
|
"https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \
|
||||||
|
&& tar xzf /tmp/dawg.tar.gz -C /dawg
|
||||||
|
|
||||||
|
# --- build -------------------------------------------------------------------
|
||||||
|
FROM golang:1.26.3-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
# git: the published solver module is fetched from Gitea directly (GOPRIVATE).
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ENV GOPRIVATE=gitea.iliadenisov.ru/*
|
||||||
|
|
||||||
|
COPY go.work go.work.sum ./
|
||||||
|
COPY pkg ./pkg
|
||||||
|
COPY backend ./backend
|
||||||
|
|
||||||
|
# Reduce the workspace to what the backend needs: backend + pkg.
|
||||||
|
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
|
||||||
|
|
||||||
|
# --- runtime -----------------------------------------------------------------
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
COPY --from=build /out/backend /usr/local/bin/backend
|
||||||
|
COPY --from=dawg /dawg /opt/dawg
|
||||||
|
ENV BACKEND_DICT_DIR=/opt/dawg
|
||||||
|
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||||
+4
-4
@@ -46,13 +46,13 @@ but their live delivery, and all REST endpoints, arrive with the `gateway`
|
|||||||
|
|
||||||
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
|
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
|
||||||
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
||||||
requests blocked — backs a human-like name pool. A background driver plays the
|
requests blocked — 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
|
robot's moves through the public game API as an ordinary seated player (so only
|
||||||
`internal/engine` imports the solver): it decides once per game whether to play to
|
`internal/engine` imports the solver): it decides once per game whether to play to
|
||||||
win (≈ 40%), targets a small score margin, and times its moves with a right-skewed
|
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
|
||||||
delay, a night-sleep window anchored to the opponent's timezone, and nudge
|
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||||
state. The matchmaker now substitutes a pooled robot after a 10-second wait and
|
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
||||||
exposes `Poll` so a waiting player can collect the started game (the live
|
exposes `Poll` so a waiting player can collect the started game (the live
|
||||||
match-found notification arrives with the `gateway`).
|
match-found notification arrives with the `gateway`).
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
hub := notify.NewHub(0)
|
hub := notify.NewHub(0)
|
||||||
|
|
||||||
accounts := account.NewStore(db)
|
accounts := account.NewStore(db)
|
||||||
|
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||||
games.SetNotifier(hub)
|
games.SetNotifier(hub)
|
||||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
@@ -93,12 +92,14 @@ type Identity struct {
|
|||||||
|
|
||||||
// Store is the Postgres-backed query surface for accounts and identities.
|
// Store is the Postgres-backed query surface for accounts and identities.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
metrics *accountMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore constructs a Store wrapping db.
|
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
||||||
|
// SetMetrics installs the real one during startup wiring.
|
||||||
func NewStore(db *sql.DB) *Store {
|
func NewStore(db *sql.DB) *Store {
|
||||||
return &Store{db: db}
|
return &Store{db: db, metrics: defaultAccountMetrics()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
||||||
@@ -110,10 +111,43 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
|||||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
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
|
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
||||||
// identity. On first contact only, it seeds the new account's preferred language
|
// 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
|
// 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.
|
// 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) {
|
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
||||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
||||||
@@ -153,19 +187,21 @@ type provisionSeed struct {
|
|||||||
|
|
||||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||||
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
|
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
||||||
// username (capped to maxDisplayName runes).
|
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
||||||
|
// editable format). When neither yields any letters, it falls back to a generated
|
||||||
|
// placeholder in the seeded language (placeholderDisplayName).
|
||||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||||
var seed provisionSeed
|
var seed provisionSeed
|
||||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
seed.preferredLanguage = lang
|
seed.preferredLanguage = lang
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(firstName)
|
name := sanitizeDisplayName(firstName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = strings.TrimSpace(username)
|
name = sanitizeDisplayName(username)
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
if name == "" {
|
||||||
name = string([]rune(name)[:maxDisplayName])
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
}
|
}
|
||||||
seed.displayName = name
|
seed.displayName = name
|
||||||
return seed
|
return seed
|
||||||
@@ -331,6 +367,11 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
|
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
|
||||||
}
|
}
|
||||||
|
// Count genuinely new durable accounts; robots are a fixed provisioned pool,
|
||||||
|
// not users, so they are excluded.
|
||||||
|
if kind != KindRobot {
|
||||||
|
s.metrics.recordCreated(ctx, kind)
|
||||||
|
}
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +396,7 @@ func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
|
|||||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
||||||
}
|
}
|
||||||
|
s.metrics.recordCreated(ctx, kindGuest)
|
||||||
return modelToAccount(row), nil
|
return modelToAccount(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
@@ -26,9 +28,10 @@ const maxAwayWindow = 12 * time.Hour
|
|||||||
|
|
||||||
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
||||||
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
||||||
// by a single space. No leading or trailing separator and no two adjacent separators,
|
// by a single space. No leading separator and no two adjacent separators (except
|
||||||
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
|
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so
|
||||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
|
// "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
|
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
||||||
// field (an unknown language, an invalid timezone, or an over-long display name).
|
// field (an unknown language, an invalid timezone, or an over-long display name).
|
||||||
@@ -110,6 +113,39 @@ func ValidateDisplayName(raw string) (string, error) {
|
|||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram
|
||||||
|
// first name) to the editable display-name format: it keeps the maximal runs of
|
||||||
|
// Unicode letters and joins them with a single space, dropping every other rune
|
||||||
|
// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The
|
||||||
|
// result therefore always satisfies ValidateDisplayName, or is empty when the input
|
||||||
|
// carries no letters — in which case the caller substitutes placeholderDisplayName.
|
||||||
|
// Mirroring the profile editor's rule means a connector-provisioned name is editable
|
||||||
|
// later without first failing validation.
|
||||||
|
func sanitizeDisplayName(raw string) string {
|
||||||
|
fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) })
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := strings.Join(fields, " ")
|
||||||
|
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||||
|
name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ")
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderDisplayName builds a fallback display name for a platform account whose
|
||||||
|
// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or
|
||||||
|
// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally
|
||||||
|
// carries digits and a hyphen, so it lies outside the editable format and the player
|
||||||
|
// is expected to rename it; provisioned names bypass that editor validation.
|
||||||
|
func placeholderDisplayName(lang string) string {
|
||||||
|
prefix := "Player"
|
||||||
|
if lang == "ru" {
|
||||||
|
prefix = "Игрок"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000))
|
||||||
|
}
|
||||||
|
|
||||||
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
||||||
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
||||||
// "no away time" and is allowed.
|
// "no away time" and is allowed.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -8,21 +9,25 @@ import (
|
|||||||
|
|
||||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||||
// the first-name / username display-name precedence, and trimming.
|
// the first-name / username display-name precedence, and the sanitization that
|
||||||
|
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
|
||||||
func TestTelegramSeed(t *testing.T) {
|
func TestTelegramSeed(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
wantLang, wantName string
|
wantLang, wantName string
|
||||||
}{
|
}{
|
||||||
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
|
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
|
||||||
"en region-tagged": {"en-US", "user", "John", "en", "John"},
|
"en region-tagged": {"en-US", "user", "John", "en", "John"},
|
||||||
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
|
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
|
||||||
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
|
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
|
||||||
"empty language": {"", "neo", "Neo", "", "Neo"},
|
"empty language": {"", "neo", "Neo", "", "Neo"},
|
||||||
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
||||||
"username fallback": {"en", "handle", "", "en", "handle"},
|
"username fallback": {"en", "handle", "", "en", "handle"},
|
||||||
"both empty": {"en", "", "", "en", ""},
|
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
||||||
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
"emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"},
|
||||||
|
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||||
|
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||||
|
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
@@ -37,6 +42,28 @@ func TestTelegramSeed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to
|
||||||
|
// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
|
||||||
|
func TestTelegramSeedPlaceholder(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
languageCode, username, firstName string
|
||||||
|
wantRe string
|
||||||
|
}{
|
||||||
|
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||||
|
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||||
|
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||||
|
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
|
||||||
|
}
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName
|
||||||
|
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
|
||||||
|
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
||||||
// maxDisplayName runes (counted in runes, not bytes).
|
// maxDisplayName runes (counted in runes, not bytes).
|
||||||
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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
|
||||||
|
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.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
|
||||||
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsers counts the filtered admin user list, for pagination.
|
||||||
|
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
||||||
|
where, args := userListWhere(f)
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("account: count users: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
|
||||||
|
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
|
||||||
|
func LikePattern(mask string) string {
|
||||||
|
mask = strings.TrimSpace(mask)
|
||||||
|
if mask == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask)
|
||||||
|
escaped = strings.ReplaceAll(escaped, "*", "%")
|
||||||
|
return strings.ReplaceAll(escaped, "?", "_")
|
||||||
|
}
|
||||||
@@ -12,19 +12,21 @@ func TestValidateDisplayName(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
ok bool
|
ok bool
|
||||||
}{
|
}{
|
||||||
"plain": {"Kaya", "Kaya", true},
|
"plain": {"Kaya", "Kaya", true},
|
||||||
"cyrillic": {"Кая", "Кая", true},
|
"cyrillic": {"Кая", "Кая", true},
|
||||||
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
|
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
|
||||||
"single dot": {"Mr.Smith", "Mr.Smith", true},
|
"single dot": {"Mr.Smith", "Mr.Smith", true},
|
||||||
"dot then space": {"Mr. Smith", "Mr. Smith", true},
|
"dot then space": {"Mr. Smith", "Mr. Smith", true},
|
||||||
"trim surrounding": {" Kaya ", "Kaya", true},
|
"trim surrounding": {" Kaya ", "Kaya", true},
|
||||||
"adjacent specials": {"Name P._Last", "", false},
|
"adjacent specials": {"Name P._Last", "", false},
|
||||||
"two spaces": {"Name Last", "", false},
|
"two spaces": {"Name Last", "", false},
|
||||||
"leading special": {"_Name", "", false},
|
"leading special": {"_Name", "", false},
|
||||||
"trailing special": {"Name.", "", false},
|
"trailing underscore": {"Name_", "", false},
|
||||||
"digit rejected": {"Name2", "", false},
|
"trailing dot ok": {"Anna B.", "Anna B.", true},
|
||||||
"blank": {" ", "", false},
|
"double trailing dot": {"Name..", "", false},
|
||||||
"too long": {strings.Repeat("a", 33), "", false},
|
"digit rejected": {"Name2", "", false},
|
||||||
|
"blank": {" ", "", false},
|
||||||
|
"too long": {strings.Repeat("a", 33), "", false},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -101,3 +101,17 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
|||||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||||
.actions form { margin: 0; }
|
.actions form { margin: 0; }
|
||||||
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */
|
||||||
|
.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; }
|
||||||
|
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||||
|
.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; }
|
||||||
|
.chart .lbl { fill: var(--ink-dim); font-size: 11px; }
|
||||||
|
.chart .ln { fill: none; stroke-width: 1.5; }
|
||||||
|
.chart .ln-min { stroke: var(--ok); }
|
||||||
|
.chart .ln-avg { stroke: var(--accent); }
|
||||||
|
.chart .ln-max { stroke: var(--danger); }
|
||||||
|
.lg { font-weight: 600; }
|
||||||
|
.lg-min { color: var(--ok); }
|
||||||
|
.lg-avg { color: var(--accent); }
|
||||||
|
.lg-max { color: var(--danger); }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
||||||
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||||
|
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
||||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
|
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
|
||||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
||||||
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||||
|
|||||||
@@ -16,8 +16,10 @@
|
|||||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||||
|
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
|
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<h1>Game {{.ID}}</h1>
|
<h1>Game {{.ID}}</h1>
|
||||||
<nav class="subnav"><a href="/_gm/games">« games</a></nav>
|
<nav class="subnav"><a href="/_gm/games">« games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
|
||||||
<section class="panel"><h2>Summary</h2>
|
<section class="panel"><h2>Summary</h2>
|
||||||
<ul class="kv">
|
<ul class="kv">
|
||||||
<li><b>Variant</b> {{.Variant}}</li>
|
<li><b>Variant</b> {{.Variant}}</li>
|
||||||
@@ -17,13 +17,14 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Seats</h2>
|
<section class="panel"><h2>Seats</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
|
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Seats}}
|
{{range .Seats}}
|
||||||
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td><td>{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}<br><small>next move {{.NextMove}}</small>{{end}}{{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{{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>
|
||||||
|
</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}}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<h1>{{.DisplayName}}</h1>
|
<h1>{{.DisplayName}}</h1>
|
||||||
<nav class="subnav"><a href="/_gm/users">« users</a></nav>
|
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<section class="panel"><h2>Account</h2>
|
<section class="panel"><h2>Account</h2>
|
||||||
<ul class="kv">
|
<ul class="kv">
|
||||||
@@ -28,6 +28,12 @@
|
|||||||
{{else}}<p class="note">no statistics</p>{{end}}
|
{{else}}<p class="note">no statistics</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
<section class="panel"><h2>Identities</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
||||||
|
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||||
|
</nav>
|
||||||
|
<form class="form" method="get" action="/_gm/users">
|
||||||
|
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
||||||
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
|
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
|
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -11,16 +21,17 @@
|
|||||||
<td>{{.Kind}}</td>
|
<td>{{.Kind}}</td>
|
||||||
<td>{{.Language}}</td>
|
<td>{{.Language}}</td>
|
||||||
<td>{{.CreatedAt}}</td>
|
<td>{{.CreatedAt}}</td>
|
||||||
|
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="5"><span class="note">no users</span></td></tr>
|
<tr><td colspan="8"><span class="note">no users</span></td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package adminconsole
|
package adminconsole
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
// The *View types are the display models the gin handlers fill and the templates
|
// The *View types are the display models the gin handlers fill and the templates
|
||||||
// render. Time values are pre-formatted to strings by the handlers so the
|
// render. Time values are pre-formatted to strings by the handlers so the
|
||||||
// templates stay logic-free.
|
// templates stay logic-free.
|
||||||
@@ -48,16 +50,53 @@ type DashboardView struct {
|
|||||||
type UsersView struct {
|
type UsersView struct {
|
||||||
Items []UserRow
|
Items []UserRow
|
||||||
Pager Pager
|
Pager Pager
|
||||||
|
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
||||||
|
// glob filters; FilterQuery is those encoded for pager/toggle links.
|
||||||
|
Robots bool
|
||||||
|
NameMask string
|
||||||
|
ExternalIDMask string
|
||||||
|
FilterQuery string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRow is one account row in the list.
|
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||||
|
// pre-formatted move-duration summary (empty when it has no timed move).
|
||||||
type UserRow struct {
|
type UserRow struct {
|
||||||
ID string
|
ID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Kind string
|
Kind string
|
||||||
Language string
|
Language string
|
||||||
Guest bool
|
Guest bool
|
||||||
CreatedAt string
|
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.
|
// UserDetailView is one account with its stats, identities and recent games.
|
||||||
@@ -80,6 +119,9 @@ type UserDetailView struct {
|
|||||||
Games []GameRow
|
Games []GameRow
|
||||||
TelegramID string
|
TelegramID string
|
||||||
ConnectorEnabled bool
|
ConnectorEnabled bool
|
||||||
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||||
|
// time (min/mean/max), empty when the account has no timed move.
|
||||||
|
MoveChart template.HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsRow is an account's lifetime statistics.
|
// StatsRow is an account's lifetime statistics.
|
||||||
@@ -129,9 +171,15 @@ type GameDetailView struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt string
|
FinishedAt string
|
||||||
Seats []SeatRow
|
Seats []SeatRow
|
||||||
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||||
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||||
|
HasRobot bool
|
||||||
|
RobotTargetPct int
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeatRow is one seat of a game.
|
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
||||||
|
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
|
||||||
|
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
|
||||||
type SeatRow struct {
|
type SeatRow struct {
|
||||||
Seat int
|
Seat int
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -139,6 +187,9 @@ type SeatRow struct {
|
|||||||
Score int
|
Score int
|
||||||
HintsUsed int
|
HintsUsed int
|
||||||
Winner bool
|
Winner bool
|
||||||
|
IsRobot bool
|
||||||
|
RobotIntent string
|
||||||
|
NextMove string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComplaintsView is the paginated complaint review queue.
|
// ComplaintsView is the paginated complaint review queue.
|
||||||
|
|||||||
@@ -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
|
// winning regardless of score. A missed-turn timeout reuses Resign in the game
|
||||||
// domain, so it inherits this win/loss.
|
// domain, so it inherits this win/loss.
|
||||||
func (g *Game) Resign() (MoveRecord, error) {
|
func (g *Game) Resign() (MoveRecord, error) {
|
||||||
|
return g.ResignSeat(g.toMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
|
||||||
|
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
|
||||||
|
// skips resigned seats). The turn cursor only advances when the seat that resigned
|
||||||
|
// was the one to move; resigning an off-turn seat leaves the current player's turn
|
||||||
|
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
|
||||||
|
// already-resigned seat.
|
||||||
|
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
|
||||||
if g.over {
|
if g.over {
|
||||||
return MoveRecord{}, ErrGameOver
|
return MoveRecord{}, ErrGameOver
|
||||||
}
|
}
|
||||||
player := g.toMove
|
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
|
||||||
g.resigned[player] = true
|
return MoveRecord{}, ErrGameOver
|
||||||
g.disposeHand(player)
|
}
|
||||||
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
|
g.resigned[seat] = true
|
||||||
|
g.disposeHand(seat)
|
||||||
|
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
|
||||||
g.log = append(g.log, rec)
|
g.log = append(g.log, rec)
|
||||||
if g.activeCount() <= 1 {
|
if g.activeCount() <= 1 {
|
||||||
g.finish(EndResign)
|
g.finish(EndResign)
|
||||||
} else {
|
} else if seat == g.toMove {
|
||||||
g.advance()
|
g.advance()
|
||||||
}
|
}
|
||||||
return rec, nil
|
return rec, nil
|
||||||
|
|||||||
@@ -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.
|
// TestResignOnFinishedGame rejects a second transition.
|
||||||
func TestResignOnFinishedGame(t *testing.T) {
|
func TestResignOnFinishedGame(t *testing.T) {
|
||||||
g := newEnglishGame(t, 1)
|
g := newEnglishGame(t, 1)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 (Stage 17): 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 (Stage 17 #6).
|
||||||
|
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,52 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingPublisher captures every published intent for assertions.
|
||||||
|
type recordingPublisher struct{ intents []notify.Intent }
|
||||||
|
|
||||||
|
func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) }
|
||||||
|
|
||||||
|
// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every
|
||||||
|
// seat — including the actor's own account, so the mover's other devices refresh —
|
||||||
|
// and your_turn only to the next mover.
|
||||||
|
func TestEmitMoveNotifiesActor(t *testing.T) {
|
||||||
|
actor, opp := uuid.New(), uuid.New()
|
||||||
|
pub := &recordingPublisher{}
|
||||||
|
svc := &Service{pub: pub}
|
||||||
|
g := Game{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Status: StatusActive,
|
||||||
|
ToMove: 1,
|
||||||
|
TurnStartedAt: time.Now(),
|
||||||
|
TurnTimeout: time.Hour,
|
||||||
|
Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}},
|
||||||
|
}
|
||||||
|
svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10})
|
||||||
|
|
||||||
|
kinds := map[uuid.UUID][]string{}
|
||||||
|
for _, in := range pub.intents {
|
||||||
|
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
|
||||||
|
}
|
||||||
|
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
|
||||||
|
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
|
||||||
|
}
|
||||||
|
if !slices.Contains(kinds[opp], notify.KindOpponentMoved) {
|
||||||
|
t.Errorf("opponent should get opponent_moved, got %v", kinds[opp])
|
||||||
|
}
|
||||||
|
if !slices.Contains(kinds[opp], notify.KindYourTurn) {
|
||||||
|
t.Errorf("next mover should get your_turn, got %v", kinds[opp])
|
||||||
|
}
|
||||||
|
if slices.Contains(kinds[actor], notify.KindYourTurn) {
|
||||||
|
t.Errorf("actor is not next to move, should not get your_turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const meterName = "scrabble/backend/game"
|
|||||||
type gameMetrics struct {
|
type gameMetrics struct {
|
||||||
replay metric.Float64Histogram
|
replay metric.Float64Histogram
|
||||||
validate metric.Float64Histogram
|
validate metric.Float64Histogram
|
||||||
|
moveDur metric.Float64Histogram
|
||||||
started metric.Int64Counter
|
started metric.Int64Counter
|
||||||
abandoned metric.Int64Counter
|
abandoned metric.Int64Counter
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics {
|
|||||||
return &gameMetrics{
|
return &gameMetrics{
|
||||||
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
|
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
|
||||||
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
|
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
|
||||||
|
moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."),
|
||||||
started: counter(meter, "games_started_total", "Games created and started."),
|
started: counter(meter, "games_started_total", "Games created and started."),
|
||||||
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
|
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,30 @@ func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, star
|
|||||||
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
|
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// recordStarted counts one started game of variant.
|
||||||
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
||||||
m.started.Add(ctx, 1, variantAttr(v))
|
m.started.Add(ctx, 1, variantAttr(v))
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
m.recordAbandoned(ctx, engine.VariantErudit)
|
m.recordAbandoned(ctx, engine.VariantErudit)
|
||||||
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
||||||
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
|
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
|
||||||
|
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
|
||||||
|
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 0) // non-positive: dropped
|
||||||
|
|
||||||
var rm metricdata.ResourceMetrics
|
var rm metricdata.ResourceMetrics
|
||||||
if err := reader.Collect(ctx, &rm); err != nil {
|
if err := reader.Collect(ctx, &rm); err != nil {
|
||||||
@@ -45,6 +47,19 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
||||||
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
||||||
}
|
}
|
||||||
|
if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
|
||||||
|
t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhaseOf checks the move-ordinal to phase bucketing.
|
||||||
|
func TestPhaseOf(t *testing.T) {
|
||||||
|
cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
|
||||||
|
for mc, want := range cases {
|
||||||
|
if got := phaseOf(mc); got != want {
|
||||||
|
t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// counterByAttr sums the int64 counter named name, grouped by the value of the
|
// counterByAttr sums the int64 counter named name, grouped by the value of the
|
||||||
|
|||||||
@@ -171,11 +171,47 @@ 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 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) {
|
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) {
|
pre, err := svc.store.GetGame(ctx, gameID)
|
||||||
rec, err := g.Resign()
|
if err != nil {
|
||||||
return rec, nil, err
|
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
|
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
||||||
@@ -185,6 +221,19 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
|
|||||||
return svc.store.GetGameVariant(ctx, gameID)
|
return svc.store.GetGameVariant(ctx, gameID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
|
||||||
|
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
|
||||||
|
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
|
||||||
|
return svc.store.RobotSchedule(ctx, gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastMoveAt returns the time of an account's most recent move in a game (and whether it
|
||||||
|
// has moved). The social service uses it to reset the nudge cooldown once a player has
|
||||||
|
// taken a turn (Stage 17).
|
||||||
|
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||||
|
return svc.store.LastMoveAt(ctx, gameID, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
// transition validates the actor and turn, applies op under the per-game lock and
|
// transition validates the actor and turn, applies op under the per-game lock and
|
||||||
// commits the result.
|
// commits the result.
|
||||||
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
|
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
|
||||||
@@ -226,9 +275,28 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveResult{}, err
|
return MoveResult{}, err
|
||||||
}
|
}
|
||||||
|
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}, nil
|
return MoveResult{Move: rec, Game: post}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// afterCommitDrafts maintains the Stage 17 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
|
// commit persists a just-applied transition: the journal row, the post-move turn
|
||||||
// cursor and scores, and on a game-ending move the finish stamp and statistics.
|
// cursor and scores, and on a game-ending move the finish stamp and statistics.
|
||||||
// On a persistence failure it evicts the now-divergent live game so the next
|
// On a persistence failure it evicts the now-divergent live game so the next
|
||||||
@@ -287,14 +355,15 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
|
|||||||
}
|
}
|
||||||
|
|
||||||
// emitMove publishes the live events for a just-committed move: opponent_moved to
|
// 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
|
// every seat — including the actor's own account, so the mover's other devices (and
|
||||||
// is still active. Delivery is best-effort (notify.Publisher never blocks).
|
// their lobby) refresh too — and your_turn to the next mover while the game is still
|
||||||
|
// active. opponent_moved is in-app only (the gateway never turns it into an
|
||||||
|
// out-of-app push), so the actor is not notified out of band about their own move.
|
||||||
|
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
|
||||||
|
// event out to all of the recipient's live streams.
|
||||||
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
||||||
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
||||||
for _, s := range post.Seats {
|
for _, s := range post.Seats {
|
||||||
if s.Seat == rec.Player {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
||||||
}
|
}
|
||||||
if post.Status == StatusActive {
|
if post.Status == StatusActive {
|
||||||
|
|||||||
@@ -651,6 +651,43 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
|
|||||||
return row.Seed, nil
|
return row.Seed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LastMoveAt returns the time of the account's most recent move in the game and true, or
|
||||||
|
// the zero time and false when it has not moved. The social service uses it to reset the
|
||||||
|
// nudge cooldown once the player has taken a turn (Stage 17).
|
||||||
|
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
|
||||||
|
var at sql.NullTime
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT MAX(m.created_at) FROM backend.game_moves m
|
||||||
|
JOIN backend.game_players p ON p.game_id = m.game_id AND p.seat = m.seat
|
||||||
|
WHERE m.game_id = $1 AND p.account_id = $2`,
|
||||||
|
gameID, accountID).Scan(&at)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false, fmt.Errorf("game: last move at %s: %w", gameID, err)
|
||||||
|
}
|
||||||
|
if !at.Valid {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
return at.Time, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RobotSchedule returns a game's bag seed and current turn-start time. The admin console
|
||||||
|
// combines them with the robot strategy to show a robot seat's play-to-win intent and its
|
||||||
|
// next-move ETA. Both are server-only state, never part of the public game view.
|
||||||
|
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.
|
// projectGame builds a Game from a games row and its ordered seat rows.
|
||||||
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||||
variant, err := engine.ParseVariant(g.Variant)
|
variant, err := engine.ParseVariant(g.Variant)
|
||||||
|
|||||||
@@ -167,6 +167,45 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
|
||||||
|
// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
|
||||||
|
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newGameService()
|
||||||
|
robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision robot: %v", err)
|
||||||
|
}
|
||||||
|
human := provisionAccount(t)
|
||||||
|
// Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
|
||||||
|
g, err := svc.Create(ctx, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
|
||||||
|
})
|
||||||
|
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("game detail = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "🤖") {
|
||||||
|
t.Error("robot seat is not marked in the game detail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
|
||||||
|
t.Error("robot play-to-win intent missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "next move") {
|
||||||
|
t.Error("robot is to move but the next-move ETA is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "~40%") {
|
||||||
|
t.Error("robot play-to-win target caption missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// consoleDo issues a request to h, optionally with an Origin header, and returns
|
// 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.
|
// 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) {
|
func consoleDo(h http.Handler, method, target, body, origin string) (int, 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,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
|
||||||
|
t.Fatalf("insert game: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO backend.game_players (game_id, seat, account_id) VALUES ($1,0,$2),($1,1,$3)`, gid, a.ID, b.ID); err != nil {
|
||||||
|
t.Fatalf("insert seats: %v", err)
|
||||||
|
}
|
||||||
|
// seq, seat, commit time as seconds from t0. Durations: A:60,50 B:120,200.
|
||||||
|
moves := []struct{ seq, seat, at int }{{0, 0, 60}, {1, 1, 180}, {2, 0, 230}, {3, 1, 430}}
|
||||||
|
for _, m := range moves {
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO backend.game_moves (game_id, seq, seat, action, created_at) VALUES ($1,$2,$3,'play',$4)`,
|
||||||
|
gid, m.seq, m.seat, t0.Add(time.Duration(m.at)*time.Second)); err != nil {
|
||||||
|
t.Fatalf("insert move %d: %v", m.seq, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store := game.NewStore(testDB)
|
||||||
|
stats, err := store.MoveDurationStats(ctx, []uuid.UUID{a.ID, b.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stats: %v", err)
|
||||||
|
}
|
||||||
|
if sa := stats[a.ID]; sa.Moves != 2 || sa.MinSecs != 50 || sa.MaxSecs != 60 || sa.AvgSecs != 55 {
|
||||||
|
t.Errorf("A stats = %+v, want min50 max60 avg55 moves2", sa)
|
||||||
|
}
|
||||||
|
if sb := stats[b.ID]; sb.Moves != 2 || sb.MinSecs != 120 || sb.MaxSecs != 200 || sb.AvgSecs != 160 {
|
||||||
|
t.Errorf("B stats = %+v, want min120 max200 avg160 moves2", sb)
|
||||||
|
}
|
||||||
|
|
||||||
|
byOrd, err := store.MoveDurationByOrdinal(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("by ordinal: %v", err)
|
||||||
|
}
|
||||||
|
want := []game.OrdinalDuration{
|
||||||
|
{Ordinal: 1, MinSecs: 60, MaxSecs: 60, AvgSecs: 60},
|
||||||
|
{Ordinal: 2, MinSecs: 50, MaxSecs: 50, AvgSecs: 50},
|
||||||
|
}
|
||||||
|
if len(byOrd) != len(want) {
|
||||||
|
t.Fatalf("by ordinal = %+v, want %+v", byOrd, want)
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if byOrd[i] != w {
|
||||||
|
t.Errorf("ordinal[%d] = %+v, want %+v", i, byOrd[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDraftPersistAndConflictReset covers Stage 17 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,42 @@ func TestResignWinnerAndStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
|
||||||
|
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
|
||||||
|
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
|
||||||
|
// despite leading on score.
|
||||||
|
func TestResignOnOpponentTurn(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newGameService()
|
||||||
|
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||||
|
seed := openingSeed(t)
|
||||||
|
g, err := svc.Create(ctx, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hint, ok := newMirror(t, seed, 2).HintView()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no opening move")
|
||||||
|
}
|
||||||
|
if _, err := svc.SubmitPlay(ctx, g.ID, seats[0], hint.Dir, hint.Tiles); err != nil { // p0 scores, now p1's turn
|
||||||
|
t.Fatalf("p0 play: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := svc.Resign(ctx, g.ID, seats[0]) // p0 resigns OFF turn
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("off-turn resign = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if res.Game.Status != game.StatusFinished || res.Game.EndReason != "resign" {
|
||||||
|
t.Fatalf("after off-turn resign: %+v", res.Game)
|
||||||
|
}
|
||||||
|
if res.Game.Seats[0].IsWinner || !res.Game.Seats[1].IsWinner {
|
||||||
|
t.Errorf("winner flags wrong (resigner must lose): %+v", res.Game.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
|
// TestTimeoutSweep auto-resigns an overdue game and records it as a timeout.
|
||||||
func TestTimeoutSweep(t *testing.T) {
|
func TestTimeoutSweep(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -82,19 +82,25 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
|
|||||||
if err := r.EnsurePool(ctx); err != nil {
|
if err := r.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool (idempotent): %v", err)
|
t.Fatalf("ensure pool (idempotent): %v", err)
|
||||||
}
|
}
|
||||||
id, err := r.Pick()
|
id, err := r.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
if !isRobotAccount(t, id) {
|
if !isRobotAccount(t, id) {
|
||||||
t.Errorf("picked account %s is not a robot identity", id)
|
t.Errorf("picked account %s is not a robot identity", id)
|
||||||
}
|
}
|
||||||
|
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
|
||||||
|
t.Errorf("russian pick = (%s, %v), want a robot account", ru, err)
|
||||||
|
}
|
||||||
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
acc, err := account.NewStore(testDB).GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get robot account: %v", err)
|
t.Fatalf("get robot account: %v", err)
|
||||||
}
|
}
|
||||||
if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests {
|
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
|
||||||
t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
// expires, mirroring a human who ignores it (Stage 17).
|
||||||
|
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
|
||||||
|
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
|
||||||
|
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +115,7 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
|
|||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool: %v", err)
|
t.Fatalf("ensure pool: %v", err)
|
||||||
}
|
}
|
||||||
robotID, err := robots.Pick()
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
@@ -210,7 +216,7 @@ func TestRobotProactiveNudge(t *testing.T) {
|
|||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool: %v", err)
|
t.Fatalf("ensure pool: %v", err)
|
||||||
}
|
}
|
||||||
robotID, err := robots.Pick()
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +15,36 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// capturePublisher records every published intent for assertions on live events.
|
||||||
|
type capturePublisher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
intents []notify.Intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capturePublisher) Publish(in ...notify.Intent) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.intents = append(c.intents, in...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notified reports whether a Notification with the given sub-kind was published to user.
|
||||||
|
func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for _, in := range c.intents {
|
||||||
|
if in.UserID == user && in.Kind == notify.KindNotification &&
|
||||||
|
string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// newSocialService builds a social service over the shared pool, reading game
|
// newSocialService builds a social service over the shared pool, reading game
|
||||||
// state through a real game service.
|
// state through a real game service.
|
||||||
func newSocialService() *social.Service {
|
func newSocialService() *social.Service {
|
||||||
@@ -40,6 +68,38 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
|
|||||||
return g.ID, seats
|
return g.ID, seats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||||
|
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||||
|
// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17).
|
||||||
|
func TestFriendRequestToRobotStaysPending(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
accs := account.NewStore(testDB)
|
||||||
|
|
||||||
|
human := provisionAccount(t)
|
||||||
|
robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision robot: %v", err)
|
||||||
|
}
|
||||||
|
if robot.BlockFriendRequests {
|
||||||
|
t.Fatal("robot must not block friend requests")
|
||||||
|
}
|
||||||
|
// A request is only allowed between players who share a game.
|
||||||
|
if _, err := newGameService().Create(ctx, game.CreateParams{
|
||||||
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
|
||||||
|
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create game: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SendFriendRequest(ctx, human, robot.ID); err != nil {
|
||||||
|
t.Fatalf("request to robot = %v, want nil (accepted as pending)", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListIncomingRequests(ctx, robot.ID); len(got) != 1 || got[0] != human {
|
||||||
|
t.Fatalf("robot incoming = %v, want [human]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFriendRequestLifecycle(t *testing.T) {
|
func TestFriendRequestLifecycle(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newSocialService()
|
svc := newSocialService()
|
||||||
@@ -282,6 +342,20 @@ func TestChatRejectsBadContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17):
|
||||||
|
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
|
||||||
|
func TestChatOnlyOnYourTurn(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening
|
||||||
|
if _, err := svc.PostMessage(ctx, gameID, seats[1], "hi", ""); !errors.Is(err, social.ErrChatNotYourTurn) {
|
||||||
|
t.Fatalf("off-turn chat = %v, want ErrChatNotYourTurn", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hi", ""); err != nil {
|
||||||
|
t.Fatalf("on-turn chat = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNudgeRulesAndRateLimit(t *testing.T) {
|
func TestNudgeRulesAndRateLimit(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newSocialService()
|
svc := newSocialService()
|
||||||
@@ -307,3 +381,174 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
|
|||||||
t.Fatalf("nudge after window: %v", err)
|
t.Fatalf("nudge after window: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
|
||||||
|
// acted (moved or chatted) since their last nudge, even within the hour (Stage 17).
|
||||||
|
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
gsvc := newGameService()
|
||||||
|
gameID, seats := newGameWithSeats(t, 2) // seat 0 to move
|
||||||
|
|
||||||
|
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
|
||||||
|
t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
|
||||||
|
}
|
||||||
|
// Seat 1 takes a turn: seat 0 passes (-> seat 1's turn), seat 1 chats then passes.
|
||||||
|
if _, err := gsvc.Pass(ctx, gameID, seats[0]); err != nil {
|
||||||
|
t.Fatalf("seat0 pass: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.PostMessage(ctx, gameID, seats[1], "thinking", ""); err != nil {
|
||||||
|
t.Fatalf("seat1 chat: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := gsvc.Pass(ctx, gameID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("seat1 pass: %v", err)
|
||||||
|
}
|
||||||
|
// Back on the opponent's turn, the cooldown is reset by the action since the nudge.
|
||||||
|
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
||||||
|
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
|
||||||
|
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
||||||
|
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
||||||
|
func TestListOutgoingRequests(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
|
||||||
|
// Pending: outgoing for the requester, not the addressee.
|
||||||
|
_, s1 := newGameWithSeats(t, 2)
|
||||||
|
a, b := s1[0], s1[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b {
|
||||||
|
t.Fatalf("outgoing pending = %v, want [b]", got)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 {
|
||||||
|
t.Fatalf("addressee outgoing = %v, want none", got)
|
||||||
|
}
|
||||||
|
// Accepted: a friendship, no longer an outgoing request.
|
||||||
|
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||||
|
t.Fatalf("accept: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 {
|
||||||
|
t.Fatalf("outgoing after accept = %v, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declined: stays outgoing (reads as sent; cannot re-send).
|
||||||
|
_, s2 := newGameWithSeats(t, 2)
|
||||||
|
c, d := s2[0], s2[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||||
|
t.Fatalf("send2: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||||
|
t.Fatalf("decline: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d {
|
||||||
|
t.Fatalf("outgoing after decline = %v, want [d]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily expired pending: omitted (may be re-sent).
|
||||||
|
_, s3 := newGameWithSeats(t, 2)
|
||||||
|
e, f := s3[0], s3[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, e, f); err != nil {
|
||||||
|
t.Fatalf("send3: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 {
|
||||||
|
t.Fatalf("expired outgoing = %v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
||||||
|
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
|
||||||
|
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
||||||
|
func TestRespondPublishesToRequester(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
pub := &capturePublisher{}
|
||||||
|
svc.SetNotifier(pub)
|
||||||
|
|
||||||
|
_, s1 := newGameWithSeats(t, 2)
|
||||||
|
a, b := s1[0], s1[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||||
|
t.Fatalf("accept: %v", err)
|
||||||
|
}
|
||||||
|
if !pub.notified(a, notify.NotifyFriendAdded) {
|
||||||
|
t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, s2 := newGameWithSeats(t, 2)
|
||||||
|
c, d := s2[0], s2[1]
|
||||||
|
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||||
|
t.Fatalf("send2: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||||
|
t.Fatalf("decline: %v", err)
|
||||||
|
}
|
||||||
|
if !pub.notified(c, notify.NotifyFriendDeclined) {
|
||||||
|
t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
|
||||||
|
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||||
|
func TestAdminListMessages(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
|
||||||
|
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.9"); err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil { // the waiting player nudges
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned to the game: the message is listed; the nudge (kind=nudge) is excluded.
|
||||||
|
msgs, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID}, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin list: %v", err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("game messages = %d, want 1 (nudge excluded)", len(msgs))
|
||||||
|
}
|
||||||
|
if m := msgs[0]; m.Body != "good luck" || m.SenderID != seats[0] || m.SenderIP != "203.0.113.9" {
|
||||||
|
t.Fatalf("message = %+v, want body=good luck sender=seat0 ip=203.0.113.9", m)
|
||||||
|
}
|
||||||
|
if msgs[0].Source != "telegram" { // provisionAccount provisions a telegram identity
|
||||||
|
t.Errorf("source = %q, want telegram", msgs[0].Source)
|
||||||
|
}
|
||||||
|
if n, _ := svc.AdminCountMessages(ctx, social.AdminMessageFilter{GameID: gameID}); n != 1 {
|
||||||
|
t.Errorf("count = %d, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender pin: seat 0 has the message; seat 1 has only a nudge.
|
||||||
|
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{SenderID: seats[0]}, 50, 0); len(got) == 0 {
|
||||||
|
t.Error("sender=seat0 returned nothing")
|
||||||
|
}
|
||||||
|
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, SenderID: seats[1]}, 50, 0); len(got) != 0 {
|
||||||
|
t.Errorf("sender=seat1 has only a nudge, got %d messages", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender glob masks: the telegram external id matches "tg-*"; bogus masks exclude.
|
||||||
|
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "tg-*"}, 50, 0); len(got) != 1 {
|
||||||
|
t.Errorf("ext mask tg-* = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, ExtMask: "zzz-*"}, 50, 0); len(got) != 0 {
|
||||||
|
t.Errorf("ext mask zzz-* = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
if got, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, NameMask: "zzz-no-such-*"}, 50, 0); len(got) != 0 {
|
||||||
|
t.Errorf("name mask miss = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
|
|||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
t.Fatalf("ensure pool: %v", err)
|
t.Fatalf("ensure pool: %v", err)
|
||||||
}
|
}
|
||||||
robotID, err := robots.Pick()
|
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pick: %v", err)
|
t.Fatalf("pick: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ type GameCreator interface {
|
|||||||
// auto-match. robot.Service satisfies it; it returns an error when no robot is
|
// auto-match. robot.Service satisfies it; it returns an error when no robot is
|
||||||
// available so the matchmaker can defer substitution.
|
// available so the matchmaker can defer substitution.
|
||||||
type RobotProvider interface {
|
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
|
// Blocker reports whether two accounts have a block between them (either
|
||||||
|
|||||||
@@ -142,11 +142,14 @@ func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult
|
|||||||
return EnqueueResult{}, nil
|
return EnqueueResult{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel removes accountID from whatever pool it waits in, reporting whether it
|
// Cancel removes accountID from whatever pool it waits in and drops any pending
|
||||||
// was queued.
|
// 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 {
|
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
delete(m.results, accountID)
|
||||||
variant, ok := m.queued[accountID]
|
variant, ok := m.queued[accountID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -197,12 +200,12 @@ func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
|
|||||||
}
|
}
|
||||||
var subs []sub
|
var subs []sub
|
||||||
for _, acc := range due {
|
for _, acc := range due {
|
||||||
robotID, err := m.robots.Pick()
|
variant := m.queued[acc]
|
||||||
|
robotID, err := m.robots.Pick(variant)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.log.Warn("robot substitution deferred", zap.Error(err))
|
m.log.Warn("robot substitution deferred", zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
variant := m.queued[acc]
|
|
||||||
m.removeLocked(acc, variant)
|
m.removeLocked(acc, variant)
|
||||||
seats := []uuid.UUID{acc, robotID}
|
seats := []uuid.UUID{acc, robotID}
|
||||||
if m.rng.Intn(2) == 0 {
|
if m.rng.Intn(2) == 0 {
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
// 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 {
|
type fakeRobots struct {
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
err error
|
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 {
|
if f.err != nil {
|
||||||
return uuid.Nil, f.err
|
return uuid.Nil, f.err
|
||||||
}
|
}
|
||||||
@@ -238,6 +240,27 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
|
||||||
|
// robot just before the player cancels: Cancel must drop the pending result so the
|
||||||
|
// abandoned game never surfaces through Poll (Stage 17).
|
||||||
|
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
||||||
|
creator := &fakeCreator{}
|
||||||
|
mm := newTestMatchmaker(creator, uuid.New())
|
||||||
|
base := time.Now()
|
||||||
|
mm.clock = func() time.Time { return base }
|
||||||
|
ctx := context.Background()
|
||||||
|
a := uuid.New()
|
||||||
|
|
||||||
|
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
|
||||||
|
t.Fatalf("enqueue: %v", err)
|
||||||
|
}
|
||||||
|
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
|
||||||
|
mm.Cancel(ctx, a) // ... then the player cancels
|
||||||
|
if got, _ := mm.Poll(ctx, a); got.Matched {
|
||||||
|
t.Error("cancel must drop the pending substituted game; Poll still matched")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
|
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
|
||||||
creator := &fakeCreator{}
|
creator := &fakeCreator{}
|
||||||
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
|
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ func MatchFound(userID, gameID uuid.UUID) Intent {
|
|||||||
|
|
||||||
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
||||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||||
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
|
// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
|
||||||
// scope its refresh.
|
// client may use to scope its refresh.
|
||||||
func Notification(userID uuid.UUID, kind string) Intent {
|
func Notification(userID uuid.UUID, kind string) Intent {
|
||||||
b := flatbuffers.NewBuilder(32)
|
b := flatbuffers.NewBuilder(32)
|
||||||
k := b.CreateString(kind)
|
k := b.CreateString(kind)
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ const (
|
|||||||
const (
|
const (
|
||||||
NotifyFriendRequest = "friend_request"
|
NotifyFriendRequest = "friend_request"
|
||||||
NotifyFriendAdded = "friend_added"
|
NotifyFriendAdded = "friend_added"
|
||||||
NotifyInvitation = "invitation"
|
// NotifyFriendDeclined tells the original requester their request was declined, so a
|
||||||
NotifyGameStarted = "game_started"
|
// game screen watching that opponent re-derives its "add to friends" state.
|
||||||
|
NotifyFriendDeclined = "friend_declined"
|
||||||
|
NotifyInvitation = "invitation"
|
||||||
|
NotifyGameStarted = "game_started"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Intent is one live event destined for a single user. Payload is the
|
// Intent is one live event destined for a single user. Payload is the
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Stage 17: a per-(game, account) draft the server persists across reloads and devices —
|
||||||
|
-- the player's preferred rack tile order (#4) and the tiles they have laid on the board but
|
||||||
|
-- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps
|
||||||
|
-- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no
|
||||||
|
-- generated jet code is needed.
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
|
||||||
|
CREATE TABLE game_drafts (
|
||||||
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
rack_order text NOT NULL DEFAULT '',
|
||||||
|
board_tiles jsonb NOT NULL DEFAULT '[]',
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (game_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
|
||||||
|
DROP TABLE game_drafts;
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package robot
|
||||||
|
|
||||||
|
// Robot display names are composed, not hand-listed. Per language there is a pool of
|
||||||
|
// 32 full first names and a paired pool of 32 colloquial forms (William/Bill,
|
||||||
|
// Анастасия/Настя), a surname pool, and three rendering forms: first name only;
|
||||||
|
// first name plus a surname initial; first name plus full surname. Because robots are
|
||||||
|
// durable accounts whose name must stay stable across restarts (a player's opponent
|
||||||
|
// must not rename itself on every deploy, nor mid-game), the composition is
|
||||||
|
// deterministic per pool slot — seeded by the slot index through mix — rather than
|
||||||
|
// re-randomised each boot. Russian surnames are gender-agreed with the first name.
|
||||||
|
|
||||||
|
// robotPoolSize is the number of robot accounts provisioned per language. It equals
|
||||||
|
// the first-name pool size, so each slot draws a distinct person.
|
||||||
|
const robotPoolSize = 32
|
||||||
|
|
||||||
|
// latinShareInRussian is the approximate percentage of Russian-variant games that
|
||||||
|
// draw a Latin-named robot rather than a Russian-named one (the owner's "≤20%").
|
||||||
|
const latinShareInRussian = 20
|
||||||
|
|
||||||
|
// name composition forms.
|
||||||
|
const (
|
||||||
|
nameFormFirstOnly = iota // "Anna"
|
||||||
|
nameFormInitial // "Anna C."
|
||||||
|
nameFormFull // "Anna Carter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genderedName is a Russian first name tagged by grammatical gender so the surname
|
||||||
|
// form (masculine vs feminine) can agree with it.
|
||||||
|
type genderedName struct {
|
||||||
|
name string
|
||||||
|
female bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// surnamePair holds a Russian surname's masculine and feminine forms.
|
||||||
|
type surnamePair struct{ m, f string }
|
||||||
|
|
||||||
|
// firstNamesFullEN and firstNamesShortEN are paired by index: the same person's
|
||||||
|
// official and colloquial English first name (William/Bill).
|
||||||
|
var firstNamesFullEN = []string{
|
||||||
|
"William", "Robert", "Thomas", "Nicholas", "Joseph", "Edward", "Charles", "Margaret",
|
||||||
|
"Elizabeth", "Katherine", "Alexander", "Samuel", "Andrew", "Christopher", "Benjamin", "Daniel",
|
||||||
|
"Matthew", "Anthony", "Michael", "Richard", "Jonathan", "Patricia", "Jennifer", "Jessica",
|
||||||
|
"Stephanie", "Victoria", "Theodore", "Frederick", "Gabriel", "Vincent", "Eleanor", "Josephine",
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstNamesShortEN = []string{
|
||||||
|
"Will", "Bob", "Tom", "Nick", "Joe", "Eddie", "Charlie", "Maggie",
|
||||||
|
"Liz", "Kate", "Alex", "Sam", "Drew", "Chris", "Ben", "Dan",
|
||||||
|
"Matt", "Tony", "Mike", "Rick", "Jon", "Pat", "Jen", "Jess",
|
||||||
|
"Steph", "Vicky", "Ted", "Fred", "Gabe", "Vince", "Ellie", "Josie",
|
||||||
|
}
|
||||||
|
|
||||||
|
// surnamesEN is a pool of gender-neutral English surnames.
|
||||||
|
var surnamesEN = []string{
|
||||||
|
"Carter", "Hayes", "Brooks", "Reed", "Cole", "Lane", "Bishop", "Hart",
|
||||||
|
"Shaw", "Wells", "Pierce", "Wade", "Frost", "Doyle", "Boyd", "Marsh",
|
||||||
|
"Hale", "Nash", "Webb", "Dale", "Park", "Lyon", "Snow", "Cross",
|
||||||
|
"Vance", "Knox", "Page", "Ford", "Banks", "Foster", "Greer", "Mills",
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNamesFullRU and firstNamesShortRU are paired by index: the same person's
|
||||||
|
// official and colloquial Russian first name (Анастасия/Настя), gender-tagged.
|
||||||
|
var firstNamesFullRU = []genderedName{
|
||||||
|
{"Александр", false}, {"Дмитрий", false}, {"Сергей", false}, {"Алексей", false},
|
||||||
|
{"Михаил", false}, {"Николай", false}, {"Владимир", false}, {"Константин", false},
|
||||||
|
{"Павел", false}, {"Григорий", false}, {"Андрей", false}, {"Иван", false},
|
||||||
|
{"Пётр", false}, {"Роман", false}, {"Антон", false}, {"Евгений", false},
|
||||||
|
{"Анна", true}, {"Мария", true}, {"Анастасия", true}, {"Наталья", true},
|
||||||
|
{"Татьяна", true}, {"Екатерина", true}, {"Юлия", true}, {"Ольга", true},
|
||||||
|
{"Елена", true}, {"Ирина", true}, {"Светлана", true}, {"Полина", true},
|
||||||
|
{"Дарья", true}, {"София", true}, {"Алина", true}, {"Вера", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstNamesShortRU = []genderedName{
|
||||||
|
{"Саша", false}, {"Дима", false}, {"Серёжа", false}, {"Лёша", false},
|
||||||
|
{"Миша", false}, {"Коля", false}, {"Володя", false}, {"Костя", false},
|
||||||
|
{"Паша", false}, {"Гриша", false}, {"Андрюша", false}, {"Ваня", false},
|
||||||
|
{"Петя", false}, {"Рома", false}, {"Антоша", false}, {"Женя", false},
|
||||||
|
{"Аня", true}, {"Маша", true}, {"Настя", true}, {"Наташа", true},
|
||||||
|
{"Таня", true}, {"Катя", true}, {"Юля", true}, {"Оля", true},
|
||||||
|
{"Лена", true}, {"Ира", true}, {"Света", true}, {"Поля", true},
|
||||||
|
{"Даша", true}, {"Соня", true}, {"Аля", true}, {"Верочка", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// surnamesRU is a pool of common Russian surnames in masculine and feminine forms.
|
||||||
|
var surnamesRU = []surnamePair{
|
||||||
|
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
|
||||||
|
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
|
||||||
|
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
|
||||||
|
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
|
||||||
|
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
|
||||||
|
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
|
||||||
|
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
|
||||||
|
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
|
||||||
|
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
|
||||||
|
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
|
||||||
|
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotDisplayNamesEN builds the English robot display-name pool, one per slot. Each
|
||||||
|
// slot draws its paired full or colloquial first name, a surname, and a form.
|
||||||
|
func robotDisplayNamesEN() []string {
|
||||||
|
out := make([]string, robotPoolSize)
|
||||||
|
for i := range out {
|
||||||
|
h := mix(int64(i), "robot-en")
|
||||||
|
first := firstNamesFullEN[i%len(firstNamesFullEN)]
|
||||||
|
if (h>>16)&1 == 1 {
|
||||||
|
first = firstNamesShortEN[i%len(firstNamesShortEN)]
|
||||||
|
}
|
||||||
|
surname := surnamesEN[h%uint64(len(surnamesEN))]
|
||||||
|
out[i] = composeName(first, surname, int((h>>8)%3))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotDisplayNamesRU builds the Russian robot display-name pool, one per slot, with
|
||||||
|
// the surname form agreeing with the first name's gender.
|
||||||
|
func robotDisplayNamesRU() []string {
|
||||||
|
out := make([]string, robotPoolSize)
|
||||||
|
for i := range out {
|
||||||
|
h := mix(int64(i), "robot-ru")
|
||||||
|
fn := firstNamesFullRU[i%len(firstNamesFullRU)]
|
||||||
|
if (h>>16)&1 == 1 {
|
||||||
|
fn = firstNamesShortRU[i%len(firstNamesShortRU)]
|
||||||
|
}
|
||||||
|
sp := surnamesRU[h%uint64(len(surnamesRU))]
|
||||||
|
surname := sp.m
|
||||||
|
if fn.female {
|
||||||
|
surname = sp.f
|
||||||
|
}
|
||||||
|
out[i] = composeName(fn.name, surname, int((h>>8)%3))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeName renders one of the three name forms from a first name and a surname.
|
||||||
|
func composeName(first, surname string, form int) string {
|
||||||
|
switch form {
|
||||||
|
case nameFormInitial:
|
||||||
|
return first + " " + string([]rune(surname)[:1]) + "."
|
||||||
|
case nameFormFull:
|
||||||
|
return first + " " + surname
|
||||||
|
default:
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package robot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestComposeName covers the three rendering forms, including a Cyrillic initial.
|
||||||
|
func TestComposeName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
first, surname string
|
||||||
|
form int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Anna", "Carter", nameFormFirstOnly, "Anna"},
|
||||||
|
{"Anna", "Carter", nameFormInitial, "Anna C."},
|
||||||
|
{"Anna", "Carter", nameFormFull, "Anna Carter"},
|
||||||
|
{"Маша", "Суханова", nameFormInitial, "Маша С."},
|
||||||
|
{"Маша", "Суханова", nameFormFull, "Маша Суханова"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := composeName(c.first, c.surname, c.form); got != c.want {
|
||||||
|
t.Errorf("composeName(%q,%q,%d) = %q, want %q", c.first, c.surname, c.form, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNamePoolsPaired checks the full and colloquial first-name pools line up by
|
||||||
|
// index (so a slot's gender and person are consistent) and the surname forms differ.
|
||||||
|
func TestNamePoolsPaired(t *testing.T) {
|
||||||
|
if len(firstNamesFullEN) != robotPoolSize || len(firstNamesShortEN) != robotPoolSize {
|
||||||
|
t.Fatalf("EN first-name pools must each hold %d names", robotPoolSize)
|
||||||
|
}
|
||||||
|
if len(firstNamesFullRU) != robotPoolSize || len(firstNamesShortRU) != robotPoolSize {
|
||||||
|
t.Fatalf("RU first-name pools must each hold %d names", robotPoolSize)
|
||||||
|
}
|
||||||
|
for i := range firstNamesFullRU {
|
||||||
|
if firstNamesFullRU[i].female != firstNamesShortRU[i].female {
|
||||||
|
t.Errorf("RU pair %d disagrees on gender: %q vs %q", i, firstNamesFullRU[i].name, firstNamesShortRU[i].name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sp := range surnamesRU {
|
||||||
|
if sp.m == sp.f {
|
||||||
|
t.Errorf("RU surname forms should differ: %q", sp.m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRobotDisplayNames checks the generated pools are the right size, non-empty and
|
||||||
|
// deterministic — durable robot accounts must keep a stable name across restarts.
|
||||||
|
func TestRobotDisplayNames(t *testing.T) {
|
||||||
|
en1, en2 := robotDisplayNamesEN(), robotDisplayNamesEN()
|
||||||
|
ru1, ru2 := robotDisplayNamesRU(), robotDisplayNamesRU()
|
||||||
|
if len(en1) != robotPoolSize || len(ru1) != robotPoolSize {
|
||||||
|
t.Fatalf("pool sizes en=%d ru=%d, want %d", len(en1), len(ru1), robotPoolSize)
|
||||||
|
}
|
||||||
|
for i := range en1 {
|
||||||
|
if en1[i] != en2[i] || ru1[i] != ru2[i] {
|
||||||
|
t.Fatalf("pool not deterministic at %d: en %q/%q ru %q/%q", i, en1[i], en2[i], ru1[i], ru2[i])
|
||||||
|
}
|
||||||
|
if en1[i] == "" || ru1[i] == "" {
|
||||||
|
t.Fatalf("empty composed name at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPickVariantRouting checks English games draw the Latin pool and Russian games
|
||||||
|
// draw mostly Russian names with a Latin minority.
|
||||||
|
func TestPickVariantRouting(t *testing.T) {
|
||||||
|
enID, ruID := uuid.New(), uuid.New()
|
||||||
|
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
|
||||||
|
t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var en, ru int
|
||||||
|
for i := 0; i < 4000; i++ {
|
||||||
|
got, err := s.Pick(engine.VariantRussianScrabble)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("russian Pick: %v", err)
|
||||||
|
}
|
||||||
|
switch got {
|
||||||
|
case enID:
|
||||||
|
en++
|
||||||
|
case ruID:
|
||||||
|
ru++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ru <= en {
|
||||||
|
t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
|
||||||
|
}
|
||||||
|
if en == 0 {
|
||||||
|
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
|
||||||
|
}
|
||||||
|
// Эрудит routes like Russian Scrabble.
|
||||||
|
if _, err := s.Pick(engine.VariantErudit); err != nil {
|
||||||
|
t.Errorf("erudit Pick: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPickFallback checks an empty side falls back to the other pool and an empty pool
|
||||||
|
// errors.
|
||||||
|
func TestPickFallback(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
|
||||||
|
t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
|
||||||
|
}
|
||||||
|
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
|
||||||
|
t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
|
||||||
|
}
|
||||||
|
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
|
||||||
|
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,13 +55,6 @@ type Nudger interface {
|
|||||||
LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error)
|
LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// robotNames is the curated, human-like name pool. Each name backs one durable
|
|
||||||
// robot account, addressed by a stable robot identity (its lower-cased name).
|
|
||||||
var robotNames = []string{
|
|
||||||
"Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan",
|
|
||||||
"Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config configures the robot subsystem.
|
// Config configures the robot subsystem.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// DriveInterval is how often the driver scans for robot turns. Sourced from
|
// DriveInterval is how often the driver scans for robot turns. Sourced from
|
||||||
@@ -91,8 +84,9 @@ type Service struct {
|
|||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
pool []uuid.UUID
|
poolEN []uuid.UUID
|
||||||
|
poolRU []uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a robot Service. games and social are the domain seams it
|
// NewService constructs a robot Service. games and social are the domain seams it
|
||||||
@@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsurePool idempotently provisions the named robot accounts and records their
|
// EnsurePool idempotently provisions the robot accounts (one per slot of each
|
||||||
// ids as the pool. Each robot is a durable account bound to a robot identity,
|
// language's composed name pool) and records their ids. Each robot is a durable
|
||||||
// with chat and friend requests blocked so it never engages socially
|
// account bound to a stable, index-keyed robot identity, with chat and friend
|
||||||
// (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary
|
// requests blocked so it never engages socially (docs/ARCHITECTURE.md §7). It is a
|
||||||
// registry: a failure fails the boot.
|
// startup dependency, like the dictionary registry: a failure fails the boot.
|
||||||
func (s *Service) EnsurePool(ctx context.Context) error {
|
func (s *Service) EnsurePool(ctx context.Context) error {
|
||||||
ids := make([]uuid.UUID, 0, len(robotNames))
|
en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN())
|
||||||
for _, name := range robotNames {
|
if err != nil {
|
||||||
acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name))
|
return err
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("robot: provision %q: %w", name, err)
|
ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU())
|
||||||
}
|
if err != nil {
|
||||||
if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests {
|
return err
|
||||||
if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
|
|
||||||
DisplayName: name,
|
|
||||||
PreferredLanguage: acc.PreferredLanguage,
|
|
||||||
TimeZone: acc.TimeZone,
|
|
||||||
AwayStart: acc.AwayStart,
|
|
||||||
AwayEnd: acc.AwayEnd,
|
|
||||||
BlockChat: true,
|
|
||||||
BlockFriendRequests: true,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("robot: profile %q: %w", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ids = append(ids, acc.ID)
|
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.pool = ids
|
s.poolEN, s.poolRU = en, ru
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick returns a random robot account from the pool, for the matchmaker to
|
// provisionPool provisions one durable robot account per name and returns their ids
|
||||||
// substitute into an auto-match. It satisfies lobby.RobotProvider.
|
// in order. The identity is keyed by language and slot index (stable across restarts
|
||||||
func (s *Service) Pick() (uuid.UUID, error) {
|
// and independent of the composed display name); account.ProvisionRobot sets the
|
||||||
s.mu.RLock()
|
// display name and social blocks and is idempotent, so EnsurePool can run every boot.
|
||||||
defer s.mu.RUnlock()
|
func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) {
|
||||||
if len(s.pool) == 0 {
|
ids := make([]uuid.UUID, 0, len(names))
|
||||||
return uuid.Nil, ErrNoRobotAvailable
|
for i, name := range names {
|
||||||
|
acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err)
|
||||||
|
}
|
||||||
|
ids = append(ids, acc.ID)
|
||||||
}
|
}
|
||||||
return s.pool[rand.IntN(len(s.pool))], nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// poolIDs returns a snapshot of the pool for the driver scan.
|
// Pick returns a random robot account for the matchmaker to substitute into an
|
||||||
|
// auto-match of the given variant. An English game draws from the Latin pool; a
|
||||||
|
// Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a
|
||||||
|
// Latin name about latinShareInRussian% of the time; either side falls back to the
|
||||||
|
// other when its pool is empty. It satisfies lobby.RobotProvider.
|
||||||
|
func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
primary, secondary := s.poolEN, s.poolRU
|
||||||
|
if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit {
|
||||||
|
primary, secondary = s.poolRU, s.poolEN
|
||||||
|
if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian {
|
||||||
|
primary, secondary = secondary, primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(primary) == 0 {
|
||||||
|
primary = secondary
|
||||||
|
}
|
||||||
|
if len(primary) == 0 {
|
||||||
|
return uuid.Nil, ErrNoRobotAvailable
|
||||||
|
}
|
||||||
|
return primary[rand.IntN(len(primary))], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolIDs returns a snapshot of the whole pool (both languages) for the driver scan,
|
||||||
|
// which is variant-agnostic — it acts on every robot's active games.
|
||||||
func (s *Service) poolIDs() []uuid.UUID {
|
func (s *Service) poolIDs() []uuid.UUID {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
return append([]uuid.UUID(nil), s.pool...)
|
ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU))
|
||||||
}
|
ids = append(ids, s.poolEN...)
|
||||||
|
ids = append(ids, s.poolRU...)
|
||||||
// externalID is the stable robot identity for a pool name.
|
return ids
|
||||||
func externalID(name string) string {
|
|
||||||
return "robot-" + name
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,27 @@ const (
|
|||||||
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
|
// human wins about 60% of games (docs/ARCHITECTURE.md §7).
|
||||||
playToWinPercent = 40
|
playToWinPercent = 40
|
||||||
|
|
||||||
// delayMinMinutes and delayMaxMinutes bound a move delay; delaySkew shapes the
|
// The robot's think time depends on how far the game has progressed: early moves
|
||||||
// right-skewed distribution (short delays frequent). With skew 3.5 the median
|
// are quick and late moves can be long (endgame deliberation). The delay is drawn
|
||||||
// is about 10 minutes and the mean about 20, with a tail out to the maximum.
|
// from a band that interpolates with the move count from [delayEarlyLoMinutes,
|
||||||
delayMinMinutes = 2.0
|
// delayEarlyHiMinutes] at the first move to [delayLateLoMinutes, delayLateHiMinutes]
|
||||||
delayMaxMinutes = 90.0
|
// by avgGameMoves, then right-skewed by delaySkew (a larger exponent concentrates
|
||||||
delaySkew = 3.5
|
// delays near the band's floor — an active player). The result is clamped to
|
||||||
|
// [delayHardMinMinutes, delayHardMaxMinutes]. The numbers are deliberate estimates,
|
||||||
|
// to be retuned once real play statistics arrive (docs/ARCHITECTURE.md §7).
|
||||||
|
delayEarlyLoMinutes = 3.0
|
||||||
|
delayEarlyHiMinutes = 10.0
|
||||||
|
delayLateLoMinutes = 10.0
|
||||||
|
delayLateHiMinutes = 90.0
|
||||||
|
delaySkew = 4.0
|
||||||
|
avgGameMoves = 28.0
|
||||||
|
delayHardMinMinutes = 1.0
|
||||||
|
delayHardMaxMinutes = 90.0
|
||||||
|
|
||||||
// nudgeReplyMinMinutes and nudgeReplyMaxMinutes bound how soon the robot
|
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
|
||||||
// answers a daytime nudge on its turn.
|
// lower band (delayBand's lo), within which the robot answers a daytime nudge on
|
||||||
nudgeReplyMinMinutes = 2.0
|
// its turn — so a nudged robot replies near the floor of its think time.
|
||||||
nudgeReplyMaxMinutes = 10.0
|
nudgeReplySpreadMinutes = 5.0
|
||||||
|
|
||||||
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
|
// sleepStartHour and sleepEndHour bound the robot's nightly sleep in its
|
||||||
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
|
// (opponent-anchored, drifted) local time: it makes no move and sends no nudge
|
||||||
@@ -104,19 +114,82 @@ func playToWin(seed int64) bool {
|
|||||||
return mix(seed, "win")%100 < playToWinPercent
|
return mix(seed, "win")%100 < playToWinPercent
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveDelay is the robot's think time for the move at moveCount, sampled from the
|
// PlayToWin exposes the once-per-game play-to-win decision for a game's bag seed, for the
|
||||||
// right-skewed distribution and bounded to [delayMinMinutes, delayMaxMinutes).
|
// admin console (it is deterministic and fixed for the whole game).
|
||||||
|
func PlayToWin(seed int64) bool { return playToWin(seed) }
|
||||||
|
|
||||||
|
// PlayToWinTargetPercent is the configured probability, in percent, that a robot plays to
|
||||||
|
// win in any given game (the admin console shows it alongside the per-game decision).
|
||||||
|
const PlayToWinTargetPercent = playToWinPercent
|
||||||
|
|
||||||
|
// NextMoveAt is the deterministic instant the robot is scheduled to play the move at
|
||||||
|
// moveCount, given when the turn started and the opponent's timezone (which anchors the
|
||||||
|
// robot's sleep window). It is the sampled think-time delay, deferred to the end of the
|
||||||
|
// sleep window when it would otherwise land while the robot is asleep. The driver acts on
|
||||||
|
// a scan tick, so the real move lands at the first scan at or after this instant. It is
|
||||||
|
// meaningful only on the robot's own turn; the admin console surfaces it as an ETA.
|
||||||
|
func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time {
|
||||||
|
t := turnStartedAt.Add(moveDelay(seed, moveCount))
|
||||||
|
drift := sleepDrift(seed)
|
||||||
|
if asleep(opponentTZ, drift, t) {
|
||||||
|
t = wakeAfter(opponentTZ, drift, t)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// wakeAfter returns the first instant at or after t when the robot is awake — the local
|
||||||
|
// hour reaches sleepEndHour in the opponent's drifted timezone — converted back to UTC.
|
||||||
|
func wakeAfter(opponentTZ string, drift time.Duration, t time.Time) time.Time {
|
||||||
|
local := t.In(loadLocation(opponentTZ)).Add(drift)
|
||||||
|
wake := time.Date(local.Year(), local.Month(), local.Day(), sleepEndHour, 0, 0, 0, local.Location())
|
||||||
|
if !wake.After(local) {
|
||||||
|
wake = wake.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
return wake.Add(-drift).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
// delayBand returns the lower and upper bounds, in minutes, of the move-delay band
|
||||||
|
// for the move at moveCount. It interpolates linearly with game progress (the move
|
||||||
|
// count over avgGameMoves, capped at 1): early moves sit in a short band and late
|
||||||
|
// moves in a long one.
|
||||||
|
func delayBand(moveCount int) (lo, hi float64) {
|
||||||
|
p := float64(moveCount) / avgGameMoves
|
||||||
|
if p > 1 {
|
||||||
|
p = 1
|
||||||
|
}
|
||||||
|
lo = delayEarlyLoMinutes + (delayLateLoMinutes-delayEarlyLoMinutes)*p
|
||||||
|
hi = delayEarlyHiMinutes + (delayLateHiMinutes-delayEarlyHiMinutes)*p
|
||||||
|
return lo, hi
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveDelay is the robot's think time for the move at moveCount: a right-skewed
|
||||||
|
// sample from the move's delayBand, clamped to the hard bounds. The skew (delaySkew
|
||||||
|
// > 1) makes short delays frequent and long ones rare, with a tail to the band's top.
|
||||||
func moveDelay(seed int64, moveCount int) time.Duration {
|
func moveDelay(seed int64, moveCount int) time.Duration {
|
||||||
|
lo, hi := delayBand(moveCount)
|
||||||
u := unitFloat(mix(seed, "delay", moveCount))
|
u := unitFloat(mix(seed, "delay", moveCount))
|
||||||
mins := delayMinMinutes + (delayMaxMinutes-delayMinMinutes)*math.Pow(u, delaySkew)
|
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
|
||||||
return time.Duration(mins * float64(time.Minute))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
|
// nudgeReplyDelay is how soon after a daytime nudge the robot answers the move at
|
||||||
// moveCount, sampled uniformly from [nudgeReplyMinMinutes, nudgeReplyMaxMinutes).
|
// moveCount: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes],
|
||||||
|
// where lo is the move's lower band — so a nudge pulls the move in near the floor of
|
||||||
|
// the robot's think time.
|
||||||
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
|
func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
|
||||||
|
lo, _ := delayBand(moveCount)
|
||||||
u := unitFloat(mix(seed, "nudge", moveCount))
|
u := unitFloat(mix(seed, "nudge", moveCount))
|
||||||
mins := nudgeReplyMinMinutes + (nudgeReplyMaxMinutes-nudgeReplyMinMinutes)*u
|
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
|
||||||
|
// bounds so an out-of-range band can never produce an absurd think time.
|
||||||
|
func clampMinutes(mins float64) time.Duration {
|
||||||
|
if mins < delayHardMinMinutes {
|
||||||
|
mins = delayHardMinMinutes
|
||||||
|
}
|
||||||
|
if mins > delayHardMaxMinutes {
|
||||||
|
mins = delayHardMaxMinutes
|
||||||
|
}
|
||||||
return time.Duration(mins * float64(time.Minute))
|
return time.Duration(mins * float64(time.Minute))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ func TestPlayToWinDistribution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in
|
// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard
|
||||||
// [2min, 90min) and is reproducible for a (seed, moveCount).
|
// bounds [1min, 90min] and is reproducible for a (seed, moveCount).
|
||||||
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
|
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
|
||||||
for seed := int64(1); seed <= 200; seed++ {
|
for seed := int64(1); seed <= 200; seed++ {
|
||||||
for mc := 0; mc < 50; mc++ {
|
for mc := 0; mc < 50; mc++ {
|
||||||
d := moveDelay(seed, mc)
|
d := moveDelay(seed, mc)
|
||||||
if d < 2*time.Minute || d >= 90*time.Minute {
|
if d < 1*time.Minute || d > 90*time.Minute {
|
||||||
t.Fatalf("delay %s out of [2m,90m) for seed=%d mc=%d", d, seed, mc)
|
t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc)
|
||||||
}
|
}
|
||||||
if moveDelay(seed, mc) != d {
|
if moveDelay(seed, mc) != d {
|
||||||
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
|
t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
|
||||||
@@ -43,22 +43,49 @@ func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMoveDelaySkew checks the distribution is right-skewed with the intended
|
// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the
|
||||||
// ~10-minute median: most delays are short, the mean sits above the median.
|
// first move lives in the short [1,5]min band, a late move in the long [10,90]min
|
||||||
|
// band, so the median think time rises with the move count.
|
||||||
|
func TestMoveDelayGrowsWithMoveCount(t *testing.T) {
|
||||||
|
median := func(mc int) float64 {
|
||||||
|
const n = 4000
|
||||||
|
xs := make([]float64, n)
|
||||||
|
for s := 0; s < n; s++ {
|
||||||
|
xs[s] = moveDelay(int64(s+1), mc).Minutes()
|
||||||
|
}
|
||||||
|
sort.Float64s(xs)
|
||||||
|
return xs[n/2]
|
||||||
|
}
|
||||||
|
for s := int64(1); s <= 500; s++ {
|
||||||
|
if d := moveDelay(s, 0).Minutes(); d < 3 || d > 10 {
|
||||||
|
t.Fatalf("first-move delay %.2f out of [3,10] for seed %d", d, s)
|
||||||
|
}
|
||||||
|
if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 {
|
||||||
|
t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if early, late := median(0), median(30); early >= late {
|
||||||
|
t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move
|
||||||
|
// count: short delays are frequent (median near the band floor) and the mean sits
|
||||||
|
// above the median, with a tail toward the cap.
|
||||||
func TestMoveDelaySkew(t *testing.T) {
|
func TestMoveDelaySkew(t *testing.T) {
|
||||||
const n = 20000
|
const n = 20000
|
||||||
mins := make([]float64, 0, n)
|
mins := make([]float64, 0, n)
|
||||||
var sum float64
|
var sum float64
|
||||||
for mc := 0; mc < n; mc++ {
|
for s := 0; s < n; s++ {
|
||||||
m := moveDelay(42, mc).Minutes()
|
m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90]
|
||||||
mins = append(mins, m)
|
mins = append(mins, m)
|
||||||
sum += m
|
sum += m
|
||||||
}
|
}
|
||||||
sort.Float64s(mins)
|
sort.Float64s(mins)
|
||||||
median := mins[n/2]
|
median := mins[n/2]
|
||||||
mean := sum / float64(n)
|
mean := sum / float64(n)
|
||||||
if median < 7 || median > 13 {
|
if median < 12 || median > 20 {
|
||||||
t.Errorf("median delay = %.1f min, want ~10 (7-13)", median)
|
t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median)
|
||||||
}
|
}
|
||||||
if mean <= median {
|
if mean <= median {
|
||||||
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
|
t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
|
||||||
@@ -180,6 +207,37 @@ func TestMixDeterministic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never
|
||||||
|
// earlier than the sampled think-time delay, and it never lands while the robot is asleep
|
||||||
|
// (a delay that would fall in the sleep window is deferred to the wake time).
|
||||||
|
func TestNextMoveAt(t *testing.T) {
|
||||||
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for seed := int64(1); seed <= 500; seed++ {
|
||||||
|
for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day
|
||||||
|
start := base.Add(time.Duration(h) * time.Hour)
|
||||||
|
at := NextMoveAt(seed, 3, start, "UTC")
|
||||||
|
if at.Before(start.Add(moveDelay(seed, 3))) {
|
||||||
|
t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at)
|
||||||
|
}
|
||||||
|
if asleep("UTC", sleepDrift(seed), at) {
|
||||||
|
t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlayToWinExport checks the exported decision matches the internal one and the target.
|
||||||
|
func TestPlayToWinExport(t *testing.T) {
|
||||||
|
for seed := int64(1); seed <= 200; seed++ {
|
||||||
|
if PlayToWin(seed) != playToWin(seed) {
|
||||||
|
t.Fatalf("PlayToWin(%d) != playToWin", seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if PlayToWinTargetPercent != playToWinPercent {
|
||||||
|
t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// plays builds candidate plays carrying only the given scores (ranked as passed).
|
// plays builds candidate plays carrying only the given scores (ranked as passed).
|
||||||
func plays(scores ...int) []engine.MoveRecord {
|
func plays(scores ...int) []engine.MoveRecord {
|
||||||
out := make([]engine.MoveRecord, len(scores))
|
out := make([]engine.MoveRecord, len(scores))
|
||||||
|
|||||||
@@ -83,16 +83,19 @@ type seatDTO struct {
|
|||||||
|
|
||||||
// gameDTO is the shared game summary.
|
// gameDTO is the shared game summary.
|
||||||
type gameDTO struct {
|
type gameDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Variant string `json:"variant"`
|
Variant string `json:"variant"`
|
||||||
DictVersion string `json:"dict_version"`
|
DictVersion string `json:"dict_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Players int `json:"players"`
|
Players int `json:"players"`
|
||||||
ToMove int `json:"to_move"`
|
ToMove int `json:"to_move"`
|
||||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
Seats []seatDTO `json:"seats"`
|
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||||
|
// game, the finish time once finished (Stage 17).
|
||||||
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
|
Seats []seatDTO `json:"seats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveResultDTO is the outcome of a committed move.
|
// moveResultDTO is the outcome of a committed move.
|
||||||
@@ -189,17 +192,22 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
IsWinner: s.IsWinner,
|
IsWinner: s.IsWinner,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
last := g.TurnStartedAt
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
last = *g.FinishedAt
|
||||||
|
}
|
||||||
return gameDTO{
|
return gameDTO{
|
||||||
ID: g.ID.String(),
|
ID: g.ID.String(),
|
||||||
Variant: g.Variant.String(),
|
Variant: g.Variant.String(),
|
||||||
DictVersion: g.DictVersion,
|
DictVersion: g.DictVersion,
|
||||||
Status: g.Status,
|
Status: g.Status,
|
||||||
Players: g.Players,
|
Players: g.Players,
|
||||||
ToMove: g.ToMove,
|
ToMove: g.ToMove,
|
||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
LastActivityUnix: last.Unix(),
|
||||||
|
Seats: seats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ func TestStatusForError(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
|
"not a player": {game.ErrNotAPlayer, http.StatusForbidden, "not_a_player"},
|
||||||
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
"not your turn": {game.ErrNotYourTurn, http.StatusConflict, "not_your_turn"},
|
||||||
|
"nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
|
||||||
|
"nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"},
|
||||||
|
"chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"},
|
||||||
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
|
||||||
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
|
||||||
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||||
u.GET("/games/:id/history", s.handleHistory)
|
u.GET("/games/:id/history", s.handleHistory)
|
||||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||||
|
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||||
|
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||||
}
|
}
|
||||||
if s.matchmaker != nil {
|
if s.matchmaker != nil {
|
||||||
u.POST("/lobby/enqueue", s.handleEnqueue)
|
u.POST("/lobby/enqueue", s.handleEnqueue)
|
||||||
|
u.POST("/lobby/cancel", s.handleCancel)
|
||||||
u.GET("/lobby/poll", s.handlePoll)
|
u.GET("/lobby/poll", s.handlePoll)
|
||||||
}
|
}
|
||||||
if s.invitations != nil {
|
if s.invitations != nil {
|
||||||
@@ -84,6 +87,7 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/games/:id/nudge", s.handleNudge)
|
u.POST("/games/:id/nudge", s.handleNudge)
|
||||||
u.GET("/friends", s.handleListFriends)
|
u.GET("/friends", s.handleListFriends)
|
||||||
u.GET("/friends/incoming", s.handleIncomingRequests)
|
u.GET("/friends/incoming", s.handleIncomingRequests)
|
||||||
|
u.GET("/friends/outgoing", s.handleOutgoingRequests)
|
||||||
u.POST("/friends/request", s.handleFriendRequest)
|
u.POST("/friends/request", s.handleFriendRequest)
|
||||||
u.POST("/friends/respond", s.handleFriendRespond)
|
u.POST("/friends/respond", s.handleFriendRespond)
|
||||||
u.POST("/friends/cancel", s.handleFriendCancel)
|
u.POST("/friends/cancel", s.handleFriendCancel)
|
||||||
@@ -148,8 +152,10 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusNotFound, "not_found"
|
return http.StatusNotFound, "not_found"
|
||||||
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
|
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
|
||||||
return http.StatusForbidden, "not_a_player"
|
return http.StatusForbidden, "not_a_player"
|
||||||
case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn):
|
case errors.Is(err, game.ErrNotYourTurn):
|
||||||
return http.StatusConflict, "not_your_turn"
|
return http.StatusConflict, "not_your_turn"
|
||||||
|
case errors.Is(err, social.ErrNudgeOnOwnTurn):
|
||||||
|
return http.StatusConflict, "nudge_own_turn"
|
||||||
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
||||||
return http.StatusConflict, "game_finished"
|
return http.StatusConflict, "game_finished"
|
||||||
case errors.Is(err, game.ErrGameActive):
|
case errors.Is(err, game.ErrGameActive):
|
||||||
@@ -198,9 +204,14 @@ func statusForError(err error) (int, string) {
|
|||||||
case errors.Is(err, session.ErrNotFound):
|
case errors.Is(err, session.ErrNotFound):
|
||||||
return http.StatusUnauthorized, "session_invalid"
|
return http.StatusUnauthorized, "session_invalid"
|
||||||
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
||||||
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
|
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent):
|
||||||
errors.Is(err, social.ErrNudgeTooSoon):
|
|
||||||
return http.StatusUnprocessableEntity, "chat_rejected"
|
return http.StatusUnprocessableEntity, "chat_rejected"
|
||||||
|
case errors.Is(err, social.ErrNudgeTooSoon):
|
||||||
|
// A too-frequent nudge is a distinct, non-content rejection — the UI must say
|
||||||
|
// "don't rush the player so often", not the chat content-rejection message.
|
||||||
|
return http.StatusConflict, "nudge_too_soon"
|
||||||
|
case errors.Is(err, social.ErrChatNotYourTurn):
|
||||||
|
return http.StatusConflict, "chat_not_your_turn"
|
||||||
case errors.Is(err, social.ErrSelfRelation):
|
case errors.Is(err, social.ErrSelfRelation):
|
||||||
return http.StatusBadRequest, "self_relation"
|
return http.StatusBadRequest, "self_relation"
|
||||||
case errors.Is(err, social.ErrRequestExists):
|
case errors.Is(err, social.ErrRequestExists):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/robot"
|
||||||
|
"scrabble/backend/internal/social"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminPageSize is the page size of the admin console's paginated lists.
|
// adminPageSize is the page size of the admin console's paginated lists.
|
||||||
@@ -49,6 +52,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.GET("/complaints", s.consoleComplaints)
|
gm.GET("/complaints", s.consoleComplaints)
|
||||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||||
|
gm.GET("/messages", s.consoleMessages)
|
||||||
gm.GET("/dictionary", s.consoleDictionary)
|
gm.GET("/dictionary", s.consoleDictionary)
|
||||||
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
|
||||||
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
|
||||||
@@ -75,26 +79,113 @@ func (s *Server) consoleDashboard(c *gin.Context) {
|
|||||||
func (s *Server) consoleUsers(c *gin.Context) {
|
func (s *Server) consoleUsers(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
page := consolePage(c)
|
page := consolePage(c)
|
||||||
total, _ := s.accounts.CountAccounts(ctx)
|
filter := account.UserFilter{
|
||||||
accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize)
|
Robots: c.Query("kind") == "robots",
|
||||||
|
NameMask: c.Query("name"),
|
||||||
|
ExternalIDMask: c.Query("ext"),
|
||||||
|
}
|
||||||
|
total, _ := s.accounts.CountUsers(ctx, filter)
|
||||||
|
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
|
q := url.Values{}
|
||||||
for _, a := range accs {
|
if filter.Robots {
|
||||||
|
q.Set("kind", "robots")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filter.NameMask) != "" {
|
||||||
|
q.Set("name", filter.NameMask)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filter.ExternalIDMask) != "" {
|
||||||
|
q.Set("ext", filter.ExternalIDMask)
|
||||||
|
}
|
||||||
|
view := adminconsole.UsersView{
|
||||||
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
|
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
|
||||||
|
FilterQuery: q.Encode(),
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
kind := "registered"
|
kind := "registered"
|
||||||
if a.IsGuest {
|
if it.IsRobot {
|
||||||
|
kind = "robot"
|
||||||
|
} else if it.IsGuest {
|
||||||
kind = "guest"
|
kind = "guest"
|
||||||
}
|
}
|
||||||
view.Items = append(view.Items, adminconsole.UserRow{
|
view.Items = append(view.Items, adminconsole.UserRow{
|
||||||
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
|
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
||||||
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
|
Language: it.PreferredLanguage, Guest: it.IsGuest, CreatedAt: fmtTime(it.CreatedAt),
|
||||||
})
|
})
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
}
|
||||||
|
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
|
||||||
|
for i := range view.Items {
|
||||||
|
if st, ok := stats[ids[i]]; ok && st.Moves > 0 {
|
||||||
|
view.Items[i].HasMoveStats = true
|
||||||
|
view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs)
|
||||||
|
view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs)
|
||||||
|
view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "users", "users", "Users", view)
|
s.renderConsole(c, "users", "users", "Users", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleMessages renders the paginated chat-message moderation list, optionally pinned to
|
||||||
|
// one game (?game=) or sender (?user=) and filtered by sender glob masks (?name / ?ext).
|
||||||
|
func (s *Server) consoleMessages(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
page := consolePage(c)
|
||||||
|
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
|
||||||
|
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
||||||
|
filter := social.AdminMessageFilter{
|
||||||
|
GameID: gameID,
|
||||||
|
SenderID: userID,
|
||||||
|
NameMask: c.Query("name"),
|
||||||
|
ExtMask: c.Query("ext"),
|
||||||
|
}
|
||||||
|
total, _ := s.social.AdminCountMessages(ctx, filter)
|
||||||
|
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
if filter.GameID != uuid.Nil {
|
||||||
|
q.Set("game", filter.GameID.String())
|
||||||
|
}
|
||||||
|
if filter.SenderID != uuid.Nil {
|
||||||
|
q.Set("user", filter.SenderID.String())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filter.NameMask) != "" {
|
||||||
|
q.Set("name", filter.NameMask)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filter.ExtMask) != "" {
|
||||||
|
q.Set("ext", filter.ExtMask)
|
||||||
|
}
|
||||||
|
view := adminconsole.MessagesView{
|
||||||
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
|
NameMask: filter.NameMask,
|
||||||
|
ExtMask: filter.ExtMask,
|
||||||
|
FilterQuery: q.Encode(),
|
||||||
|
}
|
||||||
|
if filter.GameID != uuid.Nil {
|
||||||
|
view.GameID = filter.GameID.String()
|
||||||
|
}
|
||||||
|
if filter.SenderID != uuid.Nil {
|
||||||
|
view.UserID = filter.SenderID.String()
|
||||||
|
}
|
||||||
|
for _, m := range items {
|
||||||
|
view.Items = append(view.Items, adminconsole.MessageRow{
|
||||||
|
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
|
||||||
|
Source: m.Source, IP: m.SenderIP, Body: m.Body,
|
||||||
|
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.renderConsole(c, "messages", "messages", "Messages", view)
|
||||||
|
}
|
||||||
|
|
||||||
// consoleUserDetail renders one account with its stats, identities and games.
|
// consoleUserDetail renders one account with its stats, identities and games.
|
||||||
func (s *Server) consoleUserDetail(c *gin.Context) {
|
func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
@@ -134,6 +225,13 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
view.Games = append(view.Games, gameRow(g))
|
view.Games = append(view.Games, gameRow(g))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 {
|
||||||
|
cps := make([]adminconsole.ChartPoint, len(pts))
|
||||||
|
for i, p := range pts {
|
||||||
|
cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs}
|
||||||
|
}
|
||||||
|
view.MoveChart = adminconsole.MoveDurationChart(cps)
|
||||||
|
}
|
||||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,16 +305,58 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
|
|||||||
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||||
FinishedAt: fmtTimePtr(g.FinishedAt),
|
FinishedAt: fmtTimePtr(g.FinishedAt),
|
||||||
}
|
}
|
||||||
|
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
||||||
|
// anchors the robot's sleep window for the next-move ETA.
|
||||||
|
oppTZ := ""
|
||||||
for _, seat := range g.Seats {
|
for _, seat := range g.Seats {
|
||||||
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
|
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
|
||||||
if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil {
|
acc, accErr := s.accounts.GetByID(ctx, seat.AccountID)
|
||||||
|
if accErr == nil {
|
||||||
row.DisplayName = acc.DisplayName
|
row.DisplayName = acc.DisplayName
|
||||||
}
|
}
|
||||||
|
if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot {
|
||||||
|
row.IsRobot = true
|
||||||
|
view.HasRobot = true
|
||||||
|
} else if accErr == nil {
|
||||||
|
oppTZ = acc.TimeZone
|
||||||
|
}
|
||||||
view.Seats = append(view.Seats, row)
|
view.Seats = append(view.Seats, row)
|
||||||
}
|
}
|
||||||
|
// For each robot seat, surface the game's deterministic play-to-win intent and — while
|
||||||
|
// it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed.
|
||||||
|
if view.HasRobot {
|
||||||
|
view.RobotTargetPct = robot.PlayToWinTargetPercent
|
||||||
|
if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for i := range view.Seats {
|
||||||
|
if !view.Seats[i].IsRobot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if robot.PlayToWin(seed) {
|
||||||
|
view.Seats[i].RobotIntent = "play to win"
|
||||||
|
} else {
|
||||||
|
view.Seats[i].RobotIntent = "play to lose"
|
||||||
|
}
|
||||||
|
if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat {
|
||||||
|
view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
s.renderConsole(c, "game_detail", "games", "Game", view)
|
s.renderConsole(c, "game_detail", "games", "Game", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a
|
||||||
|
// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)".
|
||||||
|
func robotETA(at, now time.Time) string {
|
||||||
|
mins := int(at.Sub(now).Round(time.Minute).Minutes())
|
||||||
|
rel := fmt.Sprintf("in ~%d min", mins)
|
||||||
|
if mins <= 0 {
|
||||||
|
rel = "due now"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel)
|
||||||
|
}
|
||||||
|
|
||||||
// consoleComplaints renders the paginated complaint review queue.
|
// consoleComplaints renders the paginated complaint review queue.
|
||||||
func (s *Server) consoleComplaints(c *gin.Context) {
|
func (s *Server) consoleComplaints(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type incomingListDTO struct {
|
|||||||
Requests []accountRefDTO `json:"requests"`
|
Requests []accountRefDTO `json:"requests"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// outgoingListDTO is the addressees the caller has already requested (a live pending
|
||||||
|
// request or one the addressee declined) and therefore cannot re-request.
|
||||||
|
type outgoingListDTO struct {
|
||||||
|
Requests []accountRefDTO `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
// friendCodeDTO is a freshly issued one-time friend code (returned once).
|
// friendCodeDTO is a freshly issued one-time friend code (returned once).
|
||||||
type friendCodeDTO struct {
|
type friendCodeDTO struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -218,6 +224,22 @@ func (s *Server) handleIncomingRequests(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOutgoingRequests returns the addressees the caller has already requested
|
||||||
|
// (pending or declined) and cannot re-request.
|
||||||
|
func (s *Server) handleOutgoingRequests(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, outgoingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
||||||
|
}
|
||||||
|
|
||||||
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
|
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
|
||||||
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
|
|||||||
@@ -318,6 +318,74 @@ func (s *Server) handleExportGCG(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// draftTileDTO is one tile a player has laid on the board but not yet submitted.
|
||||||
|
type draftTileDTO struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Blank bool `json:"blank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// draftDTO is a player's persisted client-side composition for a game (Stage 17): the
|
||||||
|
// preferred rack tile order (an opaque client string) and the board tiles laid but not yet
|
||||||
|
// submitted. The gateway forwards the JSON verbatim; this layer owns its shape.
|
||||||
|
type draftDTO struct {
|
||||||
|
RackOrder string `json:"rack_order"`
|
||||||
|
BoardTiles []draftTileDTO `json:"board_tiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// draftDTOFrom projects a stored draft into its wire DTO.
|
||||||
|
func draftDTOFrom(d game.Draft) draftDTO {
|
||||||
|
tiles := make([]draftTileDTO, 0, len(d.BoardTiles))
|
||||||
|
for _, t := range d.BoardTiles {
|
||||||
|
tiles = append(tiles, draftTileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||||
|
}
|
||||||
|
return draftDTO{RackOrder: d.RackOrder, BoardTiles: tiles}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDomain maps an inbound draft DTO to the domain draft.
|
||||||
|
func (d draftDTO) toDomain() game.Draft {
|
||||||
|
tiles := make([]game.DraftTile, 0, len(d.BoardTiles))
|
||||||
|
for _, t := range d.BoardTiles {
|
||||||
|
tiles = append(tiles, game.DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||||
|
}
|
||||||
|
return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetDraft returns the player's saved composition for a game (Stage 17), or an empty
|
||||||
|
// draft when none is stored.
|
||||||
|
func (s *Server) handleGetDraft(c *gin.Context) {
|
||||||
|
uid, gameID, ok := s.userGame(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := s.games.GetDraft(c.Request.Context(), gameID, uid)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, draftDTOFrom(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSaveDraft upserts the player's composition for a game (Stage 17). The service
|
||||||
|
// rejects a non-player with ErrNotAPlayer.
|
||||||
|
func (s *Server) handleSaveDraft(c *gin.Context) {
|
||||||
|
uid, gameID, ok := s.userGame(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req draftDTO
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
abortBadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.games.SaveDraft(c.Request.Context(), gameID, uid, req.toDomain()); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
// handleListGames returns the caller's active and finished games for the lobby.
|
// handleListGames returns the caller's active and finished games for the lobby.
|
||||||
func (s *Server) handleListGames(c *gin.Context) {
|
func (s *Server) handleListGames(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
|
|||||||
@@ -73,3 +73,28 @@ func TestSubmitPlayRejectsBadGameID(t *testing.T) {
|
|||||||
t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
|
t.Fatalf("submit play bad game id = %d, want 400", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetDraftRequiresUserID(t *testing.T) {
|
||||||
|
path := "/api/v1/user/games/" + uuid.New().String() + "/draft"
|
||||||
|
rec := do(t, newRoutingServer(), http.MethodGet, path, "", nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("get draft without X-User-ID = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDraftRejectsBadGameID(t *testing.T) {
|
||||||
|
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||||
|
rec := do(t, newRoutingServer(), http.MethodPut, "/api/v1/user/games/not-a-uuid/draft", `{"rack_order":"","board_tiles":[]}`, headers)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("save draft bad game id = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDraftRejectsBadBody(t *testing.T) {
|
||||||
|
headers := map[string]string{"X-User-ID": uuid.New().String()}
|
||||||
|
path := "/api/v1/user/games/" + uuid.New().String() + "/draft"
|
||||||
|
rec := do(t, newRoutingServer(), http.MethodPut, path, `not json`, headers)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("save draft bad body = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,20 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, dto)
|
c.JSON(http.StatusOK, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCancel removes the caller from the auto-match pool (and drops any pending
|
||||||
|
// matched result), so a cancelled quick-match neither blocks a re-queue nor later
|
||||||
|
// surfaces a robot-substituted game the player abandoned. It is idempotent: cancelling
|
||||||
|
// when not queued is a no-op success.
|
||||||
|
func (s *Server) handleCancel(c *gin.Context) {
|
||||||
|
uid, ok := userID(c)
|
||||||
|
if !ok {
|
||||||
|
abortBadRequest(c, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.matchmaker.Cancel(c.Request.Context(), uid)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// handlePoll reports whether the caller has been paired since queueing.
|
// handlePoll reports whether the caller has been paired since queueing.
|
||||||
func (s *Server) handlePoll(c *gin.Context) {
|
func (s *Server) handlePoll(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package social
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminMessage is one chat message in the admin moderation list (Stage 17): the message
|
||||||
|
// plus its sender's resolved display name and source, for the operator console.
|
||||||
|
type AdminMessage struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
GameID uuid.UUID
|
||||||
|
SenderID uuid.UUID
|
||||||
|
SenderName string
|
||||||
|
// Source is the sender's account kind: "guest", "robot", or its oldest identity kind
|
||||||
|
// (e.g. "email", "telegram"); "—" when it has none.
|
||||||
|
Source string
|
||||||
|
Body string
|
||||||
|
SenderIP string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMessageFilter narrows the admin message list. A nil GameID/SenderID leaves that
|
||||||
|
// field unfiltered; NameMask/ExtMask are glob masks (account.LikePattern) matched
|
||||||
|
// case-insensitively against the sender's display name / any identity's external id.
|
||||||
|
type AdminMessageFilter struct {
|
||||||
|
GameID uuid.UUID
|
||||||
|
SenderID uuid.UUID
|
||||||
|
NameMask string
|
||||||
|
ExtMask string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListMessages returns the filtered chat messages — real messages only, nudges
|
||||||
|
// excluded — newest first, paginated, for the admin moderation console.
|
||||||
|
func (svc *Service) AdminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
|
||||||
|
return svc.store.adminListMessages(ctx, f, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCountMessages counts the filtered chat messages, for the admin list pager.
|
||||||
|
func (svc *Service) AdminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) {
|
||||||
|
return svc.store.adminCountMessages(ctx, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminMessageSource is the SQL CASE projecting a sender's source: guest, robot, or its
|
||||||
|
// oldest identity kind ("—" when it has none).
|
||||||
|
const adminMessageSource = `CASE
|
||||||
|
WHEN a.is_guest THEN 'guest'
|
||||||
|
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
|
||||||
|
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
|
||||||
|
END`
|
||||||
|
|
||||||
|
// adminMessageWhere builds the shared WHERE clause and its positional args (from $1).
|
||||||
|
// Only real messages are listed; nudges are excluded.
|
||||||
|
func adminMessageWhere(f AdminMessageFilter) (string, []any) {
|
||||||
|
where := `m.kind = 'message'`
|
||||||
|
var args []any
|
||||||
|
if f.GameID != uuid.Nil {
|
||||||
|
args = append(args, f.GameID)
|
||||||
|
where += fmt.Sprintf(` AND m.game_id = $%d`, len(args))
|
||||||
|
}
|
||||||
|
if f.SenderID != uuid.Nil {
|
||||||
|
args = append(args, f.SenderID)
|
||||||
|
where += fmt.Sprintf(` AND m.sender_id = $%d`, len(args))
|
||||||
|
}
|
||||||
|
if name := account.LikePattern(f.NameMask); name != "" {
|
||||||
|
args = append(args, name)
|
||||||
|
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||||
|
}
|
||||||
|
if ext := account.LikePattern(f.ExtMask); ext != "" {
|
||||||
|
args = append(args, ext)
|
||||||
|
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||||
|
}
|
||||||
|
return where, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) adminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
|
||||||
|
where, args := adminMessageWhere(f)
|
||||||
|
q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at
|
||||||
|
FROM backend.chat_messages m
|
||||||
|
JOIN backend.accounts a ON a.account_id = m.sender_id
|
||||||
|
WHERE ` + where +
|
||||||
|
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("social: admin list messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []AdminMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m AdminMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("social: scan admin message: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) adminCountMessages(ctx context.Context, f AdminMessageFilter) (int, error) {
|
||||||
|
where, args := adminMessageWhere(f)
|
||||||
|
var n int
|
||||||
|
q := `SELECT COUNT(*) FROM backend.chat_messages m JOIN backend.accounts a ON a.account_id = m.sender_id WHERE ` + where
|
||||||
|
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("social: admin count messages: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -49,13 +49,22 @@ type Message struct {
|
|||||||
// rune limit, and free of links/emails/phone numbers (the content filter). The
|
// rune limit, and free of links/emails/phone numbers (the content filter). The
|
||||||
// gateway-forwarded senderIP is validated and stored for moderation.
|
// gateway-forwarded senderIP is validated and stored for moderation.
|
||||||
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
|
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
|
||||||
seats, _, _, err := svc.games.Participants(ctx, gameID)
|
seats, toMove, status, err := svc.games.Participants(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
if !slices.Contains(seats, senderID) {
|
idx := slices.Index(seats, senderID)
|
||||||
|
if idx < 0 {
|
||||||
return Message{}, ErrNotParticipant
|
return Message{}, ErrNotParticipant
|
||||||
}
|
}
|
||||||
|
// Chat is allowed only on the sender's own turn in an active game; the opponent's-turn
|
||||||
|
// control is the nudge (Stage 17).
|
||||||
|
if status != statusActive {
|
||||||
|
return Message{}, ErrGameNotActive
|
||||||
|
}
|
||||||
|
if idx != toMove {
|
||||||
|
return Message{}, ErrChatNotYourTurn
|
||||||
|
}
|
||||||
sender, err := svc.accounts.GetByID(ctx, senderID)
|
sender, err := svc.accounts.GetByID(ctx, senderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
@@ -105,7 +114,15 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
|||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
if ok && svc.now().Sub(last) < nudgeInterval {
|
if ok && svc.now().Sub(last) < nudgeInterval {
|
||||||
return Message{}, ErrNudgeTooSoon
|
// The cooldown resets once the sender has acted (moved or chatted) since the last
|
||||||
|
// nudge — engagement clears the "don't spam" limit (Stage 17).
|
||||||
|
acted, err := svc.actedSince(ctx, gameID, senderID, last)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, err
|
||||||
|
}
|
||||||
|
if !acted {
|
||||||
|
return Message{}, ErrNudgeTooSoon
|
||||||
|
}
|
||||||
}
|
}
|
||||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
|
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,6 +135,22 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
|||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actedSince reports whether senderID made a move or posted a chat message in the game
|
||||||
|
// after t — the events that reset the nudge cooldown (Stage 17).
|
||||||
|
func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) {
|
||||||
|
if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if ok && mv.After(t) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if ok && msg.After(t) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// emitChat pushes a chat message to every seated player except the sender
|
// emitChat pushes a chat message to every seated player except the sender
|
||||||
// (best-effort live delivery; the recipients still read it via Messages).
|
// (best-effort live delivery; the recipients still read it via Messages).
|
||||||
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
|
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
|
||||||
@@ -252,6 +285,27 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti
|
|||||||
return row.CreatedAt, true, nil
|
return row.CreatedAt, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lastMessageAt returns the time of senderID's most recent non-nudge chat message in
|
||||||
|
// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale
|
||||||
|
// nudge no longer blocks a new one (Stage 17).
|
||||||
|
func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
|
||||||
|
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
|
||||||
|
FROM(table.ChatMessages).
|
||||||
|
WHERE(
|
||||||
|
table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
|
||||||
|
AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
|
||||||
|
AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))),
|
||||||
|
).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
|
||||||
|
var row model.ChatMessages
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
return time.Time{}, false, fmt.Errorf("social: last message: %w", err)
|
||||||
|
}
|
||||||
|
return row.CreatedAt, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// messageFromRow projects a generated row into the public Message.
|
// messageFromRow projects a generated row into the public Message.
|
||||||
func messageFromRow(r model.ChatMessages) Message {
|
func messageFromRow(r model.ChatMessages) Message {
|
||||||
m := Message{
|
m := Message{
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ErrRequestNotFound
|
return ErrRequestNotFound
|
||||||
}
|
}
|
||||||
|
// Tell the original requester their request was answered, so a game screen watching
|
||||||
|
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
|
||||||
|
// -> stays "request sent").
|
||||||
|
if accept {
|
||||||
|
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded))
|
||||||
|
} else {
|
||||||
|
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +164,14 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI
|
|||||||
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
||||||
|
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
|
||||||
|
// permanently declined. The game's "add to friends" item reads it to stay disabled
|
||||||
|
// across reloads (a declined request reads identically to a still-pending one).
|
||||||
|
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||||
|
return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||||
|
}
|
||||||
|
|
||||||
// loadEdges returns every friendship row between a and b in either direction (at
|
// loadEdges returns every friendship row between a and b in either direction (at
|
||||||
// most one per direction). It feeds SendFriendRequest's re-send classification.
|
// most one per direction). It feeds SendFriendRequest's re-send classification.
|
||||||
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
|
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
|
||||||
@@ -294,6 +310,29 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listOutgoingRequests returns the addressees of the caller's requests that block a
|
||||||
|
// re-send: a live (created after cutoff) pending request, or a permanently declined
|
||||||
|
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
|
||||||
|
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
|
||||||
|
stmt := postgres.SELECT(table.Friendships.AddresseeID).
|
||||||
|
FROM(table.Friendships).
|
||||||
|
WHERE(
|
||||||
|
table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
|
||||||
|
OR(table.Friendships.Status.EQ(postgres.String(friendPending)).
|
||||||
|
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))),
|
||||||
|
)
|
||||||
|
var rows []model.Friendships
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("social: list outgoing requests: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]uuid.UUID, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, r.AddresseeID)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// edgeEither matches a friendship row between a and b in either direction.
|
// edgeEither matches a friendship row between a and b in either direction.
|
||||||
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
||||||
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type GameReader interface {
|
|||||||
// SharedGame reports whether two accounts are seated together in any game
|
// SharedGame reports whether two accounts are seated together in any game
|
||||||
// (active or finished); it gates the "befriend an opponent" request path.
|
// (active or finished); it gates the "befriend an opponent" request path.
|
||||||
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
|
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||||
|
// LastMoveAt is the time of an account's most recent move in a game (and whether it
|
||||||
|
// has moved); the nudge cooldown resets once the player has taken a turn.
|
||||||
|
LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sentinel errors returned by the service.
|
// Sentinel errors returned by the service.
|
||||||
@@ -67,6 +70,10 @@ var (
|
|||||||
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
|
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
|
||||||
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
|
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
|
||||||
ErrGameNotActive = errors.New("social: game is not active")
|
ErrGameNotActive = errors.New("social: game is not active")
|
||||||
|
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the
|
||||||
|
// sender's turn — chat is allowed only on your own turn (the opponent's-turn control
|
||||||
|
// is the nudge, Stage 17).
|
||||||
|
ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service is the social domain. It is the only writer of the friendships, blocks
|
// Service is the social domain. It is the only writer of the friendships, blocks
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Environment for deploy/docker-compose.yml. The CI deploy job (ci.yaml) maps the
|
||||||
|
# Gitea TEST_-prefixed secrets/variables onto these unprefixed names; Stage 18
|
||||||
|
# maps the PROD_-prefixed set the same way. Copy to deploy/.env for a local run.
|
||||||
|
#
|
||||||
|
# Full reference (required vs optional, defaults, secret-vs-variable): deploy/README.md.
|
||||||
|
|
||||||
|
# --- Postgres ---------------------------------------------------------------
|
||||||
|
POSTGRES_DB=scrabble
|
||||||
|
POSTGRES_USER=scrabble
|
||||||
|
POSTGRES_PASSWORD=change-me # required
|
||||||
|
|
||||||
|
# --- Dictionary -------------------------------------------------------------
|
||||||
|
DICT_VERSION=v1.0.0 # scrabble-dictionary release tag (image build-arg)
|
||||||
|
|
||||||
|
# --- Logging ----------------------------------------------------------------
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# --- Edge / caddy -----------------------------------------------------------
|
||||||
|
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
||||||
|
# external `edge` network). Prod (Stage 18): a domain so caddy does its own ACME.
|
||||||
|
CADDY_SITE_ADDRESS=:80
|
||||||
|
GM_BASICAUTH_USER=gm
|
||||||
|
GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt hash
|
||||||
|
|
||||||
|
# --- UI build args (baked into the gateway image) ---------------------------
|
||||||
|
VITE_TELEGRAM_BOT_ID=
|
||||||
|
VITE_TELEGRAM_LINK=
|
||||||
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot
|
||||||
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
|
||||||
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
|
# --- Gateway ----------------------------------------------------------------
|
||||||
|
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES=en,ru
|
||||||
|
|
||||||
|
# --- Grafana ----------------------------------------------------------------
|
||||||
|
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
|
||||||
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
# --- Telegram connector -----------------------------------------------------
|
||||||
|
AWG_CONF= # required; AmneziaWG sidecar config
|
||||||
|
TELEGRAM_BOT_TOKEN_EN= # at least one of EN/RU required
|
||||||
|
TELEGRAM_BOT_TOKEN_RU=
|
||||||
|
TELEGRAM_GAME_CHANNEL_ID_EN=
|
||||||
|
TELEGRAM_GAME_CHANNEL_ID_RU=
|
||||||
|
TELEGRAM_MINIAPP_URL= # required
|
||||||
|
TELEGRAM_TEST_ENV=false
|
||||||
|
TELEGRAM_API_BASE_URL=
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# deploy
|
||||||
|
|
||||||
|
The full Scrabble contour: `backend` + `gateway` + Postgres + the Telegram
|
||||||
|
connector (with a VPN sidecar) + the observability stack (OTel Collector →
|
||||||
|
Prometheus + Tempo → Grafana), fronted by a **caddy** that owns a single `/_gm`
|
||||||
|
Basic-Auth (the admin console + Grafana). Topology and the decision record are in
|
||||||
|
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §13; this file is the
|
||||||
|
operational reference for **every environment variable**.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Image | Role |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; everything else → gateway. TLS per `CADDY_SITE_ADDRESS`. |
|
||||||
|
| `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded landing at `/` and the game SPA at `/app/` + `/telegram/`; Connect-RPC edge. |
|
||||||
|
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
|
||||||
|
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
|
||||||
|
| `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. |
|
||||||
|
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
|
||||||
|
| `prometheus` | `prom/prometheus` | Metrics, 15d retention. |
|
||||||
|
| `tempo` | `grafana/tempo` | Traces, 72h retention. |
|
||||||
|
| `grafana` | `grafana/grafana` | Dashboards (provisioned), anonymous-admin behind caddy's `/_gm/grafana`. |
|
||||||
|
|
||||||
|
Networking: inter-service traffic is on the private `internal` network
|
||||||
|
(project-scoped DNS); only `caddy` joins the shared external `edge` network so the
|
||||||
|
host caddy can reach it at `scrabble:80`. `edge` must already exist on the host
|
||||||
|
(`docker network create edge`).
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
**Locally** — copy the template, fill the required values, bring it up:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp deploy/.env.example deploy/.env # then edit deploy/.env
|
||||||
|
docker network create edge # once, if it does not exist
|
||||||
|
cd deploy && docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**In CI** (the test contour) — `.gitea/workflows/ci.yaml`'s `deploy` job maps the
|
||||||
|
Gitea **`TEST_`-prefixed** secrets/variables onto the unprefixed names below and
|
||||||
|
runs `docker compose up -d --build` on the runner host. Stage 18 (prod) maps the
|
||||||
|
**`PROD_`** set the same way. So a Gitea secret named `TEST_POSTGRES_PASSWORD`
|
||||||
|
feeds the compose's `POSTGRES_PASSWORD`, etc.
|
||||||
|
|
||||||
|
The deploy job also **seeds the config files** (`caddy`, `otelcol`, `prometheus`,
|
||||||
|
`tempo`, `grafana`) to a stable host path (`$HOME/.scrabble-deploy`) and sets
|
||||||
|
`SCRABBLE_CONFIG_DIR` to it before `up`. The runner's checkout is an ephemeral act
|
||||||
|
workspace that is removed after the job — binding config straight from it would
|
||||||
|
dangle the mounts in the long-lived containers (Grafana would log
|
||||||
|
`no such file or directory`). Locally `SCRABBLE_CONFIG_DIR` defaults to `.`, so the
|
||||||
|
compose binds from this directory.
|
||||||
|
|
||||||
|
## Required variables
|
||||||
|
|
||||||
|
`docker compose` aborts immediately if any of these is unset (they use `:?`):
|
||||||
|
|
||||||
|
| Variable | Gitea kind | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
||||||
|
| `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the connector's only egress). **Must not contain a `DNS=` line** — it hijacks the shared netns's resolv.conf and breaks the connector resolving `otelcol` (telemetry export). Without it, Docker's resolver handles both `otelcol` and `api.telegram.org`. |
|
||||||
|
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||||
|
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the connector hands out in deep links / buttons. |
|
||||||
|
|
||||||
|
**Plus at least one bot token** — `TELEGRAM_BOT_TOKEN_EN` or `TELEGRAM_BOT_TOKEN_RU`
|
||||||
|
(secrets). Compose cannot express "one of", so they default to empty, but the
|
||||||
|
connector **fails at boot** if both are empty.
|
||||||
|
|
||||||
|
## Optional variables (with defaults)
|
||||||
|
|
||||||
|
| Variable | Gitea kind | Default | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||||
|
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||||
|
| `DICT_VERSION` | variable | `v1.0.0` | `scrabble-dictionary` release tag baked into the backend image (build-arg). |
|
||||||
|
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / connector (`debug\|info\|warn\|error`). |
|
||||||
|
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||||
|
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||||
|
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
|
||||||
|
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
|
||||||
|
| `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English game-channel id; empty/`0` disables channel posts. |
|
||||||
|
| `TELEGRAM_GAME_CHANNEL_ID_RU` | variable | _(empty)_ | Russian game-channel id; empty/`0` disables channel posts. |
|
||||||
|
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod (Stage 18) leaves it `false`. |
|
||||||
|
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
||||||
|
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
|
||||||
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). |
|
||||||
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
|
||||||
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
|
||||||
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
|
|
||||||
|
The five `VITE_*` are **build-args** baked into the gateway image at build time, so
|
||||||
|
changing them requires a rebuild (`--build`), not just a restart.
|
||||||
|
|
||||||
|
## Fixed internal wiring (not operator-set)
|
||||||
|
|
||||||
|
These are hard-wired in `docker-compose.yml` (no `${...}`), pointing the services
|
||||||
|
at each other on the `internal` network — listed here so they are not mistaken for
|
||||||
|
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
||||||
|
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
||||||
|
`GATEWAY_CONNECTOR_ADDR`/`BACKEND_CONNECTOR_ADDR` (→ `telegram:9091`), and all three
|
||||||
|
services' `*_OTEL_*_EXPORTER=otlp` → `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
|
||||||
|
(`_INSECURE=true`). The connector shares the VPN sidecar's netns: routing to the
|
||||||
|
collector's internal IP is fine (connected route), but its `AWG_CONF` must **not**
|
||||||
|
set a `DNS=` directive — that hijacks resolv.conf and breaks resolving `otelcol`
|
||||||
|
("produced zero addresses"); without it the netns uses Docker's resolver, which
|
||||||
|
resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentionally
|
||||||
|
**unset** — caddy owns `/_gm` in the contour.
|
||||||
|
|
||||||
|
## Host-side setup (outside this repo)
|
||||||
|
|
||||||
|
- **`edge` network** must exist on the host (`docker network create edge`).
|
||||||
|
- **Host caddy** route `<domain> → scrabble:80` (the in-compose caddy serves HTTP
|
||||||
|
in the test contour; the host caddy terminates TLS). Not needed on prod, where the
|
||||||
|
contour caddy owns TLS (set `CADDY_SITE_ADDRESS` to the domain).
|
||||||
|
- **Branch protection** requires the single status check `CI / gate` (Stage 17).
|
||||||
|
The `unit` / `integration` / `ui` jobs are path-conditional (they skip when their
|
||||||
|
code did not change), and the always-running `gate` job aggregates them (passing
|
||||||
|
when each succeeded or was skipped), so a skipped job never blocks a merge. See
|
||||||
|
[`../CLAUDE.md`](../CLAUDE.md) "Branching & CI".
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
||||||
|
# every operator surface under /_gm (the backend-rendered admin console and the
|
||||||
|
# Grafana subpath); everything else (the SPA at / and /telegram/, plus the
|
||||||
|
# Connect edge) goes to the gateway. Mirrors ../galaxy-game's /_gm model.
|
||||||
|
#
|
||||||
|
# CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS
|
||||||
|
# and forwards); set it to a domain in prod (Stage 18) so this caddy does its own
|
||||||
|
# ACME and the contour is self-contained.
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
{$CADDY_SITE_ADDRESS::80} {
|
||||||
|
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
|
||||||
|
@gm path /_gm /_gm/*
|
||||||
|
handle @gm {
|
||||||
|
basic_auth {
|
||||||
|
{$GM_BASICAUTH_USER:gm} {$GM_BASICAUTH_HASH}
|
||||||
|
}
|
||||||
|
# Grafana serves from this sub-path (GF_SERVER_SERVE_FROM_SUB_PATH=true), so
|
||||||
|
# the prefix is forwarded intact, not stripped.
|
||||||
|
handle /_gm/grafana* {
|
||||||
|
reverse_proxy grafana:3000
|
||||||
|
}
|
||||||
|
# Everything else under /_gm is the backend-rendered admin console.
|
||||||
|
handle {
|
||||||
|
reverse_proxy backend:8080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The SPA (/, /telegram/) and the Connect edge are served by the gateway.
|
||||||
|
handle {
|
||||||
|
reverse_proxy gateway:8081
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Full deploy descriptor for the Scrabble test contour: backend + gateway +
|
||||||
|
# Postgres + the Telegram connector (with its VPN sidecar) + the observability
|
||||||
|
# stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
|
||||||
|
# .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are
|
||||||
|
# interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy
|
||||||
|
# job (see deploy/.env.example for the unprefixed names).
|
||||||
|
#
|
||||||
|
# Config bind sources are prefixed with ${SCRABBLE_CONFIG_DIR:-.}: locally they bind
|
||||||
|
# straight from this directory, but CI seeds them to a stable host path and sets
|
||||||
|
# SCRABBLE_CONFIG_DIR to it, because the runner's checkout is ephemeral (act removes
|
||||||
|
# it after the job) and the bind mounts must outlive the job in the long-running
|
||||||
|
# containers (see .gitea/workflows/ci.yaml + deploy/README.md).
|
||||||
|
#
|
||||||
|
# Networking (mirrors ../galaxy-game):
|
||||||
|
# - `internal` (scrabble-internal): all inter-service traffic, project-private
|
||||||
|
# DNS so service names never collide on the shared `edge` network.
|
||||||
|
# - `edge` (external): the host caddy reaches this contour at `scrabble:80`
|
||||||
|
# (the in-compose caddy's alias). The in-compose caddy terminates only HTTP in
|
||||||
|
# the test contour; the host caddy terminates TLS and forwards. For prod
|
||||||
|
# (Stage 18, no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
|
||||||
|
# does its own ACME — the contour is then self-contained.
|
||||||
|
# - The connector egresses to api.telegram.org through the `vpn` sidecar
|
||||||
|
# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`.
|
||||||
|
name: scrabble
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
container_name: scrabble-postgres
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-scrabble}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-scrabble}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scrabble} -d ${POSTGRES_DB:-scrabble}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: scrabble-backend
|
||||||
|
image: scrabble-backend:latest
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
args:
|
||||||
|
DICT_VERSION: ${DICT_VERSION:-v1.0.0}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# search_path=backend matches the migrations (00001 creates the schema).
|
||||||
|
BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
|
||||||
|
BACKEND_HTTP_ADDR: ":8080"
|
||||||
|
BACKEND_GRPC_ADDR: ":9090"
|
||||||
|
BACKEND_CONNECTOR_ADDR: telegram:9091
|
||||||
|
BACKEND_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
BACKEND_SERVICE_NAME: scrabble-backend
|
||||||
|
BACKEND_OTEL_TRACES_EXPORTER: otlp
|
||||||
|
BACKEND_OTEL_METRICS_EXPORTER: otlp
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||||
|
# No container healthcheck: the distroless image has no shell/wget. Readiness
|
||||||
|
# is covered by the CI post-deploy probe (GET / through caddy).
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
container_name: scrabble-gateway
|
||||||
|
image: scrabble-gateway:latest
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: gateway/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||||
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
|
||||||
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
|
||||||
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [backend]
|
||||||
|
environment:
|
||||||
|
GATEWAY_HTTP_ADDR: ":8081"
|
||||||
|
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||||
|
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||||
|
GATEWAY_CONNECTOR_ADDR: telegram:9091
|
||||||
|
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${GATEWAY_DEFAULT_SUPPORTED_LANGUAGES:-en,ru}
|
||||||
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
GATEWAY_SERVICE_NAME: scrabble-gateway
|
||||||
|
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
||||||
|
GATEWAY_OTEL_METRICS_EXPORTER: otlp
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||||
|
# GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front
|
||||||
|
# caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly.
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
# --- Telegram connector (egress via the VPN sidecar) -----------------------
|
||||||
|
vpn:
|
||||||
|
container_name: scrabble-telegram-vpn
|
||||||
|
image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
AWG_CONF: ${AWG_CONF:?set AWG_CONF}
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
aliases: [telegram]
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
container_name: scrabble-telegram
|
||||||
|
image: scrabble-telegram:latest
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: platform/telegram/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [vpn]
|
||||||
|
network_mode: "service:vpn"
|
||||||
|
environment:
|
||||||
|
# The bot tokens live ONLY in this container (ARCHITECTURE.md §12). At least
|
||||||
|
# one token is required (the connector validates this at boot).
|
||||||
|
TELEGRAM_BOT_TOKEN_EN: ${TELEGRAM_BOT_TOKEN_EN:-}
|
||||||
|
TELEGRAM_BOT_TOKEN_RU: ${TELEGRAM_BOT_TOKEN_RU:-}
|
||||||
|
TELEGRAM_GAME_CHANNEL_ID_EN: ${TELEGRAM_GAME_CHANNEL_ID_EN:-}
|
||||||
|
TELEGRAM_GAME_CHANNEL_ID_RU: ${TELEGRAM_GAME_CHANNEL_ID_RU:-}
|
||||||
|
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
||||||
|
TELEGRAM_GRPC_ADDR: ":9091"
|
||||||
|
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
|
||||||
|
TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
|
||||||
|
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
TELEGRAM_SERVICE_NAME: scrabble-telegram
|
||||||
|
# The connector shares the VPN sidecar's netns. Routing to the collector's
|
||||||
|
# internal IP stays off the tunnel (connected route), but the sidecar's DNS
|
||||||
|
# hijacks name resolution: AWG_CONF must NOT carry a `DNS=` directive, else
|
||||||
|
# `otelcol` won't resolve ("produced zero addresses"). Without DNS= the netns
|
||||||
|
# uses Docker's resolver, which resolves both otelcol and api.telegram.org
|
||||||
|
# (see deploy/README.md).
|
||||||
|
TELEGRAM_OTEL_TRACES_EXPORTER: otlp
|
||||||
|
TELEGRAM_OTEL_METRICS_EXPORTER: otlp
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||||
|
|
||||||
|
# --- Edge reverse proxy (single /_gm Basic-Auth; SPA + Connect -> gateway) --
|
||||||
|
caddy:
|
||||||
|
container_name: scrabble-caddy
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [gateway, backend, grafana]
|
||||||
|
environment:
|
||||||
|
# Test: ":80" (host caddy terminates TLS). Prod: a domain for own ACME.
|
||||||
|
CADDY_SITE_ADDRESS: ${CADDY_SITE_ADDRESS:-:80}
|
||||||
|
GM_BASICAUTH_USER: ${GM_BASICAUTH_USER:-gm}
|
||||||
|
GM_BASICAUTH_HASH: ${GM_BASICAUTH_HASH:?set GM_BASICAUTH_HASH}
|
||||||
|
volumes:
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
networks:
|
||||||
|
internal: {}
|
||||||
|
edge:
|
||||||
|
aliases: [scrabble]
|
||||||
|
|
||||||
|
# --- Observability ---------------------------------------------------------
|
||||||
|
otelcol:
|
||||||
|
container_name: scrabble-otelcol
|
||||||
|
image: otel/opentelemetry-collector-contrib:0.119.0
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["--config=/etc/otelcol/config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/otelcol/config.yaml:/etc/otelcol/config.yaml:ro
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
container_name: scrabble-prometheus
|
||||||
|
image: prom/prometheus:v2.55.1
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.retention.time=15d
|
||||||
|
volumes:
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
tempo:
|
||||||
|
container_name: scrabble-tempo
|
||||||
|
image: grafana/tempo:2.7.1
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["-config.file=/etc/tempo/tempo.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
|
||||||
|
- tempo-data:/var/tempo
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
container_name: scrabble-grafana
|
||||||
|
image: grafana/grafana:11.4.0
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [prometheus, tempo]
|
||||||
|
environment:
|
||||||
|
# Served under /_gm/grafana behind caddy's Basic-Auth; anonymous Admin so a
|
||||||
|
# single shared login (caddy) gates it with no per-user Grafana accounts.
|
||||||
|
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-/_gm/grafana/}
|
||||||
|
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||||
|
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||||
|
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
||||||
|
GF_AUTH_BASIC_ENABLED: "false"
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||||
|
# Disable Grafana Live: its WebSocket (/_gm/grafana/api/live/ws) otherwise hits
|
||||||
|
# caddy's Basic-Auth and re-prompts for the password on every dashboard; the
|
||||||
|
# dashboards poll and do not need Live.
|
||||||
|
GF_LIVE_MAX_CONNECTIONS: "0"
|
||||||
|
volumes:
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
# Dashboards live under /etc/grafana (NOT /var/lib/grafana, which the
|
||||||
|
# grafana-data volume mounts over — a nested bind there is shadowed and the
|
||||||
|
# provider logs "no such file or directory").
|
||||||
|
- ${SCRABBLE_CONFIG_DIR:-.}/grafana/dashboards:/etc/grafana/dashboards:ro
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
networks: [internal]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
name: scrabble-internal
|
||||||
|
edge:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
caddy-data:
|
||||||
|
prometheus-data:
|
||||||
|
tempo-data:
|
||||||
|
grafana-data:
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"uid": "scrabble-edge",
|
||||||
|
"title": "Scrabble — Edge / UX",
|
||||||
|
"tags": ["scrabble"],
|
||||||
|
"timezone": "",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Edge request rate by message type",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count[5m])) by (message_type)", "legendFormat": "{{message_type}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Edge p95 latency",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(edge_request_duration_bucket[5m])) by (le))", "legendFormat": "p95" },
|
||||||
|
{ "refId": "B", "expr": "histogram_quantile(0.50, sum(rate(edge_request_duration_bucket[5m])) by (le))", "legendFormat": "p50" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Edge requests by result",
|
||||||
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count[5m])) by (result)", "legendFormat": "{{result}}" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"uid": "scrabble-game",
|
||||||
|
"title": "Scrabble — Game domain",
|
||||||
|
"tags": ["scrabble"],
|
||||||
|
"timezone": "",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 2,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-24h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Games started / abandoned (rate by variant)",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "sum(rate(games_started_total[15m])) by (variant)", "legendFormat": "started {{variant}}" },
|
||||||
|
{ "refId": "B", "expr": "sum(rate(games_abandoned_total[15m])) by (variant)", "legendFormat": "abandoned {{variant}}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Robot games finished (rate)",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(robot_games_finished_total[15m]))", "legendFormat": "robot games" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Live games in cache (by variant)",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(game_cache_active) by (variant)", "legendFormat": "{{variant}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Chat messages (rate by kind)",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(chat_messages_total[15m])) by (kind)", "legendFormat": "{{kind}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Journal replay p95 (by variant)",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(game_replay_duration_bucket[5m])) by (le, variant))", "legendFormat": "{{variant}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Move validate p95 (by variant)",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "histogram_quantile(0.95, sum(rate(game_move_validate_duration_bucket[5m])) by (le, variant))", "legendFormat": "{{variant}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Move think-time by phase (p50 / p95)",
|
||||||
|
"description": "Seconds a seat spent on a committed move, by game phase. Aggregates all seats including robots; per-human analysis is in the admin console.",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "histogram_quantile(0.5, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p50 {{phase}}" },
|
||||||
|
{ "refId": "B", "expr": "histogram_quantile(0.95, sum(rate(game_move_duration_bucket[15m])) by (le, phase))", "legendFormat": "p95 {{phase}}" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"uid": "scrabble-overview",
|
||||||
|
"title": "Scrabble — Service overview",
|
||||||
|
"tags": ["scrabble"],
|
||||||
|
"timezone": "",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Active users (24h)",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "max(active_users{window=\"24h\"})" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Active users (7d)",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "max(active_users{window=\"7d\"})" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Edge requests/s",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count[5m]))" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Edge error ratio",
|
||||||
|
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(edge_request_duration_count{result!=\"ok\"}[5m])) / clamp_min(sum(rate(edge_request_duration_count[5m])), 1)" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Goroutines by service",
|
||||||
|
"description": "OTel Go runtime metric; verify the exact name against live Prometheus if empty (go_goroutine_count / process_runtime_go_goroutines depending on the contrib runtime version).",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "go_goroutine_count", "legendFormat": "{{service_name}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Heap memory used by service",
|
||||||
|
"description": "OTel Go runtime metric (best-effort name go_memory_used); verify against live Prometheus if empty.",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"uid": "scrabble-users",
|
||||||
|
"title": "Scrabble — Users",
|
||||||
|
"tags": ["scrabble"],
|
||||||
|
"timezone": "",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-7d", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Active users (in-memory, single gateway)",
|
||||||
|
"description": "Distinct accounts with an authenticated action within the window. Resets on gateway restart; correct for a single instance (MVP).",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "max(active_users) by (window)", "legendFormat": "{{window}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "New accounts (rate by kind)",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(rate(accounts_created_total[1h])) by (kind)", "legendFormat": "{{kind}}" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "New accounts (cumulative by kind)",
|
||||||
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Loads the committed dashboard JSON from /var/lib/grafana/dashboards (mounted
|
||||||
|
# read-only from deploy/grafana/dashboards).
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: scrabble
|
||||||
|
orgId: 1
|
||||||
|
folder: Scrabble
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/dashboards
|
||||||
|
foldersFromFilesStructure: false
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Grafana datasources for the Scrabble contour, provisioned at startup. Metrics
|
||||||
|
# come from Prometheus (scraping the collector) and traces from Tempo.
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
- name: Tempo
|
||||||
|
type: tempo
|
||||||
|
uid: tempo
|
||||||
|
access: proxy
|
||||||
|
url: http://tempo:3200
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# OpenTelemetry Collector for the Scrabble contour. Receives OTLP/gRPC from the
|
||||||
|
# three services (backend, gateway, connector — pkg/telemetry exports OTLP only),
|
||||||
|
# fans metrics out to a Prometheus scrape endpoint and traces to Tempo.
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
processors:
|
||||||
|
batch: {}
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
# Exposes the collected metrics for Prometheus to scrape (otelcol:9464/metrics).
|
||||||
|
# add_metric_suffixes:false keeps the instrument names verbatim (no _seconds /
|
||||||
|
# _total unit/type suffixes) so the dashboards' PromQL matches the names defined
|
||||||
|
# in code; resource_to_telemetry_conversion promotes service.name to a label.
|
||||||
|
prometheus:
|
||||||
|
endpoint: 0.0.0.0:9464
|
||||||
|
add_metric_suffixes: false
|
||||||
|
resource_to_telemetry_conversion:
|
||||||
|
enabled: true
|
||||||
|
# Forwards traces to Tempo's OTLP ingest.
|
||||||
|
otlp/tempo:
|
||||||
|
endpoint: tempo:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [batch]
|
||||||
|
exporters: [otlp/tempo]
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [batch]
|
||||||
|
exporters: [prometheus]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Prometheus scrape config for the Scrabble contour. The OTel Collector exposes
|
||||||
|
# every service's metrics on its prometheus exporter; Prometheus scrapes that one
|
||||||
|
# endpoint. Retention (15d) is set on the command line in docker-compose.yml.
|
||||||
|
global:
|
||||||
|
scrape_interval: 30s
|
||||||
|
evaluation_interval: 30s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: otelcol
|
||||||
|
static_configs:
|
||||||
|
- targets: ["otelcol:9464"]
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9090"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Tempo for the Scrabble contour: single-binary, local filesystem storage, OTLP
|
||||||
|
# ingest from the collector, 72h block retention.
|
||||||
|
server:
|
||||||
|
http_listen_port: 3200
|
||||||
|
|
||||||
|
distributor:
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
ingester:
|
||||||
|
max_block_duration: 5m
|
||||||
|
|
||||||
|
compactor:
|
||||||
|
compaction:
|
||||||
|
block_retention: 72h
|
||||||
|
|
||||||
|
storage:
|
||||||
|
trace:
|
||||||
|
backend: local
|
||||||
|
local:
|
||||||
|
path: /var/tempo/blocks
|
||||||
|
wal:
|
||||||
|
path: /var/tempo/wal
|
||||||
+120
-41
@@ -235,7 +235,10 @@ Key points:
|
|||||||
applying the end-game rack-value adjustment, or a resignation. On a
|
applying the end-game rack-value adjustment, or a resignation. On a
|
||||||
**resignation the resigner keeps their accumulated score (no rack adjustment)
|
**resignation the resigner keeps their accumulated score (no rack adjustment)
|
||||||
and never wins**: the win goes to the highest score among the remaining seats,
|
and never wins**: the win goes to the highest score among the remaining seats,
|
||||||
unconditionally the other player in a two-player game. The engine exposes a
|
unconditionally the other player in a two-player game. A player may resign **on the
|
||||||
|
opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)`
|
||||||
|
resigns that player's own seat whoever is to move, and the game domain skips the turn
|
||||||
|
check for resign (Stage 17). The engine exposes a
|
||||||
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
|
||||||
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
`HintView`/`Hand`) so `internal/game` drives it without importing the solver.
|
||||||
- The **game domain** (`internal/game`) owns everything the engine does not —
|
- The **game domain** (`internal/game`) owns everything the engine does not —
|
||||||
@@ -300,10 +303,17 @@ The robot keeps **no per-game state**: every choice is derived deterministically
|
|||||||
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
|
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
|
||||||
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
|
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
|
||||||
behaviour on every scan and after a restart — the same philosophy as journal
|
behaviour on every scan and after a restart — the same philosophy as journal
|
||||||
replay. A pool of durable accounts — each a `kind='robot'` identity (§4),
|
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
|
||||||
provisioned at startup with chat and friend requests blocked — backs the
|
`robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend
|
||||||
human-like name pool; those two profile toggles are all the friend/DM blocking
|
requests open** — a request to a robot is accepted as pending and expires unanswered
|
||||||
requires (there is no DM surface; chat is per-game).
|
(the robot never responds), mirroring a human who ignores it (Stage 17); the chat
|
||||||
|
block backs the human-like names (there is no DM surface; chat is per-game). Names are
|
||||||
|
**composed per language** from a first-name pool (32 full + 32 colloquial forms) and
|
||||||
|
a surname pool (gender-agreed for Russian) in one of three forms (first only /
|
||||||
|
first + surname initial / first + full surname), deterministically per pool slot so
|
||||||
|
they stay stable across restarts. Substitution is **variant-aware**: a Russian game
|
||||||
|
(Russian Scrabble or Эрудит) draws a Russian-named robot with at most ~20% Latin, an
|
||||||
|
English game the Latin pool.
|
||||||
|
|
||||||
- **Balance**: at game start it decides once whether to play to win, with
|
- **Balance**: at game start it decides once whether to play to win, with
|
||||||
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
|
`P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
|
||||||
@@ -313,16 +323,20 @@ requires (there is no DM surface; chat is per-game).
|
|||||||
(playing to lose) is closest to a small band (**1–30 points**), rather than
|
(playing to lose) is closest to a small band (**1–30 points**), rather than
|
||||||
always the maximum; with no legal play it exchanges a full rack when the bag can
|
always the maximum; with no legal play it exchanges a full rack when the bag can
|
||||||
refill it, else passes.
|
refill it, else passes.
|
||||||
- **Timing**: per-move delay sampled from a right-skewed distribution (short
|
- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample
|
||||||
delays frequent, median ≈ 10 min), clamped to **[2, 90] minutes**; it
|
(exponent k=4, short delays frequent) from a band that interpolates from
|
||||||
|
**[3, 10] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
|
||||||
|
quick and the endgame can run long, clamped to **[1, 90] minutes**; it
|
||||||
**sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a
|
**sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a
|
||||||
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
|
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
|
||||||
rather than running anti-phase; on a daytime nudge it replies within
|
rather than running anti-phase; on a daytime nudge it replies near the move's lower
|
||||||
**2–10 minutes**; it proactively nudges the human after **12 hours** idle
|
band; it proactively nudges the human after **12 hours** idle (subject to the
|
||||||
(subject to the once-per-hour chat limit).
|
once-per-hour chat limit).
|
||||||
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
|
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
|
||||||
authoritative balance metric (target ≈ 40% robot wins) — and a
|
authoritative balance metric (target ≈ 40% robot wins) — and a
|
||||||
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
|
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
|
||||||
|
The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
|
||||||
|
the seed) and, on the robot's turn, its deterministic **next-move ETA** (Stage 17).
|
||||||
|
|
||||||
## 8. Lobby & social
|
## 8. Lobby & social
|
||||||
|
|
||||||
@@ -334,6 +348,9 @@ requires (there is no DM surface; chat is per-game).
|
|||||||
robot (§7) and starts the game. On a pairing or substitution the matchmaker
|
robot (§7) and starts the game. On a pairing or substitution the matchmaker
|
||||||
emits a **match-found** notification (§10), delivered over the live stream;
|
emits a **match-found** notification (§10), delivered over the live stream;
|
||||||
`Poll` remains as a fallback for a client that is not currently streaming.
|
`Poll` remains as a fallback for a client that is not currently streaming.
|
||||||
|
**Cancel** (`POST /lobby/cancel`) removes the player from the pool and drops any
|
||||||
|
pending matched result, so a cancelled quick-match is dequeued rather than left for
|
||||||
|
the reaper to robot-substitute (Stage 17).
|
||||||
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
|
- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time
|
||||||
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
||||||
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||||
@@ -358,7 +375,11 @@ requires (there is no DM surface; chat is per-game).
|
|||||||
lightly obfuscated forms) are rejected, since the chat is for quick reactions,
|
lightly obfuscated forms) are rejected, since the chat is for quick reactions,
|
||||||
not contact exchange. Each message stores the sender's IP (forwarded by the
|
not contact exchange. Each message stores the sender's IP (forwarded by the
|
||||||
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
|
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
|
||||||
and messages from a blocked sender are hidden from the viewer.
|
and messages from a blocked sender are hidden from the viewer. The operator console
|
||||||
|
has a **Messages** section (Stage 17) that lists posted messages (nudges excluded)
|
||||||
|
newest-first with the sender's resolved name, **source** (guest / robot / oldest
|
||||||
|
identity kind), IP and game, searchable by sender name / external-id glob masks and
|
||||||
|
pinnable to one game or sender (linked from the game and user cards).
|
||||||
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
|
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
|
||||||
the opponent may nudge **once per hour per game**; it is not allowed on one's own
|
the opponent may nudge **once per hour per game**; it is not allowed on one's own
|
||||||
turn. The platform-native delivery is wired with the gateway / platform
|
turn. The platform-native delivery is wired with the gateway / platform
|
||||||
@@ -451,11 +472,15 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
|
|||||||
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
|
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
|
||||||
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
|
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
|
||||||
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
|
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
|
||||||
robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
|
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
|
||||||
|
including the mover**, so the mover's own other devices and their lobby refresh — it is
|
||||||
|
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
||||||
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
||||||
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||||
friend-added, invitation or game-started; emitted on a friend-request and invitation
|
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
|
||||||
create and on an invitation's game start). Event payloads are FlatBuffers-encoded by
|
on answering one (accept → friend-added, decline → friend-declined — to the original
|
||||||
|
requester, so a game screen watching that opponent re-derives its "add to friends" state,
|
||||||
|
Stage 17), and on an invitation create or its game start). Event payloads are FlatBuffers-encoded by
|
||||||
the backend and forwarded verbatim. A client that is not currently streaming falls
|
the backend and forwarded verbatim. A client that is not currently streaming falls
|
||||||
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
|
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
|
||||||
badge** (incoming friend requests + open invitations), the client polls on lobby
|
badge** (incoming friend requests + open invitations), the client polls on lobby
|
||||||
@@ -489,20 +514,37 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
|||||||
available for debugging; **`otlp`** (gRPC, endpoint from the standard
|
available for debugging; **`otlp`** (gRPC, endpoint from the standard
|
||||||
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
|
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
|
||||||
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
|
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
|
||||||
and the gateway↔connector calls. The OTLP collector and Grafana dashboards are
|
and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus
|
||||||
stood up with the deploy (Stage 15).
|
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
|
||||||
|
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
||||||
|
are stood up with the deploy (`deploy/`, Stage 16); the default exporter stays
|
||||||
|
`none`, so CI needs no collector.
|
||||||
- Per-request server-side timing via gin middleware from day one (the access log
|
- Per-request server-side timing via gin middleware from day one (the access log
|
||||||
carries method, route, status, latency and the active trace id). A
|
carries method, route, status, latency and the active trace id). A
|
||||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||||
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
|
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
|
||||||
until an exporter is configured: histograms `game_replay_duration` (journal
|
until an exporter is configured: histograms `game_replay_duration` (journal
|
||||||
rebuild on a cache miss) and `game_move_validate_duration`; counters
|
rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
|
||||||
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop),
|
(Stage 17 — a seat's think time per committed move, attributed by `variant` and a
|
||||||
`chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`;
|
`phase` of opening/middle/endgame; it aggregates **all** seats including robots,
|
||||||
an observable gauge `game_cache_active`; the gateway `edge_request_duration`
|
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
|
||||||
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
|
console, below); counters `games_started_total`, `games_abandoned_total` (a
|
||||||
metrics. Game-scoped metrics carry a `variant` attribute
|
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
|
||||||
|
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
|
||||||
|
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
||||||
|
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
||||||
(english/russian_scrabble/erudit).
|
(english/russian_scrabble/erudit).
|
||||||
|
- Per-user move-time analytics (Stage 17) are **offline**, derived in the admin
|
||||||
|
console from the move journal (`game_moves.created_at` deltas, the first move from
|
||||||
|
the game's creation), not Prometheus labels (which an `account_id` would explode):
|
||||||
|
the user list shows each account's min/avg/max think time, and the user-detail page
|
||||||
|
draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
|
||||||
|
- User metrics (Stage 16): a backend counter `accounts_created_total` (`kind` =
|
||||||
|
telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
|
||||||
|
and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
|
||||||
|
distinct accounts that performed an authenticated edge action in the window. The
|
||||||
|
gauge is single-process by design (single-instance MVP, §10): it is correct for one
|
||||||
|
gateway, resets on restart, and is a live operational figure, not a billing count.
|
||||||
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
||||||
database answers a bounded ping and the session cache is warmed).
|
database answers a bounded ping and the session cache is warmed).
|
||||||
- The backend serves a **second listener** — a gRPC server
|
- The backend serves a **second listener** — a gRPC server
|
||||||
@@ -518,7 +560,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
|||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`) on the public `/_gm/*` path and reverse-proxies it **verbatim** to the backend's server-rendered admin console; the backend trusts the gateway (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
||||||
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
||||||
|
|
||||||
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
|
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
|
||||||
@@ -536,27 +578,64 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
|
|||||||
|
|
||||||
## 13. Deployment (informational)
|
## 13. Deployment (informational)
|
||||||
|
|
||||||
Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini
|
Single public origin, path-routed. The gateway **embeds** the static UI build
|
||||||
App under `/telegram/`** (the gateway serves the static UI build, wired in Stage 15;
|
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`). The Vite build has two
|
||||||
outside Telegram that path redirects to the root), the gateway public surface and the **admin console
|
entries: a lightweight **landing page** served at `/`, and the game **SPA** served at
|
||||||
at `/_gm`** (backend-rendered, Basic-Auth at the gateway) share one host that
|
`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
|
||||||
terminates TLS. The **Telegram connector** runs as a separate
|
redirects to the root — the client-side guard). Hash-named `/assets/*` are served
|
||||||
container with **no public ingress** — it long-polls Telegram and egresses through a
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
VPN sidecar, answering only internal gRPC. MVP runs one `gateway`, one `backend`, one
|
`no-cache` so a new deploy is picked up. An in-compose **caddy** is the
|
||||||
Postgres, plus the connector. The connector's Docker/compose ships now
|
contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to
|
||||||
(`platform/telegram/deploy`, mirroring `../15-puzzle`); the gateway's static UI serving
|
**Grafana** (anonymous-admin, so the one shared login gates it with no per-user
|
||||||
and the full multi-service deploy land in Stage 15.
|
Grafana accounts) and the rest of `/_gm/*` to the backend-rendered **admin console**;
|
||||||
|
everything else (`/`, `/app/`, `/telegram/`, the Connect edge) goes to the gateway. The
|
||||||
|
**Telegram connector** runs as a separate container with **no public ingress** — it
|
||||||
|
long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC.
|
||||||
|
|
||||||
|
The full contour (`deploy/docker-compose.yml`) runs one `gateway`, one `backend`,
|
||||||
|
one Postgres, the connector (+ its VPN sidecar) and the **observability stack** —
|
||||||
|
OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces) and Grafana
|
||||||
|
with provisioned datasources and dashboards. All three services export OTLP to the
|
||||||
|
collector; the connector shares the VPN sidecar's netns, so its `AWG_CONF` must not
|
||||||
|
carry a `DNS=` directive (that would hijack resolv.conf and stop it resolving
|
||||||
|
`otelcol`; without it the netns uses Docker's resolver, which resolves both
|
||||||
|
`otelcol` and `api.telegram.org`). Inter-service traffic uses a private `internal`
|
||||||
|
network (project-scoped DNS); only caddy joins the shared external `edge` network
|
||||||
|
(alias `scrabble`).
|
||||||
|
|
||||||
|
Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||||
|
- **Test** (Stage 16): auto-deploys on a PR into — or a push to — `development`
|
||||||
|
(`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner
|
||||||
|
host, then a `GET /` probe through caddy). The host caddy terminates TLS and
|
||||||
|
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||||
|
(`CADDY_SITE_ADDRESS=:80`).
|
||||||
|
- **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no
|
||||||
|
host caddy, so the contour ships its own caddy terminating TLS — set
|
||||||
|
`CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME.
|
||||||
|
|
||||||
## 14. CI & branches
|
## 14. CI & branches
|
||||||
|
|
||||||
- Trunk is **`master`**; feature work happens on `feature/*` branches merged via
|
- **Two long-lived branches** (Stage 16): **`development`** is the integration
|
||||||
PR with a green CI gate (from Stage 1 onward — the genesis commit necessarily
|
trunk and **`master`** the production trunk; `feature/*` branches are cut from
|
||||||
lands on `master`).
|
`development` and PR back into it (the genesis commit necessarily landed on
|
||||||
- `.gitea/workflows/` holds the CI. `go-unit.yaml` runs gofmt/vet/build/unit-test
|
`master`). A commit to a `feature/*` branch triggers nothing.
|
||||||
on Go changes; `integration.yaml` runs the Postgres-backed tests behind the
|
- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the
|
||||||
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
|
suite on a PR into `development`/`master` and on a push to `development`. Its
|
||||||
serial). Further workflows (ui-test, deploy) are added with the components they
|
`unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
|
||||||
cover.
|
tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
|
||||||
|
(check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (Stage 17 — a
|
||||||
|
`changes` job filters by changed paths), and an always-running **`gate`** job
|
||||||
|
aggregates them (passing when each succeeded or was **skipped**) and is the single
|
||||||
|
branch-protection required check (`CI / gate`), so a path-skipped job never blocks
|
||||||
|
a merge.
|
||||||
|
- A 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), then probes
|
||||||
|
the gateway (`GET /`) **and the Telegram connector's liveness** (Stage 17 —
|
||||||
|
`docker inspect`: running, not restarting, stable restart count, with a
|
||||||
|
VPN-handshake grace period, since the connector has no public ingress and a
|
||||||
|
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
|
||||||
|
deploy is the manual Stage 18 workflow. Secrets/variables are prefixed
|
||||||
|
`TEST_`/`PROD_` per contour.
|
||||||
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
||||||
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
|
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
|
||||||
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
|
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
|
||||||
|
|||||||
+30
-10
@@ -21,6 +21,10 @@ email, the statistics screen, and the in-game history viewer with GCG export.
|
|||||||
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
||||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
|
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
||||||
|
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||||||
|
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||||
|
|
||||||
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
||||||
A player arrives from a platform (Telegram first), via email login, or as an
|
A player arrives from a platform (Telegram first), via email login, or as an
|
||||||
@@ -54,10 +58,16 @@ account is kept and the guest's games move into it. A merge is blocked only whil
|
|||||||
two accounts share a game still in progress.
|
two accounts share a game still in progress.
|
||||||
|
|
||||||
### Lobby & matchmaking *(Stage 4 / 15)*
|
### Lobby & matchmaking *(Stage 4 / 15)*
|
||||||
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
|
Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
|
||||||
limited to the languages the player's sign-in service supports (English → English;
|
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||||||
Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is
|
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||||||
unrestricted). This gates only **starting** a new game — both auto-match and a friend
|
opponent-turn and finished games are most-recent first; it renders as a compact,
|
||||||
|
line-separated list (Stage 17). The game types offered on **New Game** are
|
||||||
|
limited to the languages the player's sign-in service supports (English → Scrabble;
|
||||||
|
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
|
||||||
|
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
|
||||||
|
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
|
||||||
|
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
|
||||||
invitation — so a player still sees and plays existing games of any language. Auto-match
|
invitation — so a player still sees and plays existing games of any language. Auto-match
|
||||||
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
|
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
|
||||||
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
|
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
|
||||||
@@ -81,7 +91,11 @@ the other player and the leaver keeps their score. In a game with three or four
|
|||||||
players the leaver's seat is dropped and the others play on, the game ending when a
|
players the leaver's seat is dropped and the others play on, the game ending when a
|
||||||
single active player remains; the disposition of the leaver's tiles (returned to
|
single active player remains; the disposition of the leaver's tiles (returned to
|
||||||
the bag or removed from play) is chosen when the game is created, and the leaver's
|
the bag or removed from play) is chosen when the game is created, and the leaver's
|
||||||
rack is never shown to the others.
|
rack is never shown to the others. A player's **board composition is kept per game**:
|
||||||
|
the rack arrangement and the tiles laid but not yet submitted are saved as they compose
|
||||||
|
and restored on return (including on another device); a player may **arrange tiles during
|
||||||
|
the opponent's turn**, but that draft is position-only — the score preview and submission
|
||||||
|
stay available only on the player's own turn.
|
||||||
|
|
||||||
### Robot opponent *(Stage 5)*
|
### Robot opponent *(Stage 5)*
|
||||||
When auto-match finds no human within ten seconds, a robot opponent takes the empty
|
When auto-match finds no human within ten seconds, a robot opponent takes the empty
|
||||||
@@ -91,7 +105,9 @@ wins most games), aims for a close score rather than crushing or throwing the ga
|
|||||||
and plays at a human pace — short thinking times for most moves, the occasional long
|
and plays at a human pace — short thinking times for most moves, the occasional long
|
||||||
one, and a night-time pause that tracks the player's own day. It answers a nudge
|
one, and a night-time pause that tracks the player's own day. It answers a nudge
|
||||||
within a few minutes and nudges back when the player has been away a long time. It
|
within a few minutes and nudges back when the player has been away a long time. It
|
||||||
carries a human-like name and neither chats nor accepts friend requests.
|
carries a human-like, language-appropriate name (a Russian game draws mostly Russian
|
||||||
|
names); it does not chat, and **silently ignores friend requests** — a request to a
|
||||||
|
robot stays pending and expires, exactly like a human who never responds.
|
||||||
|
|
||||||
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
|
### Social: friends, block, chat, nudge *(Stage 4 / 8)*
|
||||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||||
@@ -99,7 +115,10 @@ digits, valid for twelve hours), or send a **request to someone you have played
|
|||||||
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||||||
re-sent), or decline (a decline blocks further requests from you until they hand you
|
re-sent), or decline (a decline blocks further requests from you until they hand you
|
||||||
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
||||||
friendship. Block globally — switch off incoming chat
|
friendship. In a game, an **add to friends** item for each opponent mirrors the live
|
||||||
|
relationship: it reads *request sent* (disabled) while a request is pending or was
|
||||||
|
declined, and *in friends* once accepted — updating in place the moment the opponent
|
||||||
|
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
|
||||||
and/or friend requests — and block individual players (a per-user block hides that
|
and/or friend requests — and block individual players (a per-user block hides that
|
||||||
person's chat and stops requests and game invitations both ways; it also ends any
|
person's chat and stops requests and game invitations both ways; it also ends any
|
||||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||||
@@ -108,9 +127,10 @@ even disguised. Nudge the player whose turn is awaited at most once per hour (th
|
|||||||
nudge is part of the game chat); the out-of-app push is delivered via the platform.
|
nudge is part of the game chat); the out-of-app push is delivered via the platform.
|
||||||
|
|
||||||
### Profile & settings *(Stage 4 / 8)*
|
### Profile & settings *(Stage 4 / 8)*
|
||||||
Edit the display name (letters joined by single space / "." / "_" separators, up to
|
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||||
32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
|
optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the
|
||||||
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking
|
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||||
|
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||||
merge" (Stage 11).
|
merge" (Stage 11).
|
||||||
|
|
||||||
|
|||||||
+33
-13
@@ -22,6 +22,10 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
|
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||||
|
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||||
|
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||||
|
|
||||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
||||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||||
@@ -55,10 +59,16 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||||
|
|
||||||
### Лобби и подбор *(Stage 4 / 15)*
|
### Лобби и подбор *(Stage 4 / 15)*
|
||||||
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
|
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||||
ограничены языками, которые поддерживает сервис входа игрока (английский → English;
|
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||||
русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не
|
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||||
ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и
|
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||||
|
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
|
||||||
|
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
||||||
|
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
||||||
|
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
||||||
|
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
|
||||||
|
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
|
||||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
||||||
любом языке. Авто-подбор (всегда 2 игрока)
|
любом языке. Авто-подбор (всегда 2 игрока)
|
||||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||||
@@ -83,7 +93,11 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
|
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
|
||||||
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
|
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
|
||||||
убрать из игры) выбирается при создании партии, а его стойка никогда не
|
убрать из игры) выбирается при создании партии, а его стойка никогда не
|
||||||
показывается остальным.
|
показывается остальным. **Композиция на доске сохраняется по партии**: расположение
|
||||||
|
фишек на стойке и выложенные, но не отправленные фишки сохраняются по мере составления
|
||||||
|
хода и восстанавливаются при возврате (в том числе на другом устройстве); игрок может
|
||||||
|
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
|
||||||
|
предпросмотр счёта и отправка доступны лишь в собственный ход.
|
||||||
|
|
||||||
### Робот-соперник *(Stage 5)*
|
### Робот-соперник *(Stage 5)*
|
||||||
Если авто-подбор не находит человека за десять секунд, свободное место занимает
|
Если авто-подбор не находит человека за десять секунд, свободное место занимает
|
||||||
@@ -92,8 +106,10 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
|
человек выигрывает большинство партий), целится в близкий счёт, а не в разгром или
|
||||||
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
|
поддавки, и ходит с человеческим темпом — чаще короткие раздумья, изредка долгие, и
|
||||||
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
|
ночная пауза, подстроенная под день игрока. На nudge отвечает за несколько минут и
|
||||||
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается
|
сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее
|
||||||
в чате и не принимает заявки в друзья.
|
языку партии (в русской партии — в основном русские имена); не общается в чате и
|
||||||
|
**молча игнорирует заявки в друзья** — заявка роботу остаётся в ожидании и истекает,
|
||||||
|
ровно как у человека, который не отвечает.
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
|
### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)*
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
@@ -101,7 +117,10 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||||
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие
|
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||||
|
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||||
|
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||||
|
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
|
||||||
чат и/или заявки —
|
чат и/или заявки —
|
||||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||||
@@ -111,11 +130,12 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
push доставляется через платформу.
|
push доставляется через платформу.
|
||||||
|
|
||||||
### Профиль и настройки *(Stage 4 / 8)*
|
### Профиль и настройки *(Stage 4 / 8)*
|
||||||
Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
|
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||||
«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
|
«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от
|
||||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
|
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
|
||||||
переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов
|
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||||
вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
|
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||||
|
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
|
||||||
|
|
||||||
### История и статистика *(Stage 3 / 8)*
|
### История и статистика *(Stage 3 / 8)*
|
||||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||||
|
|||||||
+69
-14
@@ -33,6 +33,25 @@ Login uses `Screen`.
|
|||||||
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
|
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
|
||||||
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
|
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
|
||||||
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
|
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
|
||||||
|
- **Screen transitions** (Stage 17, `App.svelte`): navigation slides directionally — a
|
||||||
|
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||||||
|
from the left (back). Transitions are local (so they do not play on first load) and
|
||||||
|
collapse to nothing under reduce-motion. Per-game and lobby in-memory caches
|
||||||
|
(`lib/gamecache.ts`, `lib/lobbycache.ts`) render a re-opened game or the lobby instantly
|
||||||
|
and refresh in the background, removing the blank-loading flash and the lobby's "draw-in"
|
||||||
|
on lobby ↔ game navigation.
|
||||||
|
- **Telegram integration** (Stage 17, `lib/telegram.ts`): inside the Mini App the colour
|
||||||
|
scheme is forced from `Telegram.WebApp.colorScheme` (over the OS `prefers-color-scheme`,
|
||||||
|
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||||||
|
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
||||||
|
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
|
||||||
|
native header **BackButton** drives back-navigation (the app's chevron is hidden in
|
||||||
|
Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing
|
||||||
|
confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise)
|
||||||
|
are disabled so they don't fight tile drag or the board scroll; and a live stream dropped
|
||||||
|
by a background suspend reconnects silently on return — the connection banner is
|
||||||
|
suppressed while hidden and for a short grace after resume (visibilitychange +
|
||||||
|
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
||||||
|
|
||||||
## Tiles & board
|
## Tiles & board
|
||||||
|
|
||||||
@@ -43,9 +62,36 @@ Login uses `Screen`.
|
|||||||
that works consistently across browsers; no `transform`, which broke scrolling
|
that works consistently across browsers; no `transform`, which broke scrolling
|
||||||
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
|
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
|
||||||
they stay a constant size as the cells grow (relatively smaller at higher zoom).
|
they stay a constant size as the cells grow (relatively smaller at higher zoom).
|
||||||
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
|
**Double-tap** an empty/filled cell toggles zoom centred on it; double-tap a **pending**
|
||||||
target; the custom pinch and swipe-to-open-history gestures were dropped because they
|
tile recalls it. **Pinch** zooms toward the pinch midpoint (a two-finger gesture;
|
||||||
fight native scroll — history opens from the menu.
|
preventDefault fires only for two touches, so one-finger scroll stays native, and a second
|
||||||
|
finger aborts an in-progress drag). The pan is **interpolated toward a pre-clamped target**
|
||||||
|
as the real width grows/shrinks, so it magnifies evenly A→B instead of lurching and snapping
|
||||||
|
back near the edges (Stage 17). It **recentres only on a zoom-in** — placing a 2nd+ tile or
|
||||||
|
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||||
|
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||||
|
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
|
||||||
|
history opens from the menu or a tap on the players plaque (below). A **hint** auto-zooms
|
||||||
|
centred on the hint's placement, not the top-left.
|
||||||
|
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
||||||
|
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
||||||
|
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||||||
|
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
|
||||||
|
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
|
||||||
|
recalled tile returns to its original rack slot (Stage 17).
|
||||||
|
- **Players plaque & history** (`Game.svelte`, Stage 17): the seats above the board share
|
||||||
|
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
|
||||||
|
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
|
||||||
|
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
||||||
|
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||||||
|
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
||||||
|
jitter. A move's row lists every word it formed (the main word first).
|
||||||
|
- **Vertical fit & keyboard** (Stage 17): when the game does not fit the viewport, only the
|
||||||
|
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
||||||
|
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||||
|
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
||||||
|
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
|
||||||
|
keyboard-aware (they size to the area above the keyboard).
|
||||||
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
||||||
last completed word gets a dark tile background — static while it is the opponent's
|
last completed word gets a dark tile background — static while it is the opponent's
|
||||||
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
|
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
|
||||||
@@ -53,25 +99,34 @@ Login uses `Screen`.
|
|||||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||||
nothing. Default **beginner**.
|
nothing. Default **beginner**.
|
||||||
- **Grid lines**: the inter-cell gap shows a contrasting `--cell-line` (darker in light,
|
- **Grid lines** — a Settings toggle, **default off** (Stage 17). Off: a **gapless
|
||||||
lighter in dark) to avoid a wavy-line optical illusion.
|
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
|
||||||
|
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
|
||||||
|
board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
|
||||||
|
`--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion.
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
|
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
|
||||||
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
|
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
|
||||||
immediately. Reused by:
|
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
|
||||||
- **MakeMove** (appears when ≥1 tile is pending; the rack collapses its used slots and
|
- **MakeMove / Reset** (Stage 17): when ≥1 tile is pending the rack collapses its used slots
|
||||||
shifts left to free room): a **🏁** button whose popover offers **Make move ✅** /
|
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
||||||
**Reset ❌**.
|
button) beside the rack commits the move — no popover, and disabled while the pending word
|
||||||
- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
|
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
|
||||||
remaining-count badge) — each confirmed by an **Ok ✅** popover; 🔀 Shuffle has no
|
- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
|
||||||
label and no confirm. The under-board slot shows the **Scores: N** preview.
|
remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which
|
||||||
|
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
||||||
|
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
|
||||||
|
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
|
||||||
|
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
||||||
|
|
||||||
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
|
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
|
||||||
|
|
||||||
A one-line inset strip under the nav bar. Content is minimal markdown (text + links,
|
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token (Stage 17) —
|
||||||
escaped + linkified). A parameterised **rotator** drives messages: a fitting message
|
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
|
||||||
|
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
|
||||||
|
minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message
|
||||||
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
|
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
|
||||||
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
|
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
|
||||||
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
|
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Multi-stage build for the gateway service. A node stage builds the static UI
|
||||||
|
# (Vite), the result is embedded into the Go binary (gateway/internal/webui/dist),
|
||||||
|
# and the Go stage — mirroring platform/telegram/Dockerfile — yields a static
|
||||||
|
# binary shipped on distroless nonroot. So the single binary serves the SPA at /
|
||||||
|
# and /telegram/ (docs/ARCHITECTURE.md §13) with no separate static container.
|
||||||
|
#
|
||||||
|
# The production UI build vars are image build-args, baked into the bundle.
|
||||||
|
# Build from the repository root so go.work, pkg/, gateway/ and ui/ are all in the
|
||||||
|
# Docker context:
|
||||||
|
# docker build -f gateway/Dockerfile \
|
||||||
|
# --build-arg VITE_GATEWAY_URL=https://example \
|
||||||
|
# -t scrabble-gateway .
|
||||||
|
|
||||||
|
# --- UI build ----------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS ui
|
||||||
|
WORKDIR /ui
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
||||||
|
|
||||||
|
# Prod UI build vars (Vite reads VITE_-prefixed env at build; baked into the bundle).
|
||||||
|
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
||||||
|
ARG VITE_TELEGRAM_BOT_ID=
|
||||||
|
ARG VITE_TELEGRAM_LINK=
|
||||||
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
|
||||||
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
|
||||||
|
ARG VITE_GATEWAY_URL=
|
||||||
|
ARG VITE_APP_VERSION=
|
||||||
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
|
||||||
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
|
||||||
|
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||||
|
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
|
|
||||||
|
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
||||||
|
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||||
|
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY ui ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# --- Go build ----------------------------------------------------------------
|
||||||
|
FROM golang:1.26.3-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.work go.work.sum ./
|
||||||
|
COPY pkg ./pkg
|
||||||
|
COPY gateway ./gateway
|
||||||
|
|
||||||
|
# Replace the committed placeholder with the freshly built UI before compiling, so
|
||||||
|
# go:embed bakes the real bundle into the binary.
|
||||||
|
RUN rm -rf gateway/internal/webui/dist
|
||||||
|
COPY --from=ui /ui/dist gateway/internal/webui/dist
|
||||||
|
|
||||||
|
# Reduce the workspace to what the gateway needs: gateway + pkg.
|
||||||
|
RUN go work edit -dropuse=./backend -dropuse=./platform/telegram
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/gateway ./gateway/cmd/gateway
|
||||||
|
|
||||||
|
# --- runtime -----------------------------------------------------------------
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
COPY --from=build /out/gateway /usr/local/bin/gateway
|
||||||
|
ENTRYPOINT ["/usr/local/bin/gateway"]
|
||||||
+11
-5
@@ -5,9 +5,14 @@ terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
|
|||||||
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
||||||
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
||||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||||
client's in-app live channel. It also serves the backend's admin console at `/_gm`
|
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||||
on its public listener behind HTTP Basic-Auth. See
|
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||||
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12.
|
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
|
||||||
|
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||||
|
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||||
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
|
[`../deploy`](../deploy)). See
|
||||||
|
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12, §13.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
@@ -22,8 +27,9 @@ internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate)
|
|||||||
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
|
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
|
||||||
internal/push/ # live-event fan-out hub (per-user client streams)
|
internal/push/ # live-event fan-out hub (per-user client streams)
|
||||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||||
internal/connectsrv/ # the Connect Gateway service over h2c
|
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
||||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||||
|
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
|
||||||
```
|
```
|
||||||
|
|
||||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||||
@@ -72,7 +78,7 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
|||||||
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it |
|
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it |
|
||||||
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
|
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
|
||||||
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
||||||
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
|
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
|
||||||
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
|
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
|
||||||
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
|
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
|
||||||
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
|
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package backendclient
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -92,16 +93,17 @@ type SeatResp struct {
|
|||||||
|
|
||||||
// GameResp is the shared game summary.
|
// GameResp is the shared game summary.
|
||||||
type GameResp struct {
|
type GameResp struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Variant string `json:"variant"`
|
Variant string `json:"variant"`
|
||||||
DictVersion string `json:"dict_version"`
|
DictVersion string `json:"dict_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Players int `json:"players"`
|
Players int `json:"players"`
|
||||||
ToMove int `json:"to_move"`
|
ToMove int `json:"to_move"`
|
||||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
Seats []SeatResp `json:"seats"`
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
|
Seats []SeatResp `json:"seats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveResultResp is the outcome of a committed move.
|
// MoveResultResp is the outcome of a committed move.
|
||||||
@@ -255,6 +257,11 @@ func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) {
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel removes the caller from the auto-match pool (idempotent; 204 No Content).
|
||||||
|
func (c *Client) Cancel(ctx context.Context, userID string) error {
|
||||||
|
return c.do(ctx, http.MethodPost, "/api/v1/user/lobby/cancel", userID, "", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// ChatPost stores a chat message, forwarding the client IP for moderation.
|
// ChatPost stores a chat message, forwarding the client IP for moderation.
|
||||||
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
|
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
|
||||||
var out ChatResp
|
var out ChatResp
|
||||||
@@ -332,6 +339,21 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDraft returns the player's saved composition for a game (Stage 17) as the backend's
|
||||||
|
// raw JSON body. The gateway forwards it verbatim, never interpreting its shape.
|
||||||
|
func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) {
|
||||||
|
var out json.RawMessage
|
||||||
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/draft"), userID, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDraft upserts the player's composition for a game (Stage 17). body is the client's
|
||||||
|
// {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so
|
||||||
|
// there is no double-encode.
|
||||||
|
func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error {
|
||||||
|
return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
||||||
// alphabet index (Stage 13).
|
// alphabet index (Stage 13).
|
||||||
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type IncomingListResp struct {
|
|||||||
Requests []AccountRefResp `json:"requests"`
|
Requests []AccountRefResp `json:"requests"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OutgoingListResp is the addressees the caller has already requested (a live pending
|
||||||
|
// request or one the addressee declined) and cannot re-request.
|
||||||
|
type OutgoingListResp struct {
|
||||||
|
Requests []AccountRefResp `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
// FriendCodeResp is a freshly issued one-time friend code.
|
// FriendCodeResp is a freshly issued one-time friend code.
|
||||||
type FriendCodeResp struct {
|
type FriendCodeResp struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -134,6 +140,14 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListOutgoing returns the addressees the caller has already requested (pending or
|
||||||
|
// declined) and cannot re-request.
|
||||||
|
func (c *Client) ListOutgoing(ctx context.Context, userID string) (OutgoingListResp, error) {
|
||||||
|
var out OutgoingListResp
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/outgoing", userID, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// IssueFriendCode issues a one-time friend code for the caller.
|
// IssueFriendCode issues a one-time friend code for the caller.
|
||||||
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
||||||
var out FriendCodeResp
|
var out FriendCodeResp
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const (
|
|||||||
defaultBackendTimeout = 5 * time.Second
|
defaultBackendTimeout = 5 * time.Second
|
||||||
defaultSessionTTL = 10 * time.Minute
|
defaultSessionTTL = 10 * time.Minute
|
||||||
defaultSessionCacheMax = 50000
|
defaultSessionCacheMax = 50000
|
||||||
defaultPushHeartbeatInterval = 15 * time.Second
|
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17)
|
||||||
defaultServiceName = "scrabble-gateway"
|
defaultServiceName = "scrabble-gateway"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,7 +88,11 @@ var (
|
|||||||
func DefaultRateLimit() RateLimitConfig {
|
func DefaultRateLimit() RateLimitConfig {
|
||||||
return RateLimitConfig{
|
return RateLimitConfig{
|
||||||
PublicPerMinute: 30, PublicBurst: 10,
|
PublicPerMinute: 30, PublicBurst: 10,
|
||||||
UserPerMinute: 120, UserBurst: 40,
|
// Per-user (not per-IP): one user may run several devices, each holding a
|
||||||
|
// Subscribe stream and reloading state on every live event, so the authenticated
|
||||||
|
// budget is generous (a per-user cap cannot DoS the service). Raised in Stage 17
|
||||||
|
// after multi-device play tripped the old 120/40.
|
||||||
|
UserPerMinute: 300, UserBurst: 80,
|
||||||
AdminPerMinute: 60, AdminBurst: 20,
|
AdminPerMinute: 60, AdminBurst: 20,
|
||||||
EmailPer10Min: 5, EmailBurst: 2,
|
EmailPer10Min: 5, EmailBurst: 2,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package connectsrv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// activeUsers tracks distinct authenticated accounts by last-action time, backing
|
||||||
|
// the in-memory active_users gauge. It is single-process by design (the gateway is
|
||||||
|
// single-instance in the MVP, docs/ARCHITECTURE.md §10): the distinct count is
|
||||||
|
// correct for one process, resets on restart, and is a live operational gauge, not
|
||||||
|
// a billing figure. Memory is bounded by the number of distinct accounts active
|
||||||
|
// within the longest window; stale entries are pruned on observation.
|
||||||
|
type activeUsers struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lastSeen map[string]time.Time
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// newActiveUsers returns an empty tracker using the wall clock.
|
||||||
|
func newActiveUsers() *activeUsers {
|
||||||
|
return &activeUsers{lastSeen: make(map[string]time.Time), now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seen records that account uid performed an authenticated action now.
|
||||||
|
func (a *activeUsers) seen(uid string) {
|
||||||
|
if uid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
a.lastSeen[uid] = a.now()
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// counts returns, for each window, the number of distinct accounts last seen
|
||||||
|
// within it, pruning entries older than the longest window in the same pass.
|
||||||
|
func (a *activeUsers) counts(windows []time.Duration) []int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
now := a.now()
|
||||||
|
var longest time.Duration
|
||||||
|
for _, w := range windows {
|
||||||
|
if w > longest {
|
||||||
|
longest = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]int, len(windows))
|
||||||
|
for uid, ts := range a.lastSeen {
|
||||||
|
age := now.Sub(ts)
|
||||||
|
if age > longest {
|
||||||
|
delete(a.lastSeen, uid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, w := range windows {
|
||||||
|
if age <= w {
|
||||||
|
res[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package connectsrv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActiveUsersCountsAndPrune(t *testing.T) {
|
||||||
|
a := newActiveUsers()
|
||||||
|
base := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
|
||||||
|
cur := base
|
||||||
|
a.now = func() time.Time { return cur }
|
||||||
|
|
||||||
|
a.seen("u1") // at base
|
||||||
|
cur = base.Add(2 * time.Hour)
|
||||||
|
a.seen("u2") // base+2h
|
||||||
|
cur = base.Add(50 * time.Hour)
|
||||||
|
a.seen("u3") // base+50h
|
||||||
|
|
||||||
|
windows := []time.Duration{24 * time.Hour, 7 * 24 * time.Hour}
|
||||||
|
|
||||||
|
// now = base+50h: u3 within 24h; all three within 7d.
|
||||||
|
got := a.counts(windows)
|
||||||
|
if got[0] != 1 || got[1] != 3 {
|
||||||
|
t.Fatalf("counts at +50h = %v, want [1 3]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// now = base+169h: u1 (age 169h) prunes past the 7d window; u2/u3 remain in 7d.
|
||||||
|
cur = base.Add(169 * time.Hour)
|
||||||
|
got = a.counts(windows)
|
||||||
|
if got[0] != 0 || got[1] != 2 {
|
||||||
|
t.Fatalf("counts at +169h = %v, want [0 2]", got)
|
||||||
|
}
|
||||||
|
if _, ok := a.lastSeen["u1"]; ok {
|
||||||
|
t.Fatalf("u1 should have been pruned from the tracker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveUsersIgnoresEmpty(t *testing.T) {
|
||||||
|
a := newActiveUsers()
|
||||||
|
a.seen("")
|
||||||
|
if got := a.counts([]time.Duration{time.Hour}); got[0] != 0 {
|
||||||
|
t.Fatalf("empty uid recorded: got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,14 +12,26 @@ import (
|
|||||||
// meterName scopes the gateway edge's OpenTelemetry instruments.
|
// meterName scopes the gateway edge's OpenTelemetry instruments.
|
||||||
const meterName = "scrabble/gateway/edge"
|
const meterName = "scrabble/gateway/edge"
|
||||||
|
|
||||||
|
// activeUserWindows are the rolling windows the active_users gauge reports.
|
||||||
|
var activeUserWindows = []struct {
|
||||||
|
label string
|
||||||
|
dur time.Duration
|
||||||
|
}{
|
||||||
|
{label: "24h", dur: 24 * time.Hour},
|
||||||
|
{label: "7d", dur: 7 * 24 * time.Hour},
|
||||||
|
}
|
||||||
|
|
||||||
// serverMetrics holds the edge's operational instruments. It defaults to no-ops;
|
// serverMetrics holds the edge's operational instruments. It defaults to no-ops;
|
||||||
// NewServer installs the real meter when one is supplied in Deps.
|
// NewServer installs the real meter when one is supplied in Deps.
|
||||||
type serverMetrics struct {
|
type serverMetrics struct {
|
||||||
edge metric.Float64Histogram
|
edge metric.Float64Histogram
|
||||||
|
active *activeUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
||||||
// falling back to a no-op histogram on the (rare) construction error.
|
// falling back to a no-op histogram on the (rare) construction error. The
|
||||||
|
// active_users gauge is registered as an observable callback over the in-memory
|
||||||
|
// tracker.
|
||||||
func newServerMetrics(meter metric.Meter) *serverMetrics {
|
func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||||
if meter == nil {
|
if meter == nil {
|
||||||
meter = noop.NewMeterProvider().Meter(meterName)
|
meter = noop.NewMeterProvider().Meter(meterName)
|
||||||
@@ -30,7 +42,24 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
||||||
}
|
}
|
||||||
return &serverMetrics{edge: h}
|
m := &serverMetrics{edge: h, active: newActiveUsers()}
|
||||||
|
|
||||||
|
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||||
|
metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance)."))
|
||||||
|
if err == nil {
|
||||||
|
windows := make([]time.Duration, len(activeUserWindows))
|
||||||
|
for i, w := range activeUserWindows {
|
||||||
|
windows[i] = w.dur
|
||||||
|
}
|
||||||
|
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
|
||||||
|
counts := m.active.counts(windows)
|
||||||
|
for i, w := range activeUserWindows {
|
||||||
|
o.ObserveInt64(gauge, int64(counts[i]), metric.WithAttributes(attribute.String("window", w.label)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, gauge)
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordEdge records the duration of one Execute call labelled by message type and
|
// recordEdge records the duration of one Execute call labelled by message type and
|
||||||
@@ -41,3 +70,8 @@ func (m *serverMetrics) recordEdge(ctx context.Context, msgType, result string,
|
|||||||
attribute.String("result", result),
|
attribute.String("result", result),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordActive marks account uid active now, feeding the active_users gauge.
|
||||||
|
func (m *serverMetrics) recordActive(uid string) {
|
||||||
|
m.active.seen(uid)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
"scrabble/gateway/internal/session"
|
"scrabble/gateway/internal/session"
|
||||||
"scrabble/gateway/internal/transcode"
|
"scrabble/gateway/internal/transcode"
|
||||||
|
"scrabble/gateway/internal/webui"
|
||||||
edgev1 "scrabble/gateway/proto/edge/v1"
|
edgev1 "scrabble/gateway/proto/edge/v1"
|
||||||
"scrabble/gateway/proto/edge/v1/edgev1connect"
|
"scrabble/gateway/proto/edge/v1/edgev1connect"
|
||||||
)
|
)
|
||||||
@@ -89,9 +90,23 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
if s.adminProxy != nil {
|
if s.adminProxy != nil {
|
||||||
// The admin console (backend /_gm) is served on the public listener behind
|
// The admin console (backend /_gm) is served on the public listener behind
|
||||||
// the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps
|
// the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps
|
||||||
// working over h2c (docs/ARCHITECTURE.md §12).
|
// working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
|
||||||
|
// front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves
|
||||||
|
// a non-caddy (local) setup.
|
||||||
mux.Handle("/_gm/", s.adminProxy)
|
mux.Handle("/_gm/", s.adminProxy)
|
||||||
|
} else {
|
||||||
|
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below
|
||||||
|
// does not serve the app shell at the operator path.
|
||||||
|
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||||
}
|
}
|
||||||
|
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram Mini
|
||||||
|
// App), with a separate landing page at the catch-all "/" — the single-origin model
|
||||||
|
// (docs/ARCHITECTURE.md §13). All sit below the h2c wrap so the Connect edge (a more
|
||||||
|
// specific prefix) keeps priority. Each SPA mount falls back to the app shell
|
||||||
|
// (index.html) for the hash router; "/" falls back to the landing (landing.html).
|
||||||
|
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||||
|
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||||
|
mux.Handle("/", webui.Handler("", "landing.html"))
|
||||||
return h2c.NewHandler(mux, &http2.Server{})
|
return h2c.NewHandler(mux, &http2.Server{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +133,9 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
result = "unauthenticated"
|
result = "unauthenticated"
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// A valid session proving an authenticated request is an "action" for the
|
||||||
|
// active_users gauge, counted before the rate-limit/domain outcome.
|
||||||
|
s.metrics.recordActive(uid)
|
||||||
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
|
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
|
||||||
result = "rate_limited"
|
result = "rate_limited"
|
||||||
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
|
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
|
||||||
@@ -168,6 +186,14 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
|
|||||||
events, cancel := s.hub.Subscribe(uid)
|
events, cancel := s.hub.Subscribe(uid)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Send an immediate heartbeat so the stream's first byte flushes through the proxy chain
|
||||||
|
// right away and resets edge/client idle timers, instead of the connection sitting silent
|
||||||
|
// until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect
|
||||||
|
// every interval (Stage 17).
|
||||||
|
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(s.heartbeat)
|
ticker := time.NewTicker(s.heartbeat)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,17 @@ func encodeWordCheck(r backendclient.WordCheckResp) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeDraftView builds a DraftView payload wrapping the player's composition JSON. The
|
||||||
|
// string is empty for the save acknowledgement (the client ignores that payload).
|
||||||
|
func encodeDraftView(jsonStr string) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
j := b.CreateString(jsonStr)
|
||||||
|
fb.DraftViewStart(b)
|
||||||
|
fb.DraftViewAddJson(b, j)
|
||||||
|
b.Finish(fb.DraftViewEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
// encodeHistory builds a History payload (the decoded move journal).
|
// encodeHistory builds a History payload (the decoded move journal).
|
||||||
func encodeHistory(r backendclient.HistoryResp) []byte {
|
func encodeHistory(r backendclient.HistoryResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(1024)
|
b := flatbuffers.NewBuilder(1024)
|
||||||
@@ -346,6 +357,7 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
|
|||||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||||
fb.GameViewAddEndReason(b, endReason)
|
fb.GameViewAddEndReason(b, endReason)
|
||||||
fb.GameViewAddSeats(b, seats)
|
fb.GameViewAddSeats(b, seats)
|
||||||
|
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeOutgoingList builds an OutgoingRequestList payload.
|
||||||
|
func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector)
|
||||||
|
fb.OutgoingRequestListStart(b)
|
||||||
|
fb.OutgoingRequestListAddRequests(b, v)
|
||||||
|
b.Finish(fb.OutgoingRequestListEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
// encodeBlockList builds a BlockList payload.
|
// encodeBlockList builds a BlockList payload.
|
||||||
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(256)
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package transcode
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
@@ -24,6 +25,7 @@ const (
|
|||||||
MsgGameSubmitPlay = "game.submit_play"
|
MsgGameSubmitPlay = "game.submit_play"
|
||||||
MsgGameState = "game.state"
|
MsgGameState = "game.state"
|
||||||
MsgLobbyEnqueue = "lobby.enqueue"
|
MsgLobbyEnqueue = "lobby.enqueue"
|
||||||
|
MsgLobbyCancel = "lobby.cancel"
|
||||||
MsgLobbyPoll = "lobby.poll"
|
MsgLobbyPoll = "lobby.poll"
|
||||||
MsgChatPost = "chat.post"
|
MsgChatPost = "chat.post"
|
||||||
MsgGamesList = "games.list"
|
MsgGamesList = "games.list"
|
||||||
@@ -37,6 +39,8 @@ const (
|
|||||||
MsgGameHistory = "game.history"
|
MsgGameHistory = "game.history"
|
||||||
MsgChatList = "chat.list"
|
MsgChatList = "chat.list"
|
||||||
MsgChatNudge = "chat.nudge"
|
MsgChatNudge = "chat.nudge"
|
||||||
|
MsgDraftGet = "draft.get"
|
||||||
|
MsgDraftSave = "draft.save"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request is one decoded Execute call.
|
// Request is one decoded Execute call.
|
||||||
@@ -93,6 +97,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
|
|||||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
||||||
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
|
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgLobbyCancel] = Op{Handler: cancelHandler(backend), Auth: true}
|
||||||
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
|
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
|
||||||
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
|
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
|
||||||
r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true}
|
r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true}
|
||||||
@@ -106,6 +111,8 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
|
|||||||
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
|
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
|
||||||
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
|
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
|
||||||
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
|
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
|
||||||
registerStage8(r, backend)
|
registerStage8(r, backend)
|
||||||
registerStage11(r, backend, tg, defaultLanguages)
|
registerStage11(r, backend, tg, defaultLanguages)
|
||||||
return r
|
return r
|
||||||
@@ -233,6 +240,17 @@ func pollHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelHandler removes the caller from the auto-match pool. It carries no result;
|
||||||
|
// it echoes an empty (unmatched) Match so the client has a well-formed payload.
|
||||||
|
func cancelHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
if err := backend.Cancel(ctx, req.UserID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeMatch(backendclient.MatchResp{}), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func chatPostHandler(backend *backendclient.Client) Handler {
|
func chatPostHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsChatPostRequest(req.Payload, 0)
|
in := fb.GetRootAsChatPostRequest(req.Payload, 0)
|
||||||
@@ -408,3 +426,28 @@ func nudgeHandler(backend *backendclient.Client) Handler {
|
|||||||
return encodeChat(res), nil
|
return encodeChat(res), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDraftHandler returns the player's saved composition (Stage 17). It reuses
|
||||||
|
// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
|
||||||
|
func getDraftHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
|
||||||
|
raw, err := backend.GetDraft(ctx, req.UserID, string(in.GameId()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeDraftView(string(raw)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveDraftHandler upserts the player's composition (Stage 17), forwarding the opaque JSON
|
||||||
|
// string verbatim. It echoes an empty DraftView as a well-formed acknowledgement.
|
||||||
|
func saveDraftHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsDraftRequest(req.Payload, 0)
|
||||||
|
if err := backend.SaveDraft(ctx, req.UserID, string(in.GameId()), json.RawMessage(in.Json())); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeDraftView(""), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDraftSaveForwardsRawJSON checks the save handler forwards the client's composition JSON
|
||||||
|
// to the backend verbatim (the "no double-encode" contract, Stage 17) with the user header.
|
||||||
|
func TestDraftSaveForwardsRawJSON(t *testing.T) {
|
||||||
|
const body = `{"rack_order":"1,0","board_tiles":[{"row":7,"col":7,"letter":"Q","blank":false}]}`
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/games/g-1/draft" {
|
||||||
|
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("X-User-ID"); got != "u-3" {
|
||||||
|
t.Errorf("X-User-ID = %q, want u-3", got)
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
if string(raw) != body {
|
||||||
|
t.Errorf("forwarded body = %q, want %q (verbatim)", raw, body)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgDraftSave)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("draft.save not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
b := flatbuffers.NewBuilder(64)
|
||||||
|
gid := b.CreateString("g-1")
|
||||||
|
j := b.CreateString(body)
|
||||||
|
fb.DraftRequestStart(b)
|
||||||
|
fb.DraftRequestAddGameId(b, gid)
|
||||||
|
fb.DraftRequestAddJson(b, j)
|
||||||
|
b.Finish(fb.DraftRequestEnd(b))
|
||||||
|
|
||||||
|
if _, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-3"}); err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDraftGetWrapsBackendJSON checks the get handler wraps the backend's stored draft JSON in
|
||||||
|
// a DraftView verbatim (the gateway never interprets the shape).
|
||||||
|
func TestDraftGetWrapsBackendJSON(t *testing.T) {
|
||||||
|
const stored = `{"rack_order":"2,0,1","board_tiles":[]}`
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/games/g-2/draft" {
|
||||||
|
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(stored))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, _ := reg.Lookup(transcode.MsgDraftGet)
|
||||||
|
|
||||||
|
b := flatbuffers.NewBuilder(32)
|
||||||
|
gid := b.CreateString("g-2")
|
||||||
|
fb.GameActionRequestStart(b)
|
||||||
|
fb.GameActionRequestAddGameId(b, gid)
|
||||||
|
b.Finish(fb.GameActionRequestEnd(b))
|
||||||
|
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: b.FinishedBytes(), UserID: "u-4"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
v := fb.GetRootAsDraftView(payload, 0)
|
||||||
|
if string(v.Json()) != stored {
|
||||||
|
t.Fatalf("DraftView.json = %q, want %q (verbatim)", v.Json(), stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
MsgFriendsList = "friends.list"
|
MsgFriendsList = "friends.list"
|
||||||
MsgFriendsIncoming = "friends.incoming"
|
MsgFriendsIncoming = "friends.incoming"
|
||||||
|
MsgFriendsOutgoing = "friends.outgoing"
|
||||||
MsgFriendRequest = "friends.request"
|
MsgFriendRequest = "friends.request"
|
||||||
MsgFriendRespond = "friends.respond"
|
MsgFriendRespond = "friends.respond"
|
||||||
MsgFriendCancel = "friends.cancel"
|
MsgFriendCancel = "friends.cancel"
|
||||||
@@ -37,6 +38,7 @@ const (
|
|||||||
func registerStage8(r *Registry, backend *backendclient.Client) {
|
func registerStage8(r *Registry, backend *backendclient.Client) {
|
||||||
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
|
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
|
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
|
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
|
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
|
||||||
@@ -78,6 +80,16 @@ func friendsIncomingHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func friendsOutgoingHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
res, err := backend.ListOutgoing(ctx, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeOutgoingList(res), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func friendRequestHandler(backend *backendclient.Client) Handler {
|
func friendRequestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsTargetRequest(req.Payload, 0)
|
in := fb.GetRootAsTargetRequest(req.Payload, 0)
|
||||||
|
|||||||
@@ -54,6 +54,35 @@ func TestFriendsListRoundTripDecodesNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFriendsOutgoingRoundTrip(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/user/friends/outgoing" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}]}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgFriendsOutgoing)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("friends.outgoing not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
ol := fb.GetRootAsOutgoingRequestList(payload, 0)
|
||||||
|
if ol.RequestsLength() != 1 {
|
||||||
|
t.Fatalf("outgoing length = %d, want 1", ol.RequestsLength())
|
||||||
|
}
|
||||||
|
var ref fb.AccountRef
|
||||||
|
ol.Requests(&ref, 0)
|
||||||
|
if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" {
|
||||||
|
t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFriendRequestForwardsTarget(t *testing.T) {
|
func TestFriendRequestForwardsTarget(t *testing.T) {
|
||||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games" {
|
if r.URL.Path != "/api/v1/user/games" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -177,6 +177,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if string(g.Id()) != "g-1" {
|
if string(g.Id()) != "g-1" {
|
||||||
t.Errorf("game id = %q, want g-1", g.Id())
|
t.Errorf("game id = %q, want g-1", g.Id())
|
||||||
}
|
}
|
||||||
|
if g.LastActivityUnix() != 1717000000 {
|
||||||
|
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
|
||||||
|
}
|
||||||
var seat fb.SeatView
|
var seat fb.SeatView
|
||||||
g.Seats(&seat, 1)
|
g.Seats(&seat, 1)
|
||||||
if string(seat.DisplayName()) != "Ann" {
|
if string(seat.DisplayName()) != "Ann" {
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
# Placeholder so the embedded dist/assets directory exists in a plain build.
|
||||||
|
# The production gateway image replaces dist/ with the real Vite build.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user