Compare commits

..

11 Commits

Author SHA1 Message Date
Ilia Denisov ffe9150f26 fix: game.new button e2e test bundle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
2026-06-17 21:54:22 +02:00
Ilia Denisov 071ff8ac37 fix: game.new button e2e test title
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Failing after 53s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
2026-06-17 21:34:37 +02:00
Ilia Denisov 4e347702f3 chore: lobby.new, lobby.stats label
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Failing after 4m45s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
2026-06-17 20:45:23 +02:00
Ilia Denisov 63669a3b25 fix: game-over unit test expecttion
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
2026-06-17 19:18:27 +02:00
Ilia Denisov 7832bf10f5 chore: chat.awaitingReply label
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Failing after 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
2026-06-17 18:58:25 +02:00
Ilia Denisov 1fe25a3a15 chore: game-over phrases
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Failing after 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
2026-06-17 18:04:48 +02:00
Ilia Denisov 8b7ddb9df0 chore: new.searchHint label (ru)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m17s
2026-06-17 17:40:53 +02:00
Ilia Denisov 68972375f9 chore: chat.awaitingReply label
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
2026-06-17 17:37:07 +02:00
Ilia Denisov 5b0f608bb2 chore: stats tab label and ru-caption
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 50s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
2026-06-17 17:18:09 +02:00
Ilia Denisov f9faebfa91 fix(hint): stop the hint count going stale across games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m20s
The in-game hint badge re-fetched on entry and, for a game where it was the
player's turn, showed a too-high count that "reset" (e.g. back to 11) — the
wallet hint spent in another game was not reflected.

Root cause: the server sends one hints_remaining = per-game allowance + global
wallet, and the client cached that combined number per game. The wallet is
global, so spending a wallet hint in one game left every other game's cached
count stale (a my-turn game holds the stalest value: the opponent-moved delta
preserves the old number, whereas a game you just moved in re-cached a fresh
one). The backend allowance-then-wallet spend order was already correct.

Fix: split the two. StateView/HintResult gain a trailing wallet_balance field
(the global wallet alone); the client derives the per-game allowance as
hints_remaining - wallet_balance (stable, cacheable) and reads the wallet live
from the profile, refreshing it from every state/hint response. The badge is
allowance + live wallet, so a wallet hint anywhere updates every game at once.

- wire: scrabble.fbs StateView/HintResult + pkg/wire.BuildStateView (the single
  encoder for both the gateway transcode and the backend's event StateView),
  gateway encode + resp structs, regen.
- backend: game StateView/HintResult + service (GameState/Hint) + eventwire +
  notify PlayerState/encode + server DTOs.
- ui: lib/hints.ts (pure hintsLeft), Game.svelte (badge + syncWallet on
  load/hint, carry wallet_balance through applyMoveResult), codec/model, mock.
- docs: ARCHITECTURE §Hint.

Tests: hints.ts unit (incl. the staleness case), TestHintPolicy extended
(wallet_balance + allowance-first), gateway state/hint round-trips.
2026-06-17 16:50:16 +02:00
Ilia Denisov cbb485ebd6 feat(stats): show the best move word per game variant
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
Replace the single "best move" number on the statistics screen with a
full-width per-variant breakdown: the highest-scoring play in each variant
the player has played, drawn as game tiles (a wildcard shows its letter but
no value), with the words and scores right-aligned to shared edges.

- backend: new account_best_move table (PK account_id+variant) keeping the
  main word as JSON tiles {letter,value,blank}; captured at game finish in
  buildStats (blank flags taken from every placed blank — equivalent to the
  final board), upserted in the finish transaction and replaced only by a
  strictly higher-scoring play. Guest/honest-AI games still record nothing.
  GetStats + statsDTO expose best_moves.
- wire: StatsView gains best_moves:[BestMoveView{variant,score,word:[BestMoveTile]}]
  (trailing, backward-compatible); gateway encodeStats + UI codec updated.
- ui: new WordTiles component (board's tile look, fixed px size); Stats.svelte
  drops the maxWord card and adds the full-width best-move card (catalogue
  order, empty variants omitted).
- docs: ARCHITECTURE §9 + schema, FUNCTIONAL (+ru), UI_DESIGN.

Tests: mainWordTiles unit + buildStats end-to-end (inttest) + gateway and UI
codec round-trips (incl. a blank tile) + e2e.
2026-06-17 15:29:55 +02:00
306 changed files with 3614 additions and 17246 deletions
+40 -64
View File
@@ -26,13 +26,6 @@ on:
push: push:
branches: [development] branches: [development]
# The dictionary release the test suite validates against — the current
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env:
DICT_VERSION: v1.2.1
jobs: jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when # 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. # irrelevant. It defaults to running everything when the diff cannot be computed.
@@ -93,6 +86,7 @@ jobs:
# The engine consumes the published scrabble-solver module from this Gitea; # The engine consumes the published scrabble-solver module from this Gitea;
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB). # GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
GOPRIVATE: gitea.iliadenisov.ru/* GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -140,6 +134,7 @@ jobs:
# suite's TestMain terminates its own container, so disable it. # suite's TestMain terminates its own container, so disable it.
TESTCONTAINERS_RYUK_DISABLED: "true" TESTCONTAINERS_RYUK_DISABLED: "true"
GOPRIVATE: gitea.iliadenisov.ru/* GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -259,24 +254,25 @@ jobs:
AWG_CONF: ${{ secrets.TEST_AWG_CONF }} AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }} TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }} TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }} TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
# The test contour always uses Telegram's test environment — pinned here, # The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. The prod workflow leaves it false. # not an operator variable. The prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true" TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
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 }} 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. # Unset vars render empty -> the compose ":-" defaults apply.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }} POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
@@ -293,82 +289,62 @@ jobs:
mkdir -p "$conf" mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/ cp -r caddy otelcol prometheus tempo grafana "$conf"/
export SCRABBLE_CONFIG_DIR="$conf" export SCRABBLE_CONFIG_DIR="$conf"
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
# together so they always share the fresh CA (see deploy/gen-certs.sh).
bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
# App version for the About screen: the git tag if present, else the short SHA # 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). # (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)" export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
# The telegram-local profile brings the bot + its VPN sidecar; prod runs the docker compose --ansi never build --progress plain
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod docker compose --ansi never up -d --remove-orphans
# main host omits both. Without the profile they would not start here.
docker compose --ansi never --profile telegram-local build --progress plain
docker compose --ansi never --profile telegram-local up -d --remove-orphans
# The config-only services bind-mount the reseeded config dir. A plain `up -d` # 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 # 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 # changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
# pick up the fresh config. # pick up the fresh config.
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
- name: Probe the landing, gateway and backend - name: Probe the landing and the gateway through caddy
run: | run: |
set -u set -u
# Three probes. "/" is the static landing container and "/app/" the # Two probes through the contour caddy: "/" is the static
# gateway-served SPA shell (both through the contour caddy on the edge net). # landing container, "/app/" is the gateway-served SPA shell.
# The backend /readyz is probed on the internal net as well: the caddy probes
# are blind to a crash-looping backend (the landing is static and the SPA
# shell is served without it), which let a bad deploy go green while the
# backend was down — so check it directly here.
for i in $(seq 1 20); do 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/ && if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ && docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)"
echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz"
exit 0 exit 0
fi fi
sleep 3 sleep 3
done done
echo "probe failed; recent landing + gateway + backend logs:" echo "probe failed; recent landing + gateway logs:"
docker logs --tail 50 scrabble-landing || true docker logs --tail 50 scrabble-landing || true
docker logs --tail 50 scrabble-gateway || true docker logs --tail 50 scrabble-gateway || true
docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the Telegram validator and bot liveness - name: Probe the Telegram connector liveness
run: | run: |
set -u set -u
# The gateway/backend probes cannot see a crash-looping validator or bot # The gateway probe cannot see a crash-looping connector (it long-polls and
# (the validator answers only internal gRPC; the bot long-polls + egresses # egresses through the VPN sidecar, with no public ingress). Inspect the
# through the VPN sidecar with no public ingress). Inspect the containers # container directly: it must be running, not restarting, with a stable
# directly: each must be running, not restarting, with a stable restart # restart count. A grace period lets the VPN handshake settle (the connector
# count. A grace period lets the VPN handshake and the bot-link dial settle. # may restart a few times first).
sleep 20 sleep 20
for name in scrabble-telegram-validator scrabble-telegram-bot; do for i in $(seq 1 20); do
ok= status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
for i in $(seq 1 20); do restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)" if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)" c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then sleep 5
c1="$(docker inspect -f '{{.RestartCount}}' "$name")" c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
sleep 5 if [ "$c1" = "$c2" ]; then
c2="$(docker inspect -f '{{.RestartCount}}' "$name")" echo "connector healthy: status=$status restarts=$c2"
if [ "$c1" = "$c2" ]; then exit 0
echo "$name healthy: status=$status restarts=$c2"
ok=1
break
fi
echo "$name still restarting ($c1 -> $c2); waiting"
fi fi
sleep 3 echo "connector still restarting ($c1 -> $c2); waiting"
done
if [ -z "$ok" ]; then
echo "$name not healthy; recent logs:"
docker logs --tail 80 "$name" || true
exit 1
fi fi
sleep 3
done done
echo "connector not healthy; recent logs:"
docker logs --tail 80 scrabble-telegram || true
exit 1
- name: Prune dangling images - name: Prune dangling images
if: always() if: always()
-266
View File
@@ -1,266 +0,0 @@
# Manual production rollout. Runs ONLY from master, ONLY on workflow_dispatch with
# confirm=deploy (development->master is merged + green first; this is the separate,
# deliberate prod step). Visible sequential jobs from most to least significant:
# build -> deploy-main -> deploy-bot -> verify
# The per-service rolling (postgres->backend->gateway->landing->validator->caddy),
# health-gating and auto-rollback live in deploy/prod-deploy.sh on the main host and
# show in the deploy-main log. Manual post-deploy rollback is prod-rollback.yaml.
# See deploy/README.md (prod runbook).
name: prod-deploy
run-name: "prod deploy ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "deploy" to confirm a production rollout from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
DOCKER_CLI_HINTS: "false"
REGISTRY: docker.iliadenisov.ru/developer
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'deploy' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
tag: ${{ steps.ver.outputs.tag }}
env:
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version tag
id: ver
run: echo "tag=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- name: Registry login
run: echo "$PROD_REGISTRY_PASSWORD" | docker login "${REGISTRY%%/*}" -u "$PROD_REGISTRY_USER" --password-stdin
- name: Build and push images
working-directory: deploy
run: |
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
# The four main-stack images via compose (reuses the build args, incl. VERSION);
# the bot separately, since it is profiled out of the prod compose.
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
deploy-main:
needs: build
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
TAG: ${{ needs.build.outputs.tag }}
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Determine previous tag and migration
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
PREV_TAG="$(ssh_main 'cat /opt/scrabble/DEPLOYED_TAG 2>/dev/null || echo none')"
MIGRATION=0
if [ "$PREV_TAG" != none ]; then
if ! git cat-file -e "$PREV_TAG^{commit}" 2>/dev/null; then
MIGRATION=1
elif git diff --name-only "$PREV_TAG..$TAG" -- backend/internal/postgres/migrations/ | grep -q .; then
MIGRATION=1
fi
fi
{ echo "PREV_TAG=$PREV_TAG"; echo "MIGRATION=$MIGRATION"; } >> "$GITHUB_ENV"
echo "prev=$PREV_TAG migration=$MIGRATION"
- name: Render main env + certs
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
chmod 644 stage/certs-main/*
- name: Deploy the main host
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
| ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.sh "deploy@$MAIN_HOST:/opt/scrabble/env.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_main "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
ssh_main "TAG='$TAG' PREV_TAG='$PREV_TAG' MIGRATION='$MIGRATION' bash /opt/scrabble/compose/prod-deploy.sh"
deploy-bot:
needs: [build, deploy-main]
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
TAG: ${{ needs.build.outputs.tag }}
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
TG_HOST: ${{ vars.PROD_TG_HOST }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_PROMO_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
- uses: actions/checkout@v4
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Render bot env + certs
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TAG'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
chmod 644 stage/certs-bot/*
- name: Deploy the bot host
run: |
ssh_tg() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$TG_HOST" "$@"; }
ssh_tg 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.bot.yml | ssh_tg 'tar -C /opt/scrabble/compose -xzf -'
tar -C stage -czf - certs-bot \
| ssh_tg 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.bot.sh "deploy@$TG_HOST:/opt/scrabble/env.bot.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_tg "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
ssh_tg 'set -a; . /opt/scrabble/env.bot.sh; set +a; cd /opt/scrabble/compose;
docker compose -f docker-compose.bot.yml pull;
docker compose -f docker-compose.bot.yml up -d'
ssh_tg 'for i in $(seq 1 20); do
s=$(docker inspect -f "{{.State.Status}}" scrabble-telegram-bot 2>/dev/null || echo missing)
r=$(docker inspect -f "{{.State.Restarting}}" scrabble-telegram-bot 2>/dev/null || echo true)
if [ "$s" = running ] && [ "$r" = false ]; then
c1=$(docker inspect -f "{{.RestartCount}}" scrabble-telegram-bot); sleep 5
c2=$(docker inspect -f "{{.RestartCount}}" scrabble-telegram-bot)
[ "$c1" = "$c2" ] && { echo "bot healthy"; exit 0; }
fi
sleep 3
done
echo "bot not healthy:"; docker logs --tail 80 scrabble-telegram-bot; exit 1'
verify:
needs: [deploy-main, deploy-bot]
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
steps:
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Verify the public site
run: |
domain="${CADDY_SITE_ADDRESS%% *}"
ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "for i in \$(seq 1 20); do
if curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/ -o /dev/null &&
curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/app/ -o /dev/null &&
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
echo 'public site + /app/ + backend healthy'; exit 0
fi
sleep 5
done
echo 'public verify failed; recent caddy + gateway + backend logs:'
docker logs --tail 40 scrabble-caddy; docker logs --tail 40 scrabble-gateway; docker logs --tail 40 scrabble-backend
exit 1"
-223
View File
@@ -1,223 +0,0 @@
# Manual production rollback. Runs ONLY from master, ONLY on workflow_dispatch with
# confirm=rollback. Re-deploys an already-published image tag (no build): leave
# target_version blank to roll back to the previously deployed version (read from the
# main host), or set it to a specific release tag from the Releases page. The
# re-deploy is the same rolling, health-gated path as prod-deploy (TAG=target,
# MIGRATION=0 — rollback is image-only and never migrates the DB; image rollback is
# DB-safe under the expand-contract rule). See deploy/README.md (prod runbook).
name: prod-rollback
run-name: "prod rollback ${{ inputs.target_version || 'previous' }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "rollback" to confirm a production rollback.'
required: true
default: ""
target_version:
description: "Release tag to roll back to (blank = the previous deployed version)."
required: false
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
DOCKER_CLI_HINTS: "false"
REGISTRY: docker.iliadenisov.ru/developer
jobs:
rollback-main:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'rollback' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
outputs:
target: ${{ steps.resolve.outputs.target }}
env:
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
GM_BASICAUTH_USER: ${{ vars.PROD_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.PROD_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
DICT_VERSION: ${{ vars.PROD_DICT_VERSION }}
POSTGRES_DB: ${{ vars.PROD_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.PROD_POSTGRES_USER }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
INPUT_TARGET: ${{ inputs.target_version }}
steps:
- uses: actions/checkout@v4
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Resolve rollback target
id: resolve
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
CURRENT="$(ssh_main 'cat /opt/scrabble/DEPLOYED_TAG 2>/dev/null || echo none')"
if [ -n "$INPUT_TARGET" ]; then
TARGET="$INPUT_TARGET"
else
TARGET="$(ssh_main 'cat /opt/scrabble/PREVIOUS_TAG 2>/dev/null || echo none')"
fi
if [ -z "$TARGET" ] || [ "$TARGET" = none ]; then
echo "no rollback target (no PREVIOUS_TAG on the host and no target_version input)"; exit 1
fi
if [ "$TARGET" = "$CURRENT" ]; then
echo "target $TARGET is already the deployed version; nothing to do"; exit 1
fi
echo "rolling back: current=$CURRENT -> target=$TARGET"
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
{ echo "TARGET=$TARGET"; echo "CURRENT=$CURRENT"; } >> "$GITHUB_ENV"
- name: Render main env + certs
run: |
umask 077
mkdir -p stage/certs-main
cat > stage/env.sh <<EOF
export REGISTRY='$REGISTRY'
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export POSTGRES_DB='${POSTGRES_DB:-scrabble}'
export POSTGRES_USER='${POSTGRES_USER:-scrabble}'
export POSTGRES_PASSWORD='$POSTGRES_PASSWORD'
export GM_BASICAUTH_USER='${GM_BASICAUTH_USER:-gm}'
export GM_BASICAUTH_HASH='$GM_BASICAUTH_HASH'
export GRAFANA_ADMIN_PASSWORD='$GRAFANA_ADMIN_PASSWORD'
export GRAFANA_ROOT_URL='$GRAFANA_ROOT_URL'
export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$TARGET'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_CERT" > stage/certs-main/gateway.crt
printf '%s\n' "$PROD_BOTLINK_GATEWAY_KEY" > stage/certs-main/gateway.key
chmod 644 stage/certs-main/*
- name: Roll the main host back
run: |
ssh_main() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "$@"; }
ssh_main 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.yml docker-compose.prod.yml prod-deploy.sh \
| ssh_main 'tar -C /opt/scrabble/compose -xzf -'
tar -C deploy -czf - caddy otelcol prometheus tempo grafana \
| ssh_main 'tar -C /opt/scrabble -xzf -'
tar -C stage -czf - certs-main \
| ssh_main 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.sh "deploy@$MAIN_HOST:/opt/scrabble/env.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_main "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
# Image-only rollback: no migration window (TAG=target, MIGRATION=0). A failed
# rollback's auto-revert returns to the current version (PREV_TAG=$CURRENT).
ssh_main "TAG='$TARGET' PREV_TAG='$CURRENT' MIGRATION=0 bash /opt/scrabble/compose/prod-deploy.sh"
rollback-bot:
needs: rollback-main
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
TARGET: ${{ needs.rollback-main.outputs.target }}
PROD_REGISTRY_USER: ${{ vars.PROD_REGISTRY_USER }}
PROD_REGISTRY_PASSWORD: ${{ secrets.PROD_REGISTRY_PASSWORD }}
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
TG_HOST: ${{ vars.PROD_TG_HOST }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_PROMO_BOT_TOKEN }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_BOT_CERT: ${{ secrets.PROD_BOTLINK_BOT_CERT }}
PROD_BOTLINK_BOT_KEY: ${{ secrets.PROD_BOTLINK_BOT_KEY }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
- uses: actions/checkout@v4
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Render bot env + certs
run: |
umask 077
mkdir -p stage/certs-bot
cat > stage/env.bot.sh <<EOF
export SCRABBLE_CONFIG_DIR='/opt/scrabble'
export BOT_IMAGE='$REGISTRY/scrabble-telegram-bot:$TARGET'
export BOTLINK_GATEWAY_ADDR='$MAIN_HOST:9443'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
export LOG_LEVEL='${LOG_LEVEL:-info}'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-bot/ca.crt
printf '%s\n' "$PROD_BOTLINK_BOT_CERT" > stage/certs-bot/bot.crt
printf '%s\n' "$PROD_BOTLINK_BOT_KEY" > stage/certs-bot/bot.key
chmod 644 stage/certs-bot/*
- name: Roll the bot host back
run: |
ssh_tg() { ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$TG_HOST" "$@"; }
ssh_tg 'mkdir -p /opt/scrabble/compose'
tar -C deploy -czf - docker-compose.bot.yml | ssh_tg 'tar -C /opt/scrabble/compose -xzf -'
tar -C stage -czf - certs-bot \
| ssh_tg 'rm -rf /opt/scrabble/certs && mkdir -p /opt/scrabble/certs && tar -C /opt/scrabble/certs --strip-components=1 -xzf -'
scp -i ~/.ssh/id_deploy -o BatchMode=yes stage/env.bot.sh "deploy@$TG_HOST:/opt/scrabble/env.bot.sh"
echo "$PROD_REGISTRY_PASSWORD" | ssh_tg "docker login ${REGISTRY%%/*} -u $PROD_REGISTRY_USER --password-stdin"
ssh_tg 'set -a; . /opt/scrabble/env.bot.sh; set +a; cd /opt/scrabble/compose;
docker compose -f docker-compose.bot.yml pull;
docker compose -f docker-compose.bot.yml up -d'
verify:
needs: [rollback-main, rollback-bot]
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
MAIN_HOST: ${{ vars.PROD_MAIN_HOST }}
CADDY_SITE_ADDRESS: ${{ vars.PROD_CADDY_SITE_ADDRESS }}
steps:
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$PROD_SSH_KEY" > ~/.ssh/id_deploy && chmod 600 ~/.ssh/id_deploy
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Verify the public site
run: |
domain="${CADDY_SITE_ADDRESS%% *}"
ssh -i ~/.ssh/id_deploy -o BatchMode=yes "deploy@$MAIN_HOST" "for i in \$(seq 1 20); do
if curl -fsS -k --resolve $domain:443:127.0.0.1 https://$domain/ -o /dev/null &&
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
echo 'rolled-back site healthy'; exit 0
fi
sleep 5
done
echo 'verify failed'; docker logs --tail 40 scrabble-caddy; docker logs --tail 40 scrabble-backend; exit 1"
-4
View File
@@ -17,9 +17,5 @@
**/.env.local **/.env.local
**/.env.*.local **/.env.*.local
# Bot-link mTLS material: private keys never belong in the repo. The test contour
# generates them with deploy/gen-certs.sh; prod supplies them from PROD_ secrets.
deploy/certs/
# Claude Code harness runtime artifacts # Claude Code harness runtime artifacts
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
+3 -3
View File
@@ -125,9 +125,9 @@ backend/ # module scrabble/backend
internal/inttest/ # //go:build integration Postgres-backed tests internal/inttest/ # //go:build integration Postgres-backed tests
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ # added by their stages gateway/ ui/ pkg/ # added by their stages
platform/telegram/ # Telegram side-service, two binaries (Stage 9; split in phase TX): cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link) platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2) loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2)
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile has the `landing` target (R3), platform/telegram/Dockerfile has `validator`+`bot` targets (TX) backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile also has the `landing` target (R3)
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
``` ```
@@ -138,7 +138,7 @@ go build ./backend/... # per module ('./...' from the root won't span t
go vet ./backend/... go vet ./backend/...
gofmt -l . # must print nothing gofmt -l . # must print nothing
go test -count=1 ./backend/... go test -count=1 ./backend/...
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot (Stage 9; split in TX) go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9)
go run ./backend/cmd/backend # /healthz, /readyz on :8080 go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+) cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
+14 -30
View File
@@ -51,7 +51,7 @@ independent (see ARCHITECTURE §9.1).
| 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) | **done** | | 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
| 17 | Test-contour verification & defect fixes | **done** | | 17 | Test-contour verification & defect fixes | **done** |
| 18 | Prod contour deploy (registry, two-host, rolling + auto-rollback; manual after merge) | machinery built; first cutover pending DNS | | 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** | | 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** |
Scaffolding is incremental: `go.work` lists only existing modules; each stage Scaffolding is incremental: `go.work` lists only existing modules; each stage
@@ -281,12 +281,6 @@ 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`.
> **Superseded (2026-06-20, pre-release phase SB):** the two bots collapsed into **one** and the
> language-gated variant choice moved to a per-user **`variant_preferences`** profile set (default Erudit
> only). `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars,
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` and game-language push routing are gone; the single bot renders in
> the recipient's `preferred_language`. Current state: `docs/ARCHITECTURE.md` §3/§10, `PRERELEASE.md` row SB.
### Stage 16 — Deploy infra & test contour *(done)* ### 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
@@ -413,28 +407,18 @@ raw list is kept here as the record of what the first contour run surfaced.
"что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится. "что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится.
### Stage 18 — Prod contour deploy ### Stage 18 — Prod contour deploy
Scope: the **production contour** on **two remote hosts** over SSH — main (full stack, `erudit-game.ru`) Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
and tg (the bot only). Resolved open details (re-interviewed): (`docker save``scp`/ssh → `docker load``docker compose up` on the remote), the SSH key + host IP
- **Transport: a registry** (not export/import) — build + push to `docker.iliadenisov.ru`, the hosts pull by tag. in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master`
- **Cert: ACME** at the contour caddy (`CADDY_SITE_ADDRESS=erudit-game.ru www.erudit-game.ru`, no host caddy). (the Stage 16 branch model: `feature/* → development → master`, merge gated green). Two-contour config
- **No prod VPN** — the bot host has native Bot API egress (verified `api.telegram.org` → 200). uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no deployment environments (verified:
- **Rollback** — rolling per-service deploy (least → most dependent), health-gated, auto-rollback to the the `environments` API 404s), so a flat prefixed namespace is the convention.
previous image tag; a maintenance window + consistent `pg_dump` only on a schema migration Reuses the Stage 16 `deploy/docker-compose.yml` as-is, mapping the **`PROD_`** set onto the same
(expand-contract keeps the auto-rollback image-only; the dump is a manual safety net). 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
**Strictly manual** (`workflow_dispatch` from `master`, `confirm=deploy`) after `development → master` parameterised for this; the test contour leaves it `:80` behind the host caddy).
is merged green. `TEST_`/`PROD_` prefixed Gitea secrets/variables (Gitea 1.26 has no deployment Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
environments — the `environments` API 404s). Hosts are provisioned by **`deploy/ansible/`** (docker, a provided cert) at the contour caddy; prod VPN; rollback.
non-sudo `deploy` user with the CI key, key-only sshd, ufw, fail2ban). The main host is **launch-sized**
(2 vCPU / 1.9 GiB): `docker-compose.prod.yml` trims the R7 limits (`GOMAXPROCS=2`, smaller caps, 7d
Prometheus retention) and adds `node_exporter` for host-memory monitoring (launch undersized, resize at
Selectel reactively). `vpn`+`bot` are gated to a `telegram-local` compose profile (test only); the prod
bot runs standalone from `docker-compose.bot.yml`. `GATEWAY_ABUSE_BAN_ENABLED=true`.
**Built:** `deploy/ansible/` (both hosts provisioned + verified), the compose split + `node_exporter`,
`.gitea/workflows/prod-deploy.yaml` + `deploy/prod-deploy.sh`, the full `PROD_` secret/variable set.
**Remaining (acceptance):** the **first live cutover** — waits on the `erudit-game.ru` DNS delegation
(`A`/`www` → the main host) that ACME requires; then run the workflow and verify the public site end-to-end.
### Stage 19 — User feedback *(done)* ### Stage 19 — User feedback *(done)*
A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18 A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18
@@ -792,7 +776,7 @@ cannot submit; three-way admin filter.
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms); reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a width-based zoom in a fixed viewport (real native board **zoom reworked** to a width-based zoom in a fixed viewport (real native
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
tiles, last-word dark-tile highlight, and a Settings tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
**bonus-label style** (beginner/ **bonus-label style** (beginner/
classic/none); **hint lays its tiles on the board** (no spend when no move — a new classic/none); **hint lays its tiles on the board** (no spend when no move — a new
`no_hint_available` result code); the history opens as an in-place **slide-down** `no_hint_available` result code); the history opens as an in-place **slide-down**
+3 -87
View File
@@ -35,13 +35,6 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) | | AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** | | GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** | | CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs, an SSH deploy of both hosts together — is **built in Stage 18** (the two-host registry rollout; first cutover pending the `erudit-game.ru` DNS). | owner ad-hoc | **done** (code + test contour; prod wiring built — Stage 18) |
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban on in prod via Stage 18 — machinery built, cutover pending DNS) |
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## Key findings (these reshaped the raw list — read before starting a phase)
@@ -85,19 +78,6 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** - **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag**
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold, a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
**no auto-ban**. **no auto-ban**.
- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS*
defence — it detects/deceives but does not shed volumetric load, cannot cover the real
endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern,
out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot
and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision
governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed,
**prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the
existing `ratelimit` package (smallest surface); the decoy path list is a **single source of
truth in the caddy** (it tags requests with a header — the gateway keeps no second list);
bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin
console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice).
An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total`
counter cover it) to keep the diff focused.
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside - **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
joins it, or a robot fills it after a **90 s + random 090 s** wait, pushing the in-app joins it, or a robot fills it after a **90 s + random 090 s** wait, pushing the in-app
@@ -106,20 +86,6 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
empty seat. empty seat.
- **Telegram egress off-host (TX, owner ad-hoc):** the driver is **removing VPN/Telegram
traffic from the main host** (OPSEC / one fewer analysis vector), not only notification
resilience. Login is local HMAC, so it stays up regardless of the bot — confirmed in the
code and made structural by the split. **Unified topology in code** (validator + bot, the
bot dialing the gateway) in **both** contours, differing only in deployment; the test bot
keeps its VPN sidecar. Transport = a **reverse gRPC bidi stream, mTLS, bot-dials-gateway**
(no inbound/static IP on the bot), reusing the push-stream pattern; **webhook rejected**
(one URL per token, adds inbound + a static address). Delivery **at-most-once** (a dropped
nudge beats a duplicate). **One bot now**, seams (registry + `owns_updates` + command ids)
for N later. **Cert rotation** by a scheduled CI job from a long-lived CA. **Prod deploy by
SSH** (pull excluded), the bot rolled **together** with the main app (the bot-link protocol
kept back-compatible by one version as the non-atomic-two-host-deploy safety net). The bot
is monitored **from the gateway** (connection + ack metrics). The bot-host token-at-rest is
**accepted**.
## Phases ## Phases
@@ -311,7 +277,7 @@ Then Stage 18.
hammer (99.97 % rejected, p99 2 ms). **Top finding:** ~14 % `transport_error` on `game.state` at 500 hammer (99.97 % rejected, p99 2 ms). **Top finding:** ~14 % `transport_error` on `game.state` at 500
players, under CPU saturation (backend/gateway/Postgres each ~1 core) and amplified by the harness's players, under CPU saturation (backend/gateway/Postgres each ~1 core) and amplified by the harness's
single shared `http2.Transport`; the harness itself peaked at 86 % of a core on the same host, so the single shared `http2.Transport`; the harness itself peaked at 86 % of a core on the same host, so the
figures are pessimistic. Full trip report in [`../loadtest/REPORT.md`](../loadtest/REPORT.md); figures are pessimistic. Full trip report in [`../loadtest/REPORT-R2.md`](../loadtest/REPORT-R2.md);
it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports, it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports,
separate hardware, pool/limit sizing). separate hardware, pool/limit sizing).
- **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps. - **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps.
@@ -454,12 +420,7 @@ Then Stage 18.
one connection per player it bursts into its 2-core cap (the residual 2.49 % `transport_error`); backend one connection per player it bursts into its 2-core cap (the residual 2.49 % `transport_error`); backend
~0.85 core and postgres ~1.4 cores had headroom; **tempo reached its 1 GiB cap**; the backend pool sat at ~0.85 core and postgres ~1.4 cores had headroom; **tempo reached its 1 GiB cap**; the backend pool sat at
its `MaxOpenConns=25` cap (28 backends); docker logs were unbounded (~14 MiB / 30 min on the backend at its `MaxOpenConns=25` cap (28 backends); docker logs were unbounded (~14 MiB / 30 min on the backend at
info). Full write-up in [`../loadtest/REPORT.md`](../loadtest/REPORT.md). *(Superseded in part: a info). Full write-up in [`../loadtest/REPORT-R7.md`](../loadtest/REPORT-R7.md).
later pass modelling the `game.evaluate` hot path traced the gateway's CPU appetite to
**gateway→backend connection churn** — the default 2-idle-connection HTTP transport — not proxying
work. Pooling the connections cut peak gateway CPU ~7× (~1.75 → ~0.26 cores at 500 players) and
removed the ephemeral-port-exhaustion cliff behind the residual `transport_error`, so the gateway is
no longer the binding constraint — postgres is. The 3-core gateway cap below is now generous headroom.)*
- **Round-2 tuning (owner-agreed, all in `deploy/docker-compose.yml`, no code change):** gateway **2 → 3 - **Round-2 tuning (owner-agreed, all in `deploy/docker-compose.yml`, no code change):** gateway **2 → 3
cores + `GOMAXPROCS=3`**; tempo memory **1 → 2 GiB**; backend `MAX_OPEN_CONNS` **25 → 40**; a json-file cores + `GOMAXPROCS=3`**; tempo memory **1 → 2 GiB**; backend `MAX_OPEN_CONNS` **25 → 40**; a json-file
**log-rotation** default (10m × 3) applied contour-wide via a YAML anchor (level stays info). **log-rotation** default (10m × 3) applied contour-wide via a YAML anchor (level stays info).
@@ -469,7 +430,7 @@ Then Stage 18.
**burst** run (a single 100 → 500 jump) pegged the gateway at 3 cores (≈296 % sustained, 9.27 % error), **burst** run (a single 100 → 500 jump) pegged the gateway at 3 cores (≈296 % sustained, 9.27 % error),
confirming it is **connection-CPU-bound** — a true arrival spike is a **horizontal-scaling** lever, not confirming it is **connection-CPU-bound** — a true arrival spike is a **horizontal-scaling** lever, not
more cores per node (recorded in the prod-sizing recommendation). more cores per node (recorded in the prod-sizing recommendation).
- **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT.md`, `loadtest/README.md`, - **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT-R7.md` (new), `loadtest/README.md`,
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`. `docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
- **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger - **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger
@@ -585,48 +546,3 @@ Then Stage 18.
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway (`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md` transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`. (+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list):
- **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no
bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the
bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a
temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new
**`chat_muted` role** is a chat-only mute with the **game suspension dominating**
(`eligible = registered AND NOT suspended AND NOT chat_muted`).
- **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the
main bot's `t.me/<bot>?startapp` link — a `web_app` button signs initData with the *sending* bot's token,
which the main validator rejects — so the promo button reuses the UI's `VITE_TELEGRAM_LINK`. `chat_member`
updates arrive **only** when the bot is a chat **admin** with the "Ban users" right (the client label for the
Bot API `can_restrict_members`) and `chat_member` is in `allowed_updates`; bots cannot list members but can
`getChatMember` a single user, which is the membership guard on the block/unblock path.
- **Wire:** `pkg/proto/botlink/v1` gains a `ChatGateCommand` in the `Command` oneof and a unary
`ResolveChatEligibility`; the backend gains `notify.KindChatAccessChanged` (no payload, infra-only — never an
out-of-app message) and an internal `POST /api/v1/internal/chat-access` resolver; the gateway resolves the
join (by external_id) and the event (by user_id) through it and pushes the chat-gate command fire-and-forget
(at-most-once, recovered by the next moderation action or a re-join).
- **No schema change → no contour DB wipe:** `chat_muted` is a new `account.KnownRoles` entry (the
`account_roles` table is data-driven). The suspension-expiry sweeper is a new `account.SuspensionSweeper`
(a 1-minute window, idempotent) started in `cmd/backend`, alongside the guest reaper.
- **Deploy:** new `TEST_`/`PROD_` `TELEGRAM_PROMO_BOT_TOKEN` (secret), `TELEGRAM_BOT_USERNAME` and
`TELEGRAM_CHAT_ID` (variables); the promo link reuses the existing `*_VITE_TELEGRAM_LINK` variable as
`TELEGRAM_BOT_LINK`. The bot must be promoted to admin in the real discussion group, and the group default
set to no-send, as part of the Stage 18 prod cutover (the test contour exercises the code path).
- **Bake-back:** `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `platform/telegram/README.md`,
`backend/README.md`, Go Doc comments. Tests: backend resolver truth table + publish on block/unblock/role +
the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot
`chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config
parsing.
- **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy
inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot
work: Telegram intersects the chat default with each user's permission, so a per-user grant never
exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write).
The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible
member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted,
acting only when the current state differs (idempotent; the bot's own change is skipped by matching
the actor id to the bot). A present member in a default-allow group can appear as `restricted` with
`is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before
registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact
and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup
self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured
`TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at
Debug, the actual mute/unmute and warnings at Info.
+3 -5
View File
@@ -12,7 +12,7 @@
# --- dictionary artifact ----------------------------------------------------- # --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.2.1 ARG DICT_VERSION=v1.0.0
RUN apk add --no-cache curl tar RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \ RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \ && curl -fsSL -o /tmp/dawg.tar.gz \
@@ -33,16 +33,14 @@ COPY backend ./backend
# Reduce the workspace to what the backend needs: backend + pkg. loadtest and the # Reduce the workspace to what the backend needs: backend + pkg. loadtest and the
# gateway replace it requires are not in this context, so drop both. # gateway replace it requires are not in this context, so drop both.
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0 RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0
# VERSION (the deploy passes the git tag) is stamped into the binary via the linker. RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/backend ./backend/cmd/backend
# --- runtime ----------------------------------------------------------------- # --- runtime -----------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
# Re-declare the build arg in this stage so it labels the seed dictionary. One # Re-declare the build arg in this stage so it labels the seed dictionary. One
# DICT_VERSION drives both the artifact the dawg stage downloads and the version # DICT_VERSION drives both the artifact the dawg stage downloads and the version
# label the binary pins, so the resident version equals the release tag. # label the binary pins, so the resident version equals the release tag.
ARG DICT_VERSION=v1.2.1 ARG DICT_VERSION=v1.0.0
COPY --from=build /out/backend /usr/local/bin/backend COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console # mounted at /opt/dawg inherits this ownership on first use, so the admin console
+14 -41
View File
@@ -45,9 +45,6 @@ including obfuscated forms) and stored with the sender's IP. Each message carrie
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead` `unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer` clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
clears a nudge when its recipient moves — both record the publish-to-read latency. clears a nudge when its recipient moves — both record the publish-to-read latency.
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
background reaper drops a robot friend request once its game has been finished for **7 days**.
`internal/account` `internal/account`
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
development log mailer). The engine now also handles **multi-player drop-out**: in development log mailer). The engine now also handles **multi-player drop-out**: in
@@ -71,10 +68,7 @@ win (≈ 40%), targets a small score margin — with an occasional off-strategy
none as the bag empties — and times its moves with a move-number-aware none as the bag empties — and times its moves with a move-number-aware
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge 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. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound state. A background **reaper** seats a pooled robot (matching the game's language) in any open
to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think
time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal
delay, so a decided game is not dragged out while the robot never moves slower than usual. A background **reaper** seats a pooled robot (matching the game's language) in any open
game whose wait window — a fixed **90 s** plus a random **090 s** (so **90180 s**) — has game whose wait window — a fixed **90 s** plus a random **090 s** (so **90180 s**) — has
elapsed, and the waiting starter is told an opponent took the seat by an in-app elapsed, and the waiting starter is told an opponent took the seat by an in-app
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and **opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
@@ -103,16 +97,9 @@ second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) str
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login route out-of-app push to the Telegram connector; the Telegram login
seeds a new account's language and display name from the launch fields, and the seeds a new account's language and display name from the launch fields, and the
`accounts.notifications_in_app_only` flag (default true). `accounts.notifications_in_app_only` flag (default true).
The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the
bot's join-time query) or an account id (a `chat_access_changed` event) to its
**moderated-chat write eligibility**`registered AND NOT suspended AND NOT chat_muted`.
That event is emitted on an admin block/unblock, a `chat_muted` role grant/revoke, or — via
the `account.SuspensionSweeper` started in `cmd/backend` — a temporary block lapsing;
`chat_muted` is an `account.KnownRoles` entry, a chat-only mute distinct from the game
suspension (which dominates it).
`accounts.is_guest` marks an ephemeral guest — a durable row `accounts.is_guest` marks an ephemeral guest — a durable row
with no identity, excluded from statistics. The server-rendered with no identity, excluded from statistics. The server-rendered
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`; **admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
@@ -123,15 +110,16 @@ pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar
archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` + archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` +
`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the `engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the
`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a `BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a
backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
**bot-link relay** — each broadcast renders through the bot in an operator-chosen language broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
and the relay awaits the bot's delivery ack. There is one bot, holds the language tag of the bot a Telegram
so `/internal/push-target` returns the recipient's `preferred_language` as the render user last signed in through, written on every login and returned by
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + `/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
to the right bot. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional `/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs && !no_banner` role, the message language picked by `service_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module. contracts live in the sibling [`../pkg`](../pkg) module.
@@ -155,13 +143,6 @@ rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a `accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
**Clear** action on the user card; never an automatic ban). **Clear** action on the user card; never an automatic ban).
The gateway also syncs its active IP bans (prod-only — see ARCHITECTURE §11) to
`POST /api/v1/internal/bans/sync`; `internal/banview` mirrors them for the console's
**Throttled** page (an **Active IP bans** panel with an **Unban** action) and returns
the operator's pending unbans in the response, which the gateway applies on its next
sync. Like `ratewatch` it is in-memory and resets on restart — the enforced ban lives
in the gateway, not here.
## Package layout ## Package layout
``` ```
@@ -185,9 +166,8 @@ internal/lobby/ # auto-match (DB-backed open games + robot substitution) +
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet) internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts) internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
internal/banview/ # gateway active-ban mirror: the console's Active IP bans panel + the operator unban backchannel
``` ```
## Configuration (environment) ## Configuration (environment)
@@ -206,7 +186,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). | | `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. | | `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. | | `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
| `BACKEND_DICT_VERSION` | `v1` | Version label for the flat dictionary dir. Recorded in a `.seed_version` marker on first boot and authoritative after: on a seeded volume a changed value is ignored (it seeds only a fresh volume) — the seed-drift guard (ARCHITECTURE.md §5). | | `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. | | `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. | | `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. | | `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
@@ -217,7 +197,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | | `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. | | `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | | `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. | | `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
@@ -228,7 +208,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
```sh ```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg): # DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=/tmp/dawg \ BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \ GOPRIVATE='gitea.iliadenisov.ru/*' \
@@ -267,14 +247,7 @@ local solver co-development you may add a temporary replace — see `go.work`).
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary) from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
repo (one semver per set); the engine loads them by `(variant, dict_version)` from repo (one semver per set); the engine loads them by `(variant, dict_version)` from
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency `BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot). The flat directory is the seed version, (a missing dictionary aborts the boot).
labelled `BACKEND_DICT_VERSION`; uploaded versions live in `<version>/`
subdirectories the admin console writes and a restart re-loads. Because the DAWGs
carry no embedded version, the first boot records the seed in a `.seed_version`
marker that is authoritative after: on a seeded volume a changed `BACKEND_DICT_VERSION`
is ignored (it seeds only a fresh volume) — the seed-drift guard — so a live contour's
dictionary is changed through the console, never by bumping the build seed
(ARCHITECTURE.md §5).
## Tests ## Tests
-24
View File
@@ -15,13 +15,11 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge" "scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/ads" "scrabble/backend/internal/ads"
"scrabble/backend/internal/banview"
"scrabble/backend/internal/config" "scrabble/backend/internal/config"
"scrabble/backend/internal/connector" "scrabble/backend/internal/connector"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
@@ -161,15 +159,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Duration("interval", cfg.GuestReapInterval), zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention)) zap.Duration("retention", cfg.GuestRetention))
// Re-evaluate moderated-chat write access when a temporary block self-expires:
// no operator action fires then, so the sweeper emits the chat-access-changed
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
chatSweeper := account.NewSuspensionSweeper(accounts, func(id uuid.UUID) {
hub.Publish(notify.ChatAccessChanged(id))
}, logger)
go chatSweeper.Run(ctx)
logger.Info("suspension expiry sweeper started", zap.Duration("interval", chatSweeper.Interval()))
// Lobby & social domains. Their REST and stream surface lives in the gateway, // Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers. // so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger) mailer := newMailer(cfg.SMTP, logger)
@@ -182,13 +171,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// A nudge the recipient answered by moving is marked read on the move path. // A nudge the recipient answered by moving is marked read on the move path.
games.SetNudgeClearer(socialSvc.ClearNudges) games.SetNudgeClearer(socialSvc.ClearNudges)
// Reap per-game disguised-robot friend requests once their game is long finished
// (the robot ignores them; the row only pins the in-game "request sent" state).
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
go robotReqReaper.Run(ctx)
logger.Info("robot friend request reaper started",
zap.Duration("interval", robotReqReaper.Interval()),
zap.Duration("retention", robotReqReaper.Retention()))
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts) feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub) feedbackSvc.SetNotifier(hub)
@@ -207,7 +189,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger) matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
matchmaker.SetNotifier(hub) matchmaker.SetNotifier(hub)
matchmaker.SetBlocker(socialSvc)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
invitations.SetNotifier(hub) invitations.SetNotifier(hub)
@@ -222,10 +203,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold), zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
zap.Duration("flag_window", cfg.RateWatch.FlagWindow)) zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
// Ban observability: mirror the gateway's active IP bans for the admin console's
// active-bans panel and collect operator unban requests.
banView := banview.New()
// Advertising-banner domain: campaign rotation feeding the profile.get banner // Advertising-banner domain: campaign rotation feeding the profile.get banner
// block and the banner admin console section. // block and the banner admin console section.
adsSvc := ads.NewService(ads.NewStore(db)) adsSvc := ads.NewService(ads.NewStore(db))
@@ -248,7 +225,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
DictDir: cfg.Game.DictDir, DictDir: cfg.Game.DictDir,
Connector: conn, Connector: conn,
RateWatch: rateWatch, RateWatch: rateWatch,
BanView: banView,
Ads: adsSvc, Ads: adsSvc,
Notifier: hub, Notifier: hub,
}) })
+39 -35
View File
@@ -55,12 +55,12 @@ type Account struct {
HintBalance int HintBalance int
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
// VariantPreferences is the set of game variants (engine.Variant stable labels: // ServiceLanguage is the language tag (en/ru) of the bot the account last
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched // authenticated through (its last Telegram ValidateInitData); it routes the
// into. It gates the New Game picker, the matchmaker and the friend-invite the // account's out-of-app push back through the right bot. Empty when the account
// player creates; an invited friend may still accept any variant. A new account // has never signed in through a tagged bot. Distinct from PreferredLanguage (the
// defaults to Erudit only. Never empty — enforced on update and by a DB check. // interface language) and from a game's variant language.
VariantPreferences []string ServiceLanguage string
// IsGuest marks an ephemeral guest account: a durable row with no identity, // IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history. // excluded from statistics, friends and history.
IsGuest bool IsGuest bool
@@ -151,26 +151,14 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
return modelToAccount(row), nil return modelToAccount(row), nil
} }
// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity, // ProvisionTelegram provisions (or finds) the account bound to a Telegram
// reporting whether this call created it (first contact). On first contact only, it // identity. On first contact only, it seeds the new account's preferred language
// seeds the new account's preferred language from the Telegram client languageCode // from the Telegram client languageCode (when it maps to a supported language) and
// (when it maps to a supported language) and its display name sanitized from firstName // its display name sanitized from firstName (falling back to username, then to a
// (falling back to username, then to a generated placeholder when neither yields any // generated placeholder when neither yields any letters); an already-existing
// letters); an already-existing account is returned unchanged, so a later profile edit // account is returned unchanged, so a later profile edit is never overwritten.
// is never overwritten. The created flag lets the auth handler re-evaluate moderated- func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
// chat write access on first registration — the path of a user who joined the chat return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
// before registering, whom no chat_member event covers.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact. A race with a concurrent create only over- or under-reports created for
// that one call, which the idempotent chat-access re-evaluation tolerates.
_, err := s.findByIdentity(ctx, KindTelegram, externalID)
created := errors.Is(err, ErrNotFound)
if err != nil && !created {
return Account{}, false, err
}
acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
return acc, created, err
} }
// provision finds the account for (kind, externalID) or creates it with seed, // provision finds the account for (kind, externalID) or creates it with seed,
@@ -315,14 +303,6 @@ func (s *Store) CountAccounts(ctx context.Context) (int, error) {
return int(dest.Count), nil return int(dest.Count), nil
} }
// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound
// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access
// resolver uses it to tell a registered Telegram user (eligible to be granted chat
// write access) from an unregistered one (left muted).
func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
return s.findByIdentity(ctx, kind, externalID)
}
// findByIdentity joins identities to accounts and returns the matching account, // findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound. // or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) { func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
@@ -511,12 +491,36 @@ func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
return nil return nil
} }
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
// user authenticated through. It is called on every Telegram login — new and
// existing accounts — so it tracks the bot the user last came through (last-login-
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
// (a non-Telegram login carries none) and does not bump updated_at (an infra
// routing field, not a user profile edit).
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
if language == "" {
return nil
}
stmt := table.Accounts.
UPDATE(table.Accounts.ServiceLanguage).
SET(postgres.String(language)).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: set service language %s: %w", id, err)
}
return nil
}
// modelToAccount projects a generated model row into the public Account struct. // modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account { func modelToAccount(row model.Accounts) Account {
var mergedInto uuid.UUID var mergedInto uuid.UUID
if row.MergedInto != nil { if row.MergedInto != nil {
mergedInto = *row.MergedInto mergedInto = *row.MergedInto
} }
var serviceLanguage string
if row.ServiceLanguage != nil {
serviceLanguage = *row.ServiceLanguage
}
var flaggedHighRateAt time.Time var flaggedHighRateAt time.Time
if row.FlaggedHighRateAt != nil { if row.FlaggedHighRateAt != nil {
flaggedHighRateAt = *row.FlaggedHighRateAt flaggedHighRateAt = *row.FlaggedHighRateAt
@@ -525,7 +529,7 @@ func modelToAccount(row model.Accounts) Account {
ID: row.AccountID, ID: row.AccountID,
DisplayName: row.DisplayName, DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage, PreferredLanguage: row.PreferredLanguage,
VariantPreferences: []string(row.VariantPreferences), ServiceLanguage: serviceLanguage,
TimeZone: row.TimeZone, TimeZone: row.TimeZone,
AwayStart: row.AwayStart, AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd, AwayEnd: row.AwayEnd,
+2 -52
View File
@@ -14,7 +14,6 @@ import (
"github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm" "github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table" "scrabble/backend/internal/postgres/jet/backend/table"
@@ -59,46 +58,6 @@ type ProfileUpdate struct {
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
NotificationsInAppOnly bool NotificationsInAppOnly bool
// VariantPreferences is the set of game variants the player allows themselves to
// be matched into (engine.Variant stable labels). UpdateProfile cleans it to a
// deduplicated, canonically ordered subset of the known variants and rejects an
// empty set.
VariantPreferences []string
}
// knownVariants is the closed set of game-variant labels (engine.Variant stable
// labels) a profile's variant preferences may contain. It lives here so the store
// does not depend on the engine package; the server handler additionally validates
// against engine.ParseVariant, and a DB check enforces the same subset.
var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true}
// canonicalVariantOrder is the deterministic order variant preferences are stored
// in (Erudit, Russian Scrabble, English), independent of the client's order.
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
// validateVariantPreferences cleans a profile's variant-preference set: it drops
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
// returns the preferences in canonicalVariantOrder so the stored value is
// deterministic regardless of the order the client sent.
func validateVariantPreferences(prefs []string) ([]string, error) {
seen := make(map[string]bool, len(prefs))
for _, p := range prefs {
p = strings.TrimSpace(p)
if !knownVariants[p] {
return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p)
}
seen[p] = true
}
if len(seen) == 0 {
return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile)
}
out := make([]string, 0, len(seen))
for _, v := range canonicalVariantOrder {
if seen[v] {
out = append(out, v)
}
}
return out, nil
} }
// UpdateProfile validates and overwrites the editable fields of the account, then // UpdateProfile validates and overwrites the editable fields of the account, then
@@ -120,26 +79,17 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil { if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
return Account{}, err return Account{}, err
} }
prefs, err := validateVariantPreferences(p.VariantPreferences)
if err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE( stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd, table.Accounts.AwayStart, table.Accounts.AwayEnd,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences, table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
table.Accounts.UpdatedAt,
).SET( ).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz), postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd), postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
postgres.Bool(p.NotificationsInAppOnly), postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
// prefs are validated against the closed knownVariants set; bind as a text[]
// parameter (lib/pq encodes the array, the cast pins the column type).
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
+1 -23
View File
@@ -3,7 +3,6 @@ package account
import ( import (
"context" "context"
"errors" "errors"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -17,7 +16,7 @@ import (
// offset/IANA timezone), not just their unit tests in validate_test.go. // offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) { func TestUpdateProfileValidation(t *testing.T) {
s := &Store{} s := &Store{}
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}} base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"}
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) } hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
tests := []struct { tests := []struct {
name string name string
@@ -29,8 +28,6 @@ func TestUpdateProfileValidation(t *testing.T) {
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }}, {"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }}, {"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }}, {"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
{"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }},
{"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -42,22 +39,3 @@ func TestUpdateProfileValidation(t *testing.T) {
}) })
} }
} }
// TestValidateVariantPreferences checks the cleaning of a profile's variant set:
// duplicates collapse, the result is canonically ordered (Erudit, Russian Scrabble,
// English) regardless of input order, and an empty or unknown set is rejected.
func TestValidateVariantPreferences(t *testing.T) {
got, err := validateVariantPreferences([]string{"scrabble_en", "erudit_ru", "scrabble_en"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
if _, err := validateVariantPreferences(nil); !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("empty err = %v, want ErrInvalidProfile", err)
}
if _, err := validateVariantPreferences([]string{"chess"}); !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("unknown err = %v, want ErrInvalidProfile", err)
}
}
+1 -9
View File
@@ -24,19 +24,11 @@ const (
// unconditionally, overriding the usual eligibility (a free account with an // unconditionally, overriding the usual eligibility (a free account with an
// empty hint wallet otherwise sees it). See internal/ads. // empty hint wallet otherwise sees it). See internal/ads.
RoleNoBanner = "no_banner" RoleNoBanner = "no_banner"
// RoleChatMuted forbids the account from writing in the moderated Telegram
// discussion chat, without otherwise restricting the game (the chat-only
// counterpart to a full account suspension). It is one input to the chat-access
// gate; an active admin suspension mutes the player regardless, so this role only
// matters for an account that is not suspended. Granting or revoking it re-pushes
// the chat-gate command for a member currently in the chat.
RoleChatMuted = "chat_muted"
) )
// KnownRoles is the set of roles the console may grant or revoke; an operator // KnownRoles is the set of roles the console may grant or revoke; an operator
// cannot assign an unrecognised role. // cannot assign an unrecognised role.
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner, RoleChatMuted} var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner}
// IsKnownRole reports whether role is a recognised account role. // IsKnownRole reports whether role is a recognised account role.
func IsKnownRole(role string) bool { func IsKnownRole(role string) bool {
+1 -7
View File
@@ -46,11 +46,7 @@ type Stats struct {
Draws int Draws int
MaxGamePoints int MaxGamePoints int
MaxWordPoints int MaxWordPoints int
// Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the BestMoves []BestMove
// lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves).
Moves int
HintsUsed int
BestMoves []BestMove
} }
// GetStats returns the lifetime statistics for id. An account with no account_stats // GetStats returns the lifetime statistics for id. An account with no account_stats
@@ -78,8 +74,6 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
Draws: int(row.Draws), Draws: int(row.Draws),
MaxGamePoints: int(row.MaxGamePoints), MaxGamePoints: int(row.MaxGamePoints),
MaxWordPoints: int(row.MaxWordPoints), MaxWordPoints: int(row.MaxWordPoints),
Moves: int(row.Moves),
HintsUsed: int(row.HintsUsed),
BestMoves: best, BestMoves: best,
}, nil }, nil
} }
-25
View File
@@ -161,31 +161,6 @@ func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID,
return modelToSuspension(row), true, nil return modelToSuspension(row), true, nil
} }
// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the
// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The
// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires,
// since no operator action fires then. An account that still has another active block may be
// included; the eligibility resolver returns the true state, so emitting for it is harmless.
func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT account_id FROM backend.account_suspensions
WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`,
since.UTC(), until.UTC())
if err != nil {
return nil, fmt.Errorf("account: suspensions expired between: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("account: scan expired suspension: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it. // invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
// Called after Suspend and LiftSuspension. // Called after Suspend and LiftSuspension.
func (s *Store) invalidateSuspension(accountID uuid.UUID) { func (s *Store) invalidateSuspension(accountID uuid.UUID) {
@@ -1,84 +0,0 @@
package account
import (
"context"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// suspensionSweepInterval is how often the sweeper re-checks for temporary blocks
// that lapsed. A minute is well under the coarsest block grain (operators pick day
// presets) while keeping the query trivial.
const suspensionSweepInterval = time.Minute
// suspensionExpiryQuerier is the slice of the account store the sweeper depends on:
// the accounts whose temporary block lapsed in a window. *Store satisfies it; a fake
// drives the sweeper's unit tests.
type suspensionExpiryQuerier interface {
SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error)
}
// SuspensionSweeper re-evaluates chat write access when a temporary block self-
// expires. No operator action fires on expiry — the suspension gate just re-reads
// the wall clock — so without this a temporarily blocked player would stay muted in
// the moderated discussion chat after their block lapsed. Each tick it finds blocks
// that expired since the previous tick and calls onExpire for the affected accounts;
// onExpire is wired to publish the chat-access-changed event, after which the gateway
// re-resolves the true eligibility. A liberal call (an account that still has another
// active block) is therefore harmless. The window is in-memory, so a block that
// expires while the process is down is not re-granted until the next operator action
// or the player rejoins — an accepted best-effort gap.
type SuspensionSweeper struct {
store suspensionExpiryQuerier
onExpire func(accountID uuid.UUID)
log *zap.Logger
// since is the upper bound of the previous swept window; the next sweep covers
// (since, now]. It advances only on a successful query, so a failed tick retries
// the same window rather than dropping expiries.
since time.Time
}
// NewSuspensionSweeper builds the sweeper over the account store, the per-account
// expiry callback (publishing the chat-access-changed event) and a logger. The first
// window opens at construction time, so blocks that lapsed earlier are not re-emitted.
func NewSuspensionSweeper(store *Store, onExpire func(accountID uuid.UUID), log *zap.Logger) *SuspensionSweeper {
if log == nil {
log = zap.NewNop()
}
return &SuspensionSweeper{store: store, onExpire: onExpire, log: log, since: time.Now().UTC()}
}
// Interval reports the sweep cadence, for the startup log line.
func (w *SuspensionSweeper) Interval() time.Duration { return suspensionSweepInterval }
// Run sweeps every Interval until ctx is cancelled.
func (w *SuspensionSweeper) Run(ctx context.Context) {
ticker := time.NewTicker(suspensionSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.sweep(ctx)
}
}
}
// sweep emits a chat-access-changed signal for every account whose temporary block
// lapsed in (since, now], then advances the window. On a query error it keeps the
// window so the next tick retries it.
func (w *SuspensionSweeper) sweep(ctx context.Context) {
now := time.Now().UTC()
ids, err := w.store.SuspensionsExpiredBetween(ctx, w.since, now)
if err != nil {
w.log.Warn("suspension expiry sweep failed", zap.Error(err))
return
}
w.since = now
for _, id := range ids {
w.onExpire(id)
}
}
@@ -1,80 +0,0 @@
package account
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// fakeExpiryQuerier records the `since` bound of each call and replays a scripted
// result/error per call, so the sweeper's window and dispatch logic is testable
// without a database.
type fakeExpiryQuerier struct {
results [][]uuid.UUID
errs []error
sinces []time.Time
idx int
}
func (f *fakeExpiryQuerier) SuspensionsExpiredBetween(_ context.Context, since, _ time.Time) ([]uuid.UUID, error) {
f.sinces = append(f.sinces, since)
i := f.idx
f.idx++
if i < len(f.errs) && f.errs[i] != nil {
return nil, f.errs[i]
}
if i < len(f.results) {
return f.results[i], nil
}
return nil, nil
}
func newSweeper(store suspensionExpiryQuerier, onExpire func(uuid.UUID)) *SuspensionSweeper {
return &SuspensionSweeper{
store: store,
onExpire: onExpire,
log: zap.NewNop(),
since: time.Now().Add(-time.Minute).UTC(),
}
}
func TestSuspensionSweeperDispatchesAndAdvances(t *testing.T) {
id1, id2 := uuid.New(), uuid.New()
fake := &fakeExpiryQuerier{results: [][]uuid.UUID{{id1, id2}, nil}}
var got []uuid.UUID
w := newSweeper(fake, func(id uuid.UUID) { got = append(got, id) })
first := w.since
w.sweep(context.Background())
assert.Equal(t, []uuid.UUID{id1, id2}, got, "every expired account is dispatched")
assert.True(t, w.since.After(first), "the window advances on success")
// A second sweep opens the next window at the previous upper bound.
prev := w.since
w.sweep(context.Background())
require.Len(t, fake.sinces, 2)
assert.True(t, fake.sinces[1].After(fake.sinces[0]), "consecutive windows are contiguous and forward")
assert.True(t, fake.sinces[1].Equal(prev), "the next window starts at the previous upper bound")
}
func TestSuspensionSweeperKeepsWindowOnError(t *testing.T) {
fake := &fakeExpiryQuerier{errs: []error{errors.New("db down")}}
w := newSweeper(fake, func(uuid.UUID) { t.Fatal("onExpire must not run when the query fails") })
before := w.since
w.sweep(context.Background())
assert.True(t, w.since.Equal(before), "the window is retained on error so the next tick retries it")
}
func TestNewSuspensionSweeperDefaults(t *testing.T) {
w := NewSuspensionSweeper(nil, func(uuid.UUID) {}, nil)
assert.Equal(t, time.Minute, w.Interval())
assert.NotNil(t, w.log, "a nil logger is tolerated")
assert.WithinDuration(t, time.Now().UTC(), w.since, time.Second, "the first window opens at construction time")
}
+4 -46
View File
@@ -1,6 +1,5 @@
// Package accountmerge retires a secondary account into a primary one in a single // Package accountmerge retires a secondary account into a primary one in a single
// transaction: it sums statistics (merging the per-variant best moves), sums the hint // transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
// wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations, // the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone // de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
// (accounts.merged_into). It is the data core of account linking & merge // (accounts.merged_into). It is the data core of account linking & merge
@@ -69,9 +68,6 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil { if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
return err return err
} }
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil { if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err return err
} }
@@ -151,8 +147,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
return out, nil return out, nil
} }
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and // mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
// the moves/hints-used counters summed, max points kept) and deletes the secondary row. // summed, max points kept) and deletes the secondary row.
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.AccountStats var sec model.AccountStats
err := postgres.SELECT(table.AccountStats.AllColumns). err := postgres.SELECT(table.AccountStats.AllColumns).
@@ -182,16 +178,13 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
upd := table.AccountStats.UPDATE( upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws, table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
).SET( ).SET(
postgres.Int(int64(pri.Wins+sec.Wins)), postgres.Int(int64(pri.Wins+sec.Wins)),
postgres.Int(int64(pri.Losses+sec.Losses)), postgres.Int(int64(pri.Losses+sec.Losses)),
postgres.Int(int64(pri.Draws+sec.Draws)), postgres.Int(int64(pri.Draws+sec.Draws)),
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))), postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))), postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
postgres.Int(int64(pri.Moves+sec.Moves)),
postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
postgres.TimestampzT(now), postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))) ).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil { if _, err := upd.ExecContext(ctx, tx); err != nil {
@@ -205,41 +198,6 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
return nil return nil
} }
// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
// they would linger on a dead account and never reach the merged statistics screen.
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var srows []model.AccountBestMove
err := postgres.SELECT(table.AccountBestMove.AllColumns).
FROM(table.AccountBestMove).
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
}
for _, s := range srows {
ins := table.AccountBestMove.
INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
DO_UPDATE(postgres.SET(
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
}
}
del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
}
return nil
}
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag; // mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// all other profile fields stay the primary's. // all other profile fields stay the primary's.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
@@ -137,59 +137,3 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
an image attachment is previewed inline, bounded so it cannot dominate the page. */ an image attachment is previewed inline, bounded so it cannot dominate the page. */
.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; } .msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; }
.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; } .attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
/* Game replay (admin): a script-stepped board with rack panels around it, a move log and the
first-move draw. A placed tile shows its value as a subscript; a 0 value (a blank) shows none. */
.replay-stage {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: ". top ." "left board right" ". bottom .";
gap: 0.5rem;
align-items: center;
justify-items: center;
margin-bottom: 0.6rem;
}
.rack-top { grid-area: top; }
.rack-bottom { grid-area: bottom; }
.rack-left { grid-area: left; }
.rack-right { grid-area: right; }
.replay-board { grid-area: board; overflow: auto; }
.board-grid {
display: grid;
grid-template-columns: 1.4rem repeat(15, 1.7rem);
grid-auto-rows: 1.7rem;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
width: max-content;
}
.board-grid .bh { display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: var(--ink-dim); background: var(--panel); }
.board-grid .cell { position: relative; display: flex; align-items: center; justify-content: center; background: var(--panel-hi); }
.cell .prem { font-size: 0.55rem; color: var(--ink); opacity: 0.8; }
.cell.tw { background: #7a2230; }
.cell.dw { background: #a8506a; }
.cell.tl { background: #235a7a; }
.cell.dl { background: #3f87a8; }
.cell.centre .prem { font-size: 0.95rem; color: var(--warn); opacity: 1; }
.tile {
display: inline-flex; align-items: baseline; justify-content: center;
min-width: 1.35rem; height: 1.35rem; padding: 0 0.12rem;
background: #e8d9a0; color: #1b1408; border-radius: 3px;
font-weight: 700; font-size: 0.8rem; line-height: 1.35rem;
}
.tile sub { font-size: 0.5rem; font-weight: 600; line-height: 1; align-self: flex-end; margin-left: 1px; }
.tile.blank { background: #cdbfe0; }
.cell.filled { background: var(--panel-hi); }
.cell.filled .tile { width: 100%; height: 100%; border-radius: 2px; }
.rack-slot { padding: 0.25rem; border-radius: 6px; }
.rack-slot.active { outline: 2px solid var(--accent); background: var(--panel-hi); }
.rack-name { font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 0.2rem; text-align: center; }
.rack-tiles { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; }
.rack-left .rack-tiles, .rack-right .rack-tiles { flex-direction: column; }
.replay-controls { display: flex; align-items: center; gap: 0.8rem; justify-content: center; margin: 0.6rem 0; }
.replay-controls button { background: var(--panel-hi); color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
.replay-controls button:disabled { opacity: 0.4; cursor: default; }
.replay-pos { color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
.replay-log li.cur { color: var(--ink); font-weight: 600; }
+1 -6
View File
@@ -26,11 +26,6 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
}, `/_gm/users/c3`},
{"throttled", ThrottledView{ {"throttled", ThrottledView{
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}}, Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}}, Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
@@ -43,7 +38,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"}, {"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"}, {"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"}, {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"}, {"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"}, {"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
@@ -5,6 +5,7 @@
{{if .ConnectorEnabled}} {{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/broadcast"> <form class="form col" method="post" action="/_gm/broadcast">
<label>Message <textarea name="text" required></textarea></label> <label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Post to channel</button></div> <div><button type="submit">Post to channel</button></div>
</form> </form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}} {{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
@@ -5,7 +5,7 @@
<section class="panel"><h2>Message</h2> <section class="panel"><h2>Message</h2>
<ul class="kv"> <ul class="kv">
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li> <li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li> <li><b>Channel</b> {{.Channel}}{{if .BotLanguage}} (bot: {{.BotLanguage}}){{end}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li> <li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li> <li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li> <li><b>Filed</b> {{.CreatedAt}}</li>
@@ -27,87 +27,5 @@
</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}} {{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>
{{if .SetupDraws}}
<section class="panel"><h2>First-move draw</h2>
<p class="note">Each player draws a tile; the one closest to &ldquo;A&rdquo; moves first (a blank beats every letter), ties re-drawing until a single leader remains.{{if .FirstMover}} <b>{{.FirstMover}}</b> leads.{{end}}</p>
<table class="list">
<thead><tr><th>Round</th><th>Player</th><th>Tile</th><th>Rank</th></tr></thead>
<tbody>
{{range .SetupDraws}}
<tr><td>{{.Round}}</td><td>{{if .AccountID}}<a href="/_gm/users/{{.AccountID}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Letter}}{{if .Blank}} <small>(blank)</small>{{end}}</td><td>{{.Rank}}</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{if .HasReplay}}
<section class="panel"><h2>Replay</h2>
<div class="replay-stage">
<div class="rack-slot rack-top" data-seat="0"></div>
<div class="rack-slot rack-left" data-seat="2"></div>
<div class="replay-board" id="replay-board"></div>
<div class="rack-slot rack-right" data-seat="3"></div>
<div class="rack-slot rack-bottom" data-seat="1"></div>
</div>
<div class="replay-controls">
<button type="button" id="replay-prev">&#9664; prev</button>
<span class="replay-pos" id="replay-pos"></span>
<button type="button" id="replay-next">next &#9654;</button>
</div>
<ol class="replay-log" id="replay-log"></ol>
<script>
const REPLAY = {{.ReplayJSON}};
(function(){
if(!REPLAY||!REPLAY.steps){return;}
const N=15, COLS="ABCDEFGHIJKLMNO", PREM={tw:"3W",dw:"2W",tl:"3L",dl:"2L"};
const boardEl=document.getElementById("replay-board"), logEl=document.getElementById("replay-log");
const posEl=document.getElementById("replay-pos"), prevBtn=document.getElementById("replay-prev"), nextBtn=document.getElementById("replay-next");
let step=0;
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":s;return d.innerHTML;}
function tileHTML(t){const sub=(t.v&&t.v>0)?"<sub>"+t.v+"<\/sub>":"";return "<span class=\"tile"+(t.b?" blank":"")+"\">"+esc(t.l)+sub+"<\/span>";}
function placedAt(k){const m={};for(let s=1;s<=k;s++){const mv=REPLAY.steps[s]&&REPLAY.steps[s].move;if(mv&&mv.placements){for(const p of mv.placements){m[p.r+","+p.c]=p;}}}return m;}
function renderBoard(){
const placed=placedAt(step);let h="<div class=\"board-grid\"><div class=\"bh corner\"><\/div>";
for(let c=0;c<N;c++){h+="<div class=\"bh\">"+COLS[c]+"<\/div>";}
for(let r=0;r<N;r++){h+="<div class=\"bh\">"+(r+1)+"<\/div>";
for(let c=0;c<N;c++){const p=placed[r+","+c];
if(p){h+="<div class=\"cell filled\">"+tileHTML(p)+"<\/div>";continue;}
const prem=REPLAY.premium[r][c], centre=(r===REPLAY.centre[0]&&c===REPLAY.centre[1]);
const label=centre?"&#9733;":(prem?PREM[prem]:"");
h+="<div class=\"cell "+(prem||"")+(centre?" centre":"")+"\">"+(label?"<span class=\"prem\">"+label+"<\/span>":"")+"<\/div>";}}
h+="<\/div>";boardEl.innerHTML=h;
}
function renderRacks(){
const st=REPLAY.steps[step];
document.querySelectorAll(".rack-slot").forEach(function(slot){
const seat=parseInt(slot.dataset.seat,10), info=REPLAY.seats.find(function(s){return s.seat===seat;}), rack=st.racks[seat];
if(!info||!rack){slot.style.display="none";slot.innerHTML="";return;}
slot.style.display="";slot.classList.toggle("active",st.toMove===seat);
const nm=info.accountId?"<a href=\"/_gm/users/"+info.accountId+"\">"+esc(info.name)+"<\/a>":esc(info.name||("seat "+seat));
slot.innerHTML="<div class=\"rack-name\">"+nm+" &middot; "+(st.scores[seat]||0)+"<\/div><div class=\"rack-tiles\">"+rack.map(tileHTML).join("")+"<\/div>";
});
}
function renderLog(){
let h="";
for(let s=1;s<=step;s++){const st=REPLAY.steps[s], m=st.move;if(!m){continue;}
const who=((REPLAY.seats.find(function(x){return x.seat===m.seat;})||{}).name)||("seat "+m.seat);
let desc;
if(m.action==="play"){desc="played "+((m.words&&m.words.length)?m.words.join(", "):"")+" for "+m.score;}
else if(m.action==="exchange"){desc="exchanged "+((m.exchanged&&m.exchanged.length)||0)+" tiles";}
else if(m.action==="pass"){desc="passed";}
else{desc=m.action;}
const drew=(st.drawn&&st.drawn.length)?" &middot; drew "+st.drawn.map(function(t){return t.l;}).join(""):"";
h+="<li class=\""+(s===step?"cur":"")+"\">"+esc(who)+" "+esc(desc)+drew+" &middot; bag "+st.bagLen+"<\/li>";}
logEl.innerHTML=h||"<li class=\"note\">opening position<\/li>";
}
function render(){renderBoard();renderRacks();renderLog();posEl.textContent=step+" / "+(REPLAY.steps.length-1);prevBtn.disabled=step<=0;nextBtn.disabled=step>=REPLAY.steps.length-1;}
prevBtn.onclick=function(){if(step>0){step--;render();}};
nextBtn.onclick=function(){if(step<REPLAY.steps.length-1){step++;render();}};
document.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"){prevBtn.click();}else if(e.key==="ArrowRight"){nextBtn.click();}});
render();
})();
</script>
</section>
{{end}}
{{end}} {{end}}
{{- end}} {{- end}}
@@ -5,26 +5,6 @@
list is in-memory and resets on a backend restart. An account sustaining list is in-memory and resets on a backend restart. An account sustaining
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review {{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
below — never banned automatically; clear the flag on the user card.</p> below — never banned automatically; clear the flag on the user card.</p>
<section class="panel"><h2>Active IP bans</h2>
<p class="note">Temporary IP bans the gateway is currently enforcing (in-memory, prod-only;
reset on a gateway restart). Unban applies on the gateway's next sync.</p>
<table class="list">
<thead><tr><th>IP</th><th>Reason</th><th>Since</th><th>Expires</th><th></th></tr></thead>
<tbody>
{{range .Bans}}
<tr>
<td><code>{{.IP}}</code></td>
<td>{{.Reason}}</td>
<td>{{.Since}}</td>
<td>{{.Expires}}</td>
<td><form class="form" method="post" action="/_gm/bans/unban"><input type="hidden" name="ip" value="{{.IP}}"><button type="submit">Unban</button></form></td>
</tr>
{{else}}
<tr><td colspan="5"><span class="note">no active bans</span></td></tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Recent episodes</h2> <section class="panel"><h2>Recent episodes</h2>
<table class="list"> <table class="list">
<thead><tr><th>Class</th><th>Key</th><th class="num">Rejected</th><th>First seen</th><th>Last seen</th></tr></thead> <thead><tr><th>Class</th><th>Key</th><th class="num">Rejected</th><th>First seen</th><th>Last seen</th></tr></thead>
@@ -32,8 +32,6 @@
<li><b>Wins</b> {{.Stats.Wins}}</li> <li><b>Wins</b> {{.Stats.Wins}}</li>
<li><b>Losses</b> {{.Stats.Losses}}</li> <li><b>Losses</b> {{.Stats.Losses}}</li>
<li><b>Draws</b> {{.Stats.Draws}}</li> <li><b>Draws</b> {{.Stats.Draws}}</li>
<li><b>Moves</b> {{.Stats.Moves}}</li>
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li> <li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li> <li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
</ul> </ul>
@@ -102,41 +100,12 @@
</tbody> </tbody>
</table> </table>
</section> </section>
<section class="panel"><h2>Friends</h2>
<table class="list">
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
<tbody>
{{range .Friends}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocks</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .Blocks}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocked by</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .BlockedBy}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{if .TelegramID}} {{if .TelegramID}}
<section class="panel"><h2>Send Telegram message</h2> <section class="panel"><h2>Send Telegram message</h2>
{{if .ConnectorEnabled}} {{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message"> <form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
<label>Message <textarea name="text" required></textarea></label> <label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Send to user</button></div> <div><button type="submit">Send to user</button></div>
</form> </form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}} {{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
+8 -53
View File
@@ -175,21 +175,6 @@ type UserDetailView struct {
// grant form offers. The first role is the feedback ban (see internal/account). // grant form offers. The first role is the feedback ban (see internal/account).
Roles []string Roles []string
KnownRoles []string KnownRoles []string
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
// other account with the date it happened. They are the full truth; the asymmetric block
// suppression that hides relationships from players never applies to the console.
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
type RelationRow struct {
AccountID string
DisplayName string
Date string
} }
// SuspensionView is an account's current manual-block state shown on the user card: whether it // SuspensionView is an account's current manual-block state shown on the user card: whether it
@@ -218,8 +203,6 @@ type StatsRow struct {
Draws int Draws int
MaxGamePoints int MaxGamePoints int
MaxWordPoints int MaxWordPoints int
Moves int
HintsUsed int
} }
// IdentityRow is one platform/email identity of an account. // IdentityRow is one platform/email identity of an account.
@@ -268,27 +251,6 @@ type GameDetailView struct {
// RobotTargetPct is the configured global play-to-win rate, in percent. // RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool HasRobot bool
RobotTargetPct int RobotTargetPct int
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
ReplayJSON template.JS
HasReplay bool
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
// and FirstMover is the resolved name of the seat-0 player the draw elected.
SetupDraws []SetupDrawRow
FirstMover string
}
// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the
// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an
// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a
// blank) and its draw rank.
type SetupDrawRow struct {
Round int
Name string
AccountID string
Letter string
Blank bool
Rank int
} }
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's // SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
@@ -389,27 +351,17 @@ type BroadcastView struct {
ConnectorEnabled bool ConnectorEnabled bool
} }
// ThrottledView is the rate-limit observability page: the temporary IP bans the // ThrottledView is the rate-limit observability page: the recent gateway-reported
// gateway is currently enforcing, the recent gateway-reported throttle episodes // throttle episodes (in-memory, reset on restart) and the accounts currently
// (in-memory, reset on restart) and the accounts currently carrying the high-rate // carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning. // auto-flag tuning.
type ThrottledView struct { type ThrottledView struct {
Bans []BanRow
Episodes []ThrottleEpisodeRow Episodes []ThrottleEpisodeRow
Flagged []FlaggedAccountRow Flagged []FlaggedAccountRow
FlagThreshold int FlagThreshold int
FlagWindow string FlagWindow string
} }
// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its
// since/expiry timestamps; the row carries an unban action.
type BanRow struct {
IP string
Reason string
Since string
Expires string
}
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the // ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
// user card and is set only for the user class (the other classes key by IP). // user card and is set only for the user class (the other classes key by IP).
type ThrottleEpisodeRow struct { type ThrottleEpisodeRow struct {
@@ -541,8 +493,11 @@ type FeedbackDetailView struct {
SenderName string SenderName string
Source string Source string
Channel string Channel string
// InterfaceLanguage is the sender's interface language (account preference). // InterfaceLanguage is the sender's interface language (account preference);
// BotLanguage is the connector bot they last used (en/ru), set only for a
// message that arrived through an external connector (Telegram).
InterfaceLanguage string InterfaceLanguage string
BotLanguage string
IP string IP string
Body string Body string
HasAttachment bool HasAttachment bool
-92
View File
@@ -1,92 +0,0 @@
// Package banview mirrors the gateway's active IP bans for the admin console and
// collects operator unban requests for the gateway to apply. Like ratewatch it is
// in-memory, single-instance and resets on a backend restart by design — the
// gateway re-reports its active set on the next sync, and the durable effect (the
// ban itself) lives in the gateway, not here.
package banview
import (
"sort"
"sync"
"time"
)
// Ban is one active IP ban as reported by the gateway.
type Ban struct {
IP string
Reason string
Since time.Time
Expires time.Time
}
// View holds the last-reported active bans and the operator's pending unbans.
type View struct {
now func() time.Time
mu sync.Mutex
bans map[string]Ban // last reported active set, keyed by IP
unban map[string]struct{} // IPs an operator marked for unban
}
// New constructs an empty View.
func New() *View {
return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})}
}
// Ingest replaces the mirrored active set with the gateway's latest report,
// skipping entries with an empty IP or one that has already expired.
func (v *View) Ingest(active []Ban) {
now := v.now()
v.mu.Lock()
defer v.mu.Unlock()
v.bans = make(map[string]Ban, len(active))
for _, b := range active {
if b.IP == "" || !now.Before(b.Expires) {
continue
}
v.bans[b.IP] = b
}
}
// Recent returns the mirrored active bans, most recently banned first.
func (v *View) Recent() []Ban {
now := v.now()
v.mu.Lock()
defer v.mu.Unlock()
out := make([]Ban, 0, len(v.bans))
for _, b := range v.bans {
if now.Before(b.Expires) {
out = append(out, b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
return out
}
// RequestUnban records an operator request to lift the ban on ip; the gateway
// applies it on its next sync (so the console reflects it within the sync
// interval). An empty ip is ignored.
func (v *View) RequestUnban(ip string) {
if ip == "" {
return
}
v.mu.Lock()
defer v.mu.Unlock()
v.unban[ip] = struct{}{}
}
// DrainUnbans returns and clears the IPs operators have marked for unban since the
// previous drain. It returns nil when there are none.
func (v *View) DrainUnbans() []string {
v.mu.Lock()
defer v.mu.Unlock()
if len(v.unban) == 0 {
return nil
}
out := make([]string, 0, len(v.unban))
for ip := range v.unban {
out = append(out, ip)
}
clear(v.unban)
return out
}
-64
View File
@@ -1,64 +0,0 @@
package banview
import (
"testing"
"time"
)
func viewAt(clk *time.Time) *View {
v := New()
v.now = func() time.Time { return *clk }
return v
}
func TestIngestRecentDropsExpired(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{
{IP: "1.1.1.1", Reason: "tripwire", Since: clk, Expires: clk.Add(time.Hour)},
{IP: "2.2.2.2", Reason: "rejections", Since: clk.Add(-2 * time.Hour), Expires: clk.Add(-time.Hour)}, // expired
{IP: "", Reason: "x", Since: clk, Expires: clk.Add(time.Hour)}, // empty IP
})
got := v.Recent()
if len(got) != 1 || got[0].IP != "1.1.1.1" || got[0].Reason != "tripwire" {
t.Fatalf("Recent = %+v, want one live ban for 1.1.1.1", got)
}
}
func TestIngestReplaces(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{{IP: "1.1.1.1", Since: clk, Expires: clk.Add(time.Hour)}})
v.Ingest([]Ban{{IP: "2.2.2.2", Since: clk, Expires: clk.Add(time.Hour)}})
got := v.Recent()
if len(got) != 1 || got[0].IP != "2.2.2.2" {
t.Fatalf("Recent = %+v, want only the latest report (2.2.2.2)", got)
}
}
func TestRecentOrdersBySince(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.Ingest([]Ban{
{IP: "old", Since: clk.Add(-10 * time.Minute), Expires: clk.Add(time.Hour)},
{IP: "new", Since: clk.Add(-1 * time.Minute), Expires: clk.Add(time.Hour)},
})
got := v.Recent()
if len(got) != 2 || got[0].IP != "new" || got[1].IP != "old" {
t.Fatalf("Recent order = %+v, want most recent first", got)
}
}
func TestUnbanRoundTrip(t *testing.T) {
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
v := viewAt(&clk)
v.RequestUnban("3.3.3.3")
v.RequestUnban("") // ignored
drained := v.DrainUnbans()
if len(drained) != 1 || drained[0] != "3.3.3.3" {
t.Fatalf("DrainUnbans = %v, want [3.3.3.3]", drained)
}
if again := v.DrainUnbans(); again != nil {
t.Fatalf("second DrainUnbans = %v, want nil (cleared)", again)
}
}
+18 -16
View File
@@ -1,10 +1,11 @@
// Package connector is the backend's gRPC client for operator broadcasts: a direct // Package connector is the backend's gRPC client for the Telegram platform
// message to one user, or a post to the game channel. It calls the gateway's // connector side-service. The admin console uses it to send operator broadcasts:
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS // a direct message to one user, or a post to a game channel. Each broadcast
// link and reports back whether it was delivered). The relay lives on the trusted // selects the delivering bot by language (an operator choice, since the connector
// internal network, so the connection uses insecure (plaintext) transport // hosts one bot per service language). The connector lives on the trusted internal
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract, // network, so the connection uses insecure (plaintext) transport credentials
// narrowed to the two broadcast methods the admin surface needs. // (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
// the two broadcast methods the admin surface needs.
package connector package connector
import ( import (
@@ -36,21 +37,22 @@ func New(addr string) (*Client, error) {
func (c *Client) Close() error { return c.conn.Close() } func (c *Client) Close() error { return c.conn.Close() }
// SendToUser sends an operator text message to one user, addressed by their // SendToUser sends an operator text message to one user, addressed by their
// platform external_id, through the bot. delivered reports whether the connector // platform external_id, through the bot for the given language. delivered reports
// actually sent it (false when the user has not started the bot). // whether the connector actually sent it (false when the user has not started that
func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) { // bot).
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text}) func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) {
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
if err != nil { if err != nil {
return false, err return false, err
} }
return resp.GetDelivered(), nil return resp.GetDelivered(), nil
} }
// SendToGameChannel posts an operator text message to the bot's game channel. // SendToGameChannel posts an operator text message to the game channel of the bot
// delivered reports whether the connector sent it (false when the bot has no // for the given language. delivered reports whether the connector sent it (false
// channel configured). // when that bot has no channel configured).
func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) { func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text}) resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
if err != nil { if err != nil {
return false, err return false, err
} }
+7 -17
View File
@@ -70,21 +70,11 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
// immediate subdirectory of dir: a subdirectory named V contributes, under // immediate subdirectory of dir: a subdirectory named V contributes, under
// version V, the variants whose committed DAWG it carries. This is the // version V, the variants whose committed DAWG it carries. This is the
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/ // restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
// at runtime is resident again after a restart. The flat dir's version is resolved // at runtime is resident again after a restart. A subdirectory named like the
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records // boot version is skipped (the flat dir already is the boot version). A partially
// bootVersion, an already-seeded dir keeps its recorded label and ignores bootVersion, // loaded registry is closed before any error is returned.
// so a bumped build seed never relabels live bytes. A subdirectory named like the
// resolved seed version is skipped (the flat dir already is it). A partially loaded
// registry is closed before any error is returned.
func OpenWithVersions(dir, bootVersion string) (*Registry, error) { func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
// Resolve the flat dir's version from its seed marker first: on an already-seeded r, err := Open(dir, bootVersion)
// volume the marker wins and bootVersion is ignored, so a bumped build seed cannot
// relabel live bytes (see resolveSeedVersion).
seed, err := resolveSeedVersion(dir, bootVersion)
if err != nil {
return nil, err
}
r, err := Open(dir, seed)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -94,9 +84,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err) return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
} }
for _, e := range entries { for _, e := range entries {
// Skip non-directories, the resolved seed version (already loaded as the flat // Skip non-directories, the boot version (already loaded as the flat dir)
// dir) and dot-prefixed directories (the upload staging area, dir/.staging/). // and dot-prefixed directories (the upload staging area, dir/.staging/).
if !e.IsDir() || e.Name() == seed || strings.HasPrefix(e.Name(), ".") { if !e.IsDir() || e.Name() == bootVersion || strings.HasPrefix(e.Name(), ".") {
continue continue
} }
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil { if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
-98
View File
@@ -5,7 +5,6 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -113,103 +112,6 @@ func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
} }
} }
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
// version in the flat dir's marker, the marker is not mistaken for a version, and a
// reboot at the same seed version succeeds.
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("first open: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
}
_ = reg.Close()
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if err != nil {
t.Fatalf("read seed marker: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Fatalf("seed marker = %q, want v1", got)
}
reg2, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("reboot at same seed: %v", err)
}
_ = reg2.Close()
}
// TestOpenWithVersionsMarkerWinsOverBoot verifies the recorded .seed_version marker
// is authoritative: once a directory is seeded, a different bootVersion
// (BACKEND_DICT_VERSION) is ignored — the flat dir keeps its recorded label — so a
// bumped build seed on a live volume cannot relabel the already-seeded bytes.
func TestOpenWithVersionsMarkerWinsOverBoot(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1") // seeds the marker = v1
if err != nil {
t.Fatalf("seed open: %v", err)
}
_ = reg.Close()
// Reboot with a bumped boot version: the marker (v1) wins, no error, v2 ignored.
reg2, err := OpenWithVersions(dir, "v2")
if err != nil {
t.Fatalf("reboot with bumped boot version: %v", err)
}
defer func() { _ = reg2.Close() }()
if got := reg2.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want [v1] (marker wins, v2 ignored)", got)
}
if _, err := reg2.Solver(VariantEnglish, "v2"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("v2 must not be resident: got %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Errorf("marker = %q, want v1 (unchanged)", got)
}
}
// TestOpenWithVersionsBumpedBootKeepsSubdir mirrors the live-contour case: a volume
// seeded as v1 with a v2 subdirectory (uploaded via the console), booted with a bumped
// build seed bootVersion=v2. The marker (v1) wins for the flat dir, and the v2
// subdirectory is still loaded — not skipped as "the boot version" — so both versions
// stay resident. (Skipping it would silently leave only the flat v1 bytes under v2.)
func TestOpenWithVersionsBumpedBootKeepsSubdir(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg0, err := OpenWithVersions(dir, "v1") // seed marker = v1
if err != nil {
t.Fatalf("seed: %v", err)
}
_ = reg0.Close()
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // console upload
reg, err := OpenWithVersions(dir, "v2") // bumped build seed
if err != nil {
t.Fatalf("boot v2: %v", err)
}
defer func() { _ = reg.Close() }()
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("flat v1 must stay resident: %v", err)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("v2 subdir must be resident (not skipped): %v", err)
}
}
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant // TestReloadRegistersNewVersion verifies Load adds a second version to a variant
// already resident, moves the latest pointer and keeps the earlier version. // already resident, moves the latest pointer and keeps the earlier version.
func TestReloadRegistersNewVersion(t *testing.T) { func TestReloadRegistersNewVersion(t *testing.T) {
-53
View File
@@ -1,53 +0,0 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// seedMarkerFile names the file, in the flat dictionary directory, that records the
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
// version scan skips it (like the .staging upload area).
const seedMarkerFile = ".seed_version"
// resolveSeedVersion returns the version label the flat dictionary directory is
// addressed by, recording it on first use.
//
// The contour's dictionary lives on a named volume seeded from the image once and
// never re-seeded (deploy/docker-compose.yml). The flat DAWGs carry no embedded
// version, so the version a volume was first seeded as is recorded in a
// .seed_version marker and is **authoritative** from then on:
//
// - fresh directory (no marker): record bootVersion (the build's
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion.
//
// So bumping the build seed on a live volume is a harmless no-op (it only takes
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// which would void games pinned to the prior label and mis-serve new ones. New games
// still pin the active version (DB-persisted, set by the admin console), which is the
// real way a running contour moves to a new release.
//
// A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is
// returned rather than swallowed, matching the package's fail-loud dictionary setup.
func resolveSeedVersion(dir, bootVersion string) (string, error) {
path := filepath.Join(dir, seedMarkerFile)
data, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err)
}
if err == nil {
if recorded := strings.TrimSpace(string(data)); recorded != "" {
return recorded, nil
}
// An empty/corrupt marker falls through and is rewritten from bootVersion.
}
if werr := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); werr != nil {
return "", fmt.Errorf("engine: record dictionary seed marker %s: %w", path, werr)
}
return bootVersion, nil
}
-52
View File
@@ -1,52 +0,0 @@
package engine
import "fmt"
// SetupTile is one tile of a variant's full bag, decoded for the first-move draw
// (docs/ARCHITECTURE.md §6): its concrete letter (or the blank marker), a blank
// flag, and its draw rank. Lower rank wins the draw — a blank ranks above every
// letter, and letters rank by alphabet index, so the tile closest to the start of
// the alphabet ("A") wins. It is dictionary-independent, built from the variant's
// solver ruleset alone.
type SetupTile struct {
// Letter is the concrete character (the case the solver ruleset emits), or
// the blank marker "?" for a blank.
Letter string
// Blank reports whether the tile is a blank.
Blank bool
// Rank orders the draw: BlankRank for a blank (best), else the letter's
// alphabet index (0 = closest to "A").
Rank int
}
// BlankRank is the first-move draw rank of a blank: below every letter index, so a
// blank always beats a lettered tile, matching the official rule that a blank
// supersedes all letters.
const BlankRank = -1
// SetupBag returns variant's full tile bag — every lettered tile expanded by its
// count, plus one entry per blank — decoded for the first-move seeding draw. The
// order is deterministic (alphabet order, blanks last); callers shuffle it with
// their own entropy. It needs no dictionary, so it is built from the variant's
// ruleset alone and reports ErrUnknownVariant for an unrecognised variant.
func SetupBag(v Variant) ([]SetupTile, error) {
rs, ok := v.ruleset()
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
bag := make([]SetupTile, 0, 128)
for i, n := range rs.Counts {
ch, err := rs.Alphabet.Character(byte(i))
if err != nil {
// An offered variant's alphabet never yields a bad index; skip defensively.
continue
}
for range n {
bag = append(bag, SetupTile{Letter: ch, Rank: i})
}
}
for range rs.Blanks {
bag = append(bag, SetupTile{Letter: blankLetter, Blank: true, Rank: BlankRank})
}
return bag, nil
}
-47
View File
@@ -1,47 +0,0 @@
package engine
import (
"errors"
"testing"
)
func TestSetupBagEnglish(t *testing.T) {
bag, err := SetupBag(VariantEnglish)
if err != nil {
t.Fatalf("SetupBag: %v", err)
}
// English Scrabble: 98 lettered tiles + 2 blanks = 100.
if len(bag) != 100 {
t.Fatalf("bag size = %d, want 100", len(bag))
}
blanks, aCount := 0, 0
for _, tl := range bag {
switch {
case tl.Blank:
blanks++
if tl.Rank != BlankRank {
t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank)
}
if tl.Letter != blankLetter {
t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter)
}
case tl.Letter == "a":
aCount++
if tl.Rank != 0 {
t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank)
}
}
}
if blanks != 2 {
t.Errorf("blanks = %d, want 2", blanks)
}
if aCount != 9 {
t.Errorf("'a' count = %d, want 9", aCount)
}
}
func TestSetupBagUnknownVariant(t *testing.T) {
if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
t.Fatalf("err = %v, want ErrUnknownVariant", err)
}
}
+8 -3
View File
@@ -112,9 +112,14 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment attachmentName = "" // a name without bytes carries no attachment
} }
ch := normalizeChannel(channel) ch := normalizeChannel(channel)
// Snapshot the sender's interface language at submit time (acc is already loaded // Snapshot the languages at submit time (acc is already loaded for the guest check):
// for the guest check) so the operator later sees the state as it was. // the sender's interface language, and the connector bot language when the message
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP)) // came through an external connector (currently Telegram).
var channelLang string
if ch == "telegram" {
channelLang = acc.ServiceLanguage
}
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
return err return err
} }
+13 -10
View File
@@ -34,10 +34,11 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment // Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default // is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (the sender's interface language) is a snapshot // channel are stored as given. lang (the sender's interface language) and channelLang
// taken now, so the operator later sees the state at submit time. created_at defaults // (the connector bot language, empty for a non-connector channel) are snapshots taken
// to now() in the database. // now, so the operator later sees the state at submit time. created_at defaults to
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) { // now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -48,9 +49,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
} }
if _, err := s.db.ExecContext(ctx, if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages `INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip) (message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil { id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
} }
return id, nil return id, nil
@@ -227,8 +228,10 @@ type AdminMessage struct {
Source string Source string
Body string Body string
Channel string Channel string
// Lang is the sender's interface language, snapshotted at submit time. // Lang is the sender's interface language and ChannelLang the connector bot language
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
Lang string Lang string
ChannelLang string
SenderIP string SenderIP string
HasAttachment bool HasAttachment bool
AttachmentName string AttachmentName string
@@ -343,7 +346,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage var m AdminMessage
var repliedAt sql.NullTime var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''), COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''), COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -352,7 +355,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1` WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan( err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, &m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang, &m.Lang, &m.ChannelLang,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName, &m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+8 -12
View File
@@ -63,7 +63,6 @@ type gameCache struct {
type cachedGame struct { type cachedGame struct {
game *engine.Game game *engine.Game
seats []Seat
variant string variant string
lastAccess time.Time lastAccess time.Time
} }
@@ -72,27 +71,24 @@ func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now} return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
} }
// get returns the live game and its immutable seat list for id and refreshes its idle // get returns the live game for id and refreshes its idle timer, or (nil, false).
// timer, or (nil, nil, false). The seats let a read check membership (and label seats) func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
// without re-loading the game from the store, since seats never change after a game starts.
func (c *gameCache) get(id uuid.UUID) (*engine.Game, []Seat, bool) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
e, ok := c.entries[id] e, ok := c.entries[id]
if !ok { if !ok {
return nil, nil, false return nil, false
} }
e.lastAccess = c.now() e.lastAccess = c.now()
return e.game, e.seats, true return e.game, true
} }
// put stores g as the live game for id together with its seat list. variant labels the // put stores g as the live game for id. variant labels the entry so the active-
// entry so the active-games gauge can report counts by variant without inspecting engine // games gauge can report counts by variant without inspecting engine internals.
// internals; seats are the game's immutable seat standings for the membership fast path. func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string, seats []Seat) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.entries[id] = &cachedGame{game: g, seats: seats, variant: variant, lastAccess: c.now()} c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
} }
// remove drops id from the cache (used on a finished game and after a failed // remove drops id from the cache (used on a finished game and after a failed
+7
View File
@@ -68,6 +68,10 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0 if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
t.Errorf("your_turn score_line = %q, want 13:19", got) t.Errorf("your_turn score_line = %q, want 13:19", got)
} }
// Routed out-of-app by the game's language (the default Variant is English).
if yourTurn.Language != "en" {
t.Errorf("your_turn language = %q, want en", yourTurn.Language)
}
} }
// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat, // TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat,
@@ -102,4 +106,7 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) {
if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" { if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" {
t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine()) t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine())
} }
if over[winner].Language != "en" || over[loser].Language != "en" {
t.Errorf("game_over languages = %q/%q, want en/en", over[winner].Language, over[loser].Language)
}
} }
+3 -3
View File
@@ -94,8 +94,8 @@ func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0) cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur }) cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New() id := uuid.New()
cache.put(id, nil, "scrabble_en", nil) cache.put(id, nil, "scrabble_en")
if _, _, ok := cache.get(id); !ok { if _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put") t.Fatal("game must be resident after put")
} }
cur = cur.Add(30 * time.Minute) cur = cur.Add(30 * time.Minute)
@@ -104,7 +104,7 @@ func TestGameCacheEviction(t *testing.T) {
if n := cache.sweep(); n != 1 { if n := cache.sweep(); n != 1 {
t.Errorf("sweep evicted %d, want 1", n) t.Errorf("sweep evicted %d, want 1", n)
} }
if _, _, ok := cache.get(id); ok { if _, ok := cache.get(id); ok {
t.Error("game must be evicted after idle TTL") t.Error("game must be evicted after idle TTL")
} }
if cache.size() != 0 { if cache.size() != 0 {
-144
View File
@@ -1,144 +0,0 @@
package game
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// ReplayStep is one step of an admin game replay: the move that produced it (nil for the
// initial dealt state, step 0) and the resulting position — every seat's rack, the running
// scores, whose turn it is and the bag remainder. Step k's board is the union of every
// play's placements through step k, which the renderer accumulates onto an empty grid
// (docs/ARCHITECTURE.md §9.1 visual replay).
type ReplayStep struct {
// Move is the journalled move that produced this state, or nil for the initial deal.
Move *HistoryMove
// Drawn lists the tiles the mover drew from the bag after this move ("?" for a blank);
// empty for the initial deal, a pass or a resignation.
Drawn []string
// Racks holds every seat's rack at this step, indexed by seat ("?" for a blank).
Racks [][]string
// Scores holds every seat's running score, indexed by seat.
Scores []int
// ToMove is the seat to move at this step.
ToMove int
// BagLen is the number of tiles left in the bag at this step.
BagLen int
}
// ReplayTimelineView is the admin replay of a game: the persisted game plus the ordered
// replay steps (the initial deal followed by one step per journalled move).
type ReplayTimelineView struct {
Game Game
Steps []ReplayStep
}
// ReplayTimeline rebuilds a game from its pinned seed and journal and returns the ordered
// replay steps for the admin console: the initial deal (step 0) then one step per
// journalled move, each carrying the resulting racks, scores, turn cursor, bag size and the
// tiles the mover drew. The deterministic bag makes the reconstruction exact. It needs no
// dictionary beyond the engine the seed deals, and — like the live replay — stops early if a
// committed move became illegal under tightened rules rather than failing.
func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (ReplayTimelineView, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
seed, err := svc.store.GameSeed(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
})
if err != nil {
return ReplayTimelineView{}, err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
steps := make([]ReplayStep, 0, len(moves)+1)
steps = append(steps, snapshotStep(g, nil, nil))
for i := range moves {
mv := moves[i]
before := g.Hand(mv.Seat)
if err := replayMove(g, mv); err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
g.Abort()
break
}
return ReplayTimelineView{}, fmt.Errorf("game: replay-timeline %s move %d: %w", gameID, mv.Seq, err)
}
moveCopy := mv
steps = append(steps, snapshotStep(g, &moveCopy, drawnTiles(before, g.Hand(mv.Seat), usedTiles(mv))))
}
return ReplayTimelineView{Game: pre, Steps: steps}, nil
}
// snapshotStep captures the position after applying move (nil for the initial deal): every
// seat's rack and score, the turn cursor and the bag size, with the supplied drawn tiles.
func snapshotStep(g *engine.Game, move *HistoryMove, drawn []string) ReplayStep {
n := g.Players()
racks := make([][]string, n)
scores := make([]int, n)
for i := 0; i < n; i++ {
racks[i] = g.Hand(i)
scores[i] = g.Score(i)
}
return ReplayStep{Move: move, Drawn: drawn, Racks: racks, Scores: scores, ToMove: g.ToMove(), BagLen: g.BagLen()}
}
// usedTiles returns the rack tiles a move consumed ("?" for a blank): the placed tiles of a
// play or the swapped tiles of an exchange; a pass or resignation consumes none.
func usedTiles(mv HistoryMove) []string {
switch mv.Action {
case "play":
used := make([]string, len(mv.Tiles))
for i, t := range mv.Tiles {
if t.Blank {
used[i] = "?" // a placed blank leaves the rack as the blank marker
} else {
used[i] = t.Letter
}
}
return used
case "exchange":
return mv.Exchanged
}
return nil
}
// drawnTiles returns the tiles the mover drew from the bag: the post-move rack (after) minus
// the tiles kept (before minus used). It compares the racks as multisets, so duplicate
// letters are counted correctly.
func drawnTiles(before, after, used []string) []string {
kept := make(map[string]int, len(before))
for _, t := range before {
kept[t]++
}
for _, t := range used {
if kept[t] > 0 {
kept[t]--
}
}
var drawn []string
for _, t := range after {
if kept[t] > 0 {
kept[t]--
continue
}
drawn = append(drawn, t)
}
return drawn
}
@@ -1,50 +0,0 @@
package game
import (
"reflect"
"testing"
"scrabble/backend/internal/engine"
)
func TestUsedTiles(t *testing.T) {
tests := []struct {
name string
mv HistoryMove
want []string
}{
{"pass", HistoryMove{Action: "pass"}, nil},
{"resign", HistoryMove{Action: "resign"}, nil},
{"play with blank", HistoryMove{Action: "play", Tiles: []engine.TileRecord{{Letter: "a"}, {Letter: "b", Blank: true}}}, []string{"a", "?"}},
{"exchange", HistoryMove{Action: "exchange", Exchanged: []string{"a", "?"}}, []string{"a", "?"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := usedTiles(tt.mv); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("usedTiles = %v, want %v", got, tt.want)
}
})
}
}
func TestDrawnTiles(t *testing.T) {
tests := []struct {
name string
before, used []string
after []string
want []string
}{
{"play refill", []string{"a", "b", "c", "d"}, []string{"a", "b"}, []string{"c", "d", "e", "f"}, []string{"e", "f"}},
{"blank played", []string{"?", "a"}, []string{"?"}, []string{"a", "x"}, []string{"x"}},
{"pass keeps rack", []string{"a", "b"}, nil, []string{"a", "b"}, nil},
{"duplicate letters", []string{"e", "e", "e"}, []string{"e"}, []string{"e", "e", "q"}, []string{"q"}},
{"empty bag no refill", []string{"a", "b"}, []string{"a"}, []string{"b"}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := drawnTiles(tt.before, tt.after, tt.used); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("drawnTiles = %v, want %v", got, tt.want)
}
})
}
}
-156
View File
@@ -1,156 +0,0 @@
package game
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// maxSeedingRounds caps the first-move draw's tie re-draws. With a real bag a tie
// breaks with positive probability each round, so this is reached only by a
// degenerate (e.g. test) entropy source that ties forever; the cap turns that into
// an error instead of an infinite loop.
const maxSeedingRounds = 1000
// SetupDraw is one recorded tile draw of the first-move seeding — one row of
// game_setup_draws (docs/ARCHITECTURE.md §6, §9): the round, the draw order within
// it, the seated account that drew, and the decoded tile with its rank. It is
// dictionary-independent: the letter, the blank flag and the numeric rank describe
// the draw without any alphabet table.
type SetupDraw struct {
Round int
PickNo int
Account uuid.UUID
Letter string
Blank bool
Rank int
}
// seedingResult is the outcome of the first-move seeding: the winning account, the
// seated accounts rotated so the winner leads (seat 0), and the full draw log to
// persist.
type seedingResult struct {
winner uuid.UUID
order []uuid.UUID
draws []SetupDraw
}
// drawIntn returns a uniformly random integer in [0, n) for n > 0. It is the
// entropy seam of the first-move seeding: production uses crypto/rand (cryptoIntn),
// so every draw is honestly random with no single seed; tests inject a
// deterministic source.
type drawIntn func(n int) (int, error)
// cryptoIntn draws a uniform integer in [0, n) from crypto/rand — the honest,
// seedless entropy the first-move draw requires.
func cryptoIntn(n int) (int, error) {
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
if err != nil {
return 0, fmt.Errorf("game: first-move draw entropy: %w", err)
}
return int(v.Int64()), nil
}
// seedFirstMove runs the official first-move draw over accounts for variant v,
// drawing tiles with the entropy source intn. Each round every contender draws one
// tile (without replacement) from a fresh full bag; the tile closest to "A" wins, a
// blank beating every letter; contenders tied for the best tile re-draw in the next
// round until a single leader remains. It returns the leader, the rotation that
// seats the leader first (preserving the others' seating order), and every draw for
// the record. It performs no I/O beyond calling intn.
func seedFirstMove(v engine.Variant, accounts []uuid.UUID, intn drawIntn) (seedingResult, error) {
if len(accounts) < 2 {
return seedingResult{}, fmt.Errorf("game: first-move seeding needs at least 2 accounts, got %d", len(accounts))
}
full, err := engine.SetupBag(v)
if err != nil {
return seedingResult{}, err
}
contenders := append([]uuid.UUID(nil), accounts...)
var draws []SetupDraw
for round := 1; ; round++ {
if round > maxSeedingRounds {
return seedingResult{}, fmt.Errorf("game: first-move seeding unresolved after %d rounds", maxSeedingRounds)
}
bag := append([]engine.SetupTile(nil), full...)
picks := make([]engine.SetupTile, len(contenders))
for i, acc := range contenders {
tile, rest, err := drawSetupTile(bag, intn)
if err != nil {
return seedingResult{}, err
}
bag = rest
picks[i] = tile
draws = append(draws, SetupDraw{
Round: round, PickNo: i, Account: acc,
Letter: tile.Letter, Blank: tile.Blank, Rank: tile.Rank,
})
}
winners := bestContenders(contenders, picks)
if len(winners) == 1 {
return seedingResult{winner: winners[0], order: rotateToFirst(accounts, winners[0]), draws: draws}, nil
}
contenders = winners
}
}
// drawSetupTile removes one uniformly random tile from bag using the entropy source
// intn and returns it with the shrunk bag (a fresh slice, leaving bag untouched).
// It is the per-tile draw primitive — the seam a future manual "player N draws a
// tile" tournament API will drive, one call per external request.
func drawSetupTile(bag []engine.SetupTile, intn drawIntn) (engine.SetupTile, []engine.SetupTile, error) {
if len(bag) == 0 {
return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw from an empty bag")
}
i, err := intn(len(bag))
if err != nil {
return engine.SetupTile{}, nil, err
}
if i < 0 || i >= len(bag) {
return engine.SetupTile{}, nil, fmt.Errorf("game: first-move draw index %d out of range %d", i, len(bag))
}
tile := bag[i]
rest := make([]engine.SetupTile, 0, len(bag)-1)
rest = append(rest, bag[:i]...)
rest = append(rest, bag[i+1:]...)
return tile, rest, nil
}
// bestContenders returns the contenders whose drawn tile has the lowest (best)
// rank — the sole winner if one, else the tied set that re-draws. picks is aligned
// with contenders by index.
func bestContenders(contenders []uuid.UUID, picks []engine.SetupTile) []uuid.UUID {
best := picks[0].Rank
for _, p := range picks[1:] {
if p.Rank < best {
best = p.Rank
}
}
var winners []uuid.UUID
for i, p := range picks {
if p.Rank == best {
winners = append(winners, contenders[i])
}
}
return winners
}
// rotateToFirst returns accounts rotated cyclically so winner sits first (seat 0),
// preserving the seating order of the rest (docs/ARCHITECTURE.md §6). winner must be
// present in accounts.
func rotateToFirst(accounts []uuid.UUID, winner uuid.UUID) []uuid.UUID {
i := 0
for ; i < len(accounts); i++ {
if accounts[i] == winner {
break
}
}
out := make([]uuid.UUID, 0, len(accounts))
out = append(out, accounts[i:]...)
out = append(out, accounts[:i]...)
return out
}
-130
View File
@@ -1,130 +0,0 @@
package game
import (
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// scriptIntn returns a drawIntn that yields the scripted indices in order, failing
// the test if the script is exhausted or an index is out of range. It lets a test
// drive the first-move draw deterministically (English SetupBag order: 9 'a' at
// 0..8, then 'b' …, blanks last).
func scriptIntn(t *testing.T, seq ...int) drawIntn {
t.Helper()
i := 0
return func(n int) (int, error) {
if i >= len(seq) {
t.Fatalf("intn script exhausted (asked for [0,%d))", n)
}
v := seq[i]
i++
if v < 0 || v >= n {
t.Fatalf("intn script value %d out of range [0,%d)", v, n)
}
return v, nil
}
}
func TestSeedFirstMoveDirectWinner(t *testing.T) {
a, b := uuid.New(), uuid.New()
// a draws bag[0]='a' (rank 0); b draws bag[8]='b' (rank 1) → a wins, no tie.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 8))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != a {
t.Fatalf("winner = %v, want %v", res.winner, a)
}
if got := res.order; len(got) != 2 || got[0] != a || got[1] != b {
t.Fatalf("order = %v, want [a b]", got)
}
if len(res.draws) != 2 {
t.Fatalf("draws = %d, want 2", len(res.draws))
}
}
func TestSeedFirstMoveBlankSupersedes(t *testing.T) {
a, b := uuid.New(), uuid.New()
// a draws 'a' (rank 0); after the draw the two blanks sit at 97,98 — b draws
// bag[97], a blank, which beats every letter.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, scriptIntn(t, 0, 97))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != b {
t.Fatalf("winner = %v, want %v (blank supersedes)", res.winner, b)
}
last := res.draws[len(res.draws)-1]
if !last.Blank || last.Rank != engine.BlankRank || last.Letter != "?" {
t.Fatalf("winning draw = %+v, want a blank (rank %d, '?')", last, engine.BlankRank)
}
}
func TestSeedFirstMoveTieRedraw(t *testing.T) {
a, b, c := uuid.New(), uuid.New(), uuid.New()
// Round 1: a→bag[0]='a'(0), b→bag[0]='a'(0), c→bag[7]='b'(1) → a,b tie best.
// Round 2 (a,b only): a→bag[0]='a'(0), b→bag[8]='b'(1) → a wins.
res, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b, c}, scriptIntn(t, 0, 0, 7, 0, 8))
if err != nil {
t.Fatalf("seedFirstMove: %v", err)
}
if res.winner != a {
t.Fatalf("winner = %v, want %v", res.winner, a)
}
if len(res.draws) != 5 {
t.Fatalf("draws = %d, want 5 (3 in round 1, 2 in round 2)", len(res.draws))
}
if res.draws[2].Round != 1 || res.draws[3].Round != 2 {
t.Fatalf("round boundaries wrong: %+v", res.draws)
}
// The full table keeps every account's seating order, winner first.
if got := res.order; got[0] != a || got[1] != b || got[2] != c {
t.Fatalf("order = %v, want [a b c]", got)
}
}
func TestSeedFirstMoveTooFewAccounts(t *testing.T) {
if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{uuid.New()}, scriptIntn(t)); err == nil {
t.Fatal("want error for a single account")
}
}
func TestSeedFirstMovePerpetualTieCapped(t *testing.T) {
a, b := uuid.New(), uuid.New()
// Always drawing bag[0] gives both players an 'a' every round — a tie that never
// resolves; the round cap must turn it into an error, not an infinite loop.
alwaysZero := drawIntn(func(int) (int, error) { return 0, nil })
if _, err := seedFirstMove(engine.VariantEnglish, []uuid.UUID{a, b}, alwaysZero); err == nil {
t.Fatal("want error when ties never resolve")
}
}
func TestRotateToFirst(t *testing.T) {
a, b, c, d := uuid.New(), uuid.New(), uuid.New(), uuid.New()
tests := []struct {
name string
accounts []uuid.UUID
winner uuid.UUID
want []uuid.UUID
}{
{"two winner second", []uuid.UUID{a, b}, b, []uuid.UUID{b, a}},
{"three winner first", []uuid.UUID{a, b, c}, a, []uuid.UUID{a, b, c}},
{"four winner third", []uuid.UUID{a, b, c, d}, c, []uuid.UUID{c, d, a, b}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rotateToFirst(tt.accounts, tt.winner)
if len(got) != len(tt.want) {
t.Fatalf("len = %d, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("rotate = %v, want %v", got, tt.want)
}
}
})
}
}
+70 -128
View File
@@ -38,12 +38,7 @@ type Service struct {
version string version string
clock func() time.Time clock func() time.Time
rng func() int64 rng func() int64
// firstMoveEntropy returns the entropy source for one game's first-move draw pub notify.Publisher
// (docs/ARCHITECTURE.md §6). The default is crypto/rand — an honest, seedless draw;
// tests override it via SetFirstMoveEntropy for a deterministic turn order. It is a
// factory so a stateful test source restarts cleanly per game.
firstMoveEntropy func() drawIntn
pub notify.Publisher
// aiTrigger, when set, is called after an honest-AI game is created or advanced and // aiTrigger, when set, is called after an honest-AI game is created or advanced and
// is still on a robot's potential turn, so the robot replies at once instead of // is still on a robot's potential turn, so the robot replies at once instead of
// waiting for the next driver scan. It is fire-and-forget (the robot package wires // waiting for the next driver scan. It is fire-and-forget (the robot package wires
@@ -64,18 +59,17 @@ type Service struct {
func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service { func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service {
clock := func() time.Time { return time.Now().UTC() } clock := func() time.Time { return time.Now().UTC() }
return &Service{ return &Service{
store: store, store: store,
accounts: accounts, accounts: accounts,
registry: registry, registry: registry,
cache: newGameCache(cfg.CacheTTL, clock), cache: newGameCache(cfg.CacheTTL, clock),
locks: newKeyedMutex(), locks: newKeyedMutex(),
version: cfg.DictVersion, version: cfg.DictVersion,
clock: clock, clock: clock,
rng: randomSeed, rng: randomSeed,
firstMoveEntropy: func() drawIntn { return cryptoIntn }, pub: notify.Nop{},
pub: notify.Nop{}, metrics: defaultGameMetrics(),
metrics: defaultGameMetrics(), log: log,
log: log,
} }
} }
@@ -107,18 +101,6 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
svc.clearNudges = fn svc.clearNudges = fn
} }
// SetFirstMoveEntropy overrides the entropy source for the first-move draw
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
// game is created; the production default is crypto/rand and is never overridden.
// factory returns a fresh draw function per game, so a stateful deterministic test
// source restarts cleanly for each game. It exists for deterministic tests.
func (svc *Service) SetFirstMoveEntropy(factory func() func(n int) (int, error)) {
if factory == nil {
return
}
svc.firstMoveEntropy = func() drawIntn { return drawIntn(factory()) }
}
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort, // triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is // fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
// installed, so callers can invoke it unconditionally after a create or commit. // installed, so callers can invoke it unconditionally after a create or commit.
@@ -209,32 +191,15 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
} }
} }
seen := make(map[uuid.UUID]bool, len(params.Seats)) seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats { seats := make([]seatInsert, len(params.Seats))
for i, id := range params.Seats {
if seen[id] { if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id) return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
} }
seen[id] = true seen[id] = true
} // Snapshot each seat's display name at creation. For a vs-AI game this stamps the
// robot's seeded account name (unchanged behaviour); a disguised auto-match robot
// Decide who moves first by the official draw (docs/ARCHITECTURE.md §6): each seated // instead gets a fresh per-game name when the reaper attaches it (AttachRobot).
// account draws a tile, the one closest to "A" leads (a blank supersedes all letters),
// ties re-drawing until a single leader remains. Each draw uses fresh entropy, not the
// game seed, so the recorded draws — persisted with the game — are the only account of
// the outcome. The winner takes seat 0; the rest keep their order.
seeding, err := seedFirstMove(params.Variant, params.Seats, svc.firstMoveEntropy())
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) {
return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
return Game{}, err
}
// Build the seats in the drawn turn order, snapshotting each seat's display name. For a
// vs-AI game this stamps the robot's seeded account name (unchanged behaviour); a
// disguised auto-match robot instead gets a fresh per-game name when the reaper attaches
// it (AttachRobot).
seats := make([]seatInsert, len(seeding.order))
for i, id := range seeding.order {
acc, err := svc.accounts.GetByID(ctx, id) acc, err := svc.accounts.GetByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, account.ErrNotFound) { if errors.Is(err, account.ErrNotFound) {
@@ -284,15 +249,15 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI, vsAI: params.VsAI,
} }
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil { if err := svc.store.CreateGame(ctx, ins, seats); err != nil {
return Game{}, err return Game{}, err
} }
svc.cache.put(id, g, params.Variant.String())
svc.metrics.recordStarted(ctx, params.Variant, params.VsAI) svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
created, err := svc.store.GetGame(ctx, id) created, err := svc.store.GetGame(ctx, id)
if err != nil { if err != nil {
return Game{}, err return Game{}, err
} }
svc.cache.put(id, g, params.Variant.String(), created.Seats)
// Honest-AI game seated with a robot: if the robot moves first, reply at once // Honest-AI game seated with a robot: if the robot moves first, reply at once
// (the periodic driver is the fallback). No-op for every human-only game. // (the periodic driver is the fallback). No-op for every human-only game.
svc.triggerAI(created) svc.triggerAI(created)
@@ -301,15 +266,14 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in // OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
// params and returns the game they land in immediately: another waiting player's open // params and returns the game they land in immediately: another waiting player's open
// game (joined=true), or a fresh open game seating only the caller with an empty // game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open
// opponent seat that a human or the reaper's robot fills later. A re-enqueue while the // game seating only the caller with an empty opponent seat that a human or the reaper's
// caller is already waiting opens another game rather than returning their own. // robot fills later. openDeadline is when the reaper substitutes a robot into a freshly
// openDeadline is when the reaper substitutes a robot into a freshly opened game // opened game (ignored when joining one). The bag seed defaults to random; params.Seed
// (ignored when joining one). The bag seed defaults to random; params.Seed
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1 // pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the // (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue. // caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) { func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
acc, err := svc.accounts.GetByID(ctx, accountID) acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil { if err != nil {
if errors.Is(err, account.ErrNotFound) { if errors.Is(err, account.ErrNotFound) {
@@ -347,27 +311,14 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
status: StatusOpen, status: StatusOpen,
openDeadline: &deadline, openDeadline: &deadline,
} }
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a // Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves // display name; the other seat is left empty (a zero seatInsert) for the opponent.
// first — before either player acts (docs/ARCHITECTURE.md §6). The caller takes their
// drawn seat; the opponent seat is left empty. The opponent's draw rows are recorded with
// a NULL account and back-filled when a real opponent joins. Each draw uses fresh entropy,
// not the game seed. seats/draws are used only when a fresh game is opened.
seeding, err := seedFirstMove(params.Variant, []uuid.UUID{accountID, uuid.Nil}, svc.firstMoveEntropy())
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) {
return Game{}, false, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
return Game{}, false, err
}
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName} caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
seats := make([]seatInsert, len(seeding.order)) seats := []seatInsert{caller, {}}
for seat, who := range seeding.order { if seed&1 == 1 {
if who == accountID { seats = []seatInsert{{}, caller}
seats[seat] = caller // the empty opponent seat stays a zero seatInsert
}
} }
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude, seeding.draws) gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
if err != nil { if err != nil {
return Game{}, false, err return Game{}, false, err
} }
@@ -554,6 +505,16 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
return svc.store.GetGameVariant(ctx, gameID) return svc.store.GetGameVariant(ctx, gameID)
} }
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
v, err := svc.GameVariant(ctx, gameID)
if err != nil {
return "", err
}
return v.Language(), nil
}
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's // 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). // 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) { func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
@@ -585,9 +546,8 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
return MoveResult{}, ErrNotAPlayer return MoveResult{}, ErrNotAPlayer
} }
// A move is allowed while the game is active or still open (the starter may move on // A move is allowed while the game is active or still open (the starter may move on
// their turn before an opponent joins; the first-move draw ran when the game opened, so // their turn before an opponent joins); only a finished game rejects it. The turn
// the seats are already fixed); only a finished game rejects it. The turn check below // check below keeps the starter off the still-empty opponent seat.
// keeps the starter off the still-empty opponent seat.
if pre.Status == StatusFinished { if pre.Status == StatusFinished {
return MoveResult{}, ErrFinished return MoveResult{}, ErrFinished
} }
@@ -730,6 +690,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
} }
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen)) intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
} }
// Game pushes are routed out-of-app by the game's own language, not the recipient's
// last-login bot.
lang := post.Variant.Language()
switch post.Status { switch post.Status {
case StatusActive: case StatusActive:
// Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn" // Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn"
@@ -744,6 +707,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
} }
opponent := svc.displayName(ctx, post.Seats, rec.Player) opponent := svc.displayName(ctx, post.Seats, rec.Player)
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount) yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
yourTurn.Language = lang
intents = append(intents, yourTurn) intents = append(intents, yourTurn)
} }
case StatusFinished: case StatusFinished:
@@ -755,6 +719,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
continue continue
} }
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary) over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
over.Language = lang
intents = append(intents, over) intents = append(intents, over)
} }
} }
@@ -890,35 +855,26 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
// EvaluatePlay previews a tentative play for a seated player against the current // EvaluatePlay previews a tentative play for a seated player against the current
// board without committing it: whether it is legal and what it would score. // board without committing it: whether it is legal and what it would score.
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) { func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) {
unlock := svc.locks.lock(gameID) pre, err := svc.store.GetGame(ctx, gameID)
defer unlock() if err != nil {
return EvalResult{}, err
// Hot path: an active game stays cached — the engine game is mutated in place across
// moves and evicted only when it finishes — so on a hit the cached live game and its
// immutable seat list answer the membership check and the score with no DB read. This
// preview is fired on every tile placement, the hottest gameplay call at scale.
g, seats, ok := svc.cache.get(gameID)
if !ok {
// Cold path: load and validate from the store, then replay into the cache.
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return EvalResult{}, err
}
if pre.Status == StatusFinished {
return EvalResult{}, ErrFinished
}
if g, err = svc.liveGame(ctx, pre); err != nil {
return EvalResult{}, err
}
seats = pre.Seats
} }
if !seatedIn(seats, accountID) { if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer return EvalResult{}, ErrNotAPlayer
} }
if pre.Status == StatusFinished {
return EvalResult{}, ErrFinished
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return EvalResult{}, err
}
validateStart := time.Now() validateStart := time.Now()
rec, err := g.EvaluatePlay(tiles) rec, err := g.EvaluatePlay(tiles)
svc.metrics.recordValidate(ctx, g.Variant(), validateStart) svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
if err != nil { if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) { if errors.Is(err, engine.ErrIllegalPlay) {
return EvalResult{Valid: false}, nil return EvalResult{Valid: false}, nil
@@ -1071,7 +1027,12 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
} }
walletAfter := acc.HintBalance walletAfter := acc.HintBalance
if !fromAllowance { if fromAllowance {
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
} else {
spent, err := svc.accounts.SpendHint(ctx, accountID) spent, err := svc.accounts.SpendHint(ctx, accountID)
if err != nil { if err != nil {
return HintResult{}, err return HintResult{}, err
@@ -1081,13 +1042,6 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
} }
walletAfter-- walletAfter--
} }
// hints_used is the per-game total (allowance + wallet): every hint increments it. The first
// HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest
// are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics.
if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
} }
@@ -1330,14 +1284,6 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView,
return HistoryView{Game: g, Moves: moves}, nil return HistoryView{Game: g, Moves: moves}, nil
} }
// SetupDraws returns a game's recorded first-move draws (docs/ARCHITECTURE.md §6), ordered by
// round then pick. It backs the admin console's first-move section. An auto-match opponent's
// draws carry uuid.Nil until a real opponent joins and back-fills them; an empty slice means
// the game predates the draw record.
func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDraw, error) {
return svc.store.SetupDraws(ctx, gameID)
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It // ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
// is allowed only on a finished game: exporting an in-progress game would leak the // is allowed only on a finished game: exporting an in-progress game would leak the
// full move journal mid-play, so an active game yields ErrGameActive. // full move journal mid-play, so an active game yields ErrGameActive.
@@ -1368,7 +1314,7 @@ func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, er
// liveGame returns the live engine.Game for pre, rebuilding it from the journal // liveGame returns the live engine.Game for pre, rebuilding it from the journal
// on a cache miss. Callers must hold the per-game lock. // on a cache miss. Callers must hold the per-game lock.
func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) { func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) {
if g, _, ok := svc.cache.get(pre.ID); ok { if g, ok := svc.cache.get(pre.ID); ok {
return g, nil return g, nil
} }
g, err := svc.replay(ctx, pre) g, err := svc.replay(ctx, pre)
@@ -1383,7 +1329,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
} }
} }
if !g.Over() { if !g.Over() {
svc.cache.put(pre.ID, g, pre.Variant.String(), pre.Seats) svc.cache.put(pre.ID, g, pre.Variant.String())
} }
return g, nil return g, nil
} }
@@ -1483,12 +1429,10 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result() res := g.Result()
bestRec := make(map[int]engine.MoveRecord) bestRec := make(map[int]engine.MoveRecord)
blanks := make(map[[2]int]bool) blanks := make(map[[2]int]bool)
plays := make(map[int]int) // per player: count of plays (tile placements), for the "moves" stat
for _, rec := range g.Log() { for _, rec := range g.Log() {
if rec.Action != engine.ActionPlay { if rec.Action != engine.ActionPlay {
continue continue
} }
plays[rec.Player]++
for _, t := range rec.Tiles { for _, t := range rec.Tiles {
if t.Blank { if t.Blank {
blanks[[2]int{t.Row, t.Col}] = true blanks[[2]int{t.Row, t.Col}] = true
@@ -1502,9 +1446,7 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
values := letterValues(g.Variant()) values := letterValues(g.Variant())
out := make([]statDelta, 0, len(seats)) out := make([]statDelta, 0, len(seats))
for _, s := range seats { for _, s := range seats {
// moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat)}
// summed into account_stats so the screen can show the hint share (hints_used / moves).
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), moves: plays[s.Seat], hintsUsed: s.HintsUsed}
if rec, ok := bestRec[s.Seat]; ok { if rec, ok := bestRec[s.Seat]; ok {
d.wordPoints = rec.Score d.wordPoints = rec.Score
if rec.Score > 0 { if rec.Score > 0 {
+59 -216
View File
@@ -66,8 +66,6 @@ type statDelta struct {
draws int draws int
gamePoints int gamePoints int
wordPoints int wordPoints int
moves int // plays this game (tile placements), summed into account_stats.moves
hintsUsed int // hints used this game (allowance + wallet), summed into account_stats.hints_used
bestVariant string bestVariant string
bestScore int bestScore int
bestTiles []account.BestMoveTile bestTiles []account.BestMoveTile
@@ -127,14 +125,11 @@ type seatInsert struct {
displayName string displayName string
} }
// CreateGame inserts the games row, one game_players row per seat (seat 0 first) and // CreateGame inserts the games row and one game_players row per seat (seat 0
// the first-move seeding draws inside a single transaction. // first) inside a single transaction.
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error { func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error { return withTx(ctx, s.db, func(tx *sql.Tx) error {
if err := insertGameTx(ctx, tx, ins, seats); err != nil { return insertGameTx(ctx, tx, ins, seats)
return err
}
return insertSetupDrawsTx(ctx, tx, ins.id, draws)
}) })
} }
@@ -176,30 +171,6 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
return nil return nil
} }
// insertSetupDrawsTx appends the first-move seeding draws for game gameID on tx —
// the dictionary-independent record of how the first player was chosen
// (docs/ARCHITECTURE.md §6). The games row must already exist on tx (the foreign
// key), so it runs after insertGameTx. An empty draws slice is a no-op.
func insertSetupDrawsTx(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, draws []SetupDraw) error {
for _, d := range draws {
// A uuid.Nil account marks the synthetic opponent of an auto-match draw, persisted as
// a NULL account_id and back-filled when a real opponent joins.
var acc any = d.Account
if d.Account == uuid.Nil {
acc = postgres.NULL
}
di := table.GameSetupDraws.INSERT(
table.GameSetupDraws.GameID, table.GameSetupDraws.Round, table.GameSetupDraws.PickNo,
table.GameSetupDraws.AccountID, table.GameSetupDraws.Letter, table.GameSetupDraws.IsBlank,
table.GameSetupDraws.DrawRank,
).VALUES(gameID, d.Round, d.PickNo, acc, d.Letter, d.Blank, d.Rank)
if _, err := di.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert setup draw round %d pick %d: %w", d.Round, d.PickNo, err)
}
}
return nil
}
// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the // openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
// advisory-lock key that serialises concurrent enqueues for that bucket, so two // advisory-lock key that serialises concurrent enqueues for that bucket, so two
// players never both open a game instead of pairing. // players never both open a game instead of pairing.
@@ -215,42 +186,48 @@ func openMatchKey(variant string, multipleWords bool) int64 {
} }
// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it // OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
// lands in: it joins another player's waiting open game and flips it active // lands in: it re-uses the caller's own still-open game (joined=false, created=false,
// (joined=true), or opens a fresh game seating the caller with an empty opponent seat // a re-enqueue is idempotent), joins another player's waiting open game and flips it
// (created=true). A re-enqueue while the caller already has an open game in the bucket // active (joined=true), or opens a fresh game seating the caller with an empty
// opens another fresh game (or joins a different player's) rather than returning the // opponent seat (created=true). ins supplies the new game's immutable fields and is
// caller's own, so tapping "random opponent" again always starts a new search. ins // used only when a game is created. A transaction-scoped advisory lock on the
// supplies the new game's immutable fields and is used only when a game is created. A // (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
// transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent // than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
// enqueues so two callers pair rather than each opening a game. seats is the two-seat // for the still-empty opponent, in the chosen order) used only when a game is created;
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen // callerName is the caller's display-name snapshot, stamped on their seat whether they
// order) used only when a game is created; callerName is the caller's display-name // open a fresh game or fill another player's open one.
// snapshot, stamped on their seat whether they open a fresh game or fill another func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
// player's open one. seats and draws (the first-move draw recorded against the synthetic
// opponent, docs/ARCHITECTURE.md §6) are used only when a game is created.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID, draws []SetupDraw) (gameID uuid.UUID, joined, created bool, err error) {
err = withTx(ctx, s.db, func(tx *sql.Tx) error { err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil { openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
return fmt.Errorf("open match lock: %w", e) return fmt.Errorf("open match lock: %w", e)
} }
// 1. Another player's open game waiting for an opponent — fill its seat and start it. // 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
// A game whose waiting player is in exclude (the caller's per-user block set, either var own uuid.UUID
// direction) is skipped, so a block keeps the pair out of the same anonymous game; switch e := tx.QueryRowContext(ctx,
// an empty exclude (the "{}" literal) excludes nothing. `SELECT g.game_id FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3
LIMIT 1`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); {
case e == nil:
gameID = own
return nil
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find own open game: %w", e)
}
// 2. Another player's open game waiting for an opponent — fill its seat and start it.
var other uuid.UUID var other uuid.UUID
switch e := tx.QueryRowContext(ctx, switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g `SELECT g.game_id FROM backend.games g
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
AND NOT EXISTS (SELECT 1 FROM backend.game_players p AND NOT EXISTS (SELECT 1 FROM backend.game_players p
WHERE p.game_id = g.game_id AND p.account_id = $3) WHERE p.game_id = g.game_id AND p.account_id = $3)
AND NOT EXISTS (SELECT 1 FROM backend.game_players b
WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
ORDER BY g.created_at ORDER BY g.created_at
LIMIT 1 FOR UPDATE SKIP LOCKED`, LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); { ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
case e == nil: case e == nil:
if er := fillAndActivate(ctx, tx, other, accountID, callerName); er != nil { if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
return er return er
} }
gameID, joined = other, true gameID, joined = other, true
@@ -258,15 +235,10 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
case !errors.Is(e, sql.ErrNoRows): case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find open game: %w", e) return fmt.Errorf("find open game: %w", e)
} }
// 2. None waiting — open a fresh game seating the caller (the other seat empty) and // 3. None waiting — open a fresh game seating the caller (the other seat empty).
// recording the first-move draw; the synthetic opponent's rows carry a NULL account
// until a real opponent joins and back-fills them.
if e := insertGameTx(ctx, tx, ins, seats); e != nil { if e := insertGameTx(ctx, tx, ins, seats); e != nil {
return e return e
} }
if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); e != nil {
return e
}
gameID, created = ins.id, true gameID, created = ins.id, true
return nil return nil
}) })
@@ -291,7 +263,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
if status != StatusOpen { if status != StatusOpen {
return nil return nil
} }
if e := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil { if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil {
return e return e
} }
attached = true attached = true
@@ -300,23 +272,15 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
return attached, err return attached, err
} }
// fillAndActivate seats accountID in an open game's empty opponent seat — stamping // fillOpenSeat seats accountID in an open game's empty opponent seat — stamping
// displayName as the seat's snapshot — back-fills the first-move draw rows the open game // displayName as the seat's display-name snapshot — and flips the game to active with a
// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to // fresh turn clock. The caller holds the game row.
// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
// open time, so the journal (any opening move the starter made while waiting) is never
// disturbed. The caller holds the game row.
func fillAndActivate(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`, `UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID, displayName); err != nil { gameID, accountID, displayName); err != nil {
return fmt.Errorf("fill opponent seat: %w", err) return fmt.Errorf("fill opponent seat: %w", err)
} }
if _, err := tx.ExecContext(ctx,
`UPDATE backend.game_setup_draws SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
gameID, accountID); err != nil {
return fmt.Errorf("back-fill opponent draws: %w", err)
}
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now() `UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
WHERE game_id = $1`, gameID); err != nil { WHERE game_id = $1`, gameID); err != nil {
@@ -355,33 +319,27 @@ func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, err
// GetGame loads the games row joined with its seats (ordered by seat), or // GetGame loads the games row joined with its seats (ordered by seat), or
// ErrNotFound. // ErrNotFound.
func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) { func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
// One round-trip: the game joined with its seats. A LEFT JOIN keeps a (would-be) gstmt := postgres.SELECT(table.Games.AllColumns).
// seatless game returning the game with no seats, exactly as the prior two-query FROM(table.Games).
// version did; ORDER BY seat preserves seat order. The games columns repeat per seat
// row — cheap at 2-4 seats, and one round-trip instead of two, which matters because
// GetGame is the universal "load the game" step on every game operation.
stmt := postgres.SELECT(table.Games.AllColumns, table.GamePlayers.AllColumns).
FROM(table.Games.LEFT_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))). WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GamePlayers.Seat.ASC()) LIMIT(1)
var rows []struct { var grow model.Games
model.Games if err := gstmt.QueryContext(ctx, s.db, &grow); err != nil {
model.GamePlayers if errors.Is(err, qrm.ErrNoRows) {
} return Game{}, ErrNotFound
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { }
return Game{}, fmt.Errorf("game: get %s: %w", id, err) return Game{}, fmt.Errorf("game: get %s: %w", id, err)
} }
if len(rows) == 0 {
return Game{}, ErrNotFound sstmt := postgres.SELECT(table.GamePlayers.AllColumns).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GamePlayers.Seat.ASC())
var srows []model.GamePlayers
if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil {
return Game{}, fmt.Errorf("game: get seats %s: %w", id, err)
} }
seats := make([]model.GamePlayers, 0, len(rows)) return projectGame(grow, srows)
for i := range rows {
// Skip the phantom all-NULL seat row a LEFT JOIN yields for a seatless game.
if rows[i].GamePlayers.GameID == id {
seats = append(seats, rows[i].GamePlayers)
}
}
return projectGame(rows[0].Games, seats)
} }
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses // GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
@@ -634,37 +592,6 @@ func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, er
return out, nil return out, nil
} }
// SetupDraws loads the ordered first-move seeding draws for a game (round then pick
// order), or an empty slice for a game created before the draw was recorded. It
// backs the admin console's first-move section.
func (s *Store) SetupDraws(ctx context.Context, id uuid.UUID) ([]SetupDraw, error) {
stmt := postgres.SELECT(table.GameSetupDraws.AllColumns).
FROM(table.GameSetupDraws).
WHERE(table.GameSetupDraws.GameID.EQ(postgres.UUID(id))).
ORDER_BY(table.GameSetupDraws.Round.ASC(), table.GameSetupDraws.PickNo.ASC())
var rows []model.GameSetupDraws
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: get setup draws %s: %w", id, err)
}
out := make([]SetupDraw, len(rows))
for i, r := range rows {
// A NULL account is the synthetic opponent of an auto-match draw not yet back-filled.
acc := uuid.Nil
if r.AccountID != nil {
acc = *r.AccountID
}
out[i] = SetupDraw{
Round: int(r.Round),
PickNo: int(r.PickNo),
Account: acc,
Letter: r.Letter,
Blank: r.IsBlank,
Rank: int(r.DrawRank),
}
}
return out, nil
}
// CommitMove appends the move and applies the post-move game state — the turn // CommitMove appends the move and applies the post-move game state — the turn
// cursor and per-seat scores, plus the finish stamp and statistics when the move // cursor and per-seat scores, plus the finish stamp and statistics when the move
// ended the game — in one transaction. // ended the game — in one transaction.
@@ -802,17 +729,13 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
draws := row.Draws + int32(d.draws) draws := row.Draws + int32(d.draws)
maxGame := max(row.MaxGamePoints, int32(d.gamePoints)) maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
maxWord := max(row.MaxWordPoints, int32(d.wordPoints)) maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
moves := row.Moves + int32(d.moves)
hintsUsed := row.HintsUsed + int32(d.hintsUsed)
upd := table.AccountStats.UPDATE( upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws, table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt, table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
table.AccountStats.Moves, table.AccountStats.HintsUsed,
).SET( ).SET(
postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)), postgres.Int(int64(wins)), postgres.Int(int64(losses)), postgres.Int(int64(draws)),
postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now), postgres.Int(int64(maxGame)), postgres.Int(int64(maxWord)), postgres.TimestampzT(now),
postgres.Int(int64(moves)), postgres.Int(int64(hintsUsed)),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID))) ).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
if _, err := upd.ExecContext(ctx, tx); err != nil { if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("update stats %s: %w", d.accountID, err) return fmt.Errorf("update stats %s: %w", d.accountID, err)
@@ -851,10 +774,8 @@ func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time)
return nil return nil
} }
// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for // SpendHintAllowance increments a seat's per-game hint counter by one.
// every hint — both the free per-game allowance and the wallet-charged ones — so the counter func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
// is the seat's total hints used this game (the first HintsPerPlayer being the allowance).
func (s *Store) IncHintsUsed(ctx context.Context, gameID uuid.UUID, seat int) error {
stmt := table.GamePlayers. stmt := table.GamePlayers.
UPDATE(table.GamePlayers.HintsUsed). UPDATE(table.GamePlayers.HintsUsed).
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))). SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
@@ -863,7 +784,7 @@ func (s *Store) IncHintsUsed(ctx context.Context, gameID uuid.UUID, seat int) er
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))), AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
) )
if _, err := stmt.ExecContext(ctx, s.db); err != nil { if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("game: increment hints used: %w", err) return fmt.Errorf("game: spend hint allowance: %w", err)
} }
return nil return nil
} }
@@ -1098,18 +1019,13 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
for _, r := range rows { for _, r := range rows {
out = append(out, robotTurnFrom(r.Games, r.GamePlayers)) out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
} }
if err := s.fillEndgamePass(ctx, out); err != nil {
return nil, err
}
return out, nil return out, nil
} }
// RobotTurnByGame returns the robot turn for a single active game — the seat held by // RobotTurnByGame returns the robot turn for a single active game — the seat held by
// one of ids (the robot pool) — and true, or false when the game is not active, holds // one of ids (the robot pool) — and true, or false when the game is not active, holds
// no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which // no pooled robot, or is gone. It backs the honest-AI after-commit trigger, which
// drives one game at once rather than scanning the whole pool (RobotTurns). It leaves // drives one game at once rather than scanning the whole pool (RobotTurns).
// EndgamePass false: honest-AI games move at once, so the endgame think-time shrink is a
// human-mimicry concern computed only on the RobotTurns scan.
func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) { func (s *Store) RobotTurnByGame(ctx context.Context, gameID uuid.UUID, ids []uuid.UUID) (RobotTurn, bool, error) {
if len(ids) == 0 { if len(ids) == 0 {
return RobotTurn{}, false, nil return RobotTurn{}, false, nil
@@ -1162,79 +1078,6 @@ func robotTurnFrom(g model.Games, p model.GamePlayers) RobotTurn {
} }
} }
// fillEndgamePass marks the turns whose game is a dead-drawn endgame — its two most
// recent committed moves are both passes, so the board and racks are frozen and the
// seated robot is bound to pass again — setting EndgamePass and OppLastMove from the
// move journal so the driver can shorten the robot's think time. Turns whose game is
// not in that state are left unchanged. A nil or empty slice is a no-op. It runs one
// batched journal query for the whole scan, so it adds no per-game round trip.
func (s *Store) fillEndgamePass(ctx context.Context, turns []RobotTurn) error {
if len(turns) == 0 {
return nil
}
ids := make([]uuid.UUID, len(turns))
for i := range turns {
ids[i] = turns[i].GameID
}
info, err := s.endgamePassInfo(ctx, ids)
if err != nil {
return err
}
for i := range turns {
if d, ok := info[turns[i].GameID]; ok {
turns[i].EndgamePass = true
turns[i].OppLastMove = d
}
}
return nil
}
// endgamePassInfo returns, for each of ids whose two most recent committed moves are
// both passes, the human's think time on the most recent of them (the gap between the
// last two journal entries' created_at). Games with fewer than two moves, or whose last
// two are not both passes, are absent from the map. It reads the move journal only — no
// schema change — mirroring the analytics.go duration reports. A negative gap (clock
// skew) is floored to zero.
func (s *Store) endgamePassInfo(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]time.Duration, error) {
if len(ids) == 0 {
return map[uuid.UUID]time.Duration{}, nil
}
const q = `
SELECT q.game_id, q.secs FROM (
SELECT t.game_id,
bool_and(t.action = 'pass') AS both_pass,
COUNT(*) AS n,
EXTRACT(EPOCH FROM (MAX(t.created_at) - MIN(t.created_at))) AS secs
FROM (
SELECT m.game_id, m.action, m.created_at,
ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq DESC) AS rn
FROM backend.game_moves m
WHERE m.game_id = ANY($1::uuid[])
) t
WHERE t.rn <= 2
GROUP BY t.game_id
) q
WHERE q.n = 2 AND q.both_pass`
rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(ids))
if err != nil {
return nil, fmt.Errorf("game: endgame pass info: %w", err)
}
defer rows.Close()
out := make(map[uuid.UUID]time.Duration, len(ids))
for rows.Next() {
var id uuid.UUID
var secs float64
if err := rows.Scan(&id, &secs); err != nil {
return nil, fmt.Errorf("game: scan endgame pass info: %w", err)
}
if secs < 0 {
secs = 0
}
out[id] = time.Duration(secs * float64(time.Second))
}
return out, rows.Err()
}
// GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap // GameVsAI reports whether a game is an honest-AI game (games.vs_ai) — a cheap
// single-column read for the social chat/nudge gate, which must reject both in an // single-column read for the social chat/nudge gate, which must reject both in an
// AI game even though it reports status 'active'. ErrNotFound when the game is gone. // AI game even though it reports status 'active'. ErrNotFound when the game is gone.
-23
View File
@@ -154,8 +154,6 @@ type Seat struct {
Seat int Seat int
AccountID uuid.UUID AccountID uuid.UUID
Score int Score int
// HintsUsed is the total hints the seat used this game — both the free per-game allowance
// and the wallet-charged ones (the first HintsPerPlayer being the allowance).
HintsUsed int HintsUsed int
IsWinner bool IsWinner bool
// DisplayName is the seat's display-name snapshot, captured when the seat was taken // DisplayName is the seat's display-name snapshot, captured when the seat was taken
@@ -184,18 +182,6 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
return 0, false return 0, false
} }
// seatedIn reports whether accountID holds a seat in seats. It backs the read-side
// membership check against the cached, immutable seat list, so a hot read can skip
// loading the game from the store.
func seatedIn(seats []Seat, accountID uuid.UUID) bool {
for _, s := range seats {
if s.AccountID == accountID {
return true
}
}
return false
}
// MoveResult is the outcome of a committed transition: the decoded move and the // MoveResult is the outcome of a committed transition: the decoded move and the
// post-move game, plus the actor's own refilled rack and the bag size after the draw // post-move game, plus the actor's own refilled rack and the bag size after the draw
// (Rack/BagLen), so the mover renders the next state from the response without a // (Rack/BagLen), so the mover renders the next state from the response without a
@@ -280,15 +266,6 @@ type RobotTurn struct {
// VsAI is true when the game is an honest-AI game: the driver then makes the // VsAI is true when the game is an honest-AI game: the driver then makes the
// robot move immediately, with no sleep window and no proactive nudge. // robot move immediately, with no sleep window and no proactive nudge.
VsAI bool VsAI bool
// EndgamePass is true when the two most recent committed moves are both passes, so
// the board and racks are frozen and the robot is bound to pass again. The driver
// then shortens the robot's think time (see robot.endgamePassDelay) so a decided
// game is not dragged out. It is false until at least two moves exist.
EndgamePass bool
// OppLastMove is the human's think time on the most recent move — the gap between
// the last two journal entries — used to scale the shortened endgame think time. It
// is meaningful only when EndgamePass is true (zero otherwise).
OppLastMove time.Duration
} }
// Complaint is a word-check complaint in the admin review queue. It is filed // Complaint is a word-check complaint in the admin review queue. It is filed
+46 -13
View File
@@ -118,13 +118,10 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
store := account.NewStore(testDB) store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString() ext := "tg-" + uuid.NewString()
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван") acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
if !created {
t.Error("created = false on first contact, want true")
}
if acc.PreferredLanguage != "ru" { if acc.PreferredLanguage != "ru" {
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage) t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
} }
@@ -136,13 +133,10 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
} }
// A later login with different fields returns the same account, unchanged. // A later login with different fields returns the same account, unchanged.
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other") again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
if err != nil { if err != nil {
t.Fatalf("re-provision telegram: %v", err) t.Fatalf("re-provision telegram: %v", err)
} }
if created {
t.Error("created = true on a repeat login, want false")
}
if again.ID != acc.ID { if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
} }
@@ -156,7 +150,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
// language CHECK. // language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background() ctx := context.Background()
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "") acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -165,6 +159,46 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
} }
} }
// TestServiceLanguageRoundTrip checks SetServiceLanguage persists the push-routing
// language (the bot a Telegram user last signed in through): a fresh account has
// none, a set value reads back, a later login overwrites it (last-login-wins), and
// an empty value is a no-op. The push-target route coalesces it with the preferred
// language.
func TestServiceLanguageRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if acc.ServiceLanguage != "" {
t.Errorf("fresh ServiceLanguage = %q, want empty", acc.ServiceLanguage)
}
if err := store.SetServiceLanguage(ctx, acc.ID, "ru"); err != nil {
t.Fatalf("set service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "ru" {
t.Errorf("ServiceLanguage = %q, want ru", got.ServiceLanguage)
}
// A later login through the other bot updates it; a subsequent empty value
// (a non-Telegram login) leaves it unchanged.
if err := store.SetServiceLanguage(ctx, acc.ID, "en"); err != nil {
t.Fatalf("update service language: %v", err)
}
if err := store.SetServiceLanguage(ctx, acc.ID, ""); err != nil {
t.Fatalf("noop service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "en" {
t.Errorf("ServiceLanguage after update+noop = %q, want en", got.ServiceLanguage)
}
}
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account // TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode // is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after // never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
@@ -172,7 +206,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
func TestHighRateFlagRoundTrip(t *testing.T) { func TestHighRateFlagRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -228,7 +262,7 @@ func TestIdentityExternalID(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString() ext := "tg-" + uuid.NewString()
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User") acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -253,7 +287,7 @@ func TestIdentityExternalID(t *testing.T) {
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -265,7 +299,6 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
PreferredLanguage: "en", PreferredLanguage: "en",
TimeZone: "UTC", TimeZone: "UTC",
NotificationsInAppOnly: false, NotificationsInAppOnly: false,
VariantPreferences: []string{"erudit_ru"},
}) })
if err != nil { if err != nil {
t.Fatalf("update profile: %v", err) t.Fatalf("update profile: %v", err)
+1 -7
View File
@@ -206,12 +206,6 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
if !strings.Contains(body, "~40%") { if !strings.Contains(body, "~40%") {
t.Error("robot play-to-win target caption missing") t.Error("robot play-to-win target caption missing")
} }
if !strings.Contains(body, "First-move draw") {
t.Error("first-move draw section missing from the game detail")
}
if !strings.Contains(body, `id="replay-board"`) {
t.Error("replay board container missing from the game detail")
}
} }
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to // TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
@@ -222,7 +216,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
func TestConsoleThrottledViewAndFlagClear(t *testing.T) { func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background() ctx := context.Background()
accounts := account.NewStore(testDB) accounts := account.NewStore(testDB)
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player") acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
if err != nil { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
@@ -256,8 +256,7 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
id := provisionAccount(t) id := provisionAccount(t)
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` + body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
`"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true,` + `"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true}`
`"variant_preferences":["erudit_ru"]}`
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body))
req.Header.Set("X-User-ID", id.String()) req.Header.Set("X-User-ID", id.String())
-322
View File
@@ -1,322 +0,0 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social"
)
// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked
// user had left unread for the blocker in their shared games, so no stale unread badge
// lingers for someone the blocker no longer sees.
func TestBlockInstantReadsExistingUnread(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat
if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil {
t.Fatalf("post: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread {
t.Fatal("viewer should have the message unread before blocking")
}
if err := svc.Block(ctx, viewer, sender); err != nil {
t.Fatalf("block: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread {
t.Error("blocking should instant-read the blocked sender's prior unread message")
}
}
// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a
// message the blocked user sends is stored and visible to themselves, but is born read for
// the blocker, never delivered to them, and hidden from their view.
func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil {
t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's message must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindChatMessage) {
t.Error("the blocked sender's message must not be delivered to the blocker")
}
if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 {
t.Errorf("blocker still sees the blocked sender's message: %+v", msgs)
}
if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 {
t.Errorf("the blocked sender should see their own message, got %+v", msgs)
}
}
// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their
// only opponent is someone they have blocked (the composer is hidden client-side too).
func TestChatToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a
// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking
// ordinary to them) but is born read and never delivered to the blocker.
func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocked); err != nil {
t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's nudge must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindNudge) {
t.Error("the blocked sender's nudge must not be delivered to the blocker")
}
// The nudge is still recorded, so the cooldown applies (the blocked user notices nothing).
if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok {
t.Error("the suppressed nudge should still be recorded")
}
}
// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the
// awaited opponent when they have blocked them.
func TestNudgeToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so
// their other sessions update in place) and never reach the blocked user.
func TestBlockPublishesToBlockerOnly(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
blocker, blocked := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserBlocked) {
t.Error("the blocker should receive a user_blocked event")
}
if pub.notified(blocked, notify.NotifyUserBlocked) {
t.Error("the blocked user must never receive a user_blocked event")
}
if err := svc.Unblock(ctx, blocker, blocked); err != nil {
t.Fatalf("unblock: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserUnblocked) {
t.Error("the blocker should receive a user_unblocked event")
}
if pub.notified(blocked, notify.NotifyUserUnblocked) {
t.Error("the blocked user must never receive a user_unblocked event")
}
}
// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a
// block between them (either direction), while an unblocked third player still joins.
func TestMatchmakingExcludesBlockedPlayers(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
soc := newSocialService()
mm.SetBlocker(soc)
a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t)
if err := soc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
// The block is seen from both sides — the exclusion set unions both directions.
if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) {
t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked")
}
// b opens a game awaiting an opponent.
r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must open a game, not match")
}
// a must not join b's game (a blocked b): it opens its own instead.
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r2.Matched || r2.Game.ID == r1.Game.ID {
t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched)
}
// A third, unblocked player joins b's still-open game (the oldest), proving the exclusion
// is specific to the blocked pair, not a blanket refusal.
r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue c: %v", err)
}
if !r3.Matched || r3.Game.ID != r1.Game.ID {
t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID)
}
}
// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries
// return the full truth in both directions, including a friendship that a block overrides but
// does not delete.
func TestAdminSocialLists(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("request: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if err := svc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
blocks, err := svc.AdminBlocksBy(ctx, a)
if err != nil {
t.Fatalf("admin blocks by: %v", err)
}
if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() {
t.Errorf("a's blocks = %v, want [b] with a date", blocks)
}
blockedBy, err := svc.AdminBlockedBy(ctx, b)
if err != nil {
t.Fatalf("admin blocked by: %v", err)
}
if len(blockedBy) != 1 || blockedBy[0].AccountID != a {
t.Errorf("b's blocked-by = %v, want [a]", blockedBy)
}
// The friendship survives the block, so it still shows on both admin friend lists.
friendsA, err := svc.AdminFriends(ctx, a)
if err != nil {
t.Fatalf("admin friends: %v", err)
}
if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() {
t.Errorf("a's admin friends = %v, want [b] with a date", friendsA)
}
if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a {
t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err)
}
}
// TestRobotBlockIsPerGameAndMatchmakerImmune checks that blocking a disguised-robot opponent
// is recorded per-game in robot_blocks — never in the blocks table or against the shared robot
// account — so the matchmaker keeps the robot free, and that unblocking by the row id removes it.
func TestRobotBlockIsPerGameAndMatchmakerImmune(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
accs := account.NewStore(testDB)
human := provisionAccount(t)
robot, err := accs.ProvisionRobot(ctx, "robot-block-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
if err := svc.BlockInGame(ctx, human, robot.ID, g.ID); err != nil {
t.Fatalf("block robot: %v", err)
}
rbs, err := svc.ListRobotBlocks(ctx, human)
if err != nil {
t.Fatalf("list robot blocks: %v", err)
}
if len(rbs) != 1 || rbs[0].GameID != g.ID {
t.Fatalf("robot blocks = %v, want one for game %s", rbs, g.ID)
}
if bl, _ := svc.ListBlocks(ctx, human); len(bl) != 0 {
t.Errorf("blocks table must stay empty for a robot block, got %v", bl)
}
if yes, _ := svc.IsBlocked(ctx, human, robot.ID); yes {
t.Error("the shared robot account must never be blocked")
}
// Matchmaking immunity: the robot is never in the caller's exclusion set, so the matchmaker
// keeps giving robots and the player can never be starved by blocking them.
if with, _ := svc.BlockedWith(ctx, human); contains(with, robot.ID) {
t.Error("a robot block must not put the robot in BlockedWith")
}
// Unblock by the robot_blocks row id removes it.
if err := svc.Unblock(ctx, human, rbs[0].ID); err != nil {
t.Fatalf("unblock robot: %v", err)
}
if rbs, _ := svc.ListRobotBlocks(ctx, human); len(rbs) != 0 {
t.Errorf("robot block not removed by unblock: %v", rbs)
}
}
// blockedWith reads the both-direction block set for id.
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
t.Helper()
ids, err := soc.BlockedWith(context.Background(), id)
if err != nil {
t.Fatalf("blocked with: %v", err)
}
return ids
}
// contains reports whether ids includes want.
func contains(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
@@ -1,315 +0,0 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
)
// chatAccessBody mirrors the backend's /internal/chat-access JSON for the test.
type chatAccessBody struct {
ExternalID string `json:"external_id"`
Registered bool `json:"registered"`
Eligible bool `json:"eligible"`
}
// chatAccess issues the gateway-internal chat-access query and asserts a 200.
func chatAccess(t *testing.T, srv *server.Server, body string) chatAccessBody {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("chat-access %s = %d: %s", body, rec.Code, rec.Body.String())
}
var b chatAccessBody
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
t.Fatalf("decode chat-access: %v", err)
}
return b
}
// TestChatAccessResolver drives the gateway-internal eligibility resolver over HTTP:
// the registered/suspended/chat_muted truth table by Telegram identity and by account
// id, the suspension dominating the chat_muted role, an unknown identity reported
// unregistered, and an account with no Telegram identity carrying an empty external_id.
func TestChatAccessResolver(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
ext := "tg-" + uuid.NewString()
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter")
if err != nil {
t.Fatalf("provision: %v", err)
}
id := acc.ID
byExt := func() chatAccessBody { return chatAccess(t, srv, `{"external_id":"`+ext+`"}`) }
byUser := func() chatAccessBody { return chatAccess(t, srv, `{"user_id":"`+id.String()+`"}`) }
// A registered, unsuspended, unmuted account is eligible by either address, and the
// account-id query resolves back to its Telegram identity.
if b := byExt(); !b.Registered || !b.Eligible || b.ExternalID != ext {
t.Fatalf("fresh by external_id = %+v, want registered+eligible+ext", b)
}
if b := byUser(); !b.Registered || !b.Eligible || b.ExternalID != ext {
t.Fatalf("fresh by user_id = %+v, want registered+eligible+ext", b)
}
// A suspension mutes; a lift restores.
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend: %v", err)
}
if b := byExt(); !b.Registered || b.Eligible {
t.Fatalf("suspended = %+v, want registered but not eligible", b)
}
if err := accounts.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if b := byExt(); !b.Eligible {
t.Fatalf("after lift = %+v, want eligible", b)
}
// The chat_muted role mutes independently; a revoke restores.
if err := accounts.GrantRole(ctx, id, account.RoleChatMuted); err != nil {
t.Fatalf("grant chat_muted: %v", err)
}
if b := byExt(); !b.Registered || b.Eligible {
t.Fatalf("chat_muted = %+v, want registered but not eligible", b)
}
// Suspension dominates: while chat_muted is set, lifting a concurrent suspension
// must not re-grant chat (the role still mutes).
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend over mute: %v", err)
}
if b := byExt(); b.Eligible {
t.Fatalf("suspended+muted = %+v, want not eligible", b)
}
if err := accounts.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift over mute: %v", err)
}
if b := byExt(); b.Eligible {
t.Fatalf("lifted but still muted = %+v, want not eligible", b)
}
if err := accounts.RevokeRole(ctx, id, account.RoleChatMuted); err != nil {
t.Fatalf("revoke chat_muted: %v", err)
}
if b := byExt(); !b.Eligible {
t.Fatalf("after revoke = %+v, want eligible", b)
}
// An unknown Telegram identity is unregistered (and thus left muted).
if b := chatAccess(t, srv, `{"external_id":"tg-missing-`+uuid.NewString()+`"}`); b.Registered || b.Eligible {
t.Fatalf("unknown identity = %+v, want neither registered nor eligible", b)
}
// An account with no Telegram identity (a guest) carries an empty external_id, so
// the gateway has nothing to gate.
guest := provisionGuest(t)
if b := chatAccess(t, srv, `{"user_id":"`+guest.String()+`"}`); b.ExternalID != "" || b.Registered {
t.Fatalf("guest by user_id = %+v, want empty external_id and not registered", b)
}
// A request naming neither address is a bad request.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty query = %d, want 400", rec.Code)
}
}
// captureNotifier records every published intent so a test can assert which live
// events a console action emitted.
type captureNotifier struct {
mu sync.Mutex
intents []notify.Intent
}
func (c *captureNotifier) Publish(in ...notify.Intent) {
c.mu.Lock()
defer c.mu.Unlock()
c.intents = append(c.intents, in...)
}
// count returns how many intents of kind addressed to user were captured.
func (c *captureNotifier) count(user uuid.UUID, kind string) int {
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for _, in := range c.intents {
if in.UserID == user && in.Kind == kind {
n++
}
}
return n
}
// TestChatAccessPublishedOnModeration drives the admin console and asserts each
// moderation action that can change chat eligibility — block, unblock, and the
// chat_muted role grant/revoke — emits the chat_access_changed signal the gateway
// turns into a chat-gate command.
func TestChatAccessPublishedOnModeration(t *testing.T) {
notifier := &captureNotifier{}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
Notifier: notifier,
})
h := srv.Handler()
id := provisionAccount(t)
base := "http://admin.test/_gm/users/" + id.String()
const origin = "http://admin.test"
steps := []struct {
name, path, body string
want string
}{
{"block", "/block", "duration=permanent", "Blocked"},
{"unblock", "/unblock", "", "Unblocked"},
{"grant chat_muted", "/grant-role", "role=chat_muted", "Role granted"},
{"revoke chat_muted", "/revoke-role", "role=chat_muted", "Role revoked"},
}
for i, s := range steps {
code, body := consoleDo(h, http.MethodPost, base+s.path, s.body, origin)
if code != http.StatusOK || !strings.Contains(body, s.want) {
t.Fatalf("%s = %d, has %q = %v", s.name, code, s.want, strings.Contains(body, s.want))
}
if got := notifier.count(id, notify.KindChatAccessChanged); got != i+1 {
t.Fatalf("after %s: chat_access_changed count = %d, want %d", s.name, got, i+1)
}
}
}
// TestChatAccessPublishedOnFirstRegistration checks that a Telegram first contact
// (the sessions/telegram endpoint creating the account) emits chat_access_changed —
// the re-grant for a user who joined the moderated chat before registering — and that
// a repeat login does not re-emit.
func TestChatAccessPublishedOnFirstRegistration(t *testing.T) {
notifier := &captureNotifier{}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
Notifier: notifier,
})
h := srv.Handler()
ext := "tg-" + uuid.NewString()
post := func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram",
strings.NewReader(`{"external_id":"`+ext+`","language_code":"en","first_name":"Reg"}`))
req.Header.Set("Content-Type", "application/json")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
}
}
post()
acc, err := account.NewStore(testDB).AccountByIdentity(context.Background(), account.KindTelegram, ext)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
t.Fatalf("first registration: chat_access_changed count = %d, want 1", got)
}
// A repeat login (the account already exists) must not re-emit.
post()
if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
t.Fatalf("repeat login: chat_access_changed count = %d, want still 1", got)
}
}
// TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted
// temporary block whose expiry falls in the window is returned, while one outside the
// window, a permanent block, and a lifted block are not.
func TestSuspensionsExpiredBetween(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
// A temporary block whose expiry already lapsed at a known instant.
tempID := provisionAccount(t)
expiry := time.Now().Add(-time.Hour).Truncate(time.Second)
if _, err := accounts.Suspend(ctx, tempID, &expiry, "", "", nil); err != nil {
t.Fatalf("suspend temp: %v", err)
}
contains := func(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
// A window straddling the expiry returns the account.
got, err := accounts.SuspensionsExpiredBetween(ctx, expiry.Add(-time.Minute), expiry.Add(time.Minute))
if err != nil {
t.Fatalf("expired between: %v", err)
}
if !contains(got, tempID) {
t.Fatalf("window over expiry missing the lapsed block %s", tempID)
}
// A window entirely after the expiry does not.
got, err = accounts.SuspensionsExpiredBetween(ctx, expiry.Add(time.Minute), expiry.Add(2*time.Minute))
if err != nil {
t.Fatalf("expired between (after): %v", err)
}
if contains(got, tempID) {
t.Fatalf("window after expiry should not return %s", tempID)
}
// A permanent block never appears, even in a wide window.
permID := provisionAccount(t)
if _, err := accounts.Suspend(ctx, permID, nil, "", "", nil); err != nil {
t.Fatalf("suspend perm: %v", err)
}
// A lifted block does not appear either. The block must still be in force when lifted
// (LiftSuspension only lifts in-force blocks), so its expiry is in the future and the
// wide window below still covers it — yet lifted_at excludes it.
liftID := provisionAccount(t)
liftExpiry := time.Now().Add(30 * time.Minute).Truncate(time.Second)
if _, err := accounts.Suspend(ctx, liftID, &liftExpiry, "", "", nil); err != nil {
t.Fatalf("suspend lift: %v", err)
}
if err := accounts.LiftSuspension(ctx, liftID); err != nil {
t.Fatalf("lift: %v", err)
}
wide, err := accounts.SuspensionsExpiredBetween(ctx, time.Now().Add(-2*time.Hour), time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("expired between (wide): %v", err)
}
if contains(wide, permID) {
t.Fatalf("permanent block %s must not be reported as expired", permID)
}
if contains(wide, liftID) {
t.Fatalf("lifted block %s must not be reported as expired", liftID)
}
}
+2 -20
View File
@@ -16,8 +16,7 @@ import (
) )
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the // TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
// sender), surfaces through HasUnread / UnreadGames and the message-level HasUnreadMessage / // sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit.
// UnreadMessageGames (it is a real message, not a nudge), and that MarkRead clears the reader's bit.
func TestChatUnreadAndMarkRead(t *testing.T) { func TestChatUnreadAndMarkRead(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() svc := newSocialService()
@@ -38,15 +37,6 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
} else if !set[gameID] { } else if !set[gameID] {
t.Error("UnreadGames should include the game for the recipient") t.Error("UnreadGames should include the game for the recipient")
} }
// A text message also raises the message-level flags (it is not just a nudge).
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[1]); !msg {
t.Error("recipient should have the message flagged as an unread message")
}
if set, err := svc.UnreadMessageGames(ctx, seats[1]); err != nil {
t.Fatalf("unread message games: %v", err)
} else if !set[gameID] {
t.Error("UnreadMessageGames should include the game for a text message")
}
// Reading it clears the recipient's bit and reports one entry marked. // Reading it clears the recipient's bit and reports one entry marked.
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil { if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
@@ -65,8 +55,7 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the // TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge // awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
// targeting invariant, now observable through the unread bitmask. It also checks a nudge raises // targeting invariant, now observable through the unread bitmask.
// HasUnread but not the message-level flags, so the badge can colour it apart from a real message.
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) { func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() svc := newSocialService()
@@ -79,13 +68,6 @@ func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread { if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Error("the awaited seat should have the nudge unread") t.Error("the awaited seat should have the nudge unread")
} }
// A nudge is not a message: it must not raise the message-level flags.
if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[0]); msg {
t.Error("a nudge must not be flagged as an unread message")
}
if set, _ := svc.UnreadMessageGames(ctx, seats[0]); set[gameID] {
t.Error("UnreadMessageGames must not include a game whose only unread entry is a nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread { if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
t.Error("a non-awaited waiting seat must not receive the nudge") t.Error("a non-awaited waiting seat must not receive the nudge")
} }
@@ -134,13 +134,11 @@ func TestDictionaryUpdateFlow(t *testing.T) {
// newGameServiceOn builds a game service over the shared pool but a caller-supplied // newGameServiceOn builds a game service over the shared pool but a caller-supplied
// dictionary directory, version and registry, for the isolated dictionary tests. // dictionary directory, version and registry, for the isolated dictionary tests.
func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service { func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service {
svc := game.NewService( return game.NewService(
game.NewStore(testDB), account.NewStore(testDB), reg, game.NewStore(testDB), account.NewStore(testDB), reg,
game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour}, game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour},
zap.NewNop(), zap.NewNop(),
) )
svc.SetFirstMoveEntropy(seatZeroFirstMove)
return svc
} }
// seedDawgs copies the committed DAWG of every variant from src into dst (flat). // seedDawgs copies the committed DAWG of every variant from src into dst (flat).
+3 -17
View File
@@ -6,7 +6,6 @@ import (
"context" "context"
"errors" "errors"
"regexp" "regexp"
"slices"
"testing" "testing"
"time" "time"
@@ -135,20 +134,12 @@ func TestUpdateProfilePersists(t *testing.T) {
store := account.NewStore(testDB) store := account.NewStore(testDB)
acc := provisionAccount(t) acc := provisionAccount(t)
// A fresh account defaults to Erudit only (the DB-level column default).
if def, err := store.GetByID(ctx, acc); err != nil {
t.Fatalf("get default: %v", err)
} else if want := []string{"erudit_ru"}; !slices.Equal(def.VariantPreferences, want) {
t.Errorf("default variant preferences = %v, want %v", def.VariantPreferences, want)
}
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{ updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya", DisplayName: "Kaya",
PreferredLanguage: "ru", PreferredLanguage: "ru",
TimeZone: "Europe/Moscow", TimeZone: "Europe/Moscow",
BlockChat: true, BlockChat: true,
BlockFriendRequests: true, BlockFriendRequests: true,
VariantPreferences: []string{"scrabble_en", "erudit_ru"},
}) })
if err != nil { if err != nil {
t.Fatalf("update profile: %v", err) t.Fatalf("update profile: %v", err)
@@ -166,10 +157,6 @@ func TestUpdateProfilePersists(t *testing.T) {
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat { if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
t.Errorf("profile did not persist: %+v", reloaded) t.Errorf("profile did not persist: %+v", reloaded)
} }
// The text[] column round-trips and is stored canonically (Erudit-first).
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(reloaded.VariantPreferences, want) {
t.Errorf("variant preferences = %v, want %v", reloaded.VariantPreferences, want)
}
} }
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is // TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
@@ -180,10 +167,9 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
acc := provisionAccount(t) acc := provisionAccount(t)
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{ updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya", DisplayName: "Kaya",
PreferredLanguage: "en", PreferredLanguage: "en",
TimeZone: "+03:00", TimeZone: "+03:00",
VariantPreferences: []string{"erudit_ru"},
}) })
if err != nil { if err != nil {
t.Fatalf("update with offset timezone: %v", err) t.Fatalf("update with offset timezone: %v", err)
+25 -10
View File
@@ -145,33 +145,48 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
} }
} }
func TestFeedbackSnapshotsLanguage(t *testing.T) { func TestFeedbackSnapshotsLanguages(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if _, err := testDB.ExecContext(ctx, if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en' WHERE account_id = $1`, acc); err != nil { `UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("set language: %v", err) t.Fatalf("set languages: %v", err)
} }
// A message snapshots the sender's interface language at submit time. // A Telegram (connector) message snapshots both the interface language and the bot.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil { if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
id := latestFeedbackID(t, svc, acc) id := latestFeedbackID(t, svc, acc)
if m, err := svc.AdminGet(ctx, id); err != nil { if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatalf("admin get: %v", err) t.Fatalf("admin get: %v", err)
} else if m.Lang != "en" { } else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot = lang %q, want en", m.Lang) t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
} }
// Changing the account afterwards must not change the stored snapshot. // Changing the account afterwards must not change the stored snapshot.
if _, err := testDB.ExecContext(ctx, if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'ru' WHERE account_id = $1`, acc); err != nil { `UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("change language: %v", err) t.Fatalf("change languages: %v", err)
} }
if m, err := svc.AdminGet(ctx, id); err != nil { if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatal(err) t.Fatal(err)
} else if m.Lang != "en" { } else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot drifted after account change = lang %q, want en", m.Lang) t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang)
}
// A non-connector channel records no bot language even when the account has one.
acc2 := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil {
t.Fatalf("set languages 2: %v", err)
}
if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil {
t.Fatalf("submit web: %v", err)
}
if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "" {
t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang)
} }
} }
@@ -1,154 +0,0 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// The first-move-draw suite covers the official seeding (docs/ARCHITECTURE.md §6): the draw is
// recorded with the game, decides seat 0 (the first mover), and — in auto-match — runs against
// a synthetic opponent at open time whose draw rows are back-filled when a real opponent joins.
// TestCreateRecordsFirstMoveDraws checks a directly-seated game records the draw against the
// real seated accounts and seats the drawn leader (the suite's deterministic draw elects the
// first-listed account) at seat 0.
func TestCreateRecordsFirstMoveDraws(t *testing.T) {
ctx := context.Background()
svc := newGameService()
a := provisionAccount(t)
b := provisionAccount(t)
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
if g.Seats[0].AccountID != a || g.Seats[1].AccountID != b {
t.Fatalf("seats = [%s %s], want [a b]", g.Seats[0].AccountID, g.Seats[1].AccountID)
}
draws, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws: %v", err)
}
// seatZeroFirstMove resolves in one round: the first contender (a) draws a blank and wins.
if len(draws) != 2 {
t.Fatalf("draws = %d, want 2 (one round, two players)", len(draws))
}
if draws[0].Account != a || !draws[0].Blank {
t.Fatalf("draw[0] = %+v, want a having drawn a blank", draws[0])
}
if draws[1].Account != b {
t.Fatalf("draw[1] account = %s, want b", draws[1].Account)
}
}
// TestAutoMatchDrawBackfilledOnJoin checks an open auto-match game records the draw at open
// time against the synthetic opponent (a NULL account) and back-fills those rows to the real
// opponent when they join.
func TestAutoMatchDrawBackfilledOnJoin(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g := openGame(t, svc, starter, evenOpeningSeed(t))
draws, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws: %v", err)
}
if len(draws) != 2 {
t.Fatalf("open draws = %d, want 2", len(draws))
}
var nullSeen, starterSeen bool
for _, d := range draws {
switch d.Account {
case uuid.Nil:
nullSeen = true
case starter:
starterSeen = true
}
}
if !nullSeen || !starterSeen {
t.Fatalf("open draws = %+v, want the starter and a NULL synthetic opponent", draws)
}
joiner := provisionAccount(t)
if _, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil); err != nil || !joined {
t.Fatalf("join = (joined %v, err %v), want joined", joined, err)
}
after, err := svc.SetupDraws(ctx, g.ID)
if err != nil {
t.Fatalf("setup draws after join: %v", err)
}
for _, d := range after {
if d.Account == uuid.Nil {
t.Fatalf("draw still NULL after join: %+v", d)
}
if d.Account != starter && d.Account != joiner {
t.Fatalf("draw account %s is neither player", d.Account)
}
}
}
// TestReplayTimeline checks the admin replay timeline reconstructs the dealt racks and one
// played move: the step count, the drawn tiles, the bag remainder, the running score and the
// turn cursor.
func TestReplayTimeline(t *testing.T) {
ctx := context.Background()
svc := newGameService()
a := provisionAccount(t)
b := provisionAccount(t)
seed := evenOpeningSeed(t)
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, 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 for the seed")
}
if _, err := svc.SubmitPlay(ctx, g.ID, a, hint.Tiles); err != nil {
t.Fatalf("play: %v", err)
}
tl, err := svc.ReplayTimeline(ctx, g.ID)
if err != nil {
t.Fatalf("replay timeline: %v", err)
}
if len(tl.Steps) != 2 {
t.Fatalf("steps = %d, want 2 (deal + one move)", len(tl.Steps))
}
deal := tl.Steps[0]
if deal.Move != nil {
t.Fatalf("step 0 must be the deal, got move %+v", deal.Move)
}
rackSize := len(deal.Racks[0])
if rackSize == 0 || len(deal.Racks) != 2 || len(deal.Racks[1]) != rackSize {
t.Fatalf("deal racks = %v, want two equal full racks", deal.Racks)
}
move := tl.Steps[1]
if move.Move == nil || move.Move.Action != "play" {
t.Fatalf("step 1 move = %+v, want a play", move.Move)
}
if len(move.Drawn) != len(hint.Tiles) {
t.Fatalf("drawn = %v, want %d refilled tiles", move.Drawn, len(hint.Tiles))
}
if move.BagLen != deal.BagLen-len(hint.Tiles) {
t.Fatalf("bag after play = %d, want %d", move.BagLen, deal.BagLen-len(hint.Tiles))
}
if len(move.Racks[0]) != rackSize {
t.Fatalf("mover rack after refill = %d, want %d", len(move.Racks[0]), rackSize)
}
if move.Scores[0] <= 0 {
t.Fatalf("seat 0 score after play = %d, want > 0", move.Scores[0])
}
if move.ToMove != 1 {
t.Fatalf("to_move after seat 0 play = %d, want 1", move.ToMove)
}
}
+3 -5
View File
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
// An open (awaiting-opponent) quick game counts. // An open (awaiting-opponent) quick game counts.
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{ if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour, Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
}, time.Now().Add(time.Minute), nil); err != nil { }, time.Now().Add(time.Minute)); err != nil {
t.Fatalf("open quick game: %v", err) t.Fatalf("open quick game: %v", err)
} }
// An active quick game and an honest-AI quick game both count (neither has an invitation row). // An active quick game and an honest-AI quick game both count (neither has an invitation row).
@@ -97,12 +97,10 @@ func TestGameLimitGate(t *testing.T) {
if !gamesListAtLimit(t, srv, human) { if !gamesListAtLimit(t, srv, human) {
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames) t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
} }
// erudit_ru is in the default variant preferences, so the variant gate passes and the if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"scrabble_en"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
// game-limit gate is what fires here.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
} }
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String()) invBody := fmt.Sprintf(`{"variant":"scrabble_en","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" { if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
} }
-23
View File
@@ -150,14 +150,6 @@ func TestGameLifecycleAndStats(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("get stats: %v", err) t.Fatalf("get stats: %v", err)
} }
// The player made at least one scoring play (maxWord > 0), so the moves aggregate is
// positive; no hints were taken in this greedy game, so the hints aggregate stays 0.
if st.Moves <= 0 {
t.Errorf("moves = %d, want > 0", st.Moves)
}
if st.HintsUsed != 0 {
t.Errorf("hints used = %d, want 0 (no hints taken)", st.HintsUsed)
}
if len(st.BestMoves) != 1 { if len(st.BestMoves) != 1 {
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves) t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
} }
@@ -437,15 +429,6 @@ func TestHintPolicy(t *testing.T) {
if res.HintsRemaining != 1 || res.WalletBalance != 1 { if res.HintsRemaining != 1 || res.WalletBalance != 1 {
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance) t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
} }
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
// that feeds the player's lifetime hint statistics, not just the allowance.
st2, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil {
t.Fatalf("state after wallet hint: %v", err)
}
if got := st2.Game.Seats[0].HintsUsed; got != 2 {
t.Errorf("hints_used = %d after allowance + wallet hint, want 2", got)
}
off, err := svc.Create(ctx, game.CreateParams{ off, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour,
@@ -543,12 +526,6 @@ func TestEvaluatePlayPreview(t *testing.T) {
if bad.Valid { if bad.Valid {
t.Error("disconnected play must be invalid") t.Error("disconnected play must be invalid")
} }
// A non-seated account cannot preview: with the game warm in the live cache, the
// membership check runs against the cached seat list (the hot path that skips GetGame).
if _, err := svc.EvaluatePlay(ctx, g.ID, provisionAccount(t), hint.Tiles); !errors.Is(err, game.ErrNotAPlayer) {
t.Errorf("evaluate by a non-player = %v, want ErrNotAPlayer", err)
}
} }
// TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two // TestConcurrentSubmitSerialized confirms the per-game lock lets only one of two
+2 -36
View File
@@ -26,10 +26,9 @@ import (
// assembly, and the stats reader. Helpers used by a single test file stay in // assembly, and the stats reader. Helpers used by a single test file stay in
// that file; everything reused across files lives here. // that file; everything reused across files lives here.
// newGameService builds a game service over the shared pool and registry, with a // newGameService builds a game service over the shared pool and registry.
// deterministic first-move draw so the suite keeps a stable turn order.
func newGameService() *game.Service { func newGameService() *game.Service {
svc := game.NewService( return game.NewService(
game.NewStore(testDB), game.NewStore(testDB),
account.NewStore(testDB), account.NewStore(testDB),
testRegistry, testRegistry,
@@ -41,39 +40,6 @@ func newGameService() *game.Service {
}, },
zap.NewNop(), zap.NewNop(),
) )
svc.SetFirstMoveEntropy(seatZeroFirstMove)
return svc
}
// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first
// listed account as the leader, keeping the integration suite's turn order stable
// despite the real draw's randomness: the first contender draws a blank — the best
// possible tile — and wins outright, the rest draw the first remaining tile. It is a
// factory so each game restarts the one-shot "blank" pick.
func seatZeroFirstMove() func(n int) (int, error) {
first := true
return func(n int) (int, error) {
if first {
first = false
return n - 1, nil // a blank sits last in the bag → best rank
}
return 0, nil
}
}
// seatOneFirstMove is a first-move-draw entropy factory that elects the second contender (in
// auto-match, the synthetic opponent) as the leader, so the caller is seated at seat 1: the
// first contender draws the first remaining tile and the second draws a blank, winning.
func seatOneFirstMove() func(n int) (int, error) {
pick := 0
return func(n int) (int, error) {
i := 0
if pick == 1 {
i = n - 1 // the second contender draws a blank → best rank
}
pick++
return i, nil
}
} }
// newSocialService builds a social service over the shared pool, reading game // newSocialService builds a social service over the shared pool, reading game
+10 -39
View File
@@ -74,11 +74,9 @@ func TestMatchmakingOpensThenJoins(t *testing.T) {
} }
} }
// TestMatchmakingReEnqueueOpensNewGame checks a re-enqueue opens a fresh open game // TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the
// rather than returning the caller's existing one: tapping "random opponent" again while // caller gets their existing open game rather than a second one.
// still waiting starts a new search instead of bouncing the player back into the pending func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
// game. Both games stay open until an opponent (a human or the reaper's robot) joins each.
func TestMatchmakingReEnqueueOpensNewGame(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t) clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second) mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
@@ -92,13 +90,8 @@ func TestMatchmakingReEnqueueOpensNewGame(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("re-enqueue: %v", err) t.Fatalf("re-enqueue: %v", err)
} }
if r2.Matched || r2.Game.ID == r1.Game.ID { if r2.Game.ID != r1.Game.ID || r2.Matched {
t.Fatalf("re-enqueue = (game %s, matched %v), want a new open game distinct from %s, unmatched", r2.Game.ID, r2.Matched, r1.Game.ID) t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
}
for _, g := range []uuid.UUID{r1.Game.ID, r2.Game.ID} {
if _, _, status, err := newGameService().Participants(ctx, g); err != nil || status != "open" {
t.Fatalf("game %s status = %q err %v, want open", g, status, err)
}
} }
} }
@@ -198,36 +191,14 @@ func TestInvitationLazyExpiry(t *testing.T) {
func TestInvitationBlockedInvitee(t *testing.T) { func TestInvitationBlockedInvitee(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newInvitationService() svc := newInvitationService()
pub := &capturePublisher{} social := newSocialService()
svc.SetNotifier(pub)
soc := newSocialService()
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
inviter := provisionAccount(t) inviter := provisionAccount(t)
blockedInvitee := provisionAccount(t) invitee := provisionAccount(t)
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil { if err := social.Block(ctx, invitee, inviter); err != nil {
t.Fatalf("block: %v", err) t.Fatalf("block: %v", err)
} }
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) { if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err) t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
}
// An invitee who has blocked the inviter is instead suppressed: the invitation is created
// (no error — the inviter notices nothing), but that invitee is never notified and never
// sees it in their list.
inviter2 := provisionAccount(t)
suppressed := provisionAccount(t)
if err := soc.Block(ctx, suppressed, inviter2); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil {
t.Fatalf("create with a suppressing invitee = %v, want nil", err)
}
if pub.notified(suppressed, notify.NotifyInvitation) {
t.Error("an invitee who blocked the inviter must not be notified of the invitation")
}
if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 {
t.Errorf("suppressed invitee's invitation list = %v, want empty", list)
} }
} }
-55
View File
@@ -30,34 +30,6 @@ func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
} }
} }
func setStatsCounts(t *testing.T, id uuid.UUID, moves, hints int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.account_stats SET moves=$2, hints_used=$3 WHERE account_id=$1`, id, moves, hints); err != nil {
t.Fatalf("set stats counts: %v", err)
}
}
func setBestMove(t *testing.T, id uuid.UUID, variant string, score int) {
t.Helper()
tiles := `[{"letter":"c","value":3,"blank":false}]`
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.account_best_move (account_id, variant, score, tiles) VALUES ($1,$2,$3,$4)
ON CONFLICT (account_id, variant) DO UPDATE SET score=$3, tiles=$4`, id, variant, score, tiles); err != nil {
t.Fatalf("set best move: %v", err)
}
}
func bestMoveCount(t *testing.T, id uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.account_best_move WHERE account_id=$1`, id).Scan(&n); err != nil {
t.Fatalf("best move count: %v", err)
}
return n
}
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) { func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper() t.Helper()
if _, err := testDB.ExecContext(context.Background(), if _, err := testDB.ExecContext(context.Background(),
@@ -138,15 +110,8 @@ func TestAccountMergeCore(t *testing.T) {
setStats(t, primary, 1, 0, 0, 100, 90) setStats(t, primary, 1, 0, 0, 100, 90)
setStats(t, secondary, 3, 1, 2, 400, 80) setStats(t, secondary, 3, 1, 2, 400, 80)
setStatsCounts(t, primary, 100, 5)
setStatsCounts(t, secondary, 50, 8)
setWallet(t, primary, 2, false) setWallet(t, primary, 2, false)
setWallet(t, secondary, 5, true) setWallet(t, secondary, 5, true)
// Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
// scrabble_ru (30) is new to primary and carried over.
setBestMove(t, primary, "scrabble_en", 50)
setBestMove(t, secondary, "scrabble_en", 80)
setBestMove(t, secondary, "scrabble_ru", 30)
email := "merge-" + uuid.NewString() + "@example.com" email := "merge-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, secondary, email) bindEmailIdentity(t, secondary, email)
@@ -188,26 +153,6 @@ func TestAccountMergeCore(t *testing.T) {
if mergedInto(t, secondary) != primary { if mergedInto(t, secondary) != primary {
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary) t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
} }
// The moves/hints counters sum, and the per-variant best moves merge (the higher score
// per variant kept), with the secondary's best-move rows cleaned up.
st, err := store.GetStats(ctx, primary)
if err != nil {
t.Fatalf("get merged stats: %v", err)
}
if st.Moves != 150 || st.HintsUsed != 13 {
t.Errorf("primary moves/hints = %d/%d, want 150/13", st.Moves, st.HintsUsed)
}
best := map[string]int{}
for _, b := range st.BestMoves {
best[b.Variant] = b.Score
}
if len(st.BestMoves) != 2 || best["scrabble_en"] != 80 || best["scrabble_ru"] != 30 {
t.Errorf("primary best moves = %+v, want scrabble_en:80 + scrabble_ru:30", st.BestMoves)
}
if bestMoveCount(t, secondary) != 0 {
t.Error("secondary best-move rows should be deleted after merge")
}
} }
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active // TestAccountMergeActiveGameConflict refuses a merge when the two share an active
+14 -17
View File
@@ -18,9 +18,8 @@ import (
// The open-game suite covers an auto-match game that a player enters immediately and // The open-game suite covers an auto-match game that a player enters immediately and
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins. // waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
// evenOpeningSeed returns a seed whose fresh two-player English opening rack (seat 0) has a // evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
// legal move, for tests that drive or attempt an opening play. It scans even seeds; seat // moves first) whose fresh two-player English opening rack has a legal move.
// order no longer depends on the seed — the first-move draw decides it (docs/ARCHITECTURE.md §6).
func evenOpeningSeed(t *testing.T) int64 { func evenOpeningSeed(t *testing.T) int64 {
t.Helper() t.Helper()
for seed := int64(2); seed <= 400; seed += 2 { for seed := int64(2); seed <= 400; seed += 2 {
@@ -50,7 +49,7 @@ func openParams(seed int64) game.CreateParams {
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game { func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
t.Helper() t.Helper()
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil) g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
if err != nil { if err != nil {
t.Fatalf("open game: %v", err) t.Fatalf("open game: %v", err)
} }
@@ -60,9 +59,9 @@ func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) ga
return g return g
} }
// TestOpenGameStarterMovesThenWaits checks the starter may move on their turn while the game // TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
// is open (the first-move draw, run when the game opened, put them at seat 0), after which it // while the game is open, after which it is the empty opponent seat's turn and the
// is the empty opponent seat's turn and the starter just waits. // starter just waits.
func TestOpenGameStarterMovesThenWaits(t *testing.T) { func TestOpenGameStarterMovesThenWaits(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t) clearOpenGames(t)
@@ -90,30 +89,28 @@ func TestOpenGameStarterMovesThenWaits(t *testing.T) {
} }
} }
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the first-move draw seats // TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
// the starter at seat 1 (the synthetic opponent won the open draw), the still-empty seat 0 is // at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
// to move, so the starter cannot act until an opponent fills it.
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) { func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t) clearOpenGames(t)
svc := newGameService() svc := newGameService()
svc.SetFirstMoveEntropy(seatOneFirstMove) // the synthetic opponent wins → the caller sits at seat 1
starter := provisionAccount(t) starter := provisionAccount(t)
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil) g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
if err != nil { if err != nil {
t.Fatalf("open: %v", err) t.Fatalf("open: %v", err)
} }
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil { if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
t.Fatalf("opponent-won open draw: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID) t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
} }
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) { if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err) t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
} }
} }
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after the // TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
// starter has already made their first move: the seats were fixed by the draw at open, so the // the starter has already made their first move, landing on the board mid-opening and
// joiner takes the empty seat without disturbing the journal, and can act on their turn. // able to act on their turn.
func TestOpenGameJoinAfterStarterMoved(t *testing.T) { func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t) clearOpenGames(t)
@@ -131,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
} }
joiner := provisionAccount(t) joiner := provisionAccount(t)
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil) g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
if err != nil { if err != nil {
t.Fatalf("join: %v", err) t.Fatalf("join: %v", err)
} }
@@ -1,82 +0,0 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestRobotFriendRequestIsPerGameAndReaped checks that sending a friend request to a
// disguised-robot opponent is recorded per-game in robot_friend_requests — never in the
// friendships table or against the shared robot account — that a re-send is idempotent, and
// that the reaper deletes the row once its game has been finished past the retention window
// while keeping the row for an active game.
func TestRobotFriendRequestIsPerGameAndReaped(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)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil {
t.Fatalf("request robot: %v", err)
}
rfr, err := svc.ListRobotFriendRequests(ctx, human)
if err != nil {
t.Fatalf("list robot friend requests: %v", err)
}
if len(rfr) != 1 || rfr[0].GameID != g.ID || rfr[0].Seat != 1 {
t.Fatalf("robot friend requests = %v, want one for game %s seat 1", rfr, g.ID)
}
// The friendships table must stay empty: no outgoing request against the shared robot account.
if out, _ := svc.ListOutgoingRequests(ctx, human); len(out) != 0 {
t.Errorf("friendships outgoing must be empty for a robot request, got %v", out)
}
// A re-send to the same game+seat is idempotent (the UNIQUE constraint collapses it).
if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil {
t.Fatalf("re-request robot: %v", err)
}
if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 {
t.Fatalf("re-request must not duplicate, got %v", rfr)
}
// While the game is active the reaper keeps the row.
if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 0 {
t.Fatalf("reap on active game = (%d, %v), want (0, nil)", n, err)
}
if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 {
t.Fatalf("active-game request must survive the reaper, got %v", rfr)
}
// Finish the game well past the retention window: the reaper now deletes the row.
finished := time.Now().UTC().Add(-8 * 24 * time.Hour)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.games SET status='finished', finished_at=$1 WHERE game_id=$2`, finished, g.ID); err != nil {
t.Fatalf("finish game: %v", err)
}
if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 1 {
t.Fatalf("reap on long-finished game = (%d, %v), want (1, nil)", n, err)
}
if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 0 {
t.Errorf("long-finished request not reaped: %v", rfr)
}
}
+9 -132
View File
@@ -181,8 +181,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
} }
} }
// TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's // TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
// turn: nothing before the 9 h floor of the 9-12 h gap, exactly one once past its 12 h ceiling. // human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
func TestRobotProactiveNudge(t *testing.T) { func TestRobotProactiveNudge(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newGameService() svc := newGameService()
@@ -206,20 +206,18 @@ func TestRobotProactiveNudge(t *testing.T) {
t.Fatalf("create: %v", err) t.Fatalf("create: %v", err)
} }
// A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00, // A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
// both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the // nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
// sleep window, decides the outcome. No nudge at 8 h idle (before the 9 h floor); exactly one start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
// at 24 h idle (well past the 12 h ceiling, however the seed drew within 9-12 h).
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
setTurnStarted(t, g.ID, start) setTurnStarted(t, g.ID, start)
robots.Drive(ctx, start.Add(8*time.Hour)) robots.Drive(ctx, start.Add(30*time.Minute))
if n := countNudges(t, g.ID, robotID); n != 0 { if n := countNudges(t, g.ID, robotID); n != 0 {
t.Errorf("robot nudges = %d at 8h idle, want 0 (before the 9h floor)", n) t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
} }
robots.Drive(ctx, start.Add(24*time.Hour)) robots.Drive(ctx, start.Add(2*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 1 { if n := countNudges(t, g.ID, robotID); n != 1 {
t.Errorf("robot nudges = %d at 24h idle, want 1 (past the 12h ceiling)", n) t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
} }
} }
@@ -251,124 +249,3 @@ func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, hum
t.Fatalf("human pass: %v", err) t.Fatalf("human pass: %v", err)
} }
} }
// countMoves returns the number of committed moves in a game's journal.
func countMoves(t *testing.T, gameID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.game_moves WHERE game_id = $1`, gameID).Scan(&n); err != nil {
t.Fatalf("count moves: %v", err)
}
return n
}
// setLastTwoMoveTimes rewrites the created_at of a game's two most recent journal entries
// so the endgame think-time anchor (their gap, the human's last-move think time) is a known
// value, independent of the millisecond spacing of the manufactured passes.
func setLastTwoMoveTimes(t *testing.T, gameID uuid.UUID, prevAt, lastAt time.Time) {
t.Helper()
ctx := context.Background()
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.game_moves SET created_at = $2
WHERE game_id = $1 AND seq = (SELECT max(seq) FROM backend.game_moves WHERE game_id = $1)`,
gameID, lastAt); err != nil {
t.Fatalf("set last move time: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.game_moves SET created_at = $2
WHERE game_id = $1 AND seq = (SELECT max(seq) - 1 FROM backend.game_moves WHERE game_id = $1)`,
gameID, prevAt); err != nil {
t.Fatalf("set prev move time: %v", err)
}
}
// TestRobotEndgamePassShrinksThinkTime checks the dead-drawn-endgame shrink end to end: when
// the two most recent moves are both passes, RobotTurns reports EndgamePass with the human's
// last-move think time (OppLastMove), and the driver answers on the shortened schedule — well
// before the normal late-game delay — rather than dragging out the decided game. The journal
// state is manufactured with direct passes so the test isolates the timing mechanism (the
// SQL anchor and the driver gate) from a full game to an empty bag.
func TestRobotEndgamePassShrinksThinkTime(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
human := provisionAccount(t)
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID},
TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
const robotSeat = 1
store := game.NewStore(testDB)
turnOf := func(id uuid.UUID) game.RobotTurn {
t.Helper()
turns, err := store.RobotTurns(ctx, []uuid.UUID{robotID})
if err != nil {
t.Fatalf("robot turns: %v", err)
}
for _, rt := range turns {
if rt.GameID == id {
return rt
}
}
t.Fatalf("no robot turn for game %s", id)
return game.RobotTurn{}
}
// One pass so far (the human): not yet a double-pass endgame.
if _, err := svc.Pass(ctx, g.ID, human); err != nil {
t.Fatalf("human pass 1: %v", err)
}
if rt := turnOf(g.ID); rt.EndgamePass {
t.Fatalf("EndgamePass after a single pass = true, want false")
}
// Robot then human pass: the two most recent moves are now both passes and it is the
// robot's turn — the guaranteed-pass endgame state.
if _, err := svc.Pass(ctx, g.ID, robotID); err != nil {
t.Fatalf("robot pass: %v", err)
}
if _, err := svc.Pass(ctx, g.ID, human); err != nil {
t.Fatalf("human pass 2: %v", err)
}
if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove != robotSeat {
t.Fatalf("after two passes: toMove %d err %v, want robot seat %d", toMove, err, robotSeat)
}
// Anchor the human's last-move think time to 60s and start the robot's turn at daytime.
setLastTwoMoveTimes(t, g.ID, daytime.Add(-60*time.Second), daytime)
setTurnStarted(t, g.ID, daytime)
rt := turnOf(g.ID)
if !rt.EndgamePass {
t.Fatalf("EndgamePass after a double pass = false, want true")
}
if d := rt.OppLastMove; d < 59*time.Second || d > 61*time.Second {
t.Fatalf("OppLastMove = %s, want ~60s", d)
}
// The normal schedule for this move is at least the early band floor (~3.75 min); the
// 60s-anchored endgame delay is at most 90s for every seed. Driving at +150s is past the
// shrunk delay but well before the normal one, so the robot acts only because of the shrink.
before := countMoves(t, g.ID)
robots.Drive(ctx, daytime.Add(150*time.Second))
if after := countMoves(t, g.ID); after != before+1 {
t.Fatalf("robot did not act on the shrunk endgame schedule: moves %d → %d (want +1)", before, after)
}
if _, toMove, _, err := svc.Participants(ctx, g.ID); err != nil || toMove == robotSeat {
t.Fatalf("after the shrunk move: still the robot's turn (toMove %d, err %v)", toMove, err)
}
}
@@ -37,7 +37,7 @@ func TestSeatNameFrozenAfterRename(t *testing.T) {
starter := provisionAccount(t) starter := provisionAccount(t)
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{ if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}, DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC",
}); err != nil { }); err != nil {
t.Fatalf("set name: %v", err) t.Fatalf("set name: %v", err)
} }
@@ -50,7 +50,7 @@ func TestSeatNameFrozenAfterRename(t *testing.T) {
// Rename the account; the snapshot on the already-taken seat must not follow. // Rename the account; the snapshot on the already-taken seat must not follow.
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{ if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}, DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC",
}); err != nil { }); err != nil {
t.Fatalf("rename: %v", err) t.Fatalf("rename: %v", err)
} }
+34 -67
View File
@@ -45,20 +45,6 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
return false return false
} }
// delivered reports whether an intent of the given top-level kind (e.g. chat_message,
// nudge) was published to user — used to assert that a blocked sender's message or nudge
// never reaches the blocker.
func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, in := range c.intents {
if in.UserID == user && in.Kind == kind {
return true
}
}
return false
}
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as // TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
// pending rather than blocked: robots no longer block friend requests, so the request // 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. // just sits unanswered and later expires — mirroring a human who ignores it.
@@ -135,60 +121,24 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
// Toggle: the addressee does not accept friend requests. // Toggle: the addressee does not accept friend requests.
a, b := provisionAccount(t), provisionAccount(t) a, b := provisionAccount(t), provisionAccount(t)
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true, VariantPreferences: []string{"erudit_ru"}}); err != nil { if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
t.Fatalf("set toggle: %v", err) t.Fatalf("set toggle: %v", err)
} }
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) { if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err) t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
} }
// Block from the requester's own side: a requester who has blocked the addressee is // Block: the addressee has blocked the requester.
// refused outright (they are the blocker and aware of it). The reverse direction — the c, d := provisionAccount(t), provisionAccount(t)
// addressee having blocked the requester — is instead silently suppressed, covered by if err := svc.Block(ctx, d, c); err != nil {
// TestFriendRequestFromBlockedIsSuppressed.
_, seats := newGameWithSeats(t, 2)
c, d := seats[0], seats[1]
if err := svc.Block(ctx, c, d); err != nil {
t.Fatalf("block: %v", err) t.Fatalf("block: %v", err)
} }
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) { if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err) t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
} }
} }
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the func TestBlockSeversFriendship(t *testing.T) {
// addressee has blocked the requester, the requester's friend request succeeds silently
// (no error — they must not notice the block), is stored, but is never delivered to nor
// surfaced for the blocker.
func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
_, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1]
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil {
t.Fatalf("suppressed request = %v, want nil", err)
}
if pub.notified(blocker, notify.NotifyFriendRequest) {
t.Error("blocker must not be notified of a suppressed request")
}
if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 {
t.Errorf("blocker incoming = %v, want none (suppressed)", got)
}
// The blocked user sees an ordinary outgoing request — no leak of the block.
if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker {
t.Errorf("blocked outgoing = %v, want [blocker]", got)
}
}
// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a
// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is
// unchanged (so they never notice), and an unblock cleanly restores the friendship.
func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() svc := newSocialService()
_, seats := newGameWithSeats(t, 2) _, seats := newGameWithSeats(t, 2)
@@ -203,16 +153,7 @@ func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
t.Fatalf("block: %v", err) t.Fatalf("block: %v", err)
} }
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 { if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends) t.Errorf("friendship must be severed by a block, got %v", friends)
}
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends)
}
if err := svc.Unblock(ctx, a, b); err != nil {
t.Fatalf("unblock: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends)
} }
} }
@@ -353,7 +294,7 @@ func TestChatPostListAndBlocks(t *testing.T) {
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil { if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
t.Fatalf("post 2: %v", err) t.Fatalf("post 2: %v", err)
} }
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true, VariantPreferences: []string{"erudit_ru"}}); err != nil { if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
t.Fatalf("set block_chat: %v", err) t.Fatalf("set block_chat: %v", err)
} }
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 { if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
@@ -588,6 +529,32 @@ func TestRespondPublishesToRequester(t *testing.T) {
} }
} }
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
// it is delivered by the game's bot rather than the recipient's last-login bot.
func TestNudgeRoutedByGameLanguage(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
found := false
for _, in := range pub.intents {
if in.Kind == notify.KindNudge {
found = true
if in.Language != "en" {
t.Errorf("nudge language = %q, want en (the game's language)", in.Language)
}
}
}
if !found {
t.Fatal("no nudge intent published")
}
}
// TestAdminListMessages checks the admin moderation list: real messages only // TestAdminListMessages checks the admin moderation list: real messages only
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label. // (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) { func TestAdminListMessages(t *testing.T) {
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts, Accounts: accounts,
}) })
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
if err != nil { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
+1 -1
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB) st := account.NewStore(testDB)
uniq := uuid.NewString() uniq := uuid.NewString()
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
if err != nil { if err != nil {
t.Fatalf("provision human: %v", err) t.Fatalf("provision human: %v", err)
} }
@@ -1,51 +0,0 @@
//go:build integration
package inttest
import (
"fmt"
"net/http"
"testing"
"time"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/server"
)
// TestVariantPreferenceGate covers the New Game gate: a player may start a quick game
// or create a friend invitation only in a variant they have enabled in their profile
// (a fresh account defaults to Erudit only), and any other variant is refused with 400.
// The complementary case — an invited friend accepting an invitation in a variant they
// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human
// accepts an English invitation over HTTP.
func TestVariantPreferenceGate(t *testing.T) {
clearOpenGames(t)
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: newGameService(),
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
Invitations: newInvitationService(),
})
human := provisionAccount(t) // default preferences: erudit_ru only
opp := provisionAccount(t)
// A variant outside the player's preferences is refused on both create paths.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"scrabble_en"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("enqueue non-preferred variant = %d (%s), want 400", rec.Code, rec.Body.String())
}
invBody := fmt.Sprintf(`{"variant":"scrabble_en","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusBadRequest {
t.Fatalf("invitation non-preferred variant = %d (%s), want 400", rec.Code, rec.Body.String())
}
// The default-enabled variant (Erudit) is allowed: the quick enqueue opens a game.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
t.Fatalf("enqueue preferred variant = %d (%s), want 200", rec.Code, rec.Body.String())
}
clearOpenGames(t)
}
+3 -47
View File
@@ -150,14 +150,6 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
} }
intents := make([]notify.Intent, 0, len(recipients)) intents := make([]notify.Intent, 0, len(recipients))
for _, id := range recipients { for _, id := range recipients {
// An invitee who has blocked the inviter never sees this invitation, so they must not
// receive its updates either (an update would otherwise re-add it to their lobby). The
// inviter always gets updates; on a lookup error suppress rather than risk a leak.
if id != inv.InviterID {
if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk {
continue
}
}
intents = append(intents, notify.NotificationInvitationUpdate(id, summary)) intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
} }
svc.pub.Publish(intents...) svc.pub.Publish(intents...)
@@ -219,11 +211,6 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout) return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
} }
seen := map[uuid.UUID]bool{inviterID: true} seen := map[uuid.UUID]bool{inviterID: true}
// suppressed collects invitees who have blocked the inviter: the invitation is still
// created and persisted for them, but they are never notified and never see it (their
// friendship with the inviter survived the block, so they can still appear in the
// inviter's picker — they must not learn of the block).
suppressed := make(map[uuid.UUID]bool)
for _, id := range inviteeIDs { for _, id := range inviteeIDs {
if seen[id] { if seen[id] {
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id) return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
@@ -235,22 +222,13 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
} }
return Invitation{}, err return Invitation{}, err
} }
// A block the inviter placed refuses the invite (the inviter is aware; the picker hides blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
// the invitee anyway). A block the invitee placed on the inviter is instead suppressed.
iBlock, err := svc.blocker.Blocks(ctx, inviterID, id)
if err != nil { if err != nil {
return Invitation{}, err return Invitation{}, err
} }
if iBlock { if blocked {
return Invitation{}, ErrInvitationBlocked return Invitation{}, ErrInvitationBlocked
} }
theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID)
if err != nil {
return Invitation{}, err
}
if theyBlock {
suppressed[id] = true
}
} }
id, err := uuid.NewV7() id, err := uuid.NewV7()
@@ -275,18 +253,7 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
if err != nil { if err != nil {
return Invitation{}, err return Invitation{}, err
} }
// Notify every invitee except those who have blocked the inviter (their copy stays svc.emitInvitation(ctx, inv, inviteeIDs)
// stored but unseen — the store-but-hide that keeps the block invisible).
notifyIDs := inviteeIDs
if len(suppressed) > 0 {
notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs))
for _, id := range inviteeIDs {
if !suppressed[id] {
notifyIDs = append(notifyIDs, id)
}
}
}
svc.emitInvitation(ctx, inv, notifyIDs)
return inv, nil return inv, nil
} }
@@ -380,17 +347,6 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Hide an invitation whose inviter the viewer has blocked: it stays stored but must
// never surface to the blocker. The viewer's own invitations (as inviter) are unaffected.
if inv.InviterID != accountID {
blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID)
if err != nil {
return nil, err
}
if blocked {
continue
}
}
out = append(out, inv) out = append(out, inv)
} }
return out, nil return out, nil
+4 -8
View File
@@ -38,15 +38,11 @@ type RobotProvider interface {
PickNamed(variant engine.Variant) (uuid.UUID, string, error) PickNamed(variant engine.Variant) (uuid.UUID, string, error)
} }
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it. // Blocker reports whether two accounts have a block between them (either
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a // direction). social.Service satisfies it; the lobby uses it to refuse
// block the invitee placed on the inviter is silently suppressed; auto-match consults // invitations between blocked accounts.
// BlockedWith so a block either way keeps the pair out of the same anonymous game.
type Blocker interface { type Blocker interface {
// Blocks reports whether blocker has blocked blocked (exact direction). IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error)
// BlockedWith returns every account in a block with accountID in either direction.
BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error)
} }
// Auto-match defaults: a casual two-player game on the longest move clock with one // Auto-match defaults: a casual two-player game on the longest move clock with one
+8 -33
View File
@@ -18,10 +18,7 @@ import (
// reading a player's view to enrich the opponent_joined event. game.Service satisfies // reading a player's view to enrich the opponent_joined event. game.Service satisfies
// it. // it.
type GameMatcher interface { type GameMatcher interface {
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
// one; exclude lists account IDs whose open game must never be joined (the caller's
// per-user block set), so a block keeps the pair out of the same anonymous game.
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error) ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
@@ -38,9 +35,8 @@ type GameMatcher interface {
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for // Matchmaker holds only the wait policy and the live-event publisher, and is safe for
// concurrent use. // concurrent use.
// //
// Auto-match is anonymous, but it still consults per-user blocks: a player is never // Auto-match is anonymous, so it does not consult per-user blocks (those govern
// paired into a game whose waiting opponent they have blocked, or who has blocked them // friends, chat and invitations between known players).
// (either direction), so a block keeps two players apart everywhere.
type Matchmaker struct { type Matchmaker struct {
games GameMatcher games GameMatcher
robots RobotProvider robots RobotProvider
@@ -48,7 +44,6 @@ type Matchmaker struct {
jitter time.Duration jitter time.Duration
clock func() time.Time clock func() time.Time
pub notify.Publisher pub notify.Publisher
blocker Blocker
log *zap.Logger log *zap.Logger
} }
@@ -80,16 +75,6 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
} }
} }
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
// player with someone they have a block with (either direction). It must be called
// during startup wiring; the default is no blocker, in which case auto-match pairs
// anonymously (no block exclusion).
func (m *Matchmaker) SetBlocker(b Blocker) {
if b != nil {
m.blocker = b
}
}
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays // EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
// in, and whether it already had an opponent (they joined a waiting game) rather than // in, and whether it already had an opponent (they joined a waiting game) rather than
// being freshly opened and still awaiting one. // being freshly opened and still awaiting one.
@@ -100,22 +85,11 @@ type EnqueueResult struct {
// Enqueue resolves an auto-match request for accountID under variant and the per-turn // Enqueue resolves an auto-match request for accountID under variant and the per-turn
// word rule (multipleWords) into the game they enter immediately — a freshly opened // word rule (multipleWords) into the game they enter immediately — a freshly opened
// game awaiting an opponent, or another player's open game they just joined. A // game awaiting an opponent, the caller's own still-open game (a re-enqueue is
// re-enqueue while already waiting opens another game rather than returning the // idempotent), or another player's open game they just joined. When the caller joins
// caller's own. When the caller joins an existing game, opponent_joined is pushed to // an existing game, opponent_joined is pushed to that game's waiting starter.
// that game's waiting starter.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) { func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
// Exclude every account the caller has a block with (either direction) from the g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
// candidate open games, so auto-match never pairs a blocked pair into one game.
var exclude []uuid.UUID
if m.blocker != nil {
var err error
exclude, err = m.blocker.BlockedWith(ctx, accountID)
if err != nil {
return EnqueueResult{}, err
}
}
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
if err != nil { if err != nil {
return EnqueueResult{}, err return EnqueueResult{}, err
} }
@@ -206,6 +180,7 @@ func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID
return return
} }
intent := notify.OpponentJoined(starter, g.ID, state) intent := notify.OpponentJoined(starter, g.ID, state)
intent.Language = g.Variant.Language()
m.pub.Publish(intent) m.pub.Publish(intent)
} }
+1 -30
View File
@@ -25,7 +25,6 @@ type stubMatcher struct {
openErr error openErr error
openCalls int openCalls int
lastDeadline time.Time lastDeadline time.Time
lastExclude []uuid.UUID
expired []game.OpenGame expired []game.OpenGame
@@ -40,10 +39,9 @@ type stubMatcher struct {
createParams game.CreateParams createParams game.CreateParams
} }
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) { func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
s.openCalls++ s.openCalls++
s.lastDeadline = deadline s.lastDeadline = deadline
s.lastExclude = exclude
return s.openGame, s.openJoined, s.openErr return s.openGame, s.openJoined, s.openErr
} }
@@ -97,33 +95,6 @@ type capturePub struct{ intents []notify.Intent }
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) } func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
// fakeBlocker is a Blocker returning a canned both-direction block set.
type fakeBlocker struct {
with []uuid.UUID
err error
}
func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil }
func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) {
return f.with, f.err
}
// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set
// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game
// whose waiting player has a block with the caller.
func TestEnqueueExcludesBlockedStarters(t *testing.T) {
caller, blocked := uuid.New(), uuid.New()
m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)}
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}})
if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", err)
}
if !slices.Contains(m.lastExclude, blocked) {
t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked)
}
}
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1 // twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
// (uuid.Nil for a still-empty opponent seat). // (uuid.Nil for a still-empty opponent seat).
func twoSeatGame(starter, opponent uuid.UUID) game.Game { func twoSeatGame(starter, opponent uuid.UUID) game.Game {
-10
View File
@@ -216,16 +216,6 @@ func BannerChanged(userID uuid.UUID) Intent {
return Notification(userID, NotifyBanner) return Notification(userID, NotifyBanner)
} }
// ChatAccessChanged signals that userID's eligibility to write in the moderated
// Telegram discussion chat may have changed (an admin block/unblock, a chat_muted
// grant/revoke, or a temporary block lapsing). It carries no payload: the gateway
// resolves the user's Telegram identity and current eligibility and pushes the
// resulting chat-gate command to the bot. Unlike the lobby notifications it is an
// infra signal — a distinct top-level kind, never an out-of-app rendered message.
func ChatAccessChanged(userID uuid.UUID) Intent {
return Intent{UserID: userID, Kind: KindChatAccessChanged, EventID: eventID()}
}
// eventID returns a best-effort correlation id for one emitted event. // eventID returns a best-effort correlation id for one emitted event.
func eventID() string { func eventID() string {
if id, err := uuid.NewV7(); err == nil { if id, err := uuid.NewV7(); err == nil {
+5 -17
View File
@@ -35,13 +35,6 @@ const (
// KindGameOver announces a finished game to each seated player, driving the // KindGameOver announces a finished game to each seated player, driving the
// out-of-app "game over" push. // out-of-app "game over" push.
KindGameOver = "game_over" KindGameOver = "game_over"
// KindChatAccessChanged signals that a player's eligibility to write in the
// moderated Telegram discussion chat may have changed (an admin block or unblock,
// a chat_muted grant or revoke, or a temporary block lapsing). It carries no
// payload and is never fanned out to in-app clients: the gateway consumes it to
// resolve the player's Telegram identity and current eligibility and push the
// resulting chat-gate command to the bot.
KindChatAccessChanged = "chat_access_changed"
) )
// Notification sub-kinds carried in a KindNotification event payload; the client // Notification sub-kinds carried in a KindNotification event payload; the client
@@ -69,16 +62,6 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the // it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only. // banner set rides the profile response). In-app only.
NotifyBanner = "banner" NotifyBanner = "banner"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
// only to the blocker — never to the blocked user, who must not learn of the block —
// and is in-app only (no out-of-app push: blocking is the viewer's own action).
NotifyUserBlocked = "user_blocked"
// NotifyUserUnblocked confirms an unblock to the (former) blocker, carrying the
// unblocked account, so their open game screens restore the controls and un-strike
// the name in place. Like NotifyUserBlocked it reaches only the actor and is in-app only.
NotifyUserUnblocked = "user_unblocked"
) )
// 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
@@ -89,6 +72,11 @@ type Intent struct {
Kind string Kind string
Payload []byte Payload []byte
EventID string EventID string
// Language routes an out-of-app push to a specific per-language bot: for a
// game event it is the game's language ("en"/"ru"), so the notification comes from the
// game's bot rather than the recipient's last-login bot. Empty falls back to the
// recipient's service language at the gateway.
Language string
} }
// Publisher accepts live-event intents. Implementations must be safe for // Publisher accepts live-event intents. Implementations must be safe for
@@ -20,6 +20,4 @@ type AccountStats struct {
MaxGamePoints int32 MaxGamePoints int32
MaxWordPoints int32 MaxWordPoints int32
UpdatedAt time.Time UpdatedAt time.Time
Moves int32
HintsUsed int32
} }
@@ -9,7 +9,6 @@ package model
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
"time" "time"
) )
@@ -30,6 +29,6 @@ type Accounts struct {
PaidAccount bool PaidAccount bool
MergedInto *uuid.UUID MergedInto *uuid.UUID
MergedAt *time.Time MergedAt *time.Time
ServiceLanguage *string
FlaggedHighRateAt *time.Time FlaggedHighRateAt *time.Time
VariantPreferences pq.StringArray
} }
@@ -20,11 +20,12 @@ type FeedbackMessages struct {
AttachmentName *string AttachmentName *string
SenderIP *string SenderIP *string
Channel string Channel string
Lang *string
ChannelLang *string
ReadAt *time.Time ReadAt *time.Time
ArchivedAt *time.Time ArchivedAt *time.Time
ReplyBody *string ReplyBody *string
RepliedAt *time.Time RepliedAt *time.Time
ReplyReadAt *time.Time ReplyReadAt *time.Time
CreatedAt time.Time CreatedAt time.Time
Lang *string
} }
@@ -1,24 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type GameSetupDraws struct {
GameID uuid.UUID `sql:"primary_key"`
Round int16 `sql:"primary_key"`
PickNo int16 `sql:"primary_key"`
AccountID *uuid.UUID
Letter string
IsBlank bool
DrawRank int16
CreatedAt time.Time
}
@@ -24,8 +24,6 @@ type accountStatsTable struct {
MaxGamePoints postgres.ColumnInteger MaxGamePoints postgres.ColumnInteger
MaxWordPoints postgres.ColumnInteger MaxWordPoints postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz
Moves postgres.ColumnInteger
HintsUsed postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -74,11 +72,9 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points") MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points") MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
MovesColumn = postgres.IntegerColumn("moves") allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
HintsUsedColumn = postgres.IntegerColumn("hints_used") mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn} defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
) )
return accountStatsTable{ return accountStatsTable{
@@ -92,8 +88,6 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
MaxGamePoints: MaxGamePointsColumn, MaxGamePoints: MaxGamePointsColumn,
MaxWordPoints: MaxWordPointsColumn, MaxWordPoints: MaxWordPointsColumn,
UpdatedAt: UpdatedAtColumn, UpdatedAt: UpdatedAtColumn,
Moves: MovesColumn,
HintsUsed: HintsUsedColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -33,8 +33,8 @@ type accountsTable struct {
PaidAccount postgres.ColumnBool PaidAccount postgres.ColumnBool
MergedInto postgres.ColumnString MergedInto postgres.ColumnString
MergedAt postgres.ColumnTimestampz MergedAt postgres.ColumnTimestampz
ServiceLanguage postgres.ColumnString
FlaggedHighRateAt postgres.ColumnTimestampz FlaggedHighRateAt postgres.ColumnTimestampz
VariantPreferences postgres.ColumnStringArray
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -92,11 +92,11 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccountColumn = postgres.BoolColumn("paid_account") PaidAccountColumn = postgres.BoolColumn("paid_account")
MergedIntoColumn = postgres.StringColumn("merged_into") MergedIntoColumn = postgres.StringColumn("merged_into")
MergedAtColumn = postgres.TimestampzColumn("merged_at") MergedAtColumn = postgres.TimestampzColumn("merged_at")
ServiceLanguageColumn = postgres.StringColumn("service_language")
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at") FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences") allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn}
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn} defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
) )
return accountsTable{ return accountsTable{
@@ -119,8 +119,8 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccount: PaidAccountColumn, PaidAccount: PaidAccountColumn,
MergedInto: MergedIntoColumn, MergedInto: MergedIntoColumn,
MergedAt: MergedAtColumn, MergedAt: MergedAtColumn,
ServiceLanguage: ServiceLanguageColumn,
FlaggedHighRateAt: FlaggedHighRateAtColumn, FlaggedHighRateAt: FlaggedHighRateAtColumn,
VariantPreferences: VariantPreferencesColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -24,13 +24,14 @@ type feedbackMessagesTable struct {
AttachmentName postgres.ColumnString AttachmentName postgres.ColumnString
SenderIP postgres.ColumnString SenderIP postgres.ColumnString
Channel postgres.ColumnString Channel postgres.ColumnString
Lang postgres.ColumnString
ChannelLang postgres.ColumnString
ReadAt postgres.ColumnTimestampz ReadAt postgres.ColumnTimestampz
ArchivedAt postgres.ColumnTimestampz ArchivedAt postgres.ColumnTimestampz
ReplyBody postgres.ColumnString ReplyBody postgres.ColumnString
RepliedAt postgres.ColumnTimestampz RepliedAt postgres.ColumnTimestampz
ReplyReadAt postgres.ColumnTimestampz ReplyReadAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
Lang postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -79,15 +80,16 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentNameColumn = postgres.StringColumn("attachment_name") AttachmentNameColumn = postgres.StringColumn("attachment_name")
SenderIPColumn = postgres.StringColumn("sender_ip") SenderIPColumn = postgres.StringColumn("sender_ip")
ChannelColumn = postgres.StringColumn("channel") ChannelColumn = postgres.StringColumn("channel")
LangColumn = postgres.StringColumn("lang")
ChannelLangColumn = postgres.StringColumn("channel_lang")
ReadAtColumn = postgres.TimestampzColumn("read_at") ReadAtColumn = postgres.TimestampzColumn("read_at")
ArchivedAtColumn = postgres.TimestampzColumn("archived_at") ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
ReplyBodyColumn = postgres.StringColumn("reply_body") ReplyBodyColumn = postgres.StringColumn("reply_body")
RepliedAtColumn = postgres.TimestampzColumn("replied_at") RepliedAtColumn = postgres.TimestampzColumn("replied_at")
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at") ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
LangColumn = postgres.StringColumn("lang") allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn} mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn}
) )
@@ -102,13 +104,14 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentName: AttachmentNameColumn, AttachmentName: AttachmentNameColumn,
SenderIP: SenderIPColumn, SenderIP: SenderIPColumn,
Channel: ChannelColumn, Channel: ChannelColumn,
Lang: LangColumn,
ChannelLang: ChannelLangColumn,
ReadAt: ReadAtColumn, ReadAt: ReadAtColumn,
ArchivedAt: ArchivedAtColumn, ArchivedAt: ArchivedAtColumn,
ReplyBody: ReplyBodyColumn, ReplyBody: ReplyBodyColumn,
RepliedAt: RepliedAtColumn, RepliedAt: RepliedAtColumn,
ReplyReadAt: ReplyReadAtColumn, ReplyReadAt: ReplyReadAtColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
Lang: LangColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -1,99 +0,0 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GameSetupDraws = newGameSetupDrawsTable("backend", "game_setup_draws", "")
type gameSetupDrawsTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Round postgres.ColumnInteger
PickNo postgres.ColumnInteger
AccountID postgres.ColumnString
Letter postgres.ColumnString
IsBlank postgres.ColumnBool
DrawRank postgres.ColumnInteger
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameSetupDrawsTable struct {
gameSetupDrawsTable
EXCLUDED gameSetupDrawsTable
}
// AS creates new GameSetupDrawsTable with assigned alias
func (a GameSetupDrawsTable) AS(alias string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameSetupDrawsTable with assigned schema name
func (a GameSetupDrawsTable) FromSchema(schemaName string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameSetupDrawsTable with assigned table prefix
func (a GameSetupDrawsTable) WithPrefix(prefix string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameSetupDrawsTable with assigned table suffix
func (a GameSetupDrawsTable) WithSuffix(suffix string) *GameSetupDrawsTable {
return newGameSetupDrawsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameSetupDrawsTable(schemaName, tableName, alias string) *GameSetupDrawsTable {
return &GameSetupDrawsTable{
gameSetupDrawsTable: newGameSetupDrawsTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameSetupDrawsTableImpl("", "excluded", ""),
}
}
func newGameSetupDrawsTableImpl(schemaName, tableName, alias string) gameSetupDrawsTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
RoundColumn = postgres.IntegerColumn("round")
PickNoColumn = postgres.IntegerColumn("pick_no")
AccountIDColumn = postgres.StringColumn("account_id")
LetterColumn = postgres.StringColumn("letter")
IsBlankColumn = postgres.BoolColumn("is_blank")
DrawRankColumn = postgres.IntegerColumn("draw_rank")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{GameIDColumn, RoundColumn, PickNoColumn, AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, LetterColumn, IsBlankColumn, DrawRankColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{IsBlankColumn, CreatedAtColumn}
)
return gameSetupDrawsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Round: RoundColumn,
PickNo: PickNoColumn,
AccountID: AccountIDColumn,
Letter: LetterColumn,
IsBlank: IsBlankColumn,
DrawRank: DrawRankColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -31,7 +31,6 @@ func UseSchema(schema string) {
GameInvitations = GameInvitations.FromSchema(schema) GameInvitations = GameInvitations.FromSchema(schema)
GameMoves = GameMoves.FromSchema(schema) GameMoves = GameMoves.FromSchema(schema)
GamePlayers = GamePlayers.FromSchema(schema) GamePlayers = GamePlayers.FromSchema(schema)
GameSetupDraws = GameSetupDraws.FromSchema(schema)
Games = Games.FromSchema(schema) Games = Games.FromSchema(schema)
Identities = Identities.FromSchema(schema) Identities = Identities.FromSchema(schema)
Sessions = Sessions.FromSchema(schema) Sessions = Sessions.FromSchema(schema)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
-- +goose Up
-- Allow end_reason = 'aborted': a game whose journal can no longer be reconstructed (a
-- recorded move became illegal under tightened rules) is closed as a draw rather than left
-- unopenable. See engine.EndAborted and Service.voidGame.
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout', 'aborted')
);
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
);
@@ -0,0 +1,45 @@
-- +goose Up
-- Manual account blocking ("suspension"), the operator's hard counterpart to the soft,
-- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema
-- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another);
-- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to
-- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until
-- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12.
SET search_path = backend, pg_catalog;
-- The operator-editable reason picklist, one row per reason with its English and Russian text.
-- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or
-- deleting a reason here never changes or breaks a reason already shown to a blocked player.
CREATE TABLE suspension_reasons (
reason_id uuid PRIMARY KEY,
text_en text NOT NULL,
text_ru text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- The block history: one row per block. blocked_until is NULL for a permanent block and the
-- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early.
-- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was
-- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled
-- if that entry is deleted (the snapshot remains the source of truth shown to the player). An
-- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS
-- NULL OR blocked_until > now()).
CREATE TABLE account_suspensions (
suspension_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_at timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
reason_en text,
reason_ru text,
reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL,
lifted_at timestamptz
);
-- The enforcement gate looks up the newest suspension for an account on every authenticated
-- request; this index serves that "latest by account" probe.
CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_suspensions;
DROP TABLE IF EXISTS suspension_reasons;
@@ -0,0 +1,55 @@
-- +goose Up
-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the
-- operators from Settings -> Info, each with an optional single attachment, plus the operator's
-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer)
-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first
-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md.
SET search_path = backend, pg_catalog;
-- One feedback message. read_at marks "the operator has dealt with this" (set by every console
-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids
-- a new submission while the player has any read_at IS NULL message. archived_at files a handled
-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the
-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for
-- display ("delivered = read"), and the reply is hidden from the player one week after that.
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
-- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots
-- (lang, channel_lang) are added additively in 00005.
CREATE TABLE feedback_messages (
message_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
body text NOT NULL,
attachment bytea,
attachment_name text,
sender_ip text,
channel text NOT NULL,
read_at timestamptz,
archived_at timestamptz,
reply_body text,
replied_at timestamptz,
reply_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the
-- console's user-scoped search all look an account up newest-first.
CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC);
-- The console queue lists messages newest-first across all accounts.
CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC);
-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role,
-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a
-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted
-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users.
CREATE TABLE account_roles (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
role text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, role)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_roles;
DROP TABLE IF EXISTS feedback_messages;
@@ -0,0 +1,15 @@
-- +goose Up
-- Snapshot the sender's languages on each feedback message, taken at submit time so the
-- operator console shows the state as it was, not the account's current settings (the same
-- snapshot discipline as a suspension's reason). lang is the sender's interface language;
-- channel_lang is the connector bot language (en/ru) when the message arrived through an
-- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe.
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages ADD COLUMN lang text;
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang;
@@ -0,0 +1,86 @@
-- +goose Up
-- Server-driven advertising banner ("advertising network"): operator-managed campaigns rotated in
-- the client's one-line announcement strip. A campaign is one placement order with a show weight
-- (percent); simultaneously active campaigns compete for shows in proportion to their weights. The
-- single perpetual default campaign fills the unsold remainder up to 100%. Each campaign carries
-- one or more bilingual messages (en + ru, both mandatory), shown by the viewer's bot (service)
-- language. Display timings are global (one settings row). Eligibility (who sees a banner at all)
-- reuses account fields + the no_banner role, so it needs no schema here. See docs/ARCHITECTURE.md
-- and internal/ads.
SET search_path = backend, pg_catalog;
-- One advertising campaign = one placement order. weight is the show percentage (1..100). is_default
-- marks the single perpetual house campaign that fills the remainder up to 100% and is never deleted;
-- it has no validity window (its stored weight is nominal — the rotation derives its effective weight
-- as max(0, 100 - sum of active timed weights)). A time-limited campaign is active while now() lies in
-- [starts_at, ends_at] (a null bound is open-ended on that side). enabled toggles a campaign without
-- deleting it.
CREATE TABLE ad_campaigns (
campaign_id uuid PRIMARY KEY,
name text NOT NULL,
weight integer NOT NULL,
is_default boolean NOT NULL DEFAULT false,
enabled boolean NOT NULL DEFAULT true,
starts_at timestamptz,
ends_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ad_campaigns_weight_chk CHECK (weight BETWEEN 1 AND 100),
-- The default campaign is perpetual (no window).
CONSTRAINT ad_campaigns_default_window_chk CHECK (NOT is_default OR (starts_at IS NULL AND ends_at IS NULL)),
-- A closed window must not be inverted.
CONSTRAINT ad_campaigns_window_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at <= ends_at)
);
-- At most one default campaign.
CREATE UNIQUE INDEX ad_campaigns_default_idx ON ad_campaigns (is_default) WHERE is_default;
-- One bilingual message of a campaign. Both languages are mandatory: the client shows the variant for
-- the viewer's bot (service) language. position orders the messages within a campaign (the round-robin
-- order when the campaign wins a display slot). body_* is minimal markdown (text + links).
CREATE TABLE ad_messages (
message_id uuid PRIMARY KEY,
campaign_id uuid NOT NULL REFERENCES ad_campaigns (campaign_id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
body_en text NOT NULL,
body_ru text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Messages of a campaign are read in display order.
CREATE INDEX ad_messages_campaign_idx ON ad_messages (campaign_id, position);
-- Global banner display timings, a single row (id is always TRUE). The client rotator reads these:
-- hold_ms is how long one message shows; edge_pause_ms and scroll_px_per_sec drive the scroll of a
-- message wider than the strip; the transition between messages is fade_out_ms -> gap_ms -> fade_in_ms.
-- All are clamped in Go on update.
CREATE TABLE ad_settings (
id boolean PRIMARY KEY DEFAULT true,
hold_ms integer NOT NULL,
edge_pause_ms integer NOT NULL,
scroll_px_per_sec integer NOT NULL,
fade_out_ms integer NOT NULL,
gap_ms integer NOT NULL,
fade_in_ms integer NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ad_settings_singleton_chk CHECK (id)
);
-- Seed the perpetual default campaign with one bilingual house message, and the default timings
-- (the previous hardcoded UI defaults plus the new fade sequence). Fixed UUIDs keep the seed
-- deterministic across environments.
INSERT INTO ad_campaigns (campaign_id, name, weight, is_default, enabled)
VALUES ('00000000-0000-0000-0000-0000000000ad', 'Default (house)', 100, true, true);
INSERT INTO ad_messages (message_id, campaign_id, position, body_en, body_ru)
VALUES (
'00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
'Tip: a play using all 7 tiles earns a +50 bonus.',
'Совет: ход всеми 7 фишками приносит бонус +50 очков.'
);
INSERT INTO ad_settings (id, hold_ms, edge_pause_ms, scroll_px_per_sec, fade_out_ms, gap_ms, fade_in_ms)
VALUES (true, 60000, 5000, 40, 1000, 250, 1000);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS ad_settings;
DROP TABLE IF EXISTS ad_messages;
DROP TABLE IF EXISTS ad_campaigns;
@@ -0,0 +1,16 @@
-- +goose Up
-- A per-seat snapshot of the player's display name, captured when the seat is taken:
-- the human's then-current display name, or a disguised robot's freshly composed
-- per-game name. Storing the name on the seat (rather than always reading the account)
-- freezes what the opponent sees for the life of the game — a later rename no longer
-- rewrites past games — and lets the small robot pool present an ever-changing crowd of
-- differently named opponents. An empty value means "no snapshot": the reader falls back
-- to the account's current display name (legacy rows / pre-migration games). See
-- docs/ARCHITECTURE.md §7.
SET search_path = backend, pg_catalog;
ALTER TABLE game_players ADD COLUMN display_name text NOT NULL DEFAULT '';
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE game_players DROP COLUMN display_name;
@@ -0,0 +1,20 @@
-- +goose Up
-- A per-message bitmask of the seats that have NOT yet read this chat entry: bit i is
-- set while seat i still has the message unread, and is cleared when that seat reads it
-- (opens the move history / chat, or — for a nudge — takes its move). A text message
-- starts with the bits of every seated recipient except the sender; a nudge starts with
-- only the awaited player's bit. The mask is inverted so "anything unread" is a plain
-- `unread_seats <> 0`, which the lobby badge, the admin filter and the unread gauge all
-- use. The read time itself is not retained. See docs/ARCHITECTURE.md §8 (Read receipts).
SET search_path = backend, pg_catalog;
ALTER TABLE chat_messages ADD COLUMN unread_seats smallint NOT NULL DEFAULT 0;
-- Partial index serving the unread scans (per-viewer lobby lookup, admin unread filter,
-- the unread-count gauge): only the comparatively few still-unread rows are indexed.
CREATE INDEX chat_messages_unread_idx ON chat_messages (game_id) WHERE unread_seats <> 0;
-- +goose Down
SET search_path = backend, pg_catalog;
DROP INDEX chat_messages_unread_idx;
ALTER TABLE chat_messages DROP COLUMN unread_seats;
@@ -0,0 +1,25 @@
-- +goose Up
-- Per-account, per-variant best single move: the highest-scoring play the account has
-- ever made in each game variant. It exists so the statistics screen can show the word
-- itself broken down by variant, not just the dimensionless aggregate
-- account_stats.max_word_points. tiles is the move's main word as an ordered JSON array of
-- {letter, value, blank} objects (value 0 and blank true for a wildcard), so the client
-- renders it as game tiles without needing the variant's alphabet table. score is the
-- play's total points (every word it formed plus the all-tiles bonus), matching
-- max_word_points. A row is replaced only by a strictly higher-scoring play. It is written
-- at game finish alongside account_stats; guest and honest-AI games never record statistics,
-- so they never write here. See docs/ARCHITECTURE.md §9.
SET search_path = backend, pg_catalog;
CREATE TABLE account_best_move (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
variant text NOT NULL,
score integer NOT NULL,
tiles jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, variant)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_best_move;
+5 -4
View File
@@ -54,10 +54,11 @@ func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStre
return nil return nil
} }
ev := &pushv1.Event{ ev := &pushv1.Event{
UserId: in.UserID.String(), UserId: in.UserID.String(),
Kind: in.Kind, Kind: in.Kind,
Payload: in.Payload, Payload: in.Payload,
EventId: in.EventID, EventId: in.EventID,
Language: in.Language,
} }
if err := stream.Send(ev); err != nil { if err := stream.Send(ev); err != nil {
return err return err
+7 -16
View File
@@ -128,16 +128,7 @@ func (s *Service) TriggerMove(gameID uuid.UUID) {
// the opponent during the current turn pulls the move in to the short reply // the opponent during the current turn pulls the move in to the short reply
// window; otherwise the robot waits out its sampled delay. // window; otherwise the robot waits out its sampled delay.
func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.UUID, now time.Time) error { func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.UUID, now time.Time) error {
delay := moveDelay(rt.Seed, rt.MoveCount) if now.Before(rt.TurnStartedAt.Add(moveDelay(rt.Seed, rt.MoveCount))) {
if rt.EndgamePass {
// A dead-drawn endgame (the last two moves are both passes) means the robot is
// bound to pass again: answer on the shortened schedule scaled to the human's
// last move, taken as a min so it is never slower than the normal think time.
if d := endgamePassDelay(rt.Seed, rt.MoveCount, rt.OppLastMove); d < delay {
delay = d
}
}
if now.Before(rt.TurnStartedAt.Add(delay)) {
last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, oppID) last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, oppID)
if err != nil { if err != nil {
return err return err
@@ -152,12 +143,12 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U
return s.act(ctx, rt, now) return s.act(ctx, rt, now)
} }
// maybeNudge sends a proactive nudge on a sparse, randomized schedule (proactiveNudgeGap): every // maybeNudge sends a proactive nudge on a lengthening, randomized schedule (proactiveNudgeGap):
// nudge waits a uniform random 9-12 h, so a long idle turn gets only a handful of widely-spaced // the first lands ~60-90 min into the human's turn, and each one waits longer than the last, so a
// reminders rather than an hourly stream. The gap is measured from the previous nudge (or the turn // long idle turn gets a handful of increasingly-spaced reminders rather than an hourly stream. The
// start for the first). The caller's asleep gate already skips this during the robot's sleep // gap is measured from the previous nudge (or the turn start for the first). The social service
// window, so a due nudge fires at the first scan after wake. The social service still enforces the // still enforces the once-per-game floor and rejects a nudge on the robot's own turn, so any such
// once-per-game floor and rejects a nudge on the robot's own turn, so any such rejection is benign. // rejection is benign.
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error { func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
ref := rt.TurnStartedAt ref := rt.TurnStartedAt
if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil { if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil {
+22 -62
View File
@@ -49,21 +49,6 @@ const (
delayHardMinMinutes = 1.0 delayHardMinMinutes = 1.0
delayHardMaxMinutes = 90.0 delayHardMaxMinutes = 90.0
// In a dead-drawn endgame — the two most recent committed moves are both passes,
// so the board and the robot's rack are frozen and the robot is bound to pass
// again — the robot drops the long late-game think time and answers on a shortened
// schedule scaled to the human's own last-move (pass) think time: a uniform sample
// from [endgameLoFactor, endgameHiFactor] of it, clamped to [endgameFloorSeconds,
// endgameCapMinutes]. A slow human collapses to the cap (the robot never drags out
// a decided game), a fast human is tracked, and the floor keeps the robot from
// passing suspiciously instantly. The shrink only ever lowers the delay (it is
// taken as a min with the normal schedule), so it never makes the robot slower, and
// it composes with the sleep window, which is still honoured before any move.
endgameLoFactor = 0.8
endgameHiFactor = 1.5
endgameFloorSeconds = 30.0
endgameCapMinutes = 8.0
// nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's // nudgeReplySpreadMinutes is the width of the quick window, anchored at the move's
// lower band (delayBand's lo), within which the robot answers a daytime nudge on // lower band (delayBand's lo), within which the robot answers a daytime nudge on
// its turn — so a nudged robot replies near the floor of its think time. // its turn — so a nudged robot replies near the floor of its think time.
@@ -79,14 +64,16 @@ const (
// sleep window relative to the opponent's timezone, in hours. // sleep window relative to the opponent's timezone, in hours.
sleepDriftHours = 3 sleepDriftHours = 3
// The robot proactively nudges the idle human on a sparse, randomized schedule rather than an // The robot proactively nudges the idle human on a lengthening, randomized schedule rather
// hourly stream: every nudge waits a uniform random 9-12 h after its reference point (the turn // than an hourly stream: the first nudge lands ~60-90 min into the turn, and each subsequent
// start for the first nudge, the previous nudge thereafter), so even a long-neglected turn // gap grows toward 1-6 h the longer the wait drags on, so a long idle turn gets only a handful
// collects only a few widely-spaced reminders. The 3 h window width is the random spread; the // of increasingly-spaced reminders. The gap is a uniform sample in [nudgeGapFloorMinutes,
// gap does not lengthen with idle time. The driver still skips a nudge that would land in the // ceil] minutes, where ceil ramps from nudgeGapFirstCeilMinutes to nudgeGapCeilMinutes over
// robot's sleep window, deferring it to the first scan after wake. // nudgeGapRamp of idle.
nudgeGapLoHours = 9.0 nudgeGapFloorMinutes = 60.0
nudgeGapHiHours = 12.0 nudgeGapFirstCeilMinutes = 90.0
nudgeGapCeilMinutes = 360.0
nudgeGapRamp = 12 * time.Hour
) )
// defaultBand is the target resulting score margin after the robot's move: when // defaultBand is the target resulting score margin after the robot's move: when
@@ -184,9 +171,7 @@ func deviates(seed int64, moveCount, bagLen int) bool {
// robot's sleep window). It is the sampled think-time delay, deferred to the end of 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 // 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 // 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. In a // meaningful only on the robot's own turn; the admin console surfaces it as an ETA.
// dead-drawn endgame the robot may pass sooner than this (see endgamePassDelay); NextMoveAt
// remains the normal-schedule upper bound.
func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time { func NextMoveAt(seed int64, moveCount int, turnStartedAt time.Time, opponentTZ string) time.Time {
t := turnStartedAt.Add(moveDelay(seed, moveCount)) t := turnStartedAt.Add(moveDelay(seed, moveCount))
drift := sleepDrift(seed) drift := sleepDrift(seed)
@@ -230,25 +215,6 @@ func moveDelay(seed int64, moveCount int) time.Duration {
return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew)) return clampMinutes(lo + (hi-lo)*math.Pow(u, delaySkew))
} }
// endgamePassDelay is the robot's shortened think time for a guaranteed endgame pass
// (the two most recent moves are both passes), given the human's last-move think time
// oppLast: a uniform sample from [endgameLoFactor, endgameHiFactor] of oppLast, clamped
// to [endgameFloorSeconds, endgameCapMinutes]. It is deterministic per (seed, moveCount)
// like moveDelay, and oppLast is read from the persisted move journal, so the schedule is
// reproducible across restarts. The caller takes it as a min with moveDelay, so it never
// slows the robot down. A non-positive oppLast (clock skew) clamps up to the floor.
func endgamePassDelay(seed int64, moveCount int, oppLast time.Duration) time.Duration {
floor := time.Duration(endgameFloorSeconds * float64(time.Second))
ceil := time.Duration(endgameCapMinutes * float64(time.Minute))
lo := clampDur(time.Duration(float64(oppLast)*endgameLoFactor), floor, ceil)
hi := clampDur(time.Duration(float64(oppLast)*endgameHiFactor), floor, ceil)
if hi < lo {
hi = lo
}
u := unitFloat(mix(seed, "endgame", moveCount))
return lo + time.Duration(float64(hi-lo)*u)
}
// 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: a uniform sample from the quick window [lo, lo+nudgeReplySpreadMinutes], // 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 // where lo is the move's lower band — so a nudge pulls the move in near the floor of
@@ -261,13 +227,19 @@ func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
// proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the // proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the
// human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn). // human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn).
// It is a uniform sample in [nudgeGapLoHours, nudgeGapHiHours] hours, deterministic per // It is a uniform sample in [nudgeGapFloorMinutes, ceil] minutes, where ceil ramps from
// (seed, refIdle) so the driver computes the same due time on every scan. refIdle only salts the // nudgeGapFirstCeilMinutes (a ~60-90 min first gap) up to nudgeGapCeilMinutes (a 1-6 h gap) as
// draw, so each successive nudge of a still-idle turn waits a fresh 9-12 h rather than lengthening. // refIdle reaches nudgeGapRamp — so the reminders space out the longer the turn is neglected. It
// is deterministic per (seed, refIdle), so the driver computes the same due time on every scan.
func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration { func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration {
f := float64(refIdle) / float64(nudgeGapRamp)
if f > 1 {
f = 1
}
ceil := nudgeGapFirstCeilMinutes + (nudgeGapCeilMinutes-nudgeGapFirstCeilMinutes)*f
u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute)))) u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute))))
hours := nudgeGapLoHours + (nudgeGapHiHours-nudgeGapLoHours)*u mins := nudgeGapFloorMinutes + (ceil-nudgeGapFloorMinutes)*u
return time.Duration(hours * float64(time.Hour)) return time.Duration(mins * float64(time.Minute))
} }
// clampMinutes converts a minute count to a duration, clamping it to the hard delay // clampMinutes converts a minute count to a duration, clamping it to the hard delay
@@ -282,18 +254,6 @@ func clampMinutes(mins float64) time.Duration {
return time.Duration(mins * float64(time.Minute)) return time.Duration(mins * float64(time.Minute))
} }
// clampDur returns d confined to the inclusive range [lo, hi].
func clampDur(d, lo, hi time.Duration) time.Duration {
switch {
case d < lo:
return lo
case d > hi:
return hi
default:
return d
}
}
// sleepDrift is the per-game shift of the robot's sleep window relative to the // sleepDrift is the per-game shift of the robot's sleep window relative to the
// opponent's timezone, in [-sleepDriftHours, +sleepDriftHours] hours. // opponent's timezone, in [-sleepDriftHours, +sleepDriftHours] hours.
func sleepDrift(seed int64) time.Duration { func sleepDrift(seed int64) time.Duration {
+6 -67
View File
@@ -315,13 +315,13 @@ func TestDeviatesDistribution(t *testing.T) {
// as the idle grows (the median at 12 h idle exceeds the median at the start). // as the idle grows (the median at 12 h idle exceeds the median at the start).
func TestProactiveNudgeGap(t *testing.T) { func TestProactiveNudgeGap(t *testing.T) {
for seed := int64(1); seed <= 1000; seed++ { for seed := int64(1); seed <= 1000; seed++ {
if first := proactiveNudgeGap(0, seed); first < 9*time.Hour || first > 12*time.Hour { if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute {
t.Fatalf("first gap %s out of [9h,12h] for seed %d", first, seed) t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed)
} }
for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} { for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} {
g := proactiveNudgeGap(idle, seed) g := proactiveNudgeGap(idle, seed)
if g < 9*time.Hour || g > 12*time.Hour { if g < 60*time.Minute || g > 6*time.Hour {
t.Fatalf("gap %s out of [9h,12h] for seed %d idle %s", g, seed, idle) t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle)
} }
if proactiveNudgeGap(idle, seed) != g { if proactiveNudgeGap(idle, seed) != g {
t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle) t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
@@ -337,69 +337,8 @@ func TestProactiveNudgeGap(t *testing.T) {
sort.Float64s(xs) sort.Float64s(xs)
return xs[n/2] return xs[n/2]
} }
// The window is flat: the gap distribution does not lengthen with idle time, so the median if early, late := median(0), median(12*time.Hour); early >= late {
// stays near the band centre (10.5 h) and barely moves between a fresh turn and a long-idle one. t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late)
early, late := median(0), median(12*time.Hour)
for _, m := range []float64{early, late} {
if m < 9*60 || m > 12*60 {
t.Fatalf("median gap %.0f min out of [540,720]", m)
}
}
if diff := math.Abs(early - late); diff > 30 {
t.Errorf("median gap should not shift with idle (flat window): idle0=%.0f idle12h=%.0f", early, late)
}
}
// TestEndgamePassDelayBoundsAndAnchor checks the shortened endgame think time: it always
// lands in [30s, 8min], collapses a slow human to the cap, floors a fast human, tracks a
// mid human inside [0.8,1.5]*oppLast, floors a clock-skew negative gap, and is
// deterministic per (seed, moveCount).
func TestEndgamePassDelayBoundsAndAnchor(t *testing.T) {
const floor = 30 * time.Second
const ceil = 8 * time.Minute
cases := []struct {
name string
oppLast time.Duration
lo, hi time.Duration // expected inclusive output range
}{
{"clock-skew negative floors", -time.Hour, floor, floor},
{"zero floors", 0, floor, floor},
{"very fast floors", 3 * time.Second, floor, floor}, // [2.4s,4.5s] → floor
{"fast tracks above floor", 30 * time.Second, floor, 45 * time.Second}, // [24s,45s] → [30s,45s]
{"mid tracks in band", 2 * time.Minute, 96 * time.Second, 3 * time.Minute}, // [1.6m,3m]
{"at cap boundary", 8 * time.Minute, 384 * time.Second, ceil}, // [6.4m,12m] → [6.4m,8m]
{"slow caps", 3 * time.Hour, ceil, ceil}, // [2.4h,4.5h] → cap
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
for seed := int64(1); seed <= 2000; seed++ {
d := endgamePassDelay(seed, 30, c.oppLast)
if d < floor || d > ceil {
t.Fatalf("oppLast=%s seed=%d: delay %s out of hard [%s,%s]", c.oppLast, seed, d, floor, ceil)
}
if d < c.lo || d > c.hi {
t.Fatalf("oppLast=%s seed=%d: delay %s out of expected [%s,%s]", c.oppLast, seed, d, c.lo, c.hi)
}
if endgamePassDelay(seed, 30, c.oppLast) != d {
t.Fatalf("oppLast=%s seed=%d: not deterministic", c.oppLast, seed)
}
}
})
}
}
// TestEndgamePassDelayShrinksLateGame checks the endgame think time is always shorter than
// the normal late-game schedule (band floor 10min vs the 8min cap), so taking the min in the
// driver actually speeds the robot up rather than ever slowing it down.
func TestEndgamePassDelayShrinksLateGame(t *testing.T) {
for seed := int64(1); seed <= 1000; seed++ {
for mc := 28; mc <= 40; mc++ {
eg := endgamePassDelay(seed, mc, 3*time.Hour) // worst case: a slow human, caps at 8min
if nd := moveDelay(seed, mc); eg >= nd {
t.Fatalf("seed=%d mc=%d: endgame %s not shorter than normal %s", seed, mc, eg, nd)
}
}
} }
} }
+6 -2
View File
@@ -88,9 +88,13 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
} }
} }
// bannerLang resolves the message language for a viewer: their interface language // bannerLang resolves the message language for a viewer: the bot (service)
// (Russian, or English by default). // language they last signed in through, falling back to the interface language
// and then English.
func bannerLang(acc account.Account) string { func bannerLang(acc account.Account) string {
if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" {
return acc.ServiceLanguage
}
if acc.PreferredLanguage == "ru" { if acc.PreferredLanguage == "ru" {
return "ru" return "ru"
} }
-131
View File
@@ -1,131 +0,0 @@
package server
import (
"context"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
// chatAccessRequest is the gateway's chat write-eligibility query, addressed either
// by Telegram identity (ExternalID — the join path, when the bot sees a user enter
// the chat) or by account id (UserID — the change path, resolving an emitted
// chat-access-changed event). Exactly one field is set.
type chatAccessRequest struct {
ExternalID string `json:"external_id"`
UserID string `json:"user_id"`
}
// chatAccessResponse is the resolved eligibility. ExternalID echoes the account's
// Telegram identity (empty when it has none — the gateway then has nothing to gate);
// Registered reports whether the lookup found an account at all; Eligible is the
// final gate the bot applies (registered and neither admin-suspended nor chat-muted).
type chatAccessResponse struct {
ExternalID string `json:"external_id"`
Registered bool `json:"registered"`
Eligible bool `json:"eligible"`
}
// handleChatAccess resolves whether a Telegram user may write in the moderated
// discussion chat. It is gateway-internal: the gateway's bot-link serves the bot's
// join-time query (by external_id) and resolves an emitted chat-access-changed event
// (by user_id) through it.
func (s *Server) handleChatAccess(c *gin.Context) {
var req chatAccessRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid body")
return
}
switch {
case req.ExternalID != "":
s.respondChatAccessByExternalID(c, req.ExternalID)
case req.UserID != "":
s.respondChatAccessByUserID(c, req.UserID)
default:
abortBadRequest(c, "external_id or user_id required")
}
}
// respondChatAccessByExternalID answers the join-path query: an unknown identity is
// reported unregistered (and left muted); a known one carries its current eligibility.
func (s *Server) respondChatAccessByExternalID(c *gin.Context, externalID string) {
ctx := c.Request.Context()
resp := chatAccessResponse{ExternalID: externalID}
acc, err := s.accounts.AccountByIdentity(ctx, account.KindTelegram, externalID)
if errors.Is(err, account.ErrNotFound) {
c.JSON(http.StatusOK, resp)
return
}
if err != nil {
s.abortErr(c, err)
return
}
resp.Registered = true
eligible, err := s.chatEligible(ctx, acc.ID)
if err != nil {
s.abortErr(c, err)
return
}
resp.Eligible = eligible
c.JSON(http.StatusOK, resp)
}
// respondChatAccessByUserID answers the change-path query: an account with no
// Telegram identity carries an empty external_id (nothing for the gateway to gate);
// otherwise it carries the identity and the current eligibility.
func (s *Server) respondChatAccessByUserID(c *gin.Context, raw string) {
ctx := c.Request.Context()
uid, err := uuid.Parse(raw)
if err != nil {
abortBadRequest(c, "invalid user_id")
return
}
var resp chatAccessResponse
ext, err := s.accounts.IdentityExternalID(ctx, uid, account.KindTelegram)
if errors.Is(err, account.ErrNotFound) {
c.JSON(http.StatusOK, resp)
return
}
if err != nil {
s.abortErr(c, err)
return
}
resp.ExternalID = ext
resp.Registered = true
eligible, err := s.chatEligible(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
resp.Eligible = eligible
c.JSON(http.StatusOK, resp)
}
// chatEligible reports whether the account may write in the moderated discussion
// chat: not currently admin-suspended and not holding the chat_muted role. A
// suspension dominates — it mutes regardless of the role. Registration is established
// by the caller's identity lookup.
func (s *Server) chatEligible(ctx context.Context, accountID uuid.UUID) (bool, error) {
if _, blocked, err := s.accounts.CurrentSuspension(ctx, accountID); err != nil {
return false, err
} else if blocked {
return false, nil
}
muted, err := s.accounts.HasRole(ctx, accountID, account.RoleChatMuted)
if err != nil {
return false, err
}
return !muted, nil
}
// publishChatAccessChange emits the chat-access-changed signal for the account, so
// the gateway re-resolves the player's chat eligibility and pushes the chat-gate
// command to the bot. Best-effort (notify.Nop when no notifier is wired).
func (s *Server) publishChatAccessChange(id uuid.UUID) {
s.notifier.Publish(notify.ChatAccessChanged(id))
}

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