Compare commits
152 Commits
ffe9150f26
...
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 93d086a8a3 | |||
| b03e012011 | |||
| 2495446a47 | |||
| 35705f7d1e | |||
| 4f0cc81dfb | |||
| 2ba7cc3086 | |||
| 29b6c7e4d8 | |||
| 8de9fb1ecd | |||
| 8a5a5d6c4d | |||
| ea931c6680 | |||
| d0f60ee41d | |||
| b84bd1297e | |||
| 0fb6004a8b | |||
| 8fe1bdba6b | |||
| c1d1c1624b | |||
| 9207664fbd | |||
| a4581663f4 | |||
| 03dfc29a54 | |||
| c02262fcf7 | |||
| f8fab4a4c2 | |||
| 7923b3cc09 | |||
| 10264e10c8 | |||
| fc1715128e | |||
| bb18dc362b | |||
| 4891216749 | |||
| d86e022373 | |||
| 6a602aefae | |||
| f1b8769c89 | |||
| e6277dcd43 | |||
| 37070c3cb7 | |||
| 53d6883ffd | |||
| 93c57b3558 | |||
| 6f00c2f41d | |||
| 79766438a2 | |||
| 6aa5023b24 | |||
| b6f28a2423 | |||
| 508dc870ec | |||
| e3899d4755 | |||
| ae5090b851 | |||
| 0c5d3808d7 | |||
| e32ee9ce68 | |||
| 18db62e19d | |||
| d4e34efa80 | |||
| 12ff6dad86 | |||
| 5a80696fe8 | |||
| d6401bb76c | |||
| e0a5753f1a | |||
| dc946a1faf | |||
| ba57687430 | |||
| 6cb88b28c4 | |||
| 46d569720c | |||
| 9253b1bdca | |||
| 384bd143d0 | |||
| 9d1ca213d6 | |||
| 1f78bb274b | |||
| 81b716569f | |||
| aa330b726e | |||
| d5369a0188 | |||
| 170a6ae9ef | |||
| ef2c2d1eb9 | |||
| 004aca4e97 | |||
| b78ce42922 | |||
| 08c2c5f660 | |||
| 06cc4c1edc | |||
| 90f0424de2 | |||
| c36543e54e | |||
| c5d22fceca | |||
| be1627936f | |||
| 1ba52dd0b4 | |||
| bb0e3e17e5 | |||
| 1ef2bde395 | |||
| 62f66735a3 | |||
| 81680a1d5e | |||
| a393561d79 | |||
| e3e4cedc77 | |||
| deaa7a29c5 | |||
| 91de26d80b | |||
| 6b6362a629 | |||
| 8a06fbc3c7 | |||
| 48b06f4594 | |||
| 24017bcb7f | |||
| 40d8f06588 | |||
| c59e522732 | |||
| 8d45ae6e3b | |||
| 2c4f4b10dc | |||
| 520a9092fe | |||
| 9f970495ee | |||
| 3d9ba3ac3d | |||
| 171b71b7e0 | |||
| 2b399d0838 | |||
| f5f45e7afb | |||
| b54cb8878d | |||
| e336638ca8 | |||
| 62f42ed102 | |||
| ecb21bd218 | |||
| e2771826fd | |||
| dec6fac013 | |||
| c494da553a | |||
| fa8abf22db | |||
| 1ba789a1f1 | |||
| bdd1cc7d85 | |||
| 0ab1719ee9 | |||
| 380f82438c | |||
| a404513037 | |||
| b22b624d28 | |||
| e71e40eef5 | |||
| 41d21f3f6f | |||
| 9642cafc1f | |||
| ba6ee90278 | |||
| e79c1ea891 | |||
| cf9fa75d62 | |||
| 81b44c2b02 | |||
| 041106d623 | |||
| 3fffee7817 | |||
| 860cfeb30f | |||
| 6aeb529f13 | |||
| 2a8717c930 | |||
| 264097bbf6 | |||
| 9824214fd7 | |||
| c72adddb91 | |||
| 95f5703372 | |||
| d40fe1edec | |||
| a5db10c46e | |||
| c739f12d3d | |||
| 483e945209 | |||
| a21ba23e5e | |||
| 57c778f9b2 | |||
| 1933849dba | |||
| e06c6ff67f | |||
| caefc8f579 | |||
| 76d4610e6f | |||
| c9a5ca3ed7 | |||
| 9644bd6e5e | |||
| c127bc9f0e | |||
| 9ded5d3d86 | |||
| 6e77de4c1e | |||
| c67a5d51f1 | |||
| f3768d20f2 | |||
| 0eb1e0ef47 | |||
| d0681c5efe | |||
| 0dd4099d68 | |||
| b65a3ecc9c | |||
| b156d3403c | |||
| 4adb608ad1 | |||
| b5688d4848 | |||
| ab1ad998aa | |||
| 1c87313a78 | |||
| 64be0572b3 | |||
| 81b9e1529e | |||
| 9074417762 | |||
| 9d52885a6e | |||
| 8793bd34f2 |
+59
-34
@@ -26,6 +26,13 @@ on:
|
||||
push:
|
||||
branches: [development]
|
||||
|
||||
# The dictionary release the test suite validates against — the current
|
||||
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||
env:
|
||||
DICT_VERSION: v1.3.0
|
||||
|
||||
jobs:
|
||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||
# irrelevant. It defaults to running everything when the diff cannot be computed.
|
||||
@@ -86,7 +93,6 @@ jobs:
|
||||
# The engine consumes the published scrabble-solver module from this Gitea;
|
||||
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
|
||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
||||
DICT_VERSION: v1.0.0
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -134,7 +140,6 @@ jobs:
|
||||
# suite's TestMain terminates its own container, so disable it.
|
||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
||||
DICT_VERSION: v1.0.0
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -254,25 +259,25 @@ jobs:
|
||||
AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
|
||||
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||
TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
|
||||
TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
||||
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
|
||||
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
|
||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
|
||||
# The promo button reuses the UI's Mini App link variable.
|
||||
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||
# The test contour always uses Telegram's test environment — pinned here,
|
||||
# not an operator variable. The prod workflow leaves it false.
|
||||
TELEGRAM_TEST_ENV: "true"
|
||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||
VITE_TELEGRAM_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_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||
@@ -289,62 +294,82 @@ jobs:
|
||||
mkdir -p "$conf"
|
||||
cp -r caddy otelcol prometheus tempo grafana "$conf"/
|
||||
export SCRABBLE_CONFIG_DIR="$conf"
|
||||
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
|
||||
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
|
||||
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
||||
# together so they always share the fresh CA (see deploy/gen-certs.sh).
|
||||
bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
|
||||
# App version for the About screen: the git tag if present, else the short SHA
|
||||
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
|
||||
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
|
||||
docker compose --ansi never build --progress plain
|
||||
docker compose --ansi never up -d --remove-orphans
|
||||
# The telegram-local profile brings the bot + its VPN sidecar; prod runs the
|
||||
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
||||
# main host omits both. Without the profile they would not start here.
|
||||
docker compose --ansi never --profile telegram-local build --progress plain
|
||||
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
||||
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
||||
# pick up the fresh config.
|
||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
||||
|
||||
- name: Probe the landing and the gateway through caddy
|
||||
- name: Probe the landing, gateway and backend
|
||||
run: |
|
||||
set -u
|
||||
# Two probes through the contour caddy: "/" is the static
|
||||
# landing container, "/app/" is the gateway-served SPA shell.
|
||||
# Three probes. "/" is the static landing container and "/app/" the
|
||||
# gateway-served SPA shell (both through the contour caddy on the edge net).
|
||||
# The backend /readyz is probed on the internal net as well: the caddy probes
|
||||
# are blind to a crash-looping backend (the landing is static and the SPA
|
||||
# shell is served without it), which let a bad deploy go green while the
|
||||
# backend was down — so check it directly here.
|
||||
for i in $(seq 1 20); do
|
||||
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
|
||||
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/; then
|
||||
echo "healthy: GET http://scrabble/ (landing) + /app/ (gateway)"
|
||||
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ &&
|
||||
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
|
||||
echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "probe failed; recent landing + gateway logs:"
|
||||
echo "probe failed; recent landing + gateway + backend logs:"
|
||||
docker logs --tail 50 scrabble-landing || true
|
||||
docker logs --tail 50 scrabble-gateway || true
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
exit 1
|
||||
|
||||
- name: Probe the Telegram connector liveness
|
||||
- name: Probe the Telegram validator and bot liveness
|
||||
run: |
|
||||
set -u
|
||||
# The gateway probe cannot see a crash-looping connector (it long-polls and
|
||||
# egresses through the VPN sidecar, with no public ingress). Inspect the
|
||||
# container directly: it must be running, not restarting, with a stable
|
||||
# restart count. A grace period lets the VPN handshake settle (the connector
|
||||
# may restart a few times first).
|
||||
# The gateway/backend probes cannot see a crash-looping validator or bot
|
||||
# (the validator answers only internal gRPC; the bot long-polls + egresses
|
||||
# through the VPN sidecar with no public ingress). Inspect the containers
|
||||
# directly: each must be running, not restarting, with a stable restart
|
||||
# count. A grace period lets the VPN handshake and the bot-link dial settle.
|
||||
sleep 20
|
||||
for name in scrabble-telegram-validator scrabble-telegram-bot; do
|
||||
ok=
|
||||
for i in $(seq 1 20); do
|
||||
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
|
||||
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)"
|
||||
restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
|
||||
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
||||
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||
sleep 5
|
||||
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||
if [ "$c1" = "$c2" ]; then
|
||||
echo "connector healthy: status=$status restarts=$c2"
|
||||
exit 0
|
||||
echo "$name healthy: status=$status restarts=$c2"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "connector still restarting ($c1 -> $c2); waiting"
|
||||
echo "$name still restarting ($c1 -> $c2); waiting"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "connector not healthy; recent logs:"
|
||||
docker logs --tail 80 scrabble-telegram || true
|
||||
if [ -z "$ok" ]; then
|
||||
echo "$name not healthy; recent logs:"
|
||||
docker logs --tail 80 "$name" || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Prune dangling images
|
||||
if: always()
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# 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_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_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_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_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"
|
||||
@@ -0,0 +1,223 @@
|
||||
# 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"
|
||||
@@ -17,5 +17,9 @@
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
|
||||
# Bot-link mTLS material: private keys never belong in the repo. The test contour
|
||||
# generates them with deploy/gen-certs.sh; prod supplies them from PROD_ secrets.
|
||||
deploy/certs/
|
||||
|
||||
# Claude Code harness runtime artifacts
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -1,96 +1,97 @@
|
||||
# scrabble-game — project guide
|
||||
|
||||
Multiplatform Scrabble game. Read this first every session. The owner drives the
|
||||
project **one stage per session** (tariff constraint), so the repository — not
|
||||
conversation memory — is the source of continuity. Keep it that way.
|
||||
Multiplatform Scrabble game, **in production** at `https://erudit-game.ru`. Read this
|
||||
first every session. The repository — not conversation memory — is the source of
|
||||
continuity; keep it that way.
|
||||
|
||||
## Sources of truth (read before changing behaviour)
|
||||
|
||||
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
|
||||
details to interview*.
|
||||
- [`PRERELEASE.md`](PRERELEASE.md) — pre-release hardening tracker (phases R1–R7
|
||||
before Stage 18); same per-phase *interview + bake-back* discipline as `PLAN.md`.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
||||
security, the decision record. Always describes current state.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
||||
mirror) — per-domain user stories. English authoritative.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security,
|
||||
the decision record. Always describes the current state.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) mirror)
|
||||
— per-domain user stories. English authoritative.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the CI gate.
|
||||
- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system.
|
||||
- [`deploy/README.md`](deploy/README.md) — the deploy contour + the production
|
||||
rollout / rollback runbook.
|
||||
|
||||
## Mandatory per-stage workflow
|
||||
## How we work
|
||||
|
||||
**Start of a stage**
|
||||
1. Read `PLAN.md` (the stage's scope + *open details*) and the relevant `docs/`.
|
||||
2. Analyse what the stage actually requires against the current code.
|
||||
3. **Interview the owner** on every open detail and any fork not already fixed
|
||||
in the plan — do not silently pick borderline decisions. Offer options with
|
||||
brief pros/cons.
|
||||
4. Only then implement, strictly within the stage's scope.
|
||||
|
||||
**End of a stage**
|
||||
1. Bake every new agreement back into `PLAN.md`, `docs/ARCHITECTURE.md`,
|
||||
`docs/FUNCTIONAL.md` (+ `_ru`), the affected service `README`, and Go Doc
|
||||
comments — in the **same** PR. Correct earlier stages' docs/code if a new
|
||||
decision changes them.
|
||||
2. Update the stage tracker; add a line under *Refinements logged during
|
||||
implementation* for any plan deviation.
|
||||
3. Get CI green, then mark the stage done.
|
||||
|
||||
(The `stage-implementation` skill encodes this same loop and can be invoked.)
|
||||
- Inspect the relevant code path and the docs above before changing behaviour.
|
||||
- **Interview the owner on every fork** — do not silently pick borderline decisions;
|
||||
offer options with brief pros/cons.
|
||||
- Smallest correct diff. Prefer compact code; reuse before adding; do not add deps,
|
||||
seams or knobs until they are needed.
|
||||
- **Update or add tests for every functional change**, at the layers
|
||||
`docs/TESTING.md` calls out.
|
||||
- **Bake docs in the same PR**: update `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md`
|
||||
(+`_ru`), the affected service `README` and Go Doc comments alongside the change.
|
||||
- Document added packages, types, funcs, consts and vars with Go Doc comments.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All code, comments, identifiers, commits, docs, filenames in **English**.
|
||||
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian,
|
||||
the agreed persona and translation rules).
|
||||
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md`
|
||||
in the same patch (translate only the touched paragraphs).
|
||||
- Prefer compact code; do not add deps, seams or knobs until a stage needs them.
|
||||
Reuse before adding. Document added packages/types/funcs with Go Doc comments.
|
||||
- Update or add tests for every functional change.
|
||||
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, the
|
||||
agreed persona and translation rules).
|
||||
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` in the
|
||||
same patch (translate only the touched paragraphs).
|
||||
|
||||
## Branching & CI
|
||||
## Branching, CI & production
|
||||
|
||||
- **Two long-lived branches** (Stage 16 onward): **`development`** is the
|
||||
integration branch; **`master`** is the production trunk. Cut `feature/*`
|
||||
branches **from `development`** and PR them back into it. (Stages 0–15 used
|
||||
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
|
||||
on `master` by necessity.)
|
||||
- A commit to a `feature/*` branch triggers **nothing**. The single workflow
|
||||
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
|
||||
on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls
|
||||
the **test contour** on a PR into — or a push to — `development`
|
||||
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into
|
||||
`master` is test-only.
|
||||
- Merge `development → master` only when CI is green; the **prod** deploy is then a
|
||||
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
|
||||
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
|
||||
- After any push, watch the run to green before declaring a stage done — use the
|
||||
ready-made watcher, never an inline poll loop:
|
||||
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
||||
/ `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in
|
||||
`.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
|
||||
- **Two long-lived branches**: **`development`** is the integration branch; **`master`**
|
||||
is the production trunk. Cut `feature/*` from `development` and PR back into it;
|
||||
promote `development → master` via PR when ready to release. Both branches require
|
||||
one approval + the `CI / gate` check.
|
||||
- A commit to a `feature/*` branch triggers nothing. The single workflow
|
||||
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) on a
|
||||
PR into `development` or `master`, and the gated **`deploy`** job auto-rolls the
|
||||
**test contour** on a PR into — or a push to — `development`
|
||||
(`docker compose up -d --build` on the runner host + landing/SPA/backend probes). A
|
||||
PR into `master` is test-only.
|
||||
- **Production is live on two hosts** (main + the Telegram bot host) and deploys
|
||||
**only manually** (`workflow_dispatch`), never automatically:
|
||||
- **`.gitea/workflows/prod-deploy.yaml`** (`confirm=deploy`, from `master`) builds +
|
||||
pushes the images to the registry, then SSH-deploys both hosts — rolling per
|
||||
service in dependency order, health-gated, **auto-rollback to the previous tag**;
|
||||
a schema migration adds a maintenance window + a consistent `pg_dump`. Four visible
|
||||
jobs: build → deploy-main → deploy-bot → verify.
|
||||
- **`.gitea/workflows/prod-rollback.yaml`** (`confirm=rollback`) re-deploys a prior
|
||||
release (blank `target_version` = the previous deployed version) — image-only,
|
||||
rolling, health-gated.
|
||||
- **Releases are git tags `vX.Y.Z` on `master`**; the deploy stamps `git describe
|
||||
--tags` into the image tag, every binary (`pkg/version` via `-ldflags` → the
|
||||
`service.version` telemetry attribute) and the SPA About screen. Tag the release
|
||||
before deploying.
|
||||
- Hosts are provisioned idempotently by **`deploy/ansible/`**. Per-contour
|
||||
secrets/variables use the `TEST_` / `PROD_` prefix (Gitea 1.26 has no deployment
|
||||
environments). Migrations must be **expand-contract** (backward-compatible) so
|
||||
image rollback stays DB-safe. Full runbook + variable list in `deploy/README.md`.
|
||||
- After any push, merge or deploy, **watch the run to green** before declaring done —
|
||||
use the ready-made watcher (run it in the background), never an inline poll loop:
|
||||
`python3 ~/.claude/bin/gitea-ci-watch.py`. It reads `$GITEA_URL` / `$GITEA_TOKEN`;
|
||||
`gitea.iliadenisov.ru` is allow-listed in `.claude/settings.json`. Remote:
|
||||
`origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
|
||||
|
||||
## Stack
|
||||
|
||||
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Dependencies are
|
||||
added **when first used** (incremental): backend uses `gin` + `zap` +
|
||||
`pgx`/`go-jet`/`goose`/OTel (added in Stage 1). Client↔gateway is Connect-RPC +
|
||||
FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC
|
||||
server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite,
|
||||
packaged to native with Capacitor. Likely no Redis.
|
||||
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Backend uses `gin` +
|
||||
`zap` + `pgx`/`go-jet`/`goose`/OTel. Client↔gateway is Connect-RPC + FlatBuffers
|
||||
(h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC server-stream for live
|
||||
events. UI is pure HTML5/CSS on plain Svelte + Vite, packaged to native with
|
||||
Capacitor. No Redis.
|
||||
|
||||
## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3)
|
||||
|
||||
Embedded **in-process as a library** — there is no per-game container. Public
|
||||
API to reuse (do not reimplement):
|
||||
Embedded **in-process as a library** (`replace scrabble-solver => ../scrabble-solver`
|
||||
in `go.work`; CI checks out the sibling from
|
||||
`https://gitea.iliadenisov.ru/.../scrabble-solver.git`). There is no per-game
|
||||
container. Public API to reuse (do not reimplement):
|
||||
|
||||
- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked,
|
||||
highest score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`;
|
||||
`scrabble.Apply(b, m)`; types `Move/Word/Placement/Direction/Mode`
|
||||
- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, highest
|
||||
score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; `scrabble.Apply(b, m)`;
|
||||
types `Move/Word/Placement/Direction/Mode`
|
||||
(`scrabble-solver/scrabble/{solver,move,apply}.go`).
|
||||
- `rules.English() / RussianScrabble() / Erudit()`
|
||||
(`scrabble-solver/rules/rules.go`).
|
||||
- `rules.English() / RussianScrabble() / Erudit()` (`scrabble-solver/rules/rules.go`).
|
||||
- `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`;
|
||||
`selfplay.NewBag / Draw / Len` (bag pattern).
|
||||
- Load committed dictionaries with `dawg.Load(path)` from
|
||||
@@ -99,20 +100,17 @@ API to reuse (do not reimplement):
|
||||
|
||||
Constraints:
|
||||
- Words/tiles are **alphabet-index bytes**, meaningful only with the matching
|
||||
`rules.Ruleset` (`Alphabet.Decode`); blank flag carried separately. **Decode
|
||||
`rules.Ruleset` (`Alphabet.Decode`); the blank flag is carried separately. **Decode
|
||||
to real characters before persisting history** (history must be
|
||||
dictionary-independent — see `docs/ARCHITECTURE.md` §9.1).
|
||||
- The solver's `internal/*` is NOT importable from this sibling module.
|
||||
- **GCG is test-only** in the solver (no public writer) — we ship our own.
|
||||
- Wiring: add `replace scrabble-solver => ../scrabble-solver` to `go.work` in
|
||||
**Stage 2** (when `internal/engine` first imports it), and make CI check out
|
||||
the solver sibling (`https://gitea.iliadenisov.ru/.../scrabble-solver.git`).
|
||||
It uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
|
||||
- The solver uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
go.work # use the existing modules; grows per stage
|
||||
go.work # the go.work monorepo
|
||||
backend/ # module scrabble/backend
|
||||
cmd/backend/ # main: telemetry -> db+migrate -> cache -> server
|
||||
cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container)
|
||||
@@ -123,12 +121,14 @@ backend/ # module scrabble/backend
|
||||
internal/session/ # opaque tokens, sessions store, cache, service
|
||||
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
|
||||
internal/inttest/ # //go:build integration Postgres-backed tests
|
||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||
gateway/ ui/ pkg/ # added by their stages
|
||||
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
||||
loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2)
|
||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile also has the `landing` target (R3)
|
||||
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
|
||||
gateway/ # module scrabble/gateway: Connect-RPC edge, embeds the SPA
|
||||
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
||||
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
||||
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
||||
loadtest/ # module scrabble/loadtest: the load/stress harness
|
||||
docs/ .gitea/workflows/ CLAUDE.md README.md
|
||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
||||
```
|
||||
|
||||
## Build & test
|
||||
@@ -138,20 +138,19 @@ go build ./backend/... # per module ('./...' from the root won't span t
|
||||
go vet ./backend/...
|
||||
gofmt -l . # must print nothing
|
||||
go test -count=1 ./backend/...
|
||||
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9)
|
||||
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot
|
||||
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
|
||||
pnpm start # UI mock mode: lobby -> game, no backend
|
||||
|
||||
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the SPA
|
||||
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
||||
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing (R3)
|
||||
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
||||
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
||||
```
|
||||
|
||||
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job
|
||||
of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit /
|
||||
integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
|
||||
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job of
|
||||
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||
|
||||
-548
@@ -1,548 +0,0 @@
|
||||
# Pre-release plan — hardening before Stage 18
|
||||
|
||||
Living tracker for the pre-release hardening pass that runs **before Stage 18** (the
|
||||
prod cutover). Same discipline as [`PLAN.md`](PLAN.md): one phase per session,
|
||||
**interview the owner on the open details** at the start of each phase, bake every
|
||||
decision back into `PLAN.md` / `docs/` / the affected `README`s / Go Doc comments in
|
||||
the **same** PR, get CI green, then mark the phase done. Phases run as
|
||||
`feature/* → development` PRs (the Stage 16 branch model); the owner approves+merges.
|
||||
|
||||
**Why now:** the system is feature-complete through Stage 17 and the test contour is
|
||||
green, but there is **no prod data yet** — schema, wire labels and the dictionary
|
||||
layout can still change for free. These phases spend that one-time freedom and harden
|
||||
the edge before prod. Each phase maps back to the owner's raw pre-release TODO list
|
||||
(numbers in the tracker).
|
||||
|
||||
## Phase tracker
|
||||
|
||||
| # | Phase | Raw TODOs | Status |
|
||||
|---|-------|-----------|--------|
|
||||
| R1 | Schema & naming reset | 1 + 10 | **done** |
|
||||
| R2 | Stress harness + contour observability + early run | 9a | **done** |
|
||||
| R3 | Edge hardening | 2 + 8 + 3 | **done** |
|
||||
| R4 | Push enrichment + kill the last poll | 4 + 5 | **done** |
|
||||
| R5 | Bundle slimming | 6 | **done** |
|
||||
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
|
||||
| R7 | Final stress run + tuning | 9b | **done** |
|
||||
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
|
||||
| MW | "Multiple words per turn" rule for Russian games (engine v1.1.0) | owner ad-hoc | **done** |
|
||||
| MW2 | Single-word rule connectivity fix: the word must run along its own line through an existing tile (perpendicular-only contact no longer connects); single-tile direction picks the best legal word (engine v1.1.1) | owner ad-hoc | **done** |
|
||||
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
|
||||
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** |
|
||||
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
|
||||
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
|
||||
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
||||
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
|
||||
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
|
||||
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
|
||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||
|
||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||
|
||||
- **R1 (TODO 1 + 10) is one cheap moment, now.** Squashing the 12 goose migrations is
|
||||
safe precisely because there is no prod data and the contour DB is wiped. Folding the
|
||||
new variant labels (`scrabble_ru`/`scrabble_en`/`erudit_ru`) into that single baseline
|
||||
makes the rename need **no data migration and no back-compat mapping**. Today's labels
|
||||
(`english`/`russian_scrabble`/`erudit`) are persisted in `games.variant`,
|
||||
`game_invitations.variant`, in `pkg/fbs` and the UI — ~100 files, but a mechanical sweep
|
||||
on a clean DB.
|
||||
- **R4 (TODO 4 + 5): the app is already push-first.** Game state refreshes on
|
||||
`your_turn`/`opponent_moved`, the lobby on `notify`, chat on `chat_message`. The **only**
|
||||
genuine periodic server poll is `lobby.poll` (matchmaking, 2.5 s,
|
||||
`ui/src/screens/NewGame.svelte`). What remains is killing that one poll **and** enriching
|
||||
push events to carry payloads so the UI stops re-fetching after each signal.
|
||||
- **R3 (TODO 2): identity forgery is already mitigated.** Identity is always derived from
|
||||
the session (`Authorization: Bearer` → `X-User-ID`); the client cannot inject identity,
|
||||
the backend re-validates resource ownership, Telegram initData is HMAC-checked. The real
|
||||
gaps are a missing **request-body size limit** (cheap DoS) and **invisible rate-limit
|
||||
rejections** (no log/metric/admin view — that is TODO 8). Static landing serving is **not**
|
||||
covered by the gateway token bucket (it only guards `Execute`).
|
||||
- **R6 (TODO 7) scale:** ~431 `Stage N` references across ~104 files (incl. the file name
|
||||
`backend/internal/inttest/stage6_test.go`). Code is the source of truth; `docs/` describe
|
||||
current state; `PLAN.md` keeps the decision history.
|
||||
|
||||
## Locked decisions (owner interview)
|
||||
|
||||
- **Stress test (TODO 9):** **early + final** runs. Driver = **edge protocol** (Connect/FB
|
||||
through the gateway, moves generated by the solver) **plus a separate gateway-hammer**
|
||||
saturation test. Pacing = **realistic (under limits) + saturation (ramp to the knee)**.
|
||||
Resource metrics = **add cAdvisor + postgres_exporter to the contour** (today only
|
||||
Go-runtime metrics exist). The harness stays in the repo for repeats.
|
||||
- **Push (TODO 4 + 5):** **both** — kill `lobby.poll` (use the existing `match_found`, keep
|
||||
poll as the ws-down fallback) **and** enrich push events with payloads.
|
||||
- **Refactor (TODO 7):** **hygiene + structural changes by a reviewed list** —
|
||||
behaviour-preserving, test-gated, contentious items surfaced to the owner before applying.
|
||||
- **Landing (TODO 3):** **separate static container** behind the project caddy
|
||||
(`/` → landing, `/app/` + `/telegram/` → gateway); drop `landing.html` from the gateway
|
||||
`go:embed`.
|
||||
- **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** —
|
||||
a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold,
|
||||
**no auto-ban**.
|
||||
- **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside
|
||||
it** (status `open`, the opponent seat empty); a second human searching the same variant+rule
|
||||
joins it, or a robot fills it after a **90 s + random 0–90 s** wait, pushing the in-app
|
||||
**opponent_joined** event. While open, the starter may move on their turn but resign, chat and
|
||||
nudge are disabled, and the lobby + opponent card read "searching for opponent". Matchmaking is
|
||||
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
|
||||
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
|
||||
empty seat.
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase: read this tracker + the relevant `docs/`, **interview the owner on the open
|
||||
details below**, implement within scope, then update the tracker + docs/code and get CI
|
||||
green before marking it done.
|
||||
|
||||
### R1 — Schema & naming reset *(TODO 1 + 10)* — first
|
||||
Squash `backend/internal/postgres/migrations/00001..00012` into one `00001_baseline.sql`
|
||||
(method: `pg_dump --schema-only` from a fully-migrated DB → wrap as the goose baseline →
|
||||
prove a fresh migrate yields a schema identical to the 12-migration chain via the
|
||||
integration suite → delete the old files; keep goose). Bake the new variant labels into the
|
||||
baseline. Propagate `scrabble_ru`/`scrabble_en`/`erudit_ru` through the backend
|
||||
(`engine.Variant`/`ParseVariant`, `registry.dictFiles`, the CHECK values), the wire
|
||||
(`pkg/fbs` `variant:string`, regenerate FB) and the UI (`lib/model.ts` union, `variants.ts`,
|
||||
fixtures, premium/alphabet keys, tests); i18n display keys stay display-only. Tidy
|
||||
`../scrabble-dictionary` to a single source→dawg build point and align the dawg artifact
|
||||
names to the new labels (crosses into `../scrabble-solver`'s committed fixtures — keep them
|
||||
byte-identical). After merge, **wipe the contour DB** (drop the volume) so it re-provisions
|
||||
on the next deploy.
|
||||
- Critical files: `backend/internal/postgres/migrations/`,
|
||||
`backend/internal/engine/{engine,registry}.go`, `pkg/fbs/scrabble.fbs`,
|
||||
`ui/src/lib/{model,variants}.ts`, `../scrabble-dictionary/{Makefile,cmd/builddict,…}`.
|
||||
- Open details to interview: the exact dawg filename scheme; whether the dict-repo tidy is
|
||||
one PR or split; how to script the contour DB wipe in the deploy.
|
||||
|
||||
### R2 — Stress harness + contour observability + early run *(TODO 9, part 1)*
|
||||
Build the reusable load harness as a new `loadtest` module in `go.work` (reuses `pkg/fbs`,
|
||||
`connect-go`, and `scrabble-solver` for legal-move generation): a seeder that inserts
|
||||
**1000 guest + 10000 durable** accounts with pre-created sessions (token hashes) directly in
|
||||
the DB and hands the plaintext tokens to the client; a driver that runs N virtual users,
|
||||
each in 3–5 concurrent 2–4-player games, exercising submit-play / pass / exchange / nudge /
|
||||
chat / check-word / draft-move / profile-save through the **edge protocol**, in
|
||||
**realistic** (under rate limits) and **saturation** (ramp) modes; plus a separate
|
||||
**gateway-hammer** that deliberately exceeds limits to verify the limiter holds and measure
|
||||
its cost. Add **cAdvisor + postgres_exporter** to `deploy/docker-compose.yml` and a Grafana
|
||||
resource dashboard. Run the **early pass** against the freshly-wiped contour; produce a
|
||||
**trip report** (logic/concurrency bugs + a resource baseline) that feeds R3 and R6.
|
||||
- Critical files: new `loadtest/`, `deploy/docker-compose.yml`, `deploy/observability/*`,
|
||||
`docs/TESTING.md`.
|
||||
- Open details: the scale ramp steps; the move-selection policy (a mid-ranked solver move
|
||||
for realistic game progress); run duration; the pass/fail bar.
|
||||
|
||||
### R3 — Edge hardening *(TODO 2 + 8 + 3)*
|
||||
Add a **request-body size cap** at the gateway h2c mux / `Execute` (e.g. ~1 MB). Add
|
||||
**rate-limit observability**: a `gateway_rate_limited_total{class}` counter + a structured
|
||||
log per rejection; an **aggregate** Grafana panel (request rate + rejection rate — spikes
|
||||
visible without per-user label cardinality, honouring the Stage 12/17 discipline); an
|
||||
**admin-console view** of recently throttled users/IPs (in-memory ring buffer, single-
|
||||
instance, reset-on-restart, like the `active_users` gauge). Add the **conservative
|
||||
auto-flag**: when a user is *sustained*-throttled past a tunable threshold, set a soft,
|
||||
reversible `account.flagged_high_rate_at` marker (baked into the R1 baseline) surfaced in the
|
||||
admin user list/detail — **no auto-ban**; the operator clears it. Split the **landing** into
|
||||
its own static container (`deploy/` + a Caddyfile route `/` → landing) and drop
|
||||
`landing.html` from the gateway `go:embed`.
|
||||
- Critical files: `gateway/internal/connectsrv/server.go`, `gateway/internal/ratelimit/`,
|
||||
`gateway/internal/connectsrv/metrics.go`, `backend/internal/adminconsole/`,
|
||||
`deploy/caddy/Caddyfile`, `deploy/docker-compose.yml`, `gateway/internal/webui/`.
|
||||
- Open details: the auto-flag threshold/window + whether the marker is persisted vs
|
||||
in-memory; the landing image base (caddy vs nginx).
|
||||
|
||||
### R4 — Push enrichment + kill the last poll *(TODO 4 + 5)*
|
||||
Replace `lobby.poll` with the existing `match_found` push (keep the poll as a ws-down
|
||||
fallback). Enrich `your_turn`/`opponent_moved`/`notify` to carry the state payload so the UI
|
||||
renders from the event without a follow-up `game.state` (removes the lobby↔game nav latency
|
||||
the owner noticed). Wire-contract change: `pkg/fbs` event payloads → backend `notify` emit →
|
||||
UI stream consumers (`ui/src/lib/app.svelte.ts`), with the per-game cache as the landing
|
||||
spot; regenerate FB.
|
||||
- Critical files: `pkg/fbs/scrabble.fbs`, `backend/internal/notify/events.go`,
|
||||
`ui/src/lib/{app.svelte,transport}.ts`, `ui/src/screens/NewGame.svelte`.
|
||||
- Open details: which events carry full vs delta payloads; the fallback-poll cadence when the
|
||||
stream is down.
|
||||
|
||||
### R5 — Bundle slimming *(TODO 6)* — done
|
||||
Analysed the bundle against the 100 KB-gzip budget; **no code slimming was warranted**, and the
|
||||
budget metric was retargeted to measure the app correctly. The build already minifies +
|
||||
tree-shakes; the dominant cost is the Connect/FlatBuffers transport runtime + generated bindings
|
||||
+ the Svelte runtime (≈⅔ of `main`'s source is third-party/generated) — irreducible within scope.
|
||||
**Lazy-loading was rejected**: `bundle-size.mjs` sums every emitted chunk, so code-splitting yields
|
||||
no total-size win and adds request latency (+N gateway fetches on first navigation to a split
|
||||
screen). i18n lazy-load was skipped (the catalogs are a sliver of a Svelte-runtime-dominated shared
|
||||
chunk, and `en` must stay bundled as the `MessageKey` type source + fallback). Instead,
|
||||
`bundle-size.mjs` now measures **per HTML entry**, with three independent gates on the natural chunk
|
||||
boundaries — **app entry ≤ 100 KB, the Svelte+i18n shared chunk ≤ 30 KB, the landing's own chunk
|
||||
≤ 5 KB** — since the app's real payload is its entry chunk plus the shared chunk (≈97 KB), while the
|
||||
landing (≈24 KB) is reported separately and kept minimal. Same CLI + exit-code contract, so the CI
|
||||
step is unchanged.
|
||||
- Critical files: `ui/scripts/bundle-size.mjs`; no app code changed.
|
||||
|
||||
### R6 — Refactor + docs reconciliation + de-staging *(TODO 7)* — done
|
||||
Behaviour-preserving only. Three separable, separately-committed passes: (a) mechanical
|
||||
**de-staging** — remove `Stage N`/`TODO-N` references from code, comments and service
|
||||
READMEs (rename `stage6_test.go`); (b) **docs↔code reconciliation** — reconcile
|
||||
`docs/ARCHITECTURE.md` / `docs/FUNCTIONAL.md`(+`_ru`) against the code-as-truth, fixing drift
|
||||
and Go Doc comments; (c) **structural changes by a reviewed list** — surface a list of
|
||||
proposed optimizations / test-suite consolidations to the owner, apply only the approved,
|
||||
behaviour-preserving, test-gated ones. The full suite + the final stress run (R7) are the
|
||||
regression gate. Incorporates the early-run (R2) bug fixes not already shipped.
|
||||
- Open details: the structural-changes list itself (owner-approved before applying); the test
|
||||
consolidation targets.
|
||||
|
||||
### R7 — Final stress run + tuning *(TODO 9, part 2)* — done
|
||||
Re-run the R2 harness against the final, refactored system on a clean contour; analyse
|
||||
resource consumption across **all** components (gateway, backend, Postgres, the
|
||||
metrics/observability stack, docker log volume) and agree the tuning (pool sizes, rate
|
||||
limits, cache TTLs, container limits, GOMAXPROCS, log levels). Apply the agreed tuning; record
|
||||
the methodology + results in the repo.
|
||||
|
||||
→ **Stage 18** (prod contour) then proceeds per [`PLAN.md`](PLAN.md).
|
||||
|
||||
## Sequencing rationale
|
||||
|
||||
`R1` first (cheapest now; everything builds on the final schema/naming and the stress test
|
||||
must run against it). `R2` builds the harness and runs the **early** pass to surface bugs and
|
||||
a resource baseline that feed `R3` and `R6`. `R3`/`R4`/`R5` harden and improve the system.
|
||||
`R6` (de-stage + reconcile + structural) runs near the end so it sweeps settled code once and
|
||||
benefits from all accumulated bug knowledge. `R7` validates the final system and tunes it.
|
||||
Then Stage 18.
|
||||
|
||||
## Regression-safety discipline (cross-cutting)
|
||||
|
||||
- Every phase is a `feature/* → development` PR; CI (`unit` + `integration` + `ui` behind the
|
||||
`CI / gate` check) must be green before the owner merges; watch the post-merge contour
|
||||
deploy with `gitea-ci-watch.py`.
|
||||
- `R6` structural changes are behaviour-preserving, test-gated, and split from the mechanical
|
||||
sweeps; contentious items are owner-approved first.
|
||||
- The two stress runs (`R2` early, `R7` final) are the system-level regression gate.
|
||||
|
||||
## Verification (per phase)
|
||||
|
||||
- `go build ./<module>/...`, `go vet`, `gofmt -l .` clean, `go test -count=1 ./<module>/...`;
|
||||
UI: `pnpm check && pnpm test:unit && pnpm build`; the integration suite
|
||||
(`-tags integration`) for DB/schema changes; `docker compose config` for deploy changes;
|
||||
green CI on the PR + a healthy contour deploy.
|
||||
- `R1`: prove the squashed baseline yields a schema identical to the 12-migration chain
|
||||
(integration suite on a fresh DB) **before** deleting the old files.
|
||||
- `R2`/`R7`: the harness runs end-to-end against the contour; the trip report lists concrete
|
||||
defects + a resource profile from the Grafana cAdvisor/postgres_exporter panels.
|
||||
|
||||
## Refinements logged during implementation
|
||||
|
||||
- **R1** (interview + implementation):
|
||||
- **Variant labels** `english`/`russian_scrabble`/`erudit` → **`scrabble_en`/`scrabble_ru`/`erudit_ru`**
|
||||
across the backend (`engine.Variant.String`/`ParseVariant`; the `games`/`game_invitations` `variant`
|
||||
CHECK in the baseline; GCG `#lexicon` and the `variant` metric attribute both flow from `String`),
|
||||
the wire (`pkg/fbs` `variant` is a `string` field — values change with **no FlatBuffers regen**) and
|
||||
the UI (`model.ts` union, `variants.ts` records, `codec`/`premiums`/mocks/tests, the admin
|
||||
`dictionary.gohtml`). **Kept:** the Go enum identifiers (`VariantEnglish`…, internal) and the i18n
|
||||
display keys (`new.english`/`new.russian`/`new.erudit`, display-only). `complaints.variant` stays
|
||||
free-text (no CHECK, as before).
|
||||
- **dawg filenames kept descriptive** (`en_sowpods`/`ru_scrabble`/`ru_erudit`) — only the registry's
|
||||
`Variant` key carries the rename, so `registry.go`, the published `scrabble-solver` fixtures and the
|
||||
dictionary release artifact are untouched (decouples the three repos).
|
||||
- **Migrations squashed** 12 → one hand-written `00001_baseline.sql`. Verified by a
|
||||
`pg_dump --schema-only` diff (the chain vs the baseline are **identical** but for the two intended
|
||||
variant-CHECK values) plus the green integration suite. **No data migration** (no production data).
|
||||
- **Done (cross-repo + contour):** the **`scrabble-dictionary` tidy** merged (PR #2) and was re-cut as
|
||||
the **byte-identical `v1.0.1`** release for clean provenance (the backend stays on `v1.0.0` — same
|
||||
bytes, no rewire; the backend pulls a version-pinned release artifact, not master). Post-merge the
|
||||
contour `backend` schema was wiped (`DROP SCHEMA backend CASCADE` + restart, not a volume drop) and
|
||||
re-migrated to the baseline — verified the new variant CHECK (`scrabble_en/scrabble_ru/erudit_ru`),
|
||||
`games`=0 and a clean boot.
|
||||
|
||||
- **R2** (interview + implementation):
|
||||
- **Locked decisions:** game assembly via **invitations** (real path, no robots; not direct game-row
|
||||
inserts); **moderate** ramp **50 → 200 → 500** at 10 min/step; **diagnostic** pass bar (no SLO gate);
|
||||
run as a **one-shot container on `scrabble-internal`** in this PR.
|
||||
- **Harness** = new `scrabble/loadtest` module (`use ./loadtest` + a `replace scrabble/gateway` for the
|
||||
dot-free edge-proto import). It seeds 1000 guest + 10000 durable accounts + sessions **directly in
|
||||
Postgres** (token hash mirrors `backend/internal/session`), drives players over the **edge protocol**,
|
||||
generates **mid-ranked legal moves locally** with the embedded `scrabble-solver` by replaying
|
||||
`game.history` (the edge carries no board — mirrors `engine.ReplayBoard` via the public API), and a
|
||||
**gateway-hammer**. Compact CLI (`run` / `cleanup`), distroless Dockerfile (DAWGs baked), Go unit tests.
|
||||
- **Adding the module broke the other images' builds** — backend/gateway/telegram Dockerfiles reduce the
|
||||
workspace but still referenced `./loadtest` (not in their context); each now also
|
||||
`-dropuse=./loadtest` (backend/telegram additionally `-dropreplace` the gateway replace). Caught by the
|
||||
first deploy run; verified by building all four images.
|
||||
- **Harness payload fixes found by the smoke pass:** the draft DTO's `rack_order` is a string (was sent
|
||||
as `[]` → `bad_request`); the display-name validator forbids digits/colons, so the cleanup marker
|
||||
became a letters-only `Zzloadtest` so `profile.update` resends the seeded name. `chat_not_your_turn` /
|
||||
`nudge_own_turn` are **by-design** turn gates, correctly exercised.
|
||||
- **Observability:** added **cAdvisor + postgres_exporter** + the **Scrabble — Resources** dashboard +
|
||||
two Prometheus jobs. **Finding:** cAdvisor yields only the root cgroup on the contour host (separate
|
||||
XFS `/var/lib/docker` breaks its layer-ID resolution — the existing galaxy deploy has the same limit),
|
||||
so per-container CPU/RSS for the early pass was captured via `docker stats`. **R7:** adopt the otelcol
|
||||
`docker_stats` receiver (already the contrib image) for per-container metrics in Grafana.
|
||||
- **Early run (2026-06-09):** ramped clean to 500 players, no crash/deadlock, cleanup removed all 11000
|
||||
accounts. 1.2 M edge calls, 48 870 plays, 2 798 games finished; the per-user limiter held under the
|
||||
hammer (99.97 % rejected, p99 2 ms). **Top finding:** ~14 % `transport_error` on `game.state` at 500
|
||||
players, under CPU saturation (backend/gateway/Postgres each ~1 core) and amplified by the harness's
|
||||
single shared `http2.Transport`; the harness itself peaked at 86 % of a core on the same host, so the
|
||||
figures are pessimistic. Full trip report in [`../loadtest/REPORT-R2.md`](../loadtest/REPORT-R2.md);
|
||||
it feeds R3 (h2c `MaxConcurrentStreams`/timeouts, body-size cap), R6 and R7 (per-player transports,
|
||||
separate hardware, pool/limit sizing).
|
||||
- **CI:** `./loadtest/...` added to the path filter + vet/build/test; `go.work.sum` carries the new deps.
|
||||
|
||||
- **R3** (interview + implementation):
|
||||
- **Locked decisions:** the flag column lands by **editing the R1 baseline** (+ a contour schema
|
||||
wipe after merge — no migration chain accrues before prod); auto-flag defaults **1000 rejected /
|
||||
10 min** (`BACKEND_HIGHRATE_FLAG_THRESHOLD`/`_WINDOW`, rolling window, set-once, operator clears,
|
||||
no auto-ban); landing image = **caddy:2-alpine**; throttle data flows **gateway → backend** (a
|
||||
30 s per-key summary POST to the new `/api/v1/internal/ratelimit/report`, the existing trusted
|
||||
direction) with the episode window + flag rule in the backend (`internal/ratewatch`); rejection
|
||||
logging = **Warn summary per key per window + Debug per rejection** — a deliberate deviation from
|
||||
the phase's "structured log per rejection" (the R2 hammer would have logged ~522k lines in
|
||||
minutes); all three R2-report tails included (explicit h2c sizing, the session-resolve failure
|
||||
cause at Warn, reviving the admin limiter).
|
||||
- **Body cap:** `GATEWAY_MAX_BODY_BYTES` (default 1 MiB) as both the Connect per-message read limit
|
||||
and an `http.MaxBytesReader` wrap of the public mux; an oversized Execute is `resource_exhausted`.
|
||||
- **Dead config found:** `AdminPerMinute`/`AdminBurst` were never wired — the gateway `/_gm` mount is
|
||||
now 429-guarded per IP ahead of its Basic-Auth. The caddy-fronted contour path stays unlimited
|
||||
(stock caddy has no limiter) — an accepted gap, recorded in `docs/ARCHITECTURE.md` §12.
|
||||
- **Landing split:** a `landing` target in `gateway/Dockerfile` (the UI build stage is shared;
|
||||
identical compose build args keep it one cached build); the gateway drops `landing.html` from the
|
||||
embed and 308-redirects `/` → `/app/`; the contour caddy routes `/app/`, `/telegram/` and the
|
||||
Connect path to the gateway and the catch-all to the landing container; the CI deploy probe now
|
||||
checks both `/` (landing) and `/app/` (gateway).
|
||||
- **Observability:** `gateway_rate_limited_total{class}` (user/public/email/admin, aggregate-only)
|
||||
+ a rate-vs-rejections panel on the Edge/UX dashboard; the admin console gains the **Throttled**
|
||||
page (the in-memory episode window, reset-on-restart like `active_users`, plus the flagged-account
|
||||
queue) and the flag badge / clear action on the user list / card.
|
||||
- The jet regen also restored the previously missing `game_drafts`/`game_hidden` generated models
|
||||
(their tables were added after the last jetgen run; no behaviour change).
|
||||
|
||||
- **R4** (interview + implementation):
|
||||
- **Locked decisions:** **delta-first**, not full snapshots — an event carries only the new move and
|
||||
the UI applies it to its per-game cache, keyed on `move_count` (idempotent + gap-safe: a gap or the
|
||||
actor's own move falls back to a `game.state` + `game.history` refetch). `match_found` /
|
||||
`game_started` carry the recipient's **initial `StateView`** (instant lobby→game); the fallback
|
||||
refetch stays the existing two calls (no merged endpoint); the matchmaking poll runs **only while
|
||||
the stream is down** (2.5 s); **all** UI-state-changing events carry their payload (incl. lobby `notify`).
|
||||
- **Enriched events** (`pkg/fbs` trailing fields — backward-compatible, no FB regen of *values*, only
|
||||
the schema): `opponent_moved` (+`move`/`game`/`bag_len`), `your_turn` (+`move_count`), `match_found`
|
||||
(+`state`), `game_over` (+`game`), `notify` (+`account`/`invitation`/`state`). The pre-R4
|
||||
`opponent_moved` scalars (`seat`/`action`/`score`/`total`) stay for wire back-compat, now redundant
|
||||
with `move`/`game` — slated for the R6 de-stage.
|
||||
- **Encoding placement:** the `notify` package keeps ownership of the FlatBuffers encoding (a new
|
||||
`encode.go` mirrors the gateway transcode but reads wire-agnostic `notify.*` input structs +
|
||||
`engine.MoveRecord`); the game/lobby/social services map their domain types to those structs, so the
|
||||
wire schema stays out of the domain. **Flagged for R6:** this partly duplicates the gateway encoders
|
||||
(different source types) — a candidate consolidation.
|
||||
- **Actor self-fetch killed too** (beyond literal "push"): the `submit_play`/`pass`/`exchange`/`resign`
|
||||
**response** (`MoveResult`) now returns the actor's refilled rack + bag size, so the mover renders the
|
||||
next turn from the response — `Game.svelte`'s `commit`/`pass`/`exchange`/`resign` drop their `await load()`.
|
||||
- **`match_found` enrichment** needs a per-seat initial state: `lobby.GameCreator` gained `InitialState`,
|
||||
and `game.Service.InitialState` builds the `notify.PlayerState` (rack re-encoded to wire indices, the
|
||||
variant alphabet embedded for a first-seen variant).
|
||||
- **UI:** a pure `lib/gamedelta.ts` reducer (`applyMoveDelta` / `applyGameOver` / `seedInitialState`,
|
||||
unit-tested) advances the cache; `app.svelte` seeds it on `match_found` / `game_started`; `Game.svelte`
|
||||
applies the delta (falling back to `load()` while composing, on a gap, or on its own move's new rack);
|
||||
`NewGame.svelte` polls only when `app.streamAlive` is false and guards its teardown so a push-delivered
|
||||
match is not cancelled.
|
||||
- **notify (friends/invitations) scope:** the backend carries the full account / invitation payload on the
|
||||
wire (per "all events → push"); the UI seeds the game cache from `game_started` but keeps its lightweight
|
||||
**authoritative** badge refresh (`refreshNotifications`, on the rare `notify` event + on foreground) rather
|
||||
than adding client-side friend/invitation caches — the per-move hot path is fully de-fetched, which was the
|
||||
goal. Deeper lobby-cache consumption is an easy follow-up.
|
||||
- **No schema change** (no migration); the contour needs no DB wipe. Tests: `notify` FB round-trips +
|
||||
`emitMove` delta + the `gamedelta` reducer; the e2e mock now emits the enriched delta.
|
||||
|
||||
- **R5** (interview + implementation):
|
||||
- **No code slimming — by analysis.** A gzip measure + sourcemap attribution of the real `dist` showed
|
||||
the app bundle is already minified + tree-shaken and dominated by the Connect/FlatBuffers transport
|
||||
runtime + generated FB/PB bindings (≈⅔ of `main`'s source) and the Svelte runtime — all
|
||||
third-party/generated, irreducible within R5's scope. App-authored code carries no hand-trimmable fat.
|
||||
- **Lazy-load rejected** (screens *and* i18n): `bundle-size.mjs` sums every emitted chunk, so
|
||||
code-splitting moves bytes between chunks for **zero total-size win** while adding request latency (+N
|
||||
gateway fetches on first navigation to a split screen). i18n lazy-load additionally buys ≤3 KB (en-only
|
||||
users) at the cost of an async `t()`, and `en` must stay bundled (it is the `MessageKey` type source +
|
||||
fallback). **Chunk-collapsing rejected** too — keeping the near-static Svelte runtime in its own
|
||||
cacheable chunk is the recommended practice (an app deploy then re-busts only `main`, not the runtime),
|
||||
and HTTP/2 makes the extra preload request negligible.
|
||||
- **Metric retargeted to the app.** The two-entry build (`index.html` app + `landing.html`) makes Rollup
|
||||
hoist the code shared by both (Svelte runtime + i18n + `aboutContent`) into one preloaded chunk, so the
|
||||
app actually loads its entry chunk **+ the shared chunk** (≈74 + ≈23 = **≈97 KB**), never `landing.js`
|
||||
(≈1.6 KB). The old script summed all three chunks (98.8 KB), over-counting the app by `landing.js`.
|
||||
`bundle-size.mjs` now parses each built HTML for the JS it eagerly loads and gates three parts
|
||||
independently — **app entry ≤ 100 KB, shared (Svelte+i18n) ≤ 30 KB, landing-own ≤ 5 KB** — reporting the
|
||||
app total (≈97) and landing total (≈24.5). Same CLI + exit-code contract, so the CI step is unchanged.
|
||||
- **No app/source/build change** (`App.svelte`, `lib/i18n/`, `vite.config.ts` untouched); no schema
|
||||
change, no contour wipe. The stale "~82 KB" figure was corrected in `bundle-size.mjs` and `ui/README.md`.
|
||||
|
||||
- **R6** (interview + implementation):
|
||||
- **Locked decisions:** apply **both** wire/code structural changes (**B** + **A**) and **only C1+C2** of
|
||||
the test consolidation (not C3/C5); strip the `*(Stage N)*` tags from **all current-state docs**
|
||||
(ARCHITECTURE / FUNCTIONAL+`_ru` / TESTING / UI_DESIGN), keeping PLAN.md / PRERELEASE.md / CLAUDE.md as
|
||||
history; **split `stage6_test.go`** by domain. The `h2cMaxConcurrentStreams` sizing stays an **R7**
|
||||
concern (tuning, not behaviour-preserving); the R2 early run forced no code fix, so nothing was carried in.
|
||||
- **(a) De-staging:** removed the `Stage N` / `TODO-N` / `(RN)` references across code, comments, service
|
||||
READMEs and the current-state docs, rewording narratives to present tense (no technical content lost).
|
||||
Renamed the only stage-named identifiers (`registerStage8`→`registerSocialOps`,
|
||||
`registerStage11`→`registerLinkOps`) and split `stage6_test.go` (`TestEmailLoginFlow`→`email_test.go`;
|
||||
`TestGuestAutoMatchLeavesNoStats`+`provisionGuest`→`account_test.go`). De-staged the `.fbs`/`.proto`
|
||||
comments and regenerated: only the `.proto`-derived Go docstrings (`*_grpc.pb.go`, `push.pb.go`) changed —
|
||||
flatc strips schema comments, so the FB Go/TS bindings were untouched.
|
||||
- **(b) Reconciliation:** the docs were accurate (each R-phase baked its own); the one drift was a stale
|
||||
"guest-reaping deferred (TODO-3)" note in `ARCHITECTURE.md` §3 — guest reaping is implemented, so the
|
||||
note was replaced with the current behaviour (FUNCTIONAL/TESTING already described it).
|
||||
- **(c) B — dead `opponent_moved` scalars:** removed `seat/action/score/total` from `OpponentMovedEvent`
|
||||
(`pkg/fbs/scrabble.fbs` + the `notify` emit + the round-trip test); regenerated FB Go + TS. No reader
|
||||
used them (the UI codec/mock take `move`/`game`/`bag_len`; the gateway forwards the payload verbatim).
|
||||
A pre-release wire-slot renumber — free with no prod data, no DB change.
|
||||
- **(c) A — shared FB builders:** new `scrabble/pkg/wire` holds the single definition of the nested wire
|
||||
tables (GameView / MoveRecord / StateView / AccountRef / Invitation) shared by the backend `notify`
|
||||
encoder and the gateway `transcode`; both map their own source types to neutral `wire.*` structs and
|
||||
delegate. **Honest tradeoff:** the verbose `Start/Add/End` + reverse-prepend boilerplate is now written
|
||||
once, but the field *set* is still mapped per side, and the new package makes the change net **+~145 LOC**
|
||||
— a single-source / anti-drift win for the fiddly mechanics rather than a line-count cut. Behaviour-
|
||||
preserving: the two sides' field sets were verified identical and the round-trip tests pass unchanged.
|
||||
- **(c) C1+C2 — inttest fixtures:** moved the cross-file service/game fixtures (`newGameService` was used by
|
||||
10 files) into `backend/internal/inttest/helpers.go`; single-file helpers stay local. Pure relocation.
|
||||
- **No schema change → no contour DB wipe.** Regression gate: the full unit + integration + UI suites plus
|
||||
the R7 stress run.
|
||||
|
||||
- **R7** (interview + implementation):
|
||||
- **Locked decisions:** run the harness **same-host** (one-shot container on `scrabble-internal`, capped
|
||||
`--cpus=3` so the contour keeps spare cores); **apply container limits + `GOMAXPROCS` now** (not just a
|
||||
prod recommendation); **replace cAdvisor with the otelcol `docker_stats` receiver** (it resolved only the
|
||||
root cgroup on this host); keep rate-limit / h2c knobs **compiled-in** (change values only if the data
|
||||
demands — it did not).
|
||||
- **Harness refinements (pre-run):** each virtual player builds its **own `edge.Client`** (its own h2c
|
||||
connection for its Subscribe stream + Execute calls) instead of all players sharing one `http2.Transport` —
|
||||
the R2 `transport_error` artifact; and `playTurn` now reports a **finished** game so the player drops it
|
||||
from rotation. Effect, measured: `game.state` `transport_error` 14 % (R2) → **2.49 %**; `game_finished` on
|
||||
chat ≈ 3 900 → **35**.
|
||||
- **Observability:** added the `docker_stats` receiver to `otelcol` (`api_version: "1.44"` — the daemon's
|
||||
minimum is 1.40; the receiver defaults to 1.25 and crash-looped until pinned), mounted the docker socket
|
||||
read-only with `group_add` (the contrib image runs as UID 10001), dropped the cAdvisor service + its
|
||||
Prometheus job, and retargeted the **Scrabble — Resources** dashboard to the docker_stats metric names
|
||||
(`container_cpu_utilization`/100 == cores). Cross-checked against `docker stats` within sampling error.
|
||||
- **Profile (final run, 500 players, limits in force):** the **gateway is the binding constraint** — with
|
||||
one connection per player it bursts into its 2-core cap (the residual 2.49 % `transport_error`); backend
|
||||
~0.85 core and postgres ~1.4 cores had headroom; **tempo reached its 1 GiB cap**; the backend pool sat at
|
||||
its `MaxOpenConns=25` cap (28 backends); docker logs were unbounded (~14 MiB / 30 min on the backend at
|
||||
info). Full write-up in [`../loadtest/REPORT-R7.md`](../loadtest/REPORT-R7.md).
|
||||
- **Round-2 tuning (owner-agreed, all in `deploy/docker-compose.yml`, no code change):** gateway **2 → 3
|
||||
cores + `GOMAXPROCS=3`**; tempo memory **1 → 2 GiB**; backend `MAX_OPEN_CONNS` **25 → 40**; a json-file
|
||||
**log-rotation** default (10m × 3) applied contour-wide via a YAML anchor (level stays info).
|
||||
backend/postgres kept at 2 cores / 512 MiB (headroom is cheap on the shared host).
|
||||
- **Validation:** the same gradual ramp on the tuned contour cut `game.state` `transport_error` to **0.72 %**
|
||||
(gateway ~2 cores, now under the 3-core cap, no throttle; tempo ~1.27 GiB, under 2 GiB). A separate
|
||||
**burst** run (a single 100 → 500 jump) pegged the gateway at 3 cores (≈296 % sustained, 9.27 % error),
|
||||
confirming it is **connection-CPU-bound** — a true arrival spike is a **horizontal-scaling** lever, not
|
||||
more cores per node (recorded in the prod-sizing recommendation).
|
||||
- **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT-R7.md` (new), `loadtest/README.md`,
|
||||
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
|
||||
|
||||
- **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger
|
||||
`Menu.svelte` everywhere (it fought the Telegram-fullscreen layout, where it had to be re-centred).
|
||||
- **Locked decisions (interview):** the in-Settings sub-nav is a **bottom TabBar with the active tab
|
||||
highlighted** (icon-only); **Export GCG** moves to the left slot of the move-history header (free in a
|
||||
finished game, where 🏁 *leave* does not apply); the lobby **⚙️ badge counts incoming friend requests
|
||||
only** (invitations keep their own lobby section); unread chat is badged on **the score bar and the 💬**.
|
||||
- **What shipped:** a ⚙️ **Settings hub** (`screens/SettingsHub.svelte`) over the existing
|
||||
Settings/Profile/Friends/About bodies and an in-game **comms hub** (`game/CommsHub.svelte`) over
|
||||
chat + dictionary, both with in-place tabs and a fixed back target; the game's menu items relocate into
|
||||
the open move history (🏁 leave / 📤 export + 💬 comms header) and the player cards (🤝 add-friend); a
|
||||
shared **TapConfirm** (`components/TapConfirm.svelte`, `lib/tapconfirm.ts`) — tap → fading ✅ → tap —
|
||||
replaces the Skip/Hint press-and-hold popovers and drives the add-friend confirm. Fixed the move-history
|
||||
"jump" bug (the slid board is now inert and the stage can't scroll, so a swipe up genuinely closes it).
|
||||
`Menu.svelte` + `HoldConfirm.svelte` removed.
|
||||
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
|
||||
(+`_ru`). Regression gate: UI `check` + unit (`tapconfirm`) + build + bundle budget + e2e (Chromium &
|
||||
WebKit), all green.
|
||||
|
||||
- **UI — Merge Exchange/Pass; drop the dead Tournaments tab** (owner ad-hoc, not on the raw TODO
|
||||
list): the lobby's 🏆 *Tournaments* tab was an inert `lobby.soon` toast — removed (the lobby is back
|
||||
to three tabs, matching `docs/FUNCTIONAL.md`). In-game the separate 🥺 *Skip* (pass) tab folds into
|
||||
the 🔄 tab, now **Exchange/Pass**, whose dialog passes when no tile is selected and exchanges when
|
||||
tiles are.
|
||||
- **Decision — a pass is NOT an exchange of zero (verified against the rules + GCG):** the merge is
|
||||
**UI-only**. Pass and exchange stay distinct game actions end-to-end — wire (`GameActionRequest` vs
|
||||
`ExchangeRequest`), engine (`ActionPass` vs `ActionExchange`), and the GCG Poslfit dialect (a pass is
|
||||
a bare `-`, an exchange is `-TILES`). The engine forbids a zero-tile exchange (`ErrNothingToExchange`)
|
||||
and allows an exchange only with a full rack left in the bag (`ErrNotEnoughTilesToExchange`), while a
|
||||
pass is always legal — collapsing them would lose a real distinction. The dialog dispatches the
|
||||
existing `gateway.pass` / `gateway.exchange`.
|
||||
- **What shipped:** `Lobby.svelte` (tab removed); `Game.svelte` (one 🔄 Exchange/Pass tab no longer
|
||||
gated on an empty bag; the dialog disables tile selection while the bag is below a full rack
|
||||
(`bagLen >= RACK_SIZE`), its confirm button reading **Pass without exchanging** / **Exchange N**);
|
||||
i18n (`game.draw` → Exchange/Pass, new `game.passNoExchange`, dropped `game.skip` /
|
||||
`lobby.tournaments` / `lobby.soon`). No backend/wire/history/GCG change.
|
||||
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
|
||||
(+`_ru`). Regression gate: UI `check` + unit + build + bundle budget + e2e (Chromium & WebKit).
|
||||
|
||||
- **AI — Honest AI opponent in quick game** (owner ad-hoc, not on the raw TODO list): a second quick-game
|
||||
opponent the player *knowingly* chooses, distinct from the disguised robot of the random/open path
|
||||
(which is kept as-is). New Game's quick-game mode replaces the "auto-match" subtitle with a two-button
|
||||
selector **🤖 AI / 👤 Random player** (the `.seg`/`.opt` segmented style, AI the default); for AI the
|
||||
move-clock line reads "Loss after 7 days of inactivity" and the "searching" hint is hidden.
|
||||
- **Locked decisions (interview):** AI move is **event-driven** (the robot replies the instant the
|
||||
player's move commits; the 30 s driver is the fallback); AI games **do not touch `account_stats`**
|
||||
(practice, like guests); the **Stage 5 strength logic is reused unchanged** (`playToWin` 40 % from the
|
||||
seed + margin band); **no per-move timeout — a 7-day inactivity loss** instead; the 7-day line lives on
|
||||
the New Game screen (the in-game screen has no move-clock line); chat + nudge **disabled**, word-check
|
||||
kept, add-friend never drawn, opponent shown as **🤖** everywhere.
|
||||
- **The 7-day rule reuses the existing per-turn timeout:** an AI game is created with
|
||||
`turn_timeout_secs = AIInactivityTimeout` (7 days) and the existing timeout sweeper resigns the overdue
|
||||
seat — since the robot moves at once, only the human is ever on the clock, so the per-turn timeout *is*
|
||||
the abandon rule (no new column, no new sweeper).
|
||||
- **One game flag drives everything:** `games.vs_ai` (edited into the R1 baseline — pre-release, so a
|
||||
contour DB wipe after merge). It is set **only** on AI-started games, so a robot-filled random game keeps
|
||||
`vs_ai=false` and the disguised opponent is never revealed; the UI derives 🤖 / the gates **from the flag,
|
||||
never from the opponent account**. New backend path `Matchmaker.StartVsAI` (picks a pooled robot via the
|
||||
existing `Pick`, creates an **active** seated game via `game.Service.Create`, random seat order) — the AI
|
||||
request never enters the open pool, so the open-game reaper never touches it. The robot driver gains a
|
||||
`vs_ai` branch (no sleep, no proactive nudge, zero delay) and a focused `DriveGame`/`TriggerMove` fast
|
||||
path wired from the game service's after-create/after-commit hook (`SetAITrigger`, a func value so the
|
||||
game package never imports the robot package). Chat/nudge gated by a new `social` `VsAI` check
|
||||
(`ErrGameVsAI` → 409 `ai_game`); statistics skipped in `commit` when `vs_ai`.
|
||||
- **Wire:** `EnqueueRequest` += `vs_ai`, `GameView` += `vs_ai` (trailing FB fields, regenerated Go + TS),
|
||||
threaded through the backend DTO, the gateway transcode and the `pkg/wire` + `notify` builders.
|
||||
- **Tests:** `lobby` unit (StartVsAI seats a robot + flags the game; empty pool leaves no game); backend
|
||||
integration (`ai_game_test.go`: active+seated+vs_ai+7-day clock, robot moves immediately, stats skipped,
|
||||
7-day timeout resigns the human, chat/nudge rejected); UI codec round-trip (`vs_ai` on enqueue + game
|
||||
view); e2e (an AI game shows 🤖, no "searching", chat disabled, the dictionary still works) + the
|
||||
existing quick-match e2e updated to pick **Random player** (the default is now AI).
|
||||
- **Schema/wire change → a contour DB wipe** after merge (`DROP SCHEMA backend CASCADE` + restart, the
|
||||
R1/R3 pattern). Bake-back: `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `docs/UI_DESIGN.md`,
|
||||
`backend/README.md`, Go Doc comments.
|
||||
- **Post-review refinements (owner, same PR):** (1) the **GCG export labels the robot seat "AI"** rather
|
||||
than its human-like pool name (`ExportGCG` overrides the name via `accounts.IsRobot`; the in-app 🤖 is
|
||||
unchanged); (2) honest-AI games **emit no `your_turn`** — the robot replies instantly, so the signal
|
||||
would arrive with the move and be pointless; `opponent_moved` still advances the UI; (3) the **admin
|
||||
console surfaces the AI flag** — a **🤖 column** in `/games` and an "AI game" line on the game card
|
||||
(`GameRow`/`GameDetailView` gain `VsAI`); (4) `games_started_total` / `games_abandoned_total` gain a
|
||||
**`vs_ai`** attribute and the Grafana *Game domain* dashboard splits started/abandoned into **human**
|
||||
and **AI** panels.
|
||||
- **Follow-up (separate PR — strategy deviation):** the robot now plays **≈20%** of opening/midgame moves
|
||||
*against* its per-game `playToWin` intent (toward the opposite margin band — a winning robot eases off, a
|
||||
losing one surges ahead), tapering linearly to **0 over the last 14 bag tiles** and **0 once the bag is
|
||||
empty**, so the endgame follows the chosen strategy strictly while earlier outcomes can swing the human's
|
||||
way. Deterministic from the seed (`mix(seed,"deviate",moveCount)`), applied to **both** robot paths via
|
||||
the shared `selectMove`; the per-game intent (and the admin card) is unchanged. Tests: `robot` unit
|
||||
(taper bounds + monotonicity, never-in-endgame, determinism, ~20% distribution). Bake-back:
|
||||
`docs/ARCHITECTURE.md` §7, `docs/FUNCTIONAL.md` (+`_ru`), `backend/README.md`, `PLAN.md` Stage 5.
|
||||
|
||||
- **GL — Simultaneous quick-game cap** (owner ad-hoc, not on the raw TODO list): a player may hold at
|
||||
most **10** active quick games; at the cap the lobby greys **New Game** and shows a plain notice
|
||||
"Вы достигли лимита одновременных партий", both clearing automatically when an active game finishes.
|
||||
- **Locked decisions (interview):** what counts = active **+** open (searching) quick games, **including
|
||||
AI** (`vs_ai`); friend games (invitation-linked) **never** count. The backend gate refuses **all** new-game
|
||||
creation at the cap — `lobby/enqueue` **and** `invitations` — with **409 `game_limit_reached`**; **accepting**
|
||||
an invitation is never gated, so friend games are capped "from the other end". Delivery = a boolean
|
||||
**`at_game_limit`** on the existing `games.list` (no per-event payload: a turn change does not move the count,
|
||||
and the lobby already re-fetches `games.list` on entry + every game event); the first uncached lobby frame
|
||||
defaults the button **enabled** (the backend gate is the authority).
|
||||
- **What shipped:** `game.MaxActiveQuickGames` + `Store/Service.CountActiveQuickGames` (active/open seats, no
|
||||
`game_invitations` row; hidden games still count → a dedicated count, not a filter over the lobby list);
|
||||
`Server.atGameLimit`/`ensureUnderGameLimit` gating `handleEnqueue` + `handleCreateInvitation`;
|
||||
`gameListDTO.at_game_limit`; the FB `GameList` trailing `at_game_limit` (regenerated Go + TS) threaded through
|
||||
the gateway transcode + UI codec; `lib/model` + `lobbycache` snapshot + `Lobby.svelte` (disabled tab + a muted
|
||||
`.limit` notice); i18n `lobby.limitReached` (en authoritative + ru).
|
||||
- **Caveat (logged):** the gate is a pre-check, not transaction-atomic — concurrent creates from one account could
|
||||
momentarily exceed by 1–2 (harmless soft cap; the UI disables the button regardless). Strict atomicity was judged
|
||||
a disproportionate diff across the two create paths.
|
||||
- **No schema change → no contour DB wipe** (only a trailing FB field, no migration). Tests: backend integration
|
||||
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
|
||||
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
|
||||
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
|
||||
@@ -22,9 +22,8 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
||||
security, cross-service contracts.
|
||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) —
|
||||
per-domain user stories.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the per-stage CI gate.
|
||||
- [`PLAN.md`](PLAN.md) — the staged implementation plan and stage tracker.
|
||||
- [`CLAUDE.md`](CLAUDE.md) — project guide and the mandatory per-stage workflow.
|
||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the CI gate.
|
||||
- [`CLAUDE.md`](CLAUDE.md) — project guide and development workflow.
|
||||
|
||||
## Build & test
|
||||
|
||||
@@ -90,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
|
||||
services build from multi-stage distroless `*/Dockerfile`.
|
||||
|
||||
```sh
|
||||
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
|
||||
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
|
||||
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
|
||||
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
||||
```
|
||||
|
||||
+9
-5
@@ -7,12 +7,14 @@
|
||||
# (GOPRIVATE), so the build stage needs git and network.
|
||||
#
|
||||
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||
# in the Docker context:
|
||||
# docker build -f backend/Dockerfile -t scrabble-backend .
|
||||
# in the Docker context. DICT_VERSION has no default — the caller supplies the
|
||||
# scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md
|
||||
# "Bumping the dictionary version"):
|
||||
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
|
||||
|
||||
# --- dictionary artifact -----------------------------------------------------
|
||||
FROM alpine:3.20 AS dawg
|
||||
ARG DICT_VERSION=v1.0.0
|
||||
ARG DICT_VERSION
|
||||
RUN apk add --no-cache curl tar
|
||||
RUN mkdir -p /dawg \
|
||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||
@@ -33,14 +35,16 @@ COPY backend ./backend
|
||||
# Reduce the workspace to what the backend needs: backend + pkg. loadtest and the
|
||||
# gateway replace it requires are not in this context, so drop both.
|
||||
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
|
||||
# VERSION (the deploy passes the git tag) is stamped into the binary via the linker.
|
||||
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 -----------------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
# Re-declare the build arg in this stage so it labels the seed dictionary. One
|
||||
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
|
||||
# label the binary pins, so the resident version equals the release tag.
|
||||
ARG DICT_VERSION=v1.0.0
|
||||
ARG DICT_VERSION
|
||||
COPY --from=build /out/backend /usr/local/bin/backend
|
||||
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||
|
||||
+41
-14
@@ -45,6 +45,9 @@ 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`
|
||||
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
|
||||
clears a nudge when its recipient moves — both record the publish-to-read latency.
|
||||
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
|
||||
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
|
||||
background reaper drops a robot friend request once its game has been finished for **7 days**.
|
||||
`internal/account`
|
||||
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
|
||||
development log mailer). The engine now also handles **multi-player drop-out**: in
|
||||
@@ -68,7 +71,10 @@ 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
|
||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||
state. A background **reaper** seats a pooled robot (matching the game's language) in any open
|
||||
state. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound
|
||||
to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think
|
||||
time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal
|
||||
delay, so a decided game is not dragged out while the robot never moves slower than usual. A background **reaper** seats a pooled robot (matching the game's language) in any open
|
||||
game whose wait window — a fixed **90 s** plus a random **0–90 s** (so **90–180 s**) — has
|
||||
elapsed, and the waiting starter is told an opponent took the seat by an in-app
|
||||
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
|
||||
@@ -97,9 +103,16 @@ second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) str
|
||||
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
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||||
route out-of-app push to the Telegram connector; the Telegram login
|
||||
route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login
|
||||
seeds a new account's language and display name from the launch fields, and the
|
||||
`accounts.notifications_in_app_only` flag (default true).
|
||||
The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the
|
||||
bot's join-time query) or an account id (a `chat_access_changed` event) to its
|
||||
**moderated-chat write eligibility** — `registered AND NOT suspended AND NOT chat_muted`.
|
||||
That event is emitted on an admin block/unblock, a `chat_muted` role grant/revoke, or — via
|
||||
the `account.SuspensionSweeper` started in `cmd/backend` — a temporary block lapsing;
|
||||
`chat_muted` is an `account.KnownRoles` entry, a chat-only mute distinct from the game
|
||||
suspension (which dominates it).
|
||||
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||||
with no identity, excluded from statistics. The server-rendered
|
||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||||
@@ -110,16 +123,15 @@ 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` +
|
||||
`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 Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
|
||||
holds the language tag of the bot a Telegram
|
||||
user last signed in through, written on every login and returned by
|
||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||||
to the right bot. The console also manages the **advertising banner** (`/_gm/banners` +
|
||||
backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's
|
||||
**bot-link relay** — each broadcast renders through the bot in an operator-chosen language
|
||||
and the relay awaits the bot's delivery ack. There is one bot,
|
||||
so `/internal/push-target` returns the recipient's `preferred_language` as the render
|
||||
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
|
||||
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
|
||||
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
|
||||
&& !no_banner` role, the message language picked by `service_language`); changing those inputs
|
||||
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
|
||||
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
|
||||
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
@@ -143,6 +155,13 @@ rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
|
||||
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
|
||||
**Clear** action on the user card; never an automatic ban).
|
||||
|
||||
The gateway also syncs its active IP bans (prod-only — see ARCHITECTURE §11) to
|
||||
`POST /api/v1/internal/bans/sync`; `internal/banview` mirrors them for the console's
|
||||
**Throttled** page (an **Active IP bans** panel with an **Unban** action) and returns
|
||||
the operator's pending unbans in the response, which the gateway applies on its next
|
||||
sync. Like `ratewatch` it is in-memory and resets on restart — the enforced ban lives
|
||||
in the gateway, not here.
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
@@ -166,8 +185,9 @@ internal/lobby/ # auto-match (DB-backed open games + robot substitution) +
|
||||
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/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
|
||||
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
||||
internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts)
|
||||
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||||
internal/banview/ # gateway active-ban mirror: the console's Active IP bans panel + the operator unban backchannel
|
||||
```
|
||||
|
||||
## Configuration (environment)
|
||||
@@ -186,7 +206,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
|
||||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
||||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
||||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
||||
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
|
||||
| `BACKEND_DICT_VERSION` | `v1` | Version label for the flat dictionary dir. Recorded in a `.seed_version` marker on first boot and authoritative after: on a seeded volume a changed value is ignored (it seeds only a fresh volume) — the seed-drift guard (ARCHITECTURE.md §5). |
|
||||
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
||||
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
|
||||
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
||||
@@ -197,7 +217,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
|
||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
||||
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
|
||||
@@ -208,7 +228,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
|
||||
```sh
|
||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
|
||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg
|
||||
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||
BACKEND_DICT_DIR=/tmp/dawg \
|
||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||
@@ -247,7 +267,14 @@ local solver co-development you may add a temporary replace — see `go.work`).
|
||||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||||
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||
(a missing dictionary aborts the boot).
|
||||
(a missing dictionary aborts the boot). The flat directory is the seed version,
|
||||
labelled `BACKEND_DICT_VERSION`; uploaded versions live in `<version>/`
|
||||
subdirectories the admin console writes and a restart re-loads. Because the DAWGs
|
||||
carry no embedded version, the first boot records the seed in a `.seed_version`
|
||||
marker that is authoritative after: on a seeded volume a changed `BACKEND_DICT_VERSION`
|
||||
is ignored (it seeds only a fresh volume) — the seed-drift guard — so a live contour's
|
||||
dictionary is changed through the console, never by bumping the build seed
|
||||
(ARCHITECTURE.md §5).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// loads the dictionaries into the engine registry, warms the session cache,
|
||||
// constructs the game domain and starts its turn-timeout sweeper, constructs the
|
||||
// lobby and social domains, then serves the HTTP listener with the infrastructure
|
||||
// probes and the /api/v1 route-group skeleton. Domain HTTP endpoints are added
|
||||
// with the gateway in a later stage described in PLAN.md.
|
||||
// probes and the /api/v1 route group, behind which the domains expose their HTTP
|
||||
// endpoints to the gateway.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -15,11 +15,13 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountmerge"
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/banview"
|
||||
"scrabble/backend/internal/config"
|
||||
"scrabble/backend/internal/connector"
|
||||
"scrabble/backend/internal/engine"
|
||||
@@ -159,6 +161,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
zap.Duration("interval", cfg.GuestReapInterval),
|
||||
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,
|
||||
// so they are handed to the server (like the route groups) for the handlers.
|
||||
mailer := newMailer(cfg.SMTP, logger)
|
||||
@@ -171,6 +182,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||
// A nudge the recipient answered by moving is marked read on the move path.
|
||||
games.SetNudgeClearer(socialSvc.ClearNudges)
|
||||
// Reap per-game disguised-robot friend requests once their game is long finished
|
||||
// (the robot ignores them; the row only pins the in-game "request sent" state).
|
||||
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
|
||||
go robotReqReaper.Run(ctx)
|
||||
logger.Info("robot friend request reaper started",
|
||||
zap.Duration("interval", robotReqReaper.Interval()),
|
||||
zap.Duration("retention", robotReqReaper.Retention()))
|
||||
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
||||
feedbackSvc.SetNotifier(hub)
|
||||
|
||||
@@ -189,6 +207,7 @@ 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.SetNotifier(hub)
|
||||
matchmaker.SetBlocker(socialSvc)
|
||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||
invitations.SetNotifier(hub)
|
||||
@@ -203,6 +222,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
|
||||
zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
|
||||
|
||||
// Ban observability: mirror the gateway's active IP bans for the admin console's
|
||||
// active-bans panel and collect operator unban requests.
|
||||
banView := banview.New()
|
||||
|
||||
// Advertising-banner domain: campaign rotation feeding the profile.get banner
|
||||
// block and the banner admin console section.
|
||||
adsSvc := ads.NewService(ads.NewStore(db))
|
||||
@@ -225,6 +248,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
DictDir: cfg.Game.DictDir,
|
||||
Connector: conn,
|
||||
RateWatch: rateWatch,
|
||||
BanView: banView,
|
||||
Ads: adsSvc,
|
||||
Notifier: hub,
|
||||
})
|
||||
|
||||
@@ -55,12 +55,12 @@ type Account struct {
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
// ServiceLanguage is the language tag (en/ru) of the bot the account last
|
||||
// authenticated through (its last Telegram ValidateInitData); it routes the
|
||||
// account's out-of-app push back through the right bot. Empty when the account
|
||||
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the
|
||||
// interface language) and from a game's variant language.
|
||||
ServiceLanguage string
|
||||
// VariantPreferences is the set of game variants (engine.Variant stable labels:
|
||||
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
|
||||
// into. It gates the New Game picker, the matchmaker and the friend-invite the
|
||||
// player creates; an invited friend may still accept any variant. A new account
|
||||
// defaults to Erudit only. Never empty — enforced on update and by a DB check.
|
||||
VariantPreferences []string
|
||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||
// excluded from statistics, friends and history.
|
||||
IsGuest bool
|
||||
@@ -119,6 +119,16 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||
}
|
||||
|
||||
// ProvisionEmail returns the account owning the email identity externalID, creating
|
||||
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
|
||||
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
|
||||
// and leaves an existing account untouched, so a returning user's saved zone is never
|
||||
// overwritten. The email account is created here (the code-request step), not at the
|
||||
// later login, so this is where its zone is seeded.
|
||||
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
|
||||
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
|
||||
}
|
||||
|
||||
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||
// member: a KindRobot identity carrying displayName, with chat blocked but friend
|
||||
// requests NOT blocked — a request to a robot is accepted as pending and, since the
|
||||
@@ -151,14 +161,28 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
||||
// identity. On first contact only, it seeds the new account's preferred language
|
||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
||||
// its display name sanitized from firstName (falling back to username, then to a
|
||||
// generated placeholder when neither yields any letters); an already-existing
|
||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity,
|
||||
// reporting whether this call created it (first contact). On first contact only, it
|
||||
// seeds the new account's preferred language from the Telegram client languageCode
|
||||
// (when it maps to a supported language) and its display name sanitized from firstName
|
||||
// (falling back to username, then to a generated placeholder when neither yields any
|
||||
// letters); an already-existing account is returned unchanged, so a later profile edit
|
||||
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
|
||||
// chat write access on first registration — the path of a user who joined the chat
|
||||
// before registering, whom no chat_member event covers.
|
||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ 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
|
||||
}
|
||||
seed := telegramSeed(languageCode, username, firstName)
|
||||
seed.timeZone = seedZone(browserTZ)
|
||||
acc, err := s.provision(ctx, KindTelegram, externalID, seed)
|
||||
return acc, created, err
|
||||
}
|
||||
|
||||
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||
@@ -185,20 +209,33 @@ func (s *Store) provision(ctx context.Context, kind, externalID string, seed pro
|
||||
}
|
||||
|
||||
// provisionSeed carries the optional create-time profile seed for a brand-new
|
||||
// account (Telegram first contact). Empty fields fall back to the accounts table
|
||||
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
|
||||
// the ” default.
|
||||
// account (first contact). Empty fields fall back to the accounts table defaults,
|
||||
// so an unknown language keeps the 'en' default, an empty name keeps the ” default
|
||||
// and an empty time zone keeps the 'UTC' default.
|
||||
type provisionSeed struct {
|
||||
preferredLanguage string
|
||||
displayName string
|
||||
timeZone string
|
||||
}
|
||||
|
||||
// seedZone returns browserTZ when it is a well-formed zone to persist at account
|
||||
// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account
|
||||
// falls back to the accounts table's 'UTC' default. The client reports the device's
|
||||
// detected offset deterministically; a bad value is dropped rather than guessed at.
|
||||
func seedZone(browserTZ string) string {
|
||||
if validZone(browserTZ) {
|
||||
return browserTZ
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
||||
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
||||
// editable format). When neither yields any letters, it falls back to a generated
|
||||
// placeholder in the seeded language (placeholderDisplayName).
|
||||
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
|
||||
// name (firstName, sanitized to the editable format) → the @username taken verbatim
|
||||
// (already a valid handle, only trimmed and length-capped, never character-stripped)
|
||||
// → a generated placeholder in the seeded language (placeholderDisplayName), reached
|
||||
// only when firstName has no usable letters and no username is set.
|
||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
@@ -206,7 +243,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
}
|
||||
name := sanitizeDisplayName(firstName)
|
||||
if name == "" {
|
||||
name = sanitizeDisplayName(username)
|
||||
// The real name yielded nothing usable: fall back to the @username verbatim
|
||||
// (Telegram guarantees a valid handle), only trimmed and capped to the column
|
||||
// width — never character-stripped like the real name.
|
||||
name = strings.TrimSpace(username)
|
||||
if r := []rune(name); len(r) > maxDisplayName {
|
||||
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = placeholderDisplayName(seed.preferredLanguage)
|
||||
@@ -303,6 +346,14 @@ func (s *Store) CountAccounts(ctx context.Context) (int, error) {
|
||||
return int(dest.Count), nil
|
||||
}
|
||||
|
||||
// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound
|
||||
// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access
|
||||
// resolver uses it to tell a registered Telegram user (eligible to be granted chat
|
||||
// write access) from an unregistered one (left muted).
|
||||
func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
return s.findByIdentity(ctx, kind, externalID)
|
||||
}
|
||||
|
||||
// findByIdentity joins identities to accounts and returns the matching account,
|
||||
// or ErrNotFound.
|
||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
@@ -341,16 +392,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
||||
|
||||
var created Account
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
// Seed the new row's display name and language (Telegram first contact); an
|
||||
// empty seed reproduces the table defaults ('' and 'en') the other callers
|
||||
// relied on, so their behaviour is unchanged.
|
||||
// Seed the new row's display name, language and time zone (first contact); an
|
||||
// empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
|
||||
// relied on, so their behaviour is unchanged. time_zone is written explicitly (the
|
||||
// detected offset, or 'UTC' equal to the column default) so a seeded zone lands at
|
||||
// creation while an unseeded one stays UTC.
|
||||
lang := seed.preferredLanguage
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
tz := seed.timeZone
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
insertAccount := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
|
||||
VALUES(accountID, seed.displayName, lang).
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
|
||||
VALUES(accountID, seed.displayName, lang, tz).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
@@ -389,15 +446,21 @@ const guestDisplayName = "Guest"
|
||||
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
|
||||
// no identity, flagged is_guest, so it can hold a session and a game seat (both
|
||||
// foreign-key the accounts table) while being excluded from statistics, friends
|
||||
// and history. Guests are not reused — each bootstrap mints a new account.
|
||||
func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
|
||||
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
|
||||
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
|
||||
// back to the 'UTC' default when empty or malformed.
|
||||
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
|
||||
accountID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new guest id: %w", err)
|
||||
}
|
||||
tz := seedZone(browserTZ)
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest).
|
||||
VALUES(accountID, guestDisplayName, true).
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
|
||||
VALUES(accountID, guestDisplayName, true, tz).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
@@ -491,36 +554,12 @@ func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
||||
// user authenticated through. It is called on every Telegram login — new and
|
||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
||||
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
|
||||
// (a non-Telegram login carries none) and does not bump updated_at (an infra
|
||||
// routing field, not a user profile edit).
|
||||
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
|
||||
if language == "" {
|
||||
return nil
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.ServiceLanguage).
|
||||
SET(postgres.String(language)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: set service language %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelToAccount projects a generated model row into the public Account struct.
|
||||
func modelToAccount(row model.Accounts) Account {
|
||||
var mergedInto uuid.UUID
|
||||
if row.MergedInto != nil {
|
||||
mergedInto = *row.MergedInto
|
||||
}
|
||||
var serviceLanguage string
|
||||
if row.ServiceLanguage != nil {
|
||||
serviceLanguage = *row.ServiceLanguage
|
||||
}
|
||||
var flaggedHighRateAt time.Time
|
||||
if row.FlaggedHighRateAt != nil {
|
||||
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||
@@ -529,7 +568,7 @@ func modelToAccount(row model.Accounts) Account {
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
ServiceLanguage: serviceLanguage,
|
||||
VariantPreferences: []string(row.VariantPreferences),
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
|
||||
@@ -131,13 +131,15 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
||||
// the unauthenticated email-login entry point and, unlike RequestCode,
|
||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||
// login. The code is mailed to the address, so only its real owner can complete
|
||||
// the login. It returns the target account id for the subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) {
|
||||
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
|
||||
// seeds the new account's time zone. It returns the target account id for the
|
||||
// subsequent LoginWithCode.
|
||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
|
||||
addr, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr)
|
||||
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
@@ -58,6 +59,106 @@ type ProfileUpdate struct {
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
NotificationsInAppOnly bool
|
||||
// VariantPreferences is the set of game variants the player allows themselves to
|
||||
// be matched into (engine.Variant stable labels). UpdateProfile cleans it to a
|
||||
// deduplicated, canonically ordered subset of the known variants and rejects an
|
||||
// empty set.
|
||||
VariantPreferences []string
|
||||
}
|
||||
|
||||
// knownVariants is the closed set of game-variant labels (engine.Variant stable
|
||||
// labels) a profile's variant preferences may contain. It lives here so the store
|
||||
// does not depend on the engine package; the server handler additionally validates
|
||||
// against engine.ParseVariant, and a DB check enforces the same subset.
|
||||
var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true}
|
||||
|
||||
// canonicalVariantOrder is the deterministic order variant preferences are stored
|
||||
// in (Erudit, Russian Scrabble, English), independent of the client's order.
|
||||
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
|
||||
|
||||
// validateVariantPreferences cleans a profile's variant-preference set: it drops
|
||||
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
|
||||
// returns the preferences in canonicalVariantOrder so the stored value is
|
||||
// deterministic regardless of the order the client sent.
|
||||
func validateVariantPreferences(prefs []string) ([]string, error) {
|
||||
seen := make(map[string]bool, len(prefs))
|
||||
for _, p := range prefs {
|
||||
p = strings.TrimSpace(p)
|
||||
if !knownVariants[p] {
|
||||
return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile)
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, v := range canonicalVariantOrder {
|
||||
if seen[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
|
||||
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
|
||||
// canonical variant labels joined by "-". It is deliberately distinct from the routing
|
||||
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
|
||||
// router falls through to the lobby for it.
|
||||
const variantSeedPrefix = "v"
|
||||
|
||||
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
|
||||
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
|
||||
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
|
||||
// returns nil for any payload that is not a variant-seed link or that fails validation
|
||||
// against the known variants, so a malformed, empty or unrelated start-param simply
|
||||
// leaves the account on its default preferences rather than failing the login.
|
||||
func SeedVariantsFromStartParam(startParam string) []string {
|
||||
if !strings.HasPrefix(startParam, variantSeedPrefix) {
|
||||
return nil
|
||||
}
|
||||
body := strings.TrimPrefix(startParam, variantSeedPrefix)
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
// SetVariantPreferences overwrites only the variant-preference set of the account,
|
||||
// cleaning it to a deduplicated, canonically ordered subset of the known variants
|
||||
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
|
||||
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
|
||||
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
|
||||
// without disturbing its other profile fields.
|
||||
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
|
||||
clean, err := validateVariantPreferences(prefs)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
// clean is validated against the closed knownVariants set; bind as a text[]
|
||||
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
|
||||
// UpdateProfile.
|
||||
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
|
||||
postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
@@ -79,17 +180,26 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
|
||||
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
prefs, err := validateVariantPreferences(p.VariantPreferences)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
||||
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
||||
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
|
||||
table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences,
|
||||
table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
||||
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
|
||||
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
|
||||
postgres.Bool(p.NotificationsInAppOnly),
|
||||
// prefs are validated against the closed knownVariants set; bind as a text[]
|
||||
// parameter (lib/pq encodes the array, the cast pins the column type).
|
||||
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}),
|
||||
postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package account
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
||||
func TestUpdateProfileValidation(t *testing.T) {
|
||||
s := &Store{}
|
||||
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"}
|
||||
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}}
|
||||
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) {
|
||||
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
|
||||
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
|
||||
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
|
||||
{"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }},
|
||||
{"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -39,3 +42,22 @@ func TestUpdateProfileValidation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateVariantPreferences checks the cleaning of a profile's variant set:
|
||||
// duplicates collapse, the result is canonically ordered (Erudit, Russian Scrabble,
|
||||
// English) regardless of input order, and an empty or unknown set is rejected.
|
||||
func TestValidateVariantPreferences(t *testing.T) {
|
||||
got, err := validateVariantPreferences([]string{"scrabble_en", "erudit_ru", "scrabble_en"})
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
if _, err := validateVariantPreferences(nil); !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Fatalf("empty err = %v, want ErrInvalidProfile", err)
|
||||
}
|
||||
if _, err := validateVariantPreferences([]string{"chess"}); !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Fatalf("unknown err = %v, want ErrInvalidProfile", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
|
||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||
// the first-name / username display-name precedence, and the sanitization that
|
||||
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
|
||||
// the real-name → @username (verbatim) → placeholder display-name precedence, and
|
||||
// the sanitization of the real name (emoji, digits, punctuation stripped to the
|
||||
// editable format). The username, when used, is kept verbatim.
|
||||
func TestTelegramSeed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, username, firstName string
|
||||
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
|
||||
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -52,7 +54,7 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
|
||||
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`},
|
||||
"name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
@@ -24,11 +24,19 @@ const (
|
||||
// unconditionally, overriding the usual eligibility (a free account with an
|
||||
// empty hint wallet otherwise sees it). See internal/ads.
|
||||
RoleNoBanner = "no_banner"
|
||||
|
||||
// RoleChatMuted forbids the account from writing in the moderated Telegram
|
||||
// discussion chat, without otherwise restricting the game (the chat-only
|
||||
// counterpart to a full account suspension). It is one input to the chat-access
|
||||
// gate; an active admin suspension mutes the player regardless, so this role only
|
||||
// matters for an account that is not suspended. Granting or revoking it re-pushes
|
||||
// the chat-gate command for a member currently in the chat.
|
||||
RoleChatMuted = "chat_muted"
|
||||
)
|
||||
|
||||
// KnownRoles is the set of roles the console may grant or revoke; an operator
|
||||
// cannot assign an unrecognised role.
|
||||
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner}
|
||||
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner, RoleChatMuted}
|
||||
|
||||
// IsKnownRole reports whether role is a recognised account role.
|
||||
func IsKnownRole(role string) bool {
|
||||
|
||||
@@ -2,6 +2,7 @@ package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -13,16 +14,43 @@ import (
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// BestMoveTile is one letter cell of a best-move word: its concrete letter (the
|
||||
// designated letter for a blank), its tile point value (0 for a blank) and whether it
|
||||
// is a blank. It is the persisted/served shape: the game domain marshals a slice of
|
||||
// these into account_best_move.tiles, and the statistics screen renders them as game
|
||||
// tiles without consulting the variant's alphabet.
|
||||
type BestMoveTile struct {
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// BestMove is an account's highest-scoring single play within one game variant: the
|
||||
// move's total score (every word it formed plus the all-tiles bonus, matching
|
||||
// MaxWordPoints) and its main word as an ordered slice of tiles.
|
||||
type BestMove struct {
|
||||
Variant string
|
||||
Score int
|
||||
Tiles []BestMoveTile
|
||||
}
|
||||
|
||||
// Stats is a durable account's lifetime record, written by the game domain on each
|
||||
// finish and read for the player's statistics screen. MaxGamePoints is the best
|
||||
// single game's total; MaxWordPoints is the best single move's score (which already
|
||||
// includes every word it formed plus the all-tiles bonus).
|
||||
// includes every word it formed plus the all-tiles bonus). BestMoves holds the same
|
||||
// best move broken down per variant, with the word itself — empty for an account with
|
||||
// no recorded play yet, and never carrying a variant the account has not played.
|
||||
type Stats struct {
|
||||
Wins int
|
||||
Losses int
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
// Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the
|
||||
// lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves).
|
||||
Moves int
|
||||
HintsUsed int
|
||||
BestMoves []BestMove
|
||||
}
|
||||
|
||||
// GetStats returns the lifetime statistics for id. An account with no account_stats
|
||||
@@ -40,11 +68,48 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
|
||||
}
|
||||
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
|
||||
}
|
||||
best, err := s.bestMoves(ctx, id)
|
||||
if err != nil {
|
||||
return Stats{}, err
|
||||
}
|
||||
return Stats{
|
||||
Wins: int(row.Wins),
|
||||
Losses: int(row.Losses),
|
||||
Draws: int(row.Draws),
|
||||
MaxGamePoints: int(row.MaxGamePoints),
|
||||
MaxWordPoints: int(row.MaxWordPoints),
|
||||
Moves: int(row.Moves),
|
||||
HintsUsed: int(row.HintsUsed),
|
||||
BestMoves: best,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bestMoves reads an account's per-variant best moves, ordered by variant for a stable
|
||||
// response. Each row's tiles JSON is decoded into the served BestMoveTile slice. An
|
||||
// account with no recorded play yields an empty (nil) slice rather than an error.
|
||||
func (s *Store) bestMoves(ctx context.Context, id uuid.UUID) ([]BestMove, error) {
|
||||
stmt := postgres.SELECT(
|
||||
table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score,
|
||||
table.AccountBestMove.Tiles,
|
||||
).
|
||||
FROM(table.AccountBestMove).
|
||||
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(id))).
|
||||
ORDER_BY(table.AccountBestMove.Variant.ASC())
|
||||
var rows []model.AccountBestMove
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("account: best moves %s: %w", id, err)
|
||||
}
|
||||
out := make([]BestMove, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var tiles []BestMoveTile
|
||||
if err := json.Unmarshal([]byte(r.Tiles), &tiles); err != nil {
|
||||
return nil, fmt.Errorf("account: decode best-move tiles %s/%s: %w", id, r.Variant, err)
|
||||
}
|
||||
out = append(out, BestMove{Variant: r.Variant, Score: int(r.Score), Tiles: tiles})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +161,31 @@ func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID,
|
||||
return modelToSuspension(row), true, nil
|
||||
}
|
||||
|
||||
// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the
|
||||
// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The
|
||||
// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires,
|
||||
// since no operator action fires then. An account that still has another active block may be
|
||||
// included; the eligibility resolver returns the true state, so emitting for it is harmless.
|
||||
func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT account_id FROM backend.account_suspensions
|
||||
WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`,
|
||||
since.UTC(), until.UTC())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: suspensions expired between: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("account: scan expired suspension: %w", err)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
|
||||
// Called after Suspend and LiftSuspension.
|
||||
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// suspensionSweepInterval is how often the sweeper re-checks for temporary blocks
|
||||
// that lapsed. A minute is well under the coarsest block grain (operators pick day
|
||||
// presets) while keeping the query trivial.
|
||||
const suspensionSweepInterval = time.Minute
|
||||
|
||||
// suspensionExpiryQuerier is the slice of the account store the sweeper depends on:
|
||||
// the accounts whose temporary block lapsed in a window. *Store satisfies it; a fake
|
||||
// drives the sweeper's unit tests.
|
||||
type suspensionExpiryQuerier interface {
|
||||
SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error)
|
||||
}
|
||||
|
||||
// SuspensionSweeper re-evaluates chat write access when a temporary block self-
|
||||
// expires. No operator action fires on expiry — the suspension gate just re-reads
|
||||
// the wall clock — so without this a temporarily blocked player would stay muted in
|
||||
// the moderated discussion chat after their block lapsed. Each tick it finds blocks
|
||||
// that expired since the previous tick and calls onExpire for the affected accounts;
|
||||
// onExpire is wired to publish the chat-access-changed event, after which the gateway
|
||||
// re-resolves the true eligibility. A liberal call (an account that still has another
|
||||
// active block) is therefore harmless. The window is in-memory, so a block that
|
||||
// expires while the process is down is not re-granted until the next operator action
|
||||
// or the player rejoins — an accepted best-effort gap.
|
||||
type SuspensionSweeper struct {
|
||||
store suspensionExpiryQuerier
|
||||
onExpire func(accountID uuid.UUID)
|
||||
log *zap.Logger
|
||||
// since is the upper bound of the previous swept window; the next sweep covers
|
||||
// (since, now]. It advances only on a successful query, so a failed tick retries
|
||||
// the same window rather than dropping expiries.
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// NewSuspensionSweeper builds the sweeper over the account store, the per-account
|
||||
// expiry callback (publishing the chat-access-changed event) and a logger. The first
|
||||
// window opens at construction time, so blocks that lapsed earlier are not re-emitted.
|
||||
func NewSuspensionSweeper(store *Store, onExpire func(accountID uuid.UUID), log *zap.Logger) *SuspensionSweeper {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &SuspensionSweeper{store: store, onExpire: onExpire, log: log, since: time.Now().UTC()}
|
||||
}
|
||||
|
||||
// Interval reports the sweep cadence, for the startup log line.
|
||||
func (w *SuspensionSweeper) Interval() time.Duration { return suspensionSweepInterval }
|
||||
|
||||
// Run sweeps every Interval until ctx is cancelled.
|
||||
func (w *SuspensionSweeper) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(suspensionSweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.sweep(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sweep emits a chat-access-changed signal for every account whose temporary block
|
||||
// lapsed in (since, now], then advances the window. On a query error it keeps the
|
||||
// window so the next tick retries it.
|
||||
func (w *SuspensionSweeper) sweep(ctx context.Context) {
|
||||
now := time.Now().UTC()
|
||||
ids, err := w.store.SuspensionsExpiredBetween(ctx, w.since, now)
|
||||
if err != nil {
|
||||
w.log.Warn("suspension expiry sweep failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
w.since = now
|
||||
for _, id := range ids {
|
||||
w.onExpire(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fakeExpiryQuerier records the `since` bound of each call and replays a scripted
|
||||
// result/error per call, so the sweeper's window and dispatch logic is testable
|
||||
// without a database.
|
||||
type fakeExpiryQuerier struct {
|
||||
results [][]uuid.UUID
|
||||
errs []error
|
||||
sinces []time.Time
|
||||
idx int
|
||||
}
|
||||
|
||||
func (f *fakeExpiryQuerier) SuspensionsExpiredBetween(_ context.Context, since, _ time.Time) ([]uuid.UUID, error) {
|
||||
f.sinces = append(f.sinces, since)
|
||||
i := f.idx
|
||||
f.idx++
|
||||
if i < len(f.errs) && f.errs[i] != nil {
|
||||
return nil, f.errs[i]
|
||||
}
|
||||
if i < len(f.results) {
|
||||
return f.results[i], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newSweeper(store suspensionExpiryQuerier, onExpire func(uuid.UUID)) *SuspensionSweeper {
|
||||
return &SuspensionSweeper{
|
||||
store: store,
|
||||
onExpire: onExpire,
|
||||
log: zap.NewNop(),
|
||||
since: time.Now().Add(-time.Minute).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuspensionSweeperDispatchesAndAdvances(t *testing.T) {
|
||||
id1, id2 := uuid.New(), uuid.New()
|
||||
fake := &fakeExpiryQuerier{results: [][]uuid.UUID{{id1, id2}, nil}}
|
||||
var got []uuid.UUID
|
||||
w := newSweeper(fake, func(id uuid.UUID) { got = append(got, id) })
|
||||
|
||||
first := w.since
|
||||
w.sweep(context.Background())
|
||||
assert.Equal(t, []uuid.UUID{id1, id2}, got, "every expired account is dispatched")
|
||||
assert.True(t, w.since.After(first), "the window advances on success")
|
||||
|
||||
// A second sweep opens the next window at the previous upper bound.
|
||||
prev := w.since
|
||||
w.sweep(context.Background())
|
||||
require.Len(t, fake.sinces, 2)
|
||||
assert.True(t, fake.sinces[1].After(fake.sinces[0]), "consecutive windows are contiguous and forward")
|
||||
assert.True(t, fake.sinces[1].Equal(prev), "the next window starts at the previous upper bound")
|
||||
}
|
||||
|
||||
func TestSuspensionSweeperKeepsWindowOnError(t *testing.T) {
|
||||
fake := &fakeExpiryQuerier{errs: []error{errors.New("db down")}}
|
||||
w := newSweeper(fake, func(uuid.UUID) { t.Fatal("onExpire must not run when the query fails") })
|
||||
|
||||
before := w.since
|
||||
w.sweep(context.Background())
|
||||
assert.True(t, w.since.Equal(before), "the window is retained on error so the next tick retries it")
|
||||
}
|
||||
|
||||
func TestNewSuspensionSweeperDefaults(t *testing.T) {
|
||||
w := NewSuspensionSweeper(nil, func(uuid.UUID) {}, nil)
|
||||
assert.Equal(t, time.Minute, w.Interval())
|
||||
assert.NotNil(t, w.log, "a nil logger is tolerated")
|
||||
assert.WithinDuration(t, time.Now().UTC(), w.since, time.Second, "the first window opens at construction time")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
|
||||
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
|
||||
// to the canonical order and deduplicated, while anything that is not a variant-seed link
|
||||
// or that names an unknown variant yields nil (leaving the account on its defaults).
|
||||
func TestSeedVariantsFromStartParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
param string
|
||||
want []string
|
||||
}{
|
||||
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
|
||||
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
|
||||
{"empty", "", nil},
|
||||
{"prefix only", "v", nil},
|
||||
{"routing game link is not a seed", "g0190abcd", nil},
|
||||
{"friend code link is not a seed", "f123456", nil},
|
||||
{"unknown variant rejected", "vscrabble_de", nil},
|
||||
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SeedVariantsFromStartParam(tc.param)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Package accountmerge retires a secondary account into a primary one in a single
|
||||
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
||||
// transaction: it sums statistics (merging the per-variant best moves), sums the hint
|
||||
// wallet, ORs the paid flag, repoints
|
||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||
// (accounts.merged_into). It is the data core of account linking & merge
|
||||
@@ -68,6 +69,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
||||
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
||||
// summed, max points kept) and deletes the secondary row.
|
||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
|
||||
// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
|
||||
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.AccountStats
|
||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||
@@ -178,13 +182,16 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
|
||||
|
||||
upd := table.AccountStats.UPDATE(
|
||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
|
||||
table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
|
||||
).SET(
|
||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
||||
postgres.Int(int64(pri.Moves+sec.Moves)),
|
||||
postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
@@ -198,6 +205,41 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
|
||||
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
|
||||
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
|
||||
// they would linger on a dead account and never reach the merged statistics screen.
|
||||
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var srows []model.AccountBestMove
|
||||
err := postgres.SELECT(table.AccountBestMove.AllColumns).
|
||||
FROM(table.AccountBestMove).
|
||||
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &srows)
|
||||
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||
return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
|
||||
}
|
||||
for _, s := range srows {
|
||||
ins := table.AccountBestMove.
|
||||
INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
|
||||
VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
|
||||
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
|
||||
DO_UPDATE(postgres.SET(
|
||||
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
|
||||
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
|
||||
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
|
||||
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
|
||||
}
|
||||
}
|
||||
del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||
// all other profile fields stay the primary's.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
|
||||
@@ -137,3 +137,59 @@ 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. */
|
||||
.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; }
|
||||
.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
|
||||
|
||||
/* Game replay (admin): a script-stepped board with rack panels around it, a move log and the
|
||||
first-move draw. A placed tile shows its value as a subscript; a 0 value (a blank) shows none. */
|
||||
.replay-stage {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: ". top ." "left board right" ". bottom .";
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.rack-top { grid-area: top; }
|
||||
.rack-bottom { grid-area: bottom; }
|
||||
.rack-left { grid-area: left; }
|
||||
.rack-right { grid-area: right; }
|
||||
.replay-board { grid-area: board; overflow: auto; }
|
||||
.board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4rem repeat(15, 1.7rem);
|
||||
grid-auto-rows: 1.7rem;
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
width: max-content;
|
||||
}
|
||||
.board-grid .bh { display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: var(--ink-dim); background: var(--panel); }
|
||||
.board-grid .cell { position: relative; display: flex; align-items: center; justify-content: center; background: var(--panel-hi); }
|
||||
.cell .prem { font-size: 0.55rem; color: var(--ink); opacity: 0.8; }
|
||||
.cell.tw { background: #7a2230; }
|
||||
.cell.dw { background: #a8506a; }
|
||||
.cell.tl { background: #235a7a; }
|
||||
.cell.dl { background: #3f87a8; }
|
||||
.cell.centre .prem { font-size: 0.95rem; color: var(--warn); opacity: 1; }
|
||||
.tile {
|
||||
display: inline-flex; align-items: baseline; justify-content: center;
|
||||
min-width: 1.35rem; height: 1.35rem; padding: 0 0.12rem;
|
||||
background: #e8d9a0; color: #1b1408; border-radius: 3px;
|
||||
font-weight: 700; font-size: 0.8rem; line-height: 1.35rem;
|
||||
}
|
||||
.tile sub { font-size: 0.5rem; font-weight: 600; line-height: 1; align-self: flex-end; margin-left: 1px; }
|
||||
.tile.blank { background: #cdbfe0; }
|
||||
.cell.filled { background: var(--panel-hi); }
|
||||
.cell.filled .tile { width: 100%; height: 100%; border-radius: 2px; }
|
||||
.rack-slot { padding: 0.25rem; border-radius: 6px; }
|
||||
.rack-slot.active { outline: 2px solid var(--accent); background: var(--panel-hi); }
|
||||
.rack-name { font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 0.2rem; text-align: center; }
|
||||
.rack-tiles { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; }
|
||||
.rack-left .rack-tiles, .rack-right .rack-tiles { flex-direction: column; }
|
||||
.replay-controls { display: flex; align-items: center; gap: 0.8rem; justify-content: center; margin: 0.6rem 0; }
|
||||
.replay-controls button { background: var(--panel-hi); color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
|
||||
.replay-controls button:disabled { opacity: 0.4; cursor: default; }
|
||||
.replay-pos { color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||||
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
||||
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
||||
|
||||
@@ -26,6 +26,11 @@ 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", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
|
||||
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
|
||||
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
|
||||
}, `/_gm/users/c3`},
|
||||
{"throttled", ThrottledView{
|
||||
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
|
||||
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
|
||||
@@ -38,7 +43,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"},
|
||||
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
|
||||
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
|
||||
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
|
||||
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"},
|
||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
||||
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
|
||||
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/broadcast">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
||||
<div><button type="submit">Post to channel</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<section class="panel"><h2>Message</h2>
|
||||
<ul class="kv">
|
||||
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
|
||||
<li><b>Channel</b> {{.Channel}}{{if .BotLanguage}} (bot: {{.BotLanguage}}){{end}}</li>
|
||||
<li><b>Channel</b> {{.Channel}}</li>
|
||||
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
|
||||
<li><b>App version</b> {{if .Version}}<code>{{.Version}}</code>{{else}}<span class="note">unknown</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}} UTC · browser {{if .CreatedAtBrowser}}{{.CreatedAtBrowser}} ({{.BrowserTZ}}){{else}}<span class="note">N/A</span>{{end}} · user {{if .CreatedAtUser}}{{.CreatedAtUser}} ({{.UserTZ}}){{else}}<span class="note">N/A</span>{{end}}</li>
|
||||
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
|
||||
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
|
||||
</ul>
|
||||
|
||||
@@ -27,5 +27,87 @@
|
||||
</table>
|
||||
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
|
||||
</section>
|
||||
{{if .SetupDraws}}
|
||||
<section class="panel"><h2>First-move draw</h2>
|
||||
<p class="note">Each player draws a tile; the one closest to “A” moves first (a blank beats every letter), ties re-drawing until a single leader remains.{{if .FirstMover}} <b>{{.FirstMover}}</b> leads.{{end}}</p>
|
||||
<table class="list">
|
||||
<thead><tr><th>Round</th><th>Player</th><th>Tile</th><th>Rank</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .SetupDraws}}
|
||||
<tr><td>{{.Round}}</td><td>{{if .AccountID}}<a href="/_gm/users/{{.AccountID}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td>{{.Letter}}{{if .Blank}} <small>(blank)</small>{{end}}</td><td>{{.Rank}}</td></tr>
|
||||
{{end}}
|
||||
</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">◀ prev</button>
|
||||
<span class="replay-pos" id="replay-pos"></span>
|
||||
<button type="button" id="replay-next">next ▶</button>
|
||||
</div>
|
||||
<ol class="replay-log" id="replay-log"></ol>
|
||||
<script>
|
||||
const REPLAY = {{.ReplayJSON}};
|
||||
(function(){
|
||||
if(!REPLAY||!REPLAY.steps){return;}
|
||||
const N=15, COLS="ABCDEFGHIJKLMNO", PREM={tw:"3W",dw:"2W",tl:"3L",dl:"2L"};
|
||||
const boardEl=document.getElementById("replay-board"), logEl=document.getElementById("replay-log");
|
||||
const posEl=document.getElementById("replay-pos"), prevBtn=document.getElementById("replay-prev"), nextBtn=document.getElementById("replay-next");
|
||||
let step=0;
|
||||
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":s;return d.innerHTML;}
|
||||
function tileHTML(t){const sub=(t.v&&t.v>0)?"<sub>"+t.v+"<\/sub>":"";return "<span class=\"tile"+(t.b?" blank":"")+"\">"+esc(t.l)+sub+"<\/span>";}
|
||||
function placedAt(k){const m={};for(let s=1;s<=k;s++){const mv=REPLAY.steps[s]&&REPLAY.steps[s].move;if(mv&&mv.placements){for(const p of mv.placements){m[p.r+","+p.c]=p;}}}return m;}
|
||||
function renderBoard(){
|
||||
const placed=placedAt(step);let h="<div class=\"board-grid\"><div class=\"bh corner\"><\/div>";
|
||||
for(let c=0;c<N;c++){h+="<div class=\"bh\">"+COLS[c]+"<\/div>";}
|
||||
for(let r=0;r<N;r++){h+="<div class=\"bh\">"+(r+1)+"<\/div>";
|
||||
for(let c=0;c<N;c++){const p=placed[r+","+c];
|
||||
if(p){h+="<div class=\"cell filled\">"+tileHTML(p)+"<\/div>";continue;}
|
||||
const prem=REPLAY.premium[r][c], centre=(r===REPLAY.centre[0]&&c===REPLAY.centre[1]);
|
||||
const label=centre?"★":(prem?PREM[prem]:"");
|
||||
h+="<div class=\"cell "+(prem||"")+(centre?" centre":"")+"\">"+(label?"<span class=\"prem\">"+label+"<\/span>":"")+"<\/div>";}}
|
||||
h+="<\/div>";boardEl.innerHTML=h;
|
||||
}
|
||||
function renderRacks(){
|
||||
const st=REPLAY.steps[step];
|
||||
document.querySelectorAll(".rack-slot").forEach(function(slot){
|
||||
const seat=parseInt(slot.dataset.seat,10), info=REPLAY.seats.find(function(s){return s.seat===seat;}), rack=st.racks[seat];
|
||||
if(!info||!rack){slot.style.display="none";slot.innerHTML="";return;}
|
||||
slot.style.display="";slot.classList.toggle("active",st.toMove===seat);
|
||||
const nm=info.accountId?"<a href=\"/_gm/users/"+info.accountId+"\">"+esc(info.name)+"<\/a>":esc(info.name||("seat "+seat));
|
||||
slot.innerHTML="<div class=\"rack-name\">"+nm+" · "+(st.scores[seat]||0)+"<\/div><div class=\"rack-tiles\">"+rack.map(tileHTML).join("")+"<\/div>";
|
||||
});
|
||||
}
|
||||
function renderLog(){
|
||||
let h="";
|
||||
for(let s=1;s<=step;s++){const st=REPLAY.steps[s], m=st.move;if(!m){continue;}
|
||||
const who=((REPLAY.seats.find(function(x){return x.seat===m.seat;})||{}).name)||("seat "+m.seat);
|
||||
let desc;
|
||||
if(m.action==="play"){desc="played "+((m.words&&m.words.length)?m.words.join(", "):"")+" for "+m.score;}
|
||||
else if(m.action==="exchange"){desc="exchanged "+((m.exchanged&&m.exchanged.length)||0)+" tiles";}
|
||||
else if(m.action==="pass"){desc="passed";}
|
||||
else{desc=m.action;}
|
||||
const drew=(st.drawn&&st.drawn.length)?" · drew "+st.drawn.map(function(t){return t.l;}).join(""):"";
|
||||
h+="<li class=\""+(s===step?"cur":"")+"\">"+esc(who)+" "+esc(desc)+drew+" · bag "+st.bagLen+"<\/li>";}
|
||||
logEl.innerHTML=h||"<li class=\"note\">opening position<\/li>";
|
||||
}
|
||||
function render(){renderBoard();renderRacks();renderLog();posEl.textContent=step+" / "+(REPLAY.steps.length-1);prevBtn.disabled=step<=0;nextBtn.disabled=step>=REPLAY.steps.length-1;}
|
||||
prevBtn.onclick=function(){if(step>0){step--;render();}};
|
||||
nextBtn.onclick=function(){if(step<REPLAY.steps.length-1){step++;render();}};
|
||||
document.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"){prevBtn.click();}else if(e.key==="ArrowRight"){nextBtn.click();}});
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -5,6 +5,26 @@
|
||||
list is in-memory and resets on a backend restart. An account sustaining
|
||||
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
|
||||
below — never banned automatically; clear the flag on the user card.</p>
|
||||
<section class="panel"><h2>Active IP bans</h2>
|
||||
<p class="note">Temporary IP bans the gateway is currently enforcing (in-memory, prod-only;
|
||||
reset on a gateway restart). Unban applies on the gateway's next sync.</p>
|
||||
<table class="list">
|
||||
<thead><tr><th>IP</th><th>Reason</th><th>Since</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Bans}}
|
||||
<tr>
|
||||
<td><code>{{.IP}}</code></td>
|
||||
<td>{{.Reason}}</td>
|
||||
<td>{{.Since}}</td>
|
||||
<td>{{.Expires}}</td>
|
||||
<td><form class="form" method="post" action="/_gm/bans/unban"><input type="hidden" name="ip" value="{{.IP}}"><button type="submit">Unban</button></form></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><span class="note">no active bans</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Recent episodes</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Class</th><th>Key</th><th class="num">Rejected</th><th>First seen</th><th>Last seen</th></tr></thead>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||
<li><b>Moves</b> {{.Stats.Moves}}</li>
|
||||
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
|
||||
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||
</ul>
|
||||
@@ -100,12 +102,41 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Friends</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Friends}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocks</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Blocks}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocked by</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .BlockedBy}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{if .TelegramID}}
|
||||
<section class="panel"><h2>Send Telegram message</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
||||
<div><button type="submit">Send to user</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
|
||||
@@ -175,6 +175,21 @@ type UserDetailView struct {
|
||||
// grant form offers. The first role is the feedback ban (see internal/account).
|
||||
Roles []string
|
||||
KnownRoles []string
|
||||
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
|
||||
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
|
||||
// other account with the date it happened. They are the full truth; the asymmetric block
|
||||
// suppression that hides relationships from players never applies to the console.
|
||||
Blocks []RelationRow
|
||||
BlockedBy []RelationRow
|
||||
Friends []RelationRow
|
||||
}
|
||||
|
||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
||||
type RelationRow struct {
|
||||
AccountID string
|
||||
DisplayName string
|
||||
Date string
|
||||
}
|
||||
|
||||
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||
@@ -203,6 +218,8 @@ type StatsRow struct {
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
Moves int
|
||||
HintsUsed int
|
||||
}
|
||||
|
||||
// IdentityRow is one platform/email identity of an account.
|
||||
@@ -251,6 +268,27 @@ type GameDetailView struct {
|
||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||
HasRobot bool
|
||||
RobotTargetPct int
|
||||
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
|
||||
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
|
||||
ReplayJSON template.JS
|
||||
HasReplay bool
|
||||
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
|
||||
// and FirstMover is the resolved name of the seat-0 player the draw elected.
|
||||
SetupDraws []SetupDrawRow
|
||||
FirstMover string
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -351,17 +389,27 @@ type BroadcastView struct {
|
||||
ConnectorEnabled bool
|
||||
}
|
||||
|
||||
// ThrottledView is the rate-limit observability page: the recent gateway-reported
|
||||
// throttle episodes (in-memory, reset on restart) and the accounts currently
|
||||
// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active
|
||||
// auto-flag tuning.
|
||||
// ThrottledView is the rate-limit observability page: the temporary IP bans the
|
||||
// gateway is currently enforcing, the recent gateway-reported throttle episodes
|
||||
// (in-memory, reset on restart) and the accounts currently carrying the high-rate
|
||||
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning.
|
||||
type ThrottledView struct {
|
||||
Bans []BanRow
|
||||
Episodes []ThrottleEpisodeRow
|
||||
Flagged []FlaggedAccountRow
|
||||
FlagThreshold int
|
||||
FlagWindow string
|
||||
}
|
||||
|
||||
// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its
|
||||
// since/expiry timestamps; the row carries an unban action.
|
||||
type BanRow struct {
|
||||
IP string
|
||||
Reason string
|
||||
Since string
|
||||
Expires string
|
||||
}
|
||||
|
||||
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
|
||||
// user card and is set only for the user class (the other classes key by IP).
|
||||
type ThrottleEpisodeRow struct {
|
||||
@@ -493,11 +541,8 @@ type FeedbackDetailView struct {
|
||||
SenderName string
|
||||
Source string
|
||||
Channel string
|
||||
// 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 is the sender's interface language (account preference).
|
||||
InterfaceLanguage string
|
||||
BotLanguage string
|
||||
IP string
|
||||
Body string
|
||||
HasAttachment bool
|
||||
@@ -509,5 +554,17 @@ type FeedbackDetailView struct {
|
||||
ReplyBody string
|
||||
RepliedAt string
|
||||
CreatedAt string
|
||||
// Version is the client app build the report was sent from (empty for rows that predate it).
|
||||
Version string
|
||||
// The Filed time is shown in three zones so the operator can tell what is certainly known from
|
||||
// what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
|
||||
// instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
|
||||
// when the client reported none (an older build). CreatedAtUser is that instant in the sender's
|
||||
// saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
|
||||
// default — the template then shows "N/A" so the missing datum is explicit.
|
||||
CreatedAtBrowser string
|
||||
BrowserTZ string
|
||||
CreatedAtUser string
|
||||
UserTZ string
|
||||
Banned bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package banview mirrors the gateway's active IP bans for the admin console and
|
||||
// collects operator unban requests for the gateway to apply. Like ratewatch it is
|
||||
// in-memory, single-instance and resets on a backend restart by design — the
|
||||
// gateway re-reports its active set on the next sync, and the durable effect (the
|
||||
// ban itself) lives in the gateway, not here.
|
||||
package banview
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ban is one active IP ban as reported by the gateway.
|
||||
type Ban struct {
|
||||
IP string
|
||||
Reason string
|
||||
Since time.Time
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// View holds the last-reported active bans and the operator's pending unbans.
|
||||
type View struct {
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
bans map[string]Ban // last reported active set, keyed by IP
|
||||
unban map[string]struct{} // IPs an operator marked for unban
|
||||
}
|
||||
|
||||
// New constructs an empty View.
|
||||
func New() *View {
|
||||
return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})}
|
||||
}
|
||||
|
||||
// Ingest replaces the mirrored active set with the gateway's latest report,
|
||||
// skipping entries with an empty IP or one that has already expired.
|
||||
func (v *View) Ingest(active []Ban) {
|
||||
now := v.now()
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.bans = make(map[string]Ban, len(active))
|
||||
for _, b := range active {
|
||||
if b.IP == "" || !now.Before(b.Expires) {
|
||||
continue
|
||||
}
|
||||
v.bans[b.IP] = b
|
||||
}
|
||||
}
|
||||
|
||||
// Recent returns the mirrored active bans, most recently banned first.
|
||||
func (v *View) Recent() []Ban {
|
||||
now := v.now()
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := make([]Ban, 0, len(v.bans))
|
||||
for _, b := range v.bans {
|
||||
if now.Before(b.Expires) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
|
||||
return out
|
||||
}
|
||||
|
||||
// RequestUnban records an operator request to lift the ban on ip; the gateway
|
||||
// applies it on its next sync (so the console reflects it within the sync
|
||||
// interval). An empty ip is ignored.
|
||||
func (v *View) RequestUnban(ip string) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.unban[ip] = struct{}{}
|
||||
}
|
||||
|
||||
// DrainUnbans returns and clears the IPs operators have marked for unban since the
|
||||
// previous drain. It returns nil when there are none.
|
||||
func (v *View) DrainUnbans() []string {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if len(v.unban) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(v.unban))
|
||||
for ip := range v.unban {
|
||||
out = append(out, ip)
|
||||
}
|
||||
clear(v.unban)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package banview
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func viewAt(clk *time.Time) *View {
|
||||
v := New()
|
||||
v.now = func() time.Time { return *clk }
|
||||
return v
|
||||
}
|
||||
|
||||
func TestIngestRecentDropsExpired(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.Ingest([]Ban{
|
||||
{IP: "1.1.1.1", Reason: "tripwire", Since: clk, Expires: clk.Add(time.Hour)},
|
||||
{IP: "2.2.2.2", Reason: "rejections", Since: clk.Add(-2 * time.Hour), Expires: clk.Add(-time.Hour)}, // expired
|
||||
{IP: "", Reason: "x", Since: clk, Expires: clk.Add(time.Hour)}, // empty IP
|
||||
})
|
||||
got := v.Recent()
|
||||
if len(got) != 1 || got[0].IP != "1.1.1.1" || got[0].Reason != "tripwire" {
|
||||
t.Fatalf("Recent = %+v, want one live ban for 1.1.1.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestReplaces(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.Ingest([]Ban{{IP: "1.1.1.1", Since: clk, Expires: clk.Add(time.Hour)}})
|
||||
v.Ingest([]Ban{{IP: "2.2.2.2", Since: clk, Expires: clk.Add(time.Hour)}})
|
||||
got := v.Recent()
|
||||
if len(got) != 1 || got[0].IP != "2.2.2.2" {
|
||||
t.Fatalf("Recent = %+v, want only the latest report (2.2.2.2)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentOrdersBySince(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.Ingest([]Ban{
|
||||
{IP: "old", Since: clk.Add(-10 * time.Minute), Expires: clk.Add(time.Hour)},
|
||||
{IP: "new", Since: clk.Add(-1 * time.Minute), Expires: clk.Add(time.Hour)},
|
||||
})
|
||||
got := v.Recent()
|
||||
if len(got) != 2 || got[0].IP != "new" || got[1].IP != "old" {
|
||||
t.Fatalf("Recent order = %+v, want most recent first", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanRoundTrip(t *testing.T) {
|
||||
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||
v := viewAt(&clk)
|
||||
v.RequestUnban("3.3.3.3")
|
||||
v.RequestUnban("") // ignored
|
||||
drained := v.DrainUnbans()
|
||||
if len(drained) != 1 || drained[0] != "3.3.3.3" {
|
||||
t.Fatalf("DrainUnbans = %v, want [3.3.3.3]", drained)
|
||||
}
|
||||
if again := v.DrainUnbans(); again != nil {
|
||||
t.Fatalf("second DrainUnbans = %v, want nil (cleared)", again)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
// Package connector is the backend's gRPC client for the Telegram platform
|
||||
// connector side-service. The admin console uses it to send operator broadcasts:
|
||||
// a direct message to one user, or a post to a game channel. Each broadcast
|
||||
// selects the delivering bot by language (an operator choice, since the connector
|
||||
// hosts one bot per service language). The connector lives on the trusted internal
|
||||
// network, so the connection uses insecure (plaintext) transport credentials
|
||||
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
|
||||
// the two broadcast methods the admin surface needs.
|
||||
// Package connector is the backend's gRPC client for operator broadcasts: a direct
|
||||
// message to one user, or a post to the game channel. It calls the gateway's
|
||||
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS
|
||||
// link and reports back whether it was delivered). The relay lives on the trusted
|
||||
// internal network, so the connection uses insecure (plaintext) transport
|
||||
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
|
||||
// narrowed to the two broadcast methods the admin surface needs.
|
||||
package connector
|
||||
|
||||
import (
|
||||
@@ -37,22 +36,21 @@ func New(addr string) (*Client, error) {
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// SendToUser sends an operator text message to one user, addressed by their
|
||||
// platform external_id, through the bot for the given language. delivered reports
|
||||
// whether the connector actually sent it (false when the user has not started that
|
||||
// bot).
|
||||
func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) {
|
||||
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
|
||||
// platform external_id, through the bot. delivered reports whether the connector
|
||||
// actually sent it (false when the user has not started the bot).
|
||||
func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
|
||||
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetDelivered(), nil
|
||||
}
|
||||
|
||||
// SendToGameChannel posts an operator text message to the game channel of the bot
|
||||
// for the given language. delivered reports whether the connector sent it (false
|
||||
// when that bot has no channel configured).
|
||||
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
|
||||
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
|
||||
// SendToGameChannel posts an operator text message to the bot's game channel.
|
||||
// delivered reports whether the connector sent it (false when the bot has no
|
||||
// channel configured).
|
||||
func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
|
||||
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
// ActionResign abandons the game.
|
||||
ActionResign
|
||||
// ActionTimeout is the auto-resignation a missed turn becomes; recorded by
|
||||
// the game domain in a later stage, never produced by the engine itself.
|
||||
// the game domain, never produced by the engine itself.
|
||||
ActionTimeout
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
|
||||
// replay independently of any dictionary. Second, the engine owns rules and
|
||||
// scoring only: turn scheduling, the 24-hour timeout, persistence and transport
|
||||
// belong to the game domain in a later stage.
|
||||
// belong to the game domain.
|
||||
package engine
|
||||
|
||||
import (
|
||||
|
||||
@@ -31,7 +31,7 @@ type entry struct {
|
||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||
// dictionary version, and the solvers built over them. Several versions of a
|
||||
// variant may be resident at once; a game pins the version it started on. The
|
||||
// admin reload flow (a later stage) registers a new version through Load.
|
||||
// admin reload flow registers a new version through Load.
|
||||
// Registry is safe for concurrent use.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
@@ -70,11 +70,21 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
||||
// immediate subdirectory of dir: a subdirectory named V contributes, under
|
||||
// version V, the variants whose committed DAWG it carries. This is the
|
||||
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
|
||||
// at runtime is resident again after a restart. A subdirectory named like the
|
||||
// boot version is skipped (the flat dir already is the boot version). A partially
|
||||
// loaded registry is closed before any error is returned.
|
||||
// at runtime is resident again after a restart. The flat dir's version is resolved
|
||||
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records
|
||||
// bootVersion, an already-seeded dir keeps its recorded label and ignores bootVersion,
|
||||
// so a bumped build seed never relabels live bytes. A subdirectory named like the
|
||||
// resolved seed version is skipped (the flat dir already is it). A partially loaded
|
||||
// registry is closed before any error is returned.
|
||||
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
||||
r, err := Open(dir, bootVersion)
|
||||
// Resolve the flat dir's version from its seed marker first: on an already-seeded
|
||||
// volume the marker wins and bootVersion is ignored, so a bumped build seed cannot
|
||||
// relabel live bytes (see resolveSeedVersion).
|
||||
seed, err := resolveSeedVersion(dir, bootVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := Open(dir, seed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,9 +94,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
||||
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
// Skip non-directories, the boot version (already loaded as the flat dir)
|
||||
// and dot-prefixed directories (the upload staging area, dir/.staging/).
|
||||
if !e.IsDir() || e.Name() == bootVersion || strings.HasPrefix(e.Name(), ".") {
|
||||
// Skip non-directories, the resolved seed version (already loaded as the flat
|
||||
// dir) and dot-prefixed directories (the upload staging area, dir/.staging/).
|
||||
if !e.IsDir() || e.Name() == seed || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -112,6 +113,103 @@ 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
|
||||
// already resident, moves the latest pointer and keeps the earlier version.
|
||||
func TestReloadRegistersNewVersion(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// seedMarkerFile names the file, in the flat dictionary directory, that records the
|
||||
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
|
||||
// version scan skips it (like the .staging upload area).
|
||||
const seedMarkerFile = ".seed_version"
|
||||
|
||||
// resolveSeedVersion returns the version label the flat dictionary directory is
|
||||
// addressed by, recording it on first use.
|
||||
//
|
||||
// The contour's dictionary lives on a named volume seeded from the image once and
|
||||
// never re-seeded (deploy/docker-compose.yml). The flat DAWGs carry no embedded
|
||||
// version, so the version a volume was first seeded as is recorded in a
|
||||
// .seed_version marker and is **authoritative** from then on:
|
||||
//
|
||||
// - fresh directory (no marker): record bootVersion (the build's
|
||||
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
|
||||
// - already-seeded directory: return the recorded marker and ignore bootVersion.
|
||||
//
|
||||
// So bumping the build seed on a live volume is a harmless no-op (it only takes
|
||||
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
|
||||
// which would void games pinned to the prior label and mis-serve new ones. New games
|
||||
// still pin the active version (DB-persisted, set by the admin console), which is the
|
||||
// real way a running contour moves to a new release.
|
||||
//
|
||||
// A directory that cannot be written makes the first record fail; that also breaks
|
||||
// the admin console (which writes version subdirectories here), so the error is
|
||||
// returned rather than swallowed, matching the package's fail-loud dictionary setup.
|
||||
func resolveSeedVersion(dir, bootVersion string) (string, error) {
|
||||
path := filepath.Join(dir, seedMarkerFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err)
|
||||
}
|
||||
if err == nil {
|
||||
if recorded := strings.TrimSpace(string(data)); recorded != "" {
|
||||
return recorded, nil
|
||||
}
|
||||
// An empty/corrupt marker falls through and is rewritten from bootVersion.
|
||||
}
|
||||
if werr := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); werr != nil {
|
||||
return "", fmt.Errorf("engine: record dictionary seed marker %s: %w", path, werr)
|
||||
}
|
||||
return bootVersion, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package engine
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SetupTile is one tile of a variant's full bag, decoded for the first-move draw
|
||||
// (docs/ARCHITECTURE.md §6): its concrete letter (or the blank marker), a blank
|
||||
// flag, and its draw rank. Lower rank wins the draw — a blank ranks above every
|
||||
// letter, and letters rank by alphabet index, so the tile closest to the start of
|
||||
// the alphabet ("A") wins. It is dictionary-independent, built from the variant's
|
||||
// solver ruleset alone.
|
||||
type SetupTile struct {
|
||||
// Letter is the concrete character (the case the solver ruleset emits), or
|
||||
// the blank marker "?" for a blank.
|
||||
Letter string
|
||||
// Blank reports whether the tile is a blank.
|
||||
Blank bool
|
||||
// Rank orders the draw: BlankRank for a blank (best), else the letter's
|
||||
// alphabet index (0 = closest to "A").
|
||||
Rank int
|
||||
}
|
||||
|
||||
// BlankRank is the first-move draw rank of a blank: below every letter index, so a
|
||||
// blank always beats a lettered tile, matching the official rule that a blank
|
||||
// supersedes all letters.
|
||||
const BlankRank = -1
|
||||
|
||||
// SetupBag returns variant's full tile bag — every lettered tile expanded by its
|
||||
// count, plus one entry per blank — decoded for the first-move seeding draw. The
|
||||
// order is deterministic (alphabet order, blanks last); callers shuffle it with
|
||||
// their own entropy. It needs no dictionary, so it is built from the variant's
|
||||
// ruleset alone and reports ErrUnknownVariant for an unrecognised variant.
|
||||
func SetupBag(v Variant) ([]SetupTile, error) {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
bag := make([]SetupTile, 0, 128)
|
||||
for i, n := range rs.Counts {
|
||||
ch, err := rs.Alphabet.Character(byte(i))
|
||||
if err != nil {
|
||||
// An offered variant's alphabet never yields a bad index; skip defensively.
|
||||
continue
|
||||
}
|
||||
for range n {
|
||||
bag = append(bag, SetupTile{Letter: ch, Rank: i})
|
||||
}
|
||||
}
|
||||
for range rs.Blanks {
|
||||
bag = append(bag, SetupTile{Letter: blankLetter, Blank: true, Rank: BlankRank})
|
||||
}
|
||||
return bag, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetupBagEnglish(t *testing.T) {
|
||||
bag, err := SetupBag(VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("SetupBag: %v", err)
|
||||
}
|
||||
// English Scrabble: 98 lettered tiles + 2 blanks = 100.
|
||||
if len(bag) != 100 {
|
||||
t.Fatalf("bag size = %d, want 100", len(bag))
|
||||
}
|
||||
blanks, aCount := 0, 0
|
||||
for _, tl := range bag {
|
||||
switch {
|
||||
case tl.Blank:
|
||||
blanks++
|
||||
if tl.Rank != BlankRank {
|
||||
t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank)
|
||||
}
|
||||
if tl.Letter != blankLetter {
|
||||
t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter)
|
||||
}
|
||||
case tl.Letter == "a":
|
||||
aCount++
|
||||
if tl.Rank != 0 {
|
||||
t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank)
|
||||
}
|
||||
}
|
||||
}
|
||||
if blanks != 2 {
|
||||
t.Errorf("blanks = %d, want 2", blanks)
|
||||
}
|
||||
if aCount != 9 {
|
||||
t.Errorf("'a' count = %d, want 9", aCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupBagUnknownVariant(t *testing.T) {
|
||||
if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
|
||||
t.Fatalf("err = %v, want ErrUnknownVariant", err)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
|
||||
// validates the body (non-empty, within the rune limit) and the optional
|
||||
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
|
||||
// client IP (validated); channel is the submitting platform.
|
||||
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
|
||||
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, browserTZ, senderIP string) error {
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -112,14 +112,10 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
|
||||
attachmentName = "" // a name without bytes carries no attachment
|
||||
}
|
||||
ch := normalizeChannel(channel)
|
||||
// Snapshot the languages at submit time (acc is already loaded for the guest check):
|
||||
// the sender's interface language, and the connector bot language when the message
|
||||
// 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))
|
||||
// Snapshot the sender's interface language, the client app version and the client's
|
||||
// detected UTC offset at submit time (acc is already loaded for the guest check) so the
|
||||
// operator later sees the state as it was.
|
||||
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, browserTZ, parseIP(senderIP))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,10 @@ func NewStore(db *sql.DB) *Store {
|
||||
|
||||
// Insert stores one feedback message from accountID and returns its id. attachment
|
||||
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
||||
// channel are stored as given. lang (the sender's interface language) and channelLang
|
||||
// (the connector bot language, empty for a non-connector channel) are snapshots taken
|
||||
// now, so the operator later sees the state at submit time. created_at defaults to
|
||||
// now() in the database.
|
||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
|
||||
// channel are stored as given. lang (interface language), version (client app build) and
|
||||
// browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator
|
||||
// later sees the state at submit time. created_at defaults to now() in the database.
|
||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version, browserTZ string, ip *string) (uuid.UUID, error) {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
||||
@@ -49,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO backend.feedback_messages
|
||||
(message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
|
||||
(message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, browser_tz, sender_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
@@ -228,10 +227,16 @@ type AdminMessage struct {
|
||||
Source string
|
||||
Body string
|
||||
Channel string
|
||||
// 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 is the sender's interface language, snapshotted at submit time.
|
||||
Lang string
|
||||
ChannelLang string
|
||||
// Version is the client app build the report was sent from, snapshotted at submit time.
|
||||
Version string
|
||||
// BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the
|
||||
// filed time can be shown in the sender's browser-local zone even before they save a profile.
|
||||
BrowserTZ string
|
||||
// TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for
|
||||
// rendering CreatedAt in the sender's own configured time alongside UTC.
|
||||
TimeZone string
|
||||
SenderIP string
|
||||
HasAttachment bool
|
||||
AttachmentName string
|
||||
@@ -346,7 +351,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
||||
var m AdminMessage
|
||||
var repliedAt sql.NullTime
|
||||
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
||||
COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
|
||||
COALESCE(m.lang, ''), COALESCE(m.app_version, ''), COALESCE(m.browser_tz, ''), a.time_zone,
|
||||
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
|
||||
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
|
||||
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
|
||||
@@ -355,7 +360,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
||||
WHERE m.message_id = $1`
|
||||
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
||||
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
||||
&m.Lang, &m.ChannelLang,
|
||||
&m.Lang, &m.Version, &m.BrowserTZ, &m.TimeZone,
|
||||
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
||||
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -63,6 +63,7 @@ type gameCache struct {
|
||||
|
||||
type cachedGame struct {
|
||||
game *engine.Game
|
||||
seats []Seat
|
||||
variant string
|
||||
lastAccess time.Time
|
||||
}
|
||||
@@ -71,24 +72,27 @@ func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
|
||||
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
|
||||
}
|
||||
|
||||
// get returns the live game for id and refreshes its idle timer, or (nil, false).
|
||||
func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
|
||||
// get returns the live game and its immutable seat list for id and refreshes its idle
|
||||
// timer, or (nil, nil, false). The seats let a read check membership (and label seats)
|
||||
// without re-loading the game from the store, since seats never change after a game starts.
|
||||
func (c *gameCache) get(id uuid.UUID) (*engine.Game, []Seat, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.entries[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
return nil, nil, false
|
||||
}
|
||||
e.lastAccess = c.now()
|
||||
return e.game, true
|
||||
return e.game, e.seats, true
|
||||
}
|
||||
|
||||
// put stores g as the live game for id. variant labels the entry so the active-
|
||||
// games gauge can report counts by variant without inspecting engine internals.
|
||||
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
|
||||
// put stores g as the live game for id together with its seat list. variant labels the
|
||||
// entry so the active-games gauge can report counts by variant without inspecting engine
|
||||
// internals; seats are the game's immutable seat standings for the membership fast path.
|
||||
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string, seats []Seat) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
|
||||
c.entries[id] = &cachedGame{game: g, seats: seats, variant: variant, lastAccess: c.now()}
|
||||
}
|
||||
|
||||
// remove drops id from the cache (used on a finished game and after a failed
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
// word-check tool with complaint capture, per-player game state, history and GCG
|
||||
// export, and the per-game turn-timeout sweeper that auto-resigns an overdue
|
||||
// player (honouring their daily away window). The HTTP surface that fronts these
|
||||
// operations is added with the gateway in a later stage.
|
||||
// operations is exposed to the gateway.
|
||||
package game
|
||||
|
||||
@@ -68,10 +68,6 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
|
||||
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
|
||||
t.Errorf("your_turn score_line = %q, want 13:19", got)
|
||||
}
|
||||
// Routed out-of-app by the game's language (the default Variant is English).
|
||||
if yourTurn.Language != "en" {
|
||||
t.Errorf("your_turn language = %q, want en", yourTurn.Language)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat,
|
||||
@@ -106,7 +102,4 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) {
|
||||
if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" {
|
||||
t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine())
|
||||
}
|
||||
if over[winner].Language != "en" || over[loser].Language != "en" {
|
||||
t.Errorf("game_over languages = %q/%q, want en/en", over[winner].Language, over[loser].Language)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
|
||||
@@ -94,8 +94,8 @@ func TestGameCacheEviction(t *testing.T) {
|
||||
cur := time.Unix(1_700_000_000, 0)
|
||||
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
||||
id := uuid.New()
|
||||
cache.put(id, nil, "scrabble_en")
|
||||
if _, ok := cache.get(id); !ok {
|
||||
cache.put(id, nil, "scrabble_en", nil)
|
||||
if _, _, ok := cache.get(id); !ok {
|
||||
t.Fatal("game must be resident after put")
|
||||
}
|
||||
cur = cur.Add(30 * time.Minute)
|
||||
@@ -104,7 +104,7 @@ func TestGameCacheEviction(t *testing.T) {
|
||||
if n := cache.sweep(); n != 1 {
|
||||
t.Errorf("sweep evicted %d, want 1", n)
|
||||
}
|
||||
if _, ok := cache.get(id); ok {
|
||||
if _, _, ok := cache.get(id); ok {
|
||||
t.Error("game must be evicted after idle TTL")
|
||||
}
|
||||
if cache.size() != 0 {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// ReplayStep is one step of an admin game replay: the move that produced it (nil for the
|
||||
// initial dealt state, step 0) and the resulting position — every seat's rack, the running
|
||||
// scores, whose turn it is and the bag remainder. Step k's board is the union of every
|
||||
// play's placements through step k, which the renderer accumulates onto an empty grid
|
||||
// (docs/ARCHITECTURE.md §9.1 visual replay).
|
||||
type ReplayStep struct {
|
||||
// Move is the journalled move that produced this state, or nil for the initial deal.
|
||||
Move *HistoryMove
|
||||
// Drawn lists the tiles the mover drew from the bag after this move ("?" for a blank);
|
||||
// empty for the initial deal, a pass or a resignation.
|
||||
Drawn []string
|
||||
// Racks holds every seat's rack at this step, indexed by seat ("?" for a blank).
|
||||
Racks [][]string
|
||||
// Scores holds every seat's running score, indexed by seat.
|
||||
Scores []int
|
||||
// ToMove is the seat to move at this step.
|
||||
ToMove int
|
||||
// BagLen is the number of tiles left in the bag at this step.
|
||||
BagLen int
|
||||
}
|
||||
|
||||
// ReplayTimelineView is the admin replay of a game: the persisted game plus the ordered
|
||||
// replay steps (the initial deal followed by one step per journalled move).
|
||||
type ReplayTimelineView struct {
|
||||
Game Game
|
||||
Steps []ReplayStep
|
||||
}
|
||||
|
||||
// ReplayTimeline rebuilds a game from its pinned seed and journal and returns the ordered
|
||||
// replay steps for the admin console: the initial deal (step 0) then one step per
|
||||
// journalled move, each carrying the resulting racks, scores, turn cursor, bag size and the
|
||||
// tiles the mover drew. The deterministic bag makes the reconstruction exact. It needs no
|
||||
// dictionary beyond the engine the seed deals, and — like the live replay — stops early if a
|
||||
// committed move became illegal under tightened rules rather than failing.
|
||||
func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (ReplayTimelineView, error) {
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return ReplayTimelineView{}, err
|
||||
}
|
||||
seed, err := svc.store.GameSeed(ctx, gameID)
|
||||
if err != nil {
|
||||
return ReplayTimelineView{}, err
|
||||
}
|
||||
g, err := engine.New(svc.registry, engine.Options{
|
||||
Variant: pre.Variant,
|
||||
Version: pre.DictVersion,
|
||||
Players: pre.Players,
|
||||
Seed: seed,
|
||||
DropoutTiles: pre.DropoutTiles,
|
||||
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
||||
})
|
||||
if err != nil {
|
||||
return ReplayTimelineView{}, err
|
||||
}
|
||||
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||
if err != nil {
|
||||
return ReplayTimelineView{}, err
|
||||
}
|
||||
steps := make([]ReplayStep, 0, len(moves)+1)
|
||||
steps = append(steps, snapshotStep(g, nil, nil))
|
||||
for i := range moves {
|
||||
mv := moves[i]
|
||||
before := g.Hand(mv.Seat)
|
||||
if err := replayMove(g, mv); err != nil {
|
||||
if errors.Is(err, engine.ErrIllegalPlay) {
|
||||
g.Abort()
|
||||
break
|
||||
}
|
||||
return ReplayTimelineView{}, fmt.Errorf("game: replay-timeline %s move %d: %w", gameID, mv.Seq, err)
|
||||
}
|
||||
moveCopy := mv
|
||||
steps = append(steps, snapshotStep(g, &moveCopy, drawnTiles(before, g.Hand(mv.Seat), usedTiles(mv))))
|
||||
}
|
||||
return ReplayTimelineView{Game: pre, Steps: steps}, nil
|
||||
}
|
||||
|
||||
// snapshotStep captures the position after applying move (nil for the initial deal): every
|
||||
// seat's rack and score, the turn cursor and the bag size, with the supplied drawn tiles.
|
||||
func snapshotStep(g *engine.Game, move *HistoryMove, drawn []string) ReplayStep {
|
||||
n := g.Players()
|
||||
racks := make([][]string, n)
|
||||
scores := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
racks[i] = g.Hand(i)
|
||||
scores[i] = g.Score(i)
|
||||
}
|
||||
return ReplayStep{Move: move, Drawn: drawn, Racks: racks, Scores: scores, ToMove: g.ToMove(), BagLen: g.BagLen()}
|
||||
}
|
||||
|
||||
// usedTiles returns the rack tiles a move consumed ("?" for a blank): the placed tiles of a
|
||||
// play or the swapped tiles of an exchange; a pass or resignation consumes none.
|
||||
func usedTiles(mv HistoryMove) []string {
|
||||
switch mv.Action {
|
||||
case "play":
|
||||
used := make([]string, len(mv.Tiles))
|
||||
for i, t := range mv.Tiles {
|
||||
if t.Blank {
|
||||
used[i] = "?" // a placed blank leaves the rack as the blank marker
|
||||
} else {
|
||||
used[i] = t.Letter
|
||||
}
|
||||
}
|
||||
return used
|
||||
case "exchange":
|
||||
return mv.Exchanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawnTiles returns the tiles the mover drew from the bag: the post-move rack (after) minus
|
||||
// the tiles kept (before minus used). It compares the racks as multisets, so duplicate
|
||||
// letters are counted correctly.
|
||||
func drawnTiles(before, after, used []string) []string {
|
||||
kept := make(map[string]int, len(before))
|
||||
for _, t := range before {
|
||||
kept[t]++
|
||||
}
|
||||
for _, t := range used {
|
||||
if kept[t] > 0 {
|
||||
kept[t]--
|
||||
}
|
||||
}
|
||||
var drawn []string
|
||||
for _, t := range after {
|
||||
if kept[t] > 0 {
|
||||
kept[t]--
|
||||
continue
|
||||
}
|
||||
drawn = append(drawn, t)
|
||||
}
|
||||
return drawn
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
func TestUsedTiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mv HistoryMove
|
||||
want []string
|
||||
}{
|
||||
{"pass", HistoryMove{Action: "pass"}, nil},
|
||||
{"resign", HistoryMove{Action: "resign"}, nil},
|
||||
{"play with blank", HistoryMove{Action: "play", Tiles: []engine.TileRecord{{Letter: "a"}, {Letter: "b", Blank: true}}}, []string{"a", "?"}},
|
||||
{"exchange", HistoryMove{Action: "exchange", Exchanged: []string{"a", "?"}}, []string{"a", "?"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := usedTiles(tt.mv); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("usedTiles = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawnTiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
before, used []string
|
||||
after []string
|
||||
want []string
|
||||
}{
|
||||
{"play refill", []string{"a", "b", "c", "d"}, []string{"a", "b"}, []string{"c", "d", "e", "f"}, []string{"e", "f"}},
|
||||
{"blank played", []string{"?", "a"}, []string{"?"}, []string{"a", "x"}, []string{"x"}},
|
||||
{"pass keeps rack", []string{"a", "b"}, nil, []string{"a", "b"}, nil},
|
||||
{"duplicate letters", []string{"e", "e", "e"}, []string{"e"}, []string{"e", "e", "q"}, []string{"q"}},
|
||||
{"empty bag no refill", []string{"a", "b"}, []string{"a"}, []string{"b"}, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := drawnTiles(tt.before, tt.after, tt.used); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("drawnTiles = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ type Service struct {
|
||||
version string
|
||||
clock func() time.Time
|
||||
rng func() int64
|
||||
// firstMoveEntropy returns the entropy source for one game's first-move draw
|
||||
// (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
|
||||
// is still on a robot's potential turn, so the robot replies at once instead of
|
||||
@@ -67,6 +72,7 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
|
||||
version: cfg.DictVersion,
|
||||
clock: clock,
|
||||
rng: randomSeed,
|
||||
firstMoveEntropy: func() drawIntn { return cryptoIntn },
|
||||
pub: notify.Nop{},
|
||||
metrics: defaultGameMetrics(),
|
||||
log: log,
|
||||
@@ -101,6 +107,18 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
|
||||
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,
|
||||
// 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.
|
||||
@@ -191,15 +209,32 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
}
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(params.Seats))
|
||||
seats := make([]seatInsert, len(params.Seats))
|
||||
for i, id := range params.Seats {
|
||||
for _, id := range params.Seats {
|
||||
if seen[id] {
|
||||
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
|
||||
}
|
||||
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
|
||||
// instead gets a fresh per-game name when the reaper attaches it (AttachRobot).
|
||||
}
|
||||
|
||||
// Decide who moves first by the official draw (docs/ARCHITECTURE.md §6): each seated
|
||||
// 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)
|
||||
if err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
@@ -249,15 +284,15 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
vsAI: params.VsAI,
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, seats); err != nil {
|
||||
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.cache.put(id, g, params.Variant.String())
|
||||
svc.metrics.recordStarted(ctx, params.Variant, params.VsAI)
|
||||
created, err := svc.store.GetGame(ctx, id)
|
||||
if err != nil {
|
||||
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
|
||||
// (the periodic driver is the fallback). No-op for every human-only game.
|
||||
svc.triggerAI(created)
|
||||
@@ -266,14 +301,15 @@ 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
|
||||
// params and returns the game they land in immediately: another waiting player's open
|
||||
// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open
|
||||
// game seating only the caller with an empty opponent seat that a human or the reaper's
|
||||
// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly
|
||||
// opened game (ignored when joining one). The bag seed defaults to random; params.Seed
|
||||
// game (joined=true), or a fresh open game seating only the caller with an empty
|
||||
// opponent seat that a human or the reaper's robot fills later. A re-enqueue while the
|
||||
// caller is already waiting opens another game rather than returning their own.
|
||||
// openDeadline is when the reaper substitutes a robot into a freshly opened game
|
||||
// (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
|
||||
// (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.
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) {
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
@@ -311,14 +347,27 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
}
|
||||
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their
|
||||
// display name; the other seat is left empty (a zero seatInsert) for the opponent.
|
||||
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
|
||||
seats := []seatInsert{caller, {}}
|
||||
if seed&1 == 1 {
|
||||
seats = []seatInsert{{}, caller}
|
||||
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
||||
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
||||
// 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)
|
||||
}
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
|
||||
return Game{}, false, err
|
||||
}
|
||||
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
|
||||
seats := make([]seatInsert, len(seeding.order))
|
||||
for seat, who := range seeding.order {
|
||||
if who == accountID {
|
||||
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)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
@@ -505,16 +554,6 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
|
||||
return svc.store.GetGameVariant(ctx, gameID)
|
||||
}
|
||||
|
||||
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
|
||||
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
|
||||
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
v, err := svc.GameVariant(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v.Language(), nil
|
||||
}
|
||||
|
||||
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
|
||||
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
|
||||
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
|
||||
@@ -546,8 +585,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
return MoveResult{}, ErrNotAPlayer
|
||||
}
|
||||
// A move is allowed while the game is active or still open (the starter may move on
|
||||
// their turn before an opponent joins); only a finished game rejects it. The turn
|
||||
// check below keeps the starter off the still-empty opponent seat.
|
||||
// their turn before an opponent joins; the first-move draw ran when the game opened, so
|
||||
// the seats are already fixed); only a finished game rejects it. The turn check below
|
||||
// keeps the starter off the still-empty opponent seat.
|
||||
if pre.Status == StatusFinished {
|
||||
return MoveResult{}, ErrFinished
|
||||
}
|
||||
@@ -690,9 +730,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
}
|
||||
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 {
|
||||
case StatusActive:
|
||||
// Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn"
|
||||
@@ -707,7 +744,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
}
|
||||
opponent := svc.displayName(ctx, post.Seats, rec.Player)
|
||||
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
|
||||
yourTurn.Language = lang
|
||||
intents = append(intents, yourTurn)
|
||||
}
|
||||
case StatusFinished:
|
||||
@@ -719,7 +755,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
continue
|
||||
}
|
||||
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
|
||||
over.Language = lang
|
||||
intents = append(intents, over)
|
||||
}
|
||||
}
|
||||
@@ -855,26 +890,35 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
|
||||
// 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.
|
||||
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (EvalResult, error) {
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
|
||||
// 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 _, ok := pre.seatOf(accountID); !ok {
|
||||
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 {
|
||||
if g, err = svc.liveGame(ctx, pre); err != nil {
|
||||
return EvalResult{}, err
|
||||
}
|
||||
seats = pre.Seats
|
||||
}
|
||||
if !seatedIn(seats, accountID) {
|
||||
return EvalResult{}, ErrNotAPlayer
|
||||
}
|
||||
|
||||
validateStart := time.Now()
|
||||
rec, err := g.EvaluatePlay(tiles)
|
||||
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
|
||||
svc.metrics.recordValidate(ctx, g.Variant(), validateStart)
|
||||
if err != nil {
|
||||
if errors.Is(err, engine.ErrIllegalPlay) {
|
||||
return EvalResult{Valid: false}, nil
|
||||
@@ -1027,12 +1071,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
}
|
||||
|
||||
walletAfter := acc.HintBalance
|
||||
if fromAllowance {
|
||||
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
used++
|
||||
} else {
|
||||
if !fromAllowance {
|
||||
spent, err := svc.accounts.SpendHint(ctx, accountID)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
@@ -1042,7 +1081,14 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
}
|
||||
walletAfter--
|
||||
}
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
|
||||
// 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
|
||||
}
|
||||
|
||||
// Candidates returns the to-move player's legal plays for a seated player on
|
||||
@@ -1130,6 +1176,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1283,6 +1330,14 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView,
|
||||
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
|
||||
// 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.
|
||||
@@ -1313,7 +1368,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
|
||||
// on a cache miss. Callers must hold the per-game lock.
|
||||
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
|
||||
}
|
||||
g, err := svc.replay(ctx, pre)
|
||||
@@ -1328,7 +1383,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
|
||||
}
|
||||
}
|
||||
if !g.Over() {
|
||||
svc.cache.put(pre.ID, g, pre.Variant.String())
|
||||
svc.cache.put(pre.ID, g, pre.Variant.String(), pre.Seats)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -1419,19 +1474,45 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
|
||||
}
|
||||
|
||||
// buildStats derives each seat's statistics contribution from a finished game:
|
||||
// win/loss/draw from the (resignation-aware) winner, the final score, and the
|
||||
// best single-move score from the log.
|
||||
// win/loss/draw from the (resignation-aware) winner, the final score, and the best
|
||||
// single play from the log — its score and, for the per-variant breakdown, its main
|
||||
// word as rendering tiles. Blank flags are taken from every blank ever placed (so a
|
||||
// blank laid by an earlier move and embedded in the best word is honoured), which is
|
||||
// equivalent to reading the final board since a placed tile never moves.
|
||||
func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||
res := g.Result()
|
||||
best := make(map[int]int)
|
||||
bestRec := make(map[int]engine.MoveRecord)
|
||||
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() {
|
||||
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
|
||||
best[rec.Player] = rec.Score
|
||||
if rec.Action != engine.ActionPlay {
|
||||
continue
|
||||
}
|
||||
plays[rec.Player]++
|
||||
for _, t := range rec.Tiles {
|
||||
if t.Blank {
|
||||
blanks[[2]int{t.Row, t.Col}] = true
|
||||
}
|
||||
}
|
||||
if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score {
|
||||
bestRec[rec.Player] = rec
|
||||
}
|
||||
}
|
||||
variant := g.Variant().String()
|
||||
values := letterValues(g.Variant())
|
||||
out := make([]statDelta, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
|
||||
// moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are
|
||||
// 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 {
|
||||
d.wordPoints = rec.Score
|
||||
if rec.Score > 0 {
|
||||
d.bestVariant = variant
|
||||
d.bestScore = rec.Score
|
||||
d.bestTiles = mainWordTiles(rec, blanks, values)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case res.Winner < 0:
|
||||
d.draws = 1
|
||||
@@ -1445,6 +1526,49 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||
return out
|
||||
}
|
||||
|
||||
// letterValues builds a lower-cased letter -> tile value lookup for a variant from the
|
||||
// engine's alphabet table, so a best-move word can be rendered with per-tile values on a
|
||||
// screen (statistics) that has not cached the variant's alphabet. It is empty for an
|
||||
// unrecognised variant, leaving every value zero.
|
||||
func letterValues(v engine.Variant) map[string]int {
|
||||
table, err := engine.AlphabetTable(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]int, len(table))
|
||||
for _, e := range table {
|
||||
m[strings.ToLower(e.Letter)] = e.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// mainWordTiles decodes a play's main word into rendering tiles: each letter with its
|
||||
// tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a
|
||||
// blank was ever placed on; values maps a lower-cased letter to its tile value. It walks
|
||||
// the word from its first-letter coordinate along the play's orientation.
|
||||
func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile {
|
||||
if len(rec.Words) == 0 {
|
||||
return nil
|
||||
}
|
||||
dr, dc := 0, 1
|
||||
if rec.Dir == engine.Vertical {
|
||||
dr, dc = 1, 0
|
||||
}
|
||||
letters := []rune(rec.Words[0])
|
||||
out := make([]account.BestMoveTile, len(letters))
|
||||
for i, r := range letters {
|
||||
row, col := rec.MainRow+i*dr, rec.MainCol+i*dc
|
||||
blank := blanks[[2]int{row, col}]
|
||||
letter := string(r)
|
||||
value := 0
|
||||
if !blank {
|
||||
value = values[strings.ToLower(letter)]
|
||||
}
|
||||
out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nonGuestSeats filters out guest seats so the finish-time statistics are
|
||||
// recomputed for durable non-guest accounts only — guests never accrue
|
||||
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// TestMainWordTilesHorizontalWithBlank checks that a horizontal main word is decoded into
|
||||
// tiles along its row, that the per-letter value is looked up from the value table, and that
|
||||
// a blank cell (here the middle 'a') is rendered with a zero value regardless of its letter
|
||||
// value — the blank flag is taken from the placed-blank set, not from this move's own tiles.
|
||||
func TestMainWordTilesHorizontalWithBlank(t *testing.T) {
|
||||
rec := engine.MoveRecord{Dir: engine.Horizontal, MainRow: 7, MainCol: 5, Words: []string{"cat"}}
|
||||
blanks := map[[2]int]bool{{7, 6}: true}
|
||||
values := map[string]int{"c": 3, "a": 1, "t": 1}
|
||||
got := mainWordTiles(rec, blanks, values)
|
||||
want := []account.BestMoveTile{
|
||||
{Letter: "c", Value: 3, Blank: false},
|
||||
{Letter: "a", Value: 0, Blank: true},
|
||||
{Letter: "t", Value: 1, Blank: false},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainWordTilesVerticalCyrillic checks vertical walk and multi-byte (Cyrillic) letters:
|
||||
// the word must be split by rune, not byte, and laid down its column.
|
||||
func TestMainWordTilesVerticalCyrillic(t *testing.T) {
|
||||
rec := engine.MoveRecord{Dir: engine.Vertical, MainRow: 3, MainCol: 8, Words: []string{"съёмка"}}
|
||||
values := map[string]int{"с": 1, "ъ": 10, "ё": 4, "м": 2, "к": 2, "а": 1}
|
||||
got := mainWordTiles(rec, map[[2]int]bool{}, values)
|
||||
want := []account.BestMoveTile{
|
||||
{Letter: "с", Value: 1}, {Letter: "ъ", Value: 10}, {Letter: "ё", Value: 4},
|
||||
{Letter: "м", Value: 2}, {Letter: "к", Value: 2}, {Letter: "а", Value: 1},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mainWordTiles = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainWordTilesNoWord checks the defensive nil for a record carrying no words.
|
||||
func TestMainWordTilesNoWord(t *testing.T) {
|
||||
if got := mainWordTiles(engine.MoveRecord{}, nil, nil); got != nil {
|
||||
t.Errorf("mainWordTiles(no words) = %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
+261
-58
@@ -3,6 +3,7 @@ package game
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
@@ -53,6 +55,10 @@ type gameInsert struct {
|
||||
}
|
||||
|
||||
// statDelta is one account's contribution to its statistics on a game finish.
|
||||
// bestVariant/bestScore/bestTiles describe the game's best play for this account when
|
||||
// it scored (bestVariant empty otherwise): the variant label, the play's total score and
|
||||
// its main word as rendering tiles. They feed the per-variant account_best_move upsert,
|
||||
// which keeps only the account's highest-scoring play per variant.
|
||||
type statDelta struct {
|
||||
accountID uuid.UUID
|
||||
wins int
|
||||
@@ -60,6 +66,11 @@ type statDelta struct {
|
||||
draws int
|
||||
gamePoints 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
|
||||
bestScore int
|
||||
bestTiles []account.BestMoveTile
|
||||
}
|
||||
|
||||
// commit is everything a single committed transition persists: the journal row,
|
||||
@@ -116,11 +127,14 @@ type seatInsert struct {
|
||||
displayName string
|
||||
}
|
||||
|
||||
// CreateGame inserts the games row and one game_players row per seat (seat 0
|
||||
// first) inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error {
|
||||
// CreateGame inserts the games row, one game_players row per seat (seat 0 first) and
|
||||
// the first-move seeding draws inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
return insertGameTx(ctx, tx, ins, seats)
|
||||
if err := insertGameTx(ctx, tx, ins, seats); err != nil {
|
||||
return err
|
||||
}
|
||||
return insertSetupDrawsTx(ctx, tx, ins.id, draws)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,6 +176,30 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
||||
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
|
||||
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
|
||||
// players never both open a game instead of pairing.
|
||||
@@ -177,48 +215,42 @@ func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
}
|
||||
|
||||
// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
|
||||
// lands in: it re-uses the caller's own still-open game (joined=false, created=false,
|
||||
// a re-enqueue is idempotent), joins another player's waiting open game and flips it
|
||||
// active (joined=true), or opens a fresh game seating the caller with an empty
|
||||
// opponent seat (created=true). ins supplies the new game's immutable fields and is
|
||||
// used only when a game is created. A transaction-scoped advisory lock on the
|
||||
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
|
||||
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
|
||||
// for the still-empty opponent, in the chosen order) used only when a game is created;
|
||||
// callerName is the caller's display-name snapshot, stamped on their seat whether they
|
||||
// open a fresh game or fill another player's open one.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
// lands in: it joins another player's waiting open game and flips it active
|
||||
// (joined=true), or opens a fresh game seating the caller with an empty opponent seat
|
||||
// (created=true). A re-enqueue while the caller already has an open game in the bucket
|
||||
// opens another fresh game (or joins a different player's) rather than returning the
|
||||
// caller's own, so tapping "random opponent" again always starts a new search. ins
|
||||
// supplies the new game's immutable fields and is used only when a game is created. A
|
||||
// transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent
|
||||
// enqueues so two callers pair rather than each opening a game. seats is the two-seat
|
||||
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen
|
||||
// order) used only when a game is created; callerName is the caller's display-name
|
||||
// snapshot, stamped on their seat whether they open a fresh game or fill another
|
||||
// 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 {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
return fmt.Errorf("open match lock: %w", e)
|
||||
}
|
||||
// 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
|
||||
var own uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`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.
|
||||
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
|
||||
// A game whose waiting player is in exclude (the caller's per-user block set, either
|
||||
// direction) is skipped, so a block keeps the pair out of the same anonymous game;
|
||||
// an empty exclude (the "{}" literal) excludes nothing.
|
||||
var other uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
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
|
||||
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
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
||||
if er := fillAndActivate(ctx, tx, other, accountID, callerName); er != nil {
|
||||
return er
|
||||
}
|
||||
gameID, joined = other, true
|
||||
@@ -226,10 +258,15 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
|
||||
case !errors.Is(e, sql.ErrNoRows):
|
||||
return fmt.Errorf("find open game: %w", e)
|
||||
}
|
||||
// 3. None waiting — open a fresh game seating the caller (the other seat empty).
|
||||
// 2. None waiting — open a fresh game seating the caller (the other seat empty) and
|
||||
// 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 {
|
||||
return e
|
||||
}
|
||||
if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); e != nil {
|
||||
return e
|
||||
}
|
||||
gameID, created = ins.id, true
|
||||
return nil
|
||||
})
|
||||
@@ -254,7 +291,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
|
||||
if status != StatusOpen {
|
||||
return nil
|
||||
}
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil {
|
||||
if e := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil {
|
||||
return e
|
||||
}
|
||||
attached = true
|
||||
@@ -263,15 +300,23 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
|
||||
return attached, err
|
||||
}
|
||||
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat — stamping
|
||||
// displayName as the seat's display-name snapshot — and flips the game to active with a
|
||||
// fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
|
||||
// fillAndActivate 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
|
||||
// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to
|
||||
// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at
|
||||
// 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,
|
||||
`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 {
|
||||
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,
|
||||
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
|
||||
WHERE game_id = $1`, gameID); err != nil {
|
||||
@@ -310,27 +355,33 @@ 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
|
||||
// ErrNotFound.
|
||||
func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
|
||||
gstmt := postgres.SELECT(table.Games.AllColumns).
|
||||
FROM(table.Games).
|
||||
// One round-trip: the game joined with its seats. A LEFT JOIN keeps a (would-be)
|
||||
// seatless game returning the game with no seats, exactly as the prior two-query
|
||||
// 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))).
|
||||
LIMIT(1)
|
||||
var grow model.Games
|
||||
if err := gstmt.QueryContext(ctx, s.db, &grow); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Game{}, ErrNotFound
|
||||
ORDER_BY(table.GamePlayers.Seat.ASC())
|
||||
var rows []struct {
|
||||
model.Games
|
||||
model.GamePlayers
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return Game{}, fmt.Errorf("game: get %s: %w", id, err)
|
||||
}
|
||||
|
||||
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)
|
||||
if len(rows) == 0 {
|
||||
return Game{}, ErrNotFound
|
||||
}
|
||||
return projectGame(grow, srows)
|
||||
seats := make([]model.GamePlayers, 0, len(rows))
|
||||
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
|
||||
@@ -583,6 +634,37 @@ func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, er
|
||||
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
|
||||
// cursor and per-seat scores, plus the finish stamp and statistics when the move
|
||||
// ended the game — in one transaction.
|
||||
@@ -633,6 +715,9 @@ func (s *Store) CommitMove(ctx context.Context, c commit) error {
|
||||
if err := upsertStats(ctx, tx, d, c.now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertBestMove(ctx, tx, d, c.now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -662,6 +747,9 @@ func (s *Store) VoidGame(ctx context.Context, v voidCommit) error {
|
||||
if err := upsertStats(ctx, tx, d, v.now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertBestMove(ctx, tx, d, v.now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -714,13 +802,17 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
|
||||
draws := row.Draws + int32(d.draws)
|
||||
maxGame := max(row.MaxGamePoints, int32(d.gamePoints))
|
||||
maxWord := max(row.MaxWordPoints, int32(d.wordPoints))
|
||||
moves := row.Moves + int32(d.moves)
|
||||
hintsUsed := row.HintsUsed + int32(d.hintsUsed)
|
||||
|
||||
upd := table.AccountStats.UPDATE(
|
||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
||||
table.AccountStats.Moves, table.AccountStats.HintsUsed,
|
||||
).SET(
|
||||
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(moves)), postgres.Int(int64(hintsUsed)),
|
||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(d.accountID)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("update stats %s: %w", d.accountID, err)
|
||||
@@ -728,8 +820,41 @@ func upsertStats(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpendHintAllowance increments a seat's per-game hint counter by one.
|
||||
func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat int) error {
|
||||
// upsertBestMove records the account's best play for a variant, keeping only the
|
||||
// highest-scoring one: a first play inserts, a later one replaces it only when it scored
|
||||
// strictly higher (the conditional DO UPDATE makes the upsert atomic under concurrent
|
||||
// finishes without a separate lock). It is a no-op when the finish carries no scoring play
|
||||
// for the account (a draw with no plays, or an exchange/pass-only game).
|
||||
func upsertBestMove(ctx context.Context, tx *sql.Tx, d statDelta, now time.Time) error {
|
||||
if d.bestVariant == "" || len(d.bestTiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
tiles, err := json.Marshal(d.bestTiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal best move %s/%s: %w", d.accountID, d.bestVariant, err)
|
||||
}
|
||||
stmt := table.AccountBestMove.
|
||||
INSERT(
|
||||
table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
|
||||
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt,
|
||||
).
|
||||
VALUES(d.accountID, d.bestVariant, d.bestScore, string(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 := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("upsert best move %s/%s: %w", d.accountID, d.bestVariant, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncHintsUsed increments a seat's per-game hints-used counter by one. It is called for
|
||||
// every hint — both the free per-game allowance and the wallet-charged ones — so the counter
|
||||
// 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.
|
||||
UPDATE(table.GamePlayers.HintsUsed).
|
||||
SET(table.GamePlayers.HintsUsed.ADD(postgres.Int(1))).
|
||||
@@ -738,7 +863,7 @@ func (s *Store) SpendHintAllowance(ctx context.Context, gameID uuid.UUID, seat i
|
||||
AND(table.GamePlayers.Seat.EQ(postgres.Int(int64(seat)))),
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("game: spend hint allowance: %w", err)
|
||||
return fmt.Errorf("game: increment hints used: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -973,13 +1098,18 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
|
||||
for _, r := range rows {
|
||||
out = append(out, robotTurnFrom(r.Games, r.GamePlayers))
|
||||
}
|
||||
if err := s.fillEndgamePass(ctx, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
// drives one game at once rather than scanning the whole pool (RobotTurns). It leaves
|
||||
// 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) {
|
||||
if len(ids) == 0 {
|
||||
return RobotTurn{}, false, nil
|
||||
@@ -1032,6 +1162,79 @@ 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
|
||||
// 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.
|
||||
|
||||
@@ -105,7 +105,7 @@ const MaxActiveQuickGames = 10
|
||||
const aiPlayerName = "AI"
|
||||
|
||||
// CreateParams describes a new game. Seats lists the seated accounts in turn
|
||||
// order (seat 0 moves first); lobby/matchmaking assembles it in a later stage.
|
||||
// order (seat 0 moves first); lobby/matchmaking assembles it.
|
||||
type CreateParams struct {
|
||||
Variant engine.Variant
|
||||
Seats []uuid.UUID
|
||||
@@ -154,6 +154,8 @@ type Seat struct {
|
||||
Seat int
|
||||
AccountID uuid.UUID
|
||||
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
|
||||
IsWinner bool
|
||||
// DisplayName is the seat's display-name snapshot, captured when the seat was taken
|
||||
@@ -182,6 +184,18 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
|
||||
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
|
||||
// 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
|
||||
@@ -194,10 +208,13 @@ type MoveResult struct {
|
||||
}
|
||||
|
||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
||||
// budget (per-seat allowance plus profile wallet) after spending one.
|
||||
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
|
||||
// the global wallet alone, so the client can keep its live wallet authoritative and
|
||||
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
|
||||
type HintResult struct {
|
||||
Move engine.MoveRecord
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
}
|
||||
|
||||
// EvalResult previews a tentative play without committing it. Dir is the
|
||||
@@ -220,6 +237,9 @@ type StateView struct {
|
||||
Rack []string
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||
WalletBalance int
|
||||
}
|
||||
|
||||
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||
@@ -260,6 +280,15 @@ type RobotTurn struct {
|
||||
// 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.
|
||||
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
|
||||
|
||||
@@ -89,7 +89,8 @@ func TestGetStatsZeroForFreshAccount(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
if (st != account.Stats{}) {
|
||||
zero := st.Wins == 0 && st.Losses == 0 && st.Draws == 0 && st.MaxGamePoints == 0 && st.MaxWordPoints == 0
|
||||
if !zero || len(st.BestMoves) != 0 {
|
||||
t.Fatalf("fresh stats = %+v, want zero", st)
|
||||
}
|
||||
}
|
||||
@@ -109,38 +110,92 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
}
|
||||
|
||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||
// seeds the new account's language and display name from the launch fields,
|
||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||
// later login (language seeding).
|
||||
// seeds the new account's language, display name and time zone from the launch
|
||||
// fields / detected offset, defaults the in-app-only flag on, and never overwrites
|
||||
// an existing account on a later login (language and zone seeding).
|
||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "tg-" + uuid.NewString()
|
||||
|
||||
acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
|
||||
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван", "+03:00")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Error("created = false on first contact, want true")
|
||||
}
|
||||
if acc.PreferredLanguage != "ru" {
|
||||
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||
}
|
||||
if acc.DisplayName != "Иван" {
|
||||
t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
|
||||
}
|
||||
if acc.TimeZone != "+03:00" {
|
||||
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
|
||||
}
|
||||
if !acc.NotificationsInAppOnly {
|
||||
t.Error("NotificationsInAppOnly should default to true")
|
||||
}
|
||||
|
||||
// A later login with different fields returns the same account, unchanged.
|
||||
again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
|
||||
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other", "+09:00")
|
||||
if err != nil {
|
||||
t.Fatalf("re-provision telegram: %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("created = true on a repeat login, want false")
|
||||
}
|
||||
if again.ID != acc.ID {
|
||||
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||
}
|
||||
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
|
||||
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
|
||||
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" {
|
||||
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
||||
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
||||
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
|
||||
// missing or malformed offset falls back to the "UTC" column default rather than
|
||||
// being guessed at.
|
||||
func TestProvisionSeedsTimeZone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
|
||||
// A detected zero offset is written as "+00:00" — we record that the zone was
|
||||
// detected (and equals UTC), distinct from the "UTC" default meaning "unknown".
|
||||
utcDetected, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Zero", "+00:00")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram +00:00: %v", err)
|
||||
}
|
||||
if utcDetected.TimeZone != "+00:00" {
|
||||
t.Errorf("TimeZone = %q, want the seeded +00:00", utcDetected.TimeZone)
|
||||
}
|
||||
|
||||
// A malformed offset is dropped: the account keeps the UTC default.
|
||||
bad, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Bad", "not-a-zone")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram bad tz: %v", err)
|
||||
}
|
||||
if bad.TimeZone != "UTC" {
|
||||
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
|
||||
}
|
||||
|
||||
// A guest is seeded its detected offset; an empty one keeps the UTC default.
|
||||
guest, err := store.ProvisionGuest(ctx, "-05:30")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
if guest.TimeZone != "-05:30" {
|
||||
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
|
||||
}
|
||||
plainGuest, err := store.ProvisionGuest(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision plain guest: %v", err)
|
||||
}
|
||||
if plainGuest.TimeZone != "UTC" {
|
||||
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +204,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
// language CHECK.
|
||||
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
@@ -158,46 +213,6 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceLanguageRoundTrip checks SetServiceLanguage persists the push-routing
|
||||
// language (the bot a Telegram user last signed in through): a fresh account has
|
||||
// none, a set value reads back, a later login overwrites it (last-login-wins), and
|
||||
// an empty value is a no-op. The push-target route coalesces it with the preferred
|
||||
// language.
|
||||
func TestServiceLanguageRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.ServiceLanguage != "" {
|
||||
t.Errorf("fresh ServiceLanguage = %q, want empty", acc.ServiceLanguage)
|
||||
}
|
||||
|
||||
if err := store.SetServiceLanguage(ctx, acc.ID, "ru"); err != nil {
|
||||
t.Fatalf("set service language: %v", err)
|
||||
}
|
||||
if got, err := store.GetByID(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
} else if got.ServiceLanguage != "ru" {
|
||||
t.Errorf("ServiceLanguage = %q, want ru", got.ServiceLanguage)
|
||||
}
|
||||
|
||||
// A later login through the other bot updates it; a subsequent empty value
|
||||
// (a non-Telegram login) leaves it unchanged.
|
||||
if err := store.SetServiceLanguage(ctx, acc.ID, "en"); err != nil {
|
||||
t.Fatalf("update service language: %v", err)
|
||||
}
|
||||
if err := store.SetServiceLanguage(ctx, acc.ID, ""); err != nil {
|
||||
t.Fatalf("noop service language: %v", err)
|
||||
}
|
||||
if got, err := store.GetByID(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
} else if got.ServiceLanguage != "en" {
|
||||
t.Errorf("ServiceLanguage after update+noop = %q, want en", got.ServiceLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
|
||||
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
|
||||
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
|
||||
@@ -205,7 +220,7 @@ func TestServiceLanguageRoundTrip(t *testing.T) {
|
||||
func TestHighRateFlagRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
@@ -261,7 +276,7 @@ func TestIdentityExternalID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
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 {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
@@ -286,7 +301,7 @@ func TestIdentityExternalID(t *testing.T) {
|
||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
@@ -298,6 +313,7 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
NotificationsInAppOnly: false,
|
||||
VariantPreferences: []string{"erudit_ru"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update profile: %v", err)
|
||||
|
||||
@@ -206,6 +206,12 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
if !strings.Contains(body, "~40%") {
|
||||
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
|
||||
@@ -216,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
|
||||
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
|
||||
@@ -256,7 +256,8 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
id := provisionAccount(t)
|
||||
|
||||
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()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body))
|
||||
req.Header.Set("X-User-ID", id.String())
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
)
|
||||
|
||||
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
|
||||
// sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit.
|
||||
// sender), surfaces through HasUnread / UnreadGames and the message-level HasUnreadMessage /
|
||||
// UnreadMessageGames (it is a real message, not a nudge), and that MarkRead clears the reader's bit.
|
||||
func TestChatUnreadAndMarkRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -37,6 +38,15 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
|
||||
} else if !set[gameID] {
|
||||
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.
|
||||
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
|
||||
@@ -55,7 +65,8 @@ func TestChatUnreadAndMarkRead(t *testing.T) {
|
||||
|
||||
// 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
|
||||
// targeting invariant, now observable through the unread bitmask.
|
||||
// targeting invariant, now observable through the unread bitmask. It also checks a nudge raises
|
||||
// HasUnread but not the message-level flags, so the badge can colour it apart from a real message.
|
||||
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
@@ -68,6 +79,13 @@ func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !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 {
|
||||
t.Error("a non-awaited waiting seat must not receive the nudge")
|
||||
}
|
||||
|
||||
@@ -134,11 +134,13 @@ func TestDictionaryUpdateFlow(t *testing.T) {
|
||||
// newGameServiceOn builds a game service over the shared pool but a caller-supplied
|
||||
// dictionary directory, version and registry, for the isolated dictionary tests.
|
||||
func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service {
|
||||
return game.NewService(
|
||||
svc := game.NewService(
|
||||
game.NewStore(testDB), account.NewStore(testDB), reg,
|
||||
game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour},
|
||||
zap.NewNop(),
|
||||
)
|
||||
svc.SetFirstMoveEntropy(seatZeroFirstMove)
|
||||
return svc
|
||||
}
|
||||
|
||||
// seedDawgs copies the committed DAWG of every variant from src into dst (flat).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -61,7 +62,7 @@ func TestEmailConfirmFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a
|
||||
// different account (merge is a later stage).
|
||||
// different account (combining two accounts is the separate link/merge flow).
|
||||
func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -134,12 +135,20 @@ func TestUpdateProfilePersists(t *testing.T) {
|
||||
store := account.NewStore(testDB)
|
||||
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{
|
||||
DisplayName: "Kaya",
|
||||
PreferredLanguage: "ru",
|
||||
TimeZone: "Europe/Moscow",
|
||||
BlockChat: true,
|
||||
BlockFriendRequests: true,
|
||||
VariantPreferences: []string{"scrabble_en", "erudit_ru"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update profile: %v", err)
|
||||
@@ -157,6 +166,10 @@ func TestUpdateProfilePersists(t *testing.T) {
|
||||
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
|
||||
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
|
||||
@@ -170,6 +183,7 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
|
||||
DisplayName: "Kaya",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "+03:00",
|
||||
VariantPreferences: []string{"erudit_ru"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update with offset timezone: %v", err)
|
||||
@@ -192,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
||||
svc := account.NewEmailService(account.NewStore(testDB), mailer)
|
||||
email := "login-" + uuid.NewString() + "@example.com"
|
||||
|
||||
accountID, err := svc.RequestLoginCode(ctx, email)
|
||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
|
||||
if err != nil {
|
||||
t.Fatalf("request login code: %v", err)
|
||||
}
|
||||
@@ -211,12 +225,15 @@ func TestEmailLoginFlow(t *testing.T) {
|
||||
if acc.IsGuest {
|
||||
t.Error("an email account must be durable, not a guest")
|
||||
}
|
||||
if acc.TimeZone != "+02:00" {
|
||||
t.Errorf("TimeZone = %q, want the +02:00 seeded at the request step", acc.TimeZone)
|
||||
}
|
||||
if !identityConfirmed(t, account.KindEmail, email) {
|
||||
t.Error("the email identity must be confirmed after login")
|
||||
}
|
||||
|
||||
// A second login for the same email is the returning user: same account.
|
||||
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
|
||||
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||
|
||||
@@ -38,7 +38,7 @@ func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.U
|
||||
func TestFeedbackGuestRejected(t *testing.T) {
|
||||
svc := newFeedbackService()
|
||||
guest := provisionGuest(t)
|
||||
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
|
||||
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "v1", "+05:00", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
|
||||
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
|
||||
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil {
|
||||
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "v1.2.0", "+03:00", "9.9.9.9"); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
// Anti-spam gate: a second message is refused while the first is unreviewed.
|
||||
if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) {
|
||||
if err := svc.Submit(ctx, acc, "again", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrPendingReview) {
|
||||
t.Fatalf("second submit err = %v, want ErrPendingReview", err)
|
||||
}
|
||||
if st, err := svc.State(ctx, acc); err != nil {
|
||||
@@ -69,7 +69,7 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
|
||||
if m.Body != "please fix the board" { // trimmed
|
||||
t.Fatalf("body = %q, want trimmed", m.Body)
|
||||
}
|
||||
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" {
|
||||
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" || m.Version != "v1.2.0" || m.BrowserTZ != "+03:00" {
|
||||
t.Fatalf("admin message = %+v", m)
|
||||
}
|
||||
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
|
||||
@@ -116,7 +116,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
|
||||
acc := provisionAccount(t)
|
||||
|
||||
// msg1, replied → the player can send again and currently sees the reply.
|
||||
if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil {
|
||||
if err := svc.Submit(ctx, acc, "first", nil, "", "web", "", "", ""); err != nil {
|
||||
t.Fatalf("submit msg1: %v", err)
|
||||
}
|
||||
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
|
||||
@@ -130,7 +130,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
|
||||
|
||||
// Sending a new message immediately drops the previous reply (it now belongs to an
|
||||
// older message), even though it is well within the one-week window.
|
||||
if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil {
|
||||
if err := svc.Submit(ctx, acc, "second", nil, "", "web", "", "", ""); err != nil {
|
||||
t.Fatalf("submit msg2: %v", err)
|
||||
}
|
||||
st, err := svc.State(ctx, acc)
|
||||
@@ -145,48 +145,33 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackSnapshotsLanguages(t *testing.T) {
|
||||
func TestFeedbackSnapshotsLanguage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
|
||||
t.Fatalf("set languages: %v", err)
|
||||
`UPDATE backend.accounts SET preferred_language = 'en' WHERE account_id = $1`, acc); err != nil {
|
||||
t.Fatalf("set language: %v", err)
|
||||
}
|
||||
// A Telegram (connector) message snapshots both the interface language and the bot.
|
||||
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
|
||||
// A message snapshots the sender's interface language at submit time.
|
||||
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", "", "", ""); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
id := latestFeedbackID(t, svc, acc)
|
||||
if m, err := svc.AdminGet(ctx, id); err != nil {
|
||||
t.Fatalf("admin get: %v", err)
|
||||
} else if m.Lang != "en" || m.ChannelLang != "ru" {
|
||||
t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
|
||||
} else if m.Lang != "en" {
|
||||
t.Fatalf("snapshot = lang %q, want en", m.Lang)
|
||||
}
|
||||
// Changing the account afterwards must not change the stored snapshot.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
|
||||
t.Fatalf("change languages: %v", err)
|
||||
`UPDATE backend.accounts SET preferred_language = 'ru' WHERE account_id = $1`, acc); err != nil {
|
||||
t.Fatalf("change language: %v", err)
|
||||
}
|
||||
if m, err := svc.AdminGet(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if m.Lang != "en" || m.ChannelLang != "ru" {
|
||||
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)
|
||||
} else if m.Lang != "en" {
|
||||
t.Fatalf("snapshot drifted after account change = lang %q, want en", m.Lang)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) {
|
||||
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
||||
t.Fatalf("grant role: %v", err)
|
||||
}
|
||||
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) {
|
||||
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrBanned) {
|
||||
t.Fatalf("banned submit err = %v, want ErrBanned", err)
|
||||
}
|
||||
if st, err := svc.State(ctx, acc); err != nil {
|
||||
@@ -211,7 +196,7 @@ func TestFeedbackBanRole(t *testing.T) {
|
||||
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil {
|
||||
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", "", "", ""); err != nil {
|
||||
t.Fatalf("submit after unban: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
acc := provisionAccount(t) // fresh account so the pending gate never fires first
|
||||
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) {
|
||||
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", "", "", ""); !errors.Is(err, tt.want) {
|
||||
t.Fatalf("submit err = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -246,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) {
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
|
||||
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil {
|
||||
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", "", "", ""); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
id := latestFeedbackID(t, svc, acc)
|
||||
@@ -291,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
|
||||
svc := newFeedbackService()
|
||||
acc := provisionAccount(t)
|
||||
|
||||
if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil {
|
||||
if err := svc.Submit(ctx, acc, "one", nil, "", "web", "", "", ""); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
|
||||
@@ -301,7 +286,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
|
||||
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
|
||||
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
|
||||
}
|
||||
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil {
|
||||
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", "", "", ""); err != nil {
|
||||
t.Fatalf("submit after delete-all: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
|
||||
// An open (awaiting-opponent) quick game counts.
|
||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
||||
}, time.Now().Add(time.Minute)); err != nil {
|
||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
||||
t.Fatalf("open quick game: %v", err)
|
||||
}
|
||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||
@@ -97,10 +97,12 @@ func TestGameLimitGate(t *testing.T) {
|
||||
if !gamesListAtLimit(t, srv, human) {
|
||||
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
|
||||
}
|
||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"scrabble_en"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||
// erudit_ru is in the default variant preferences, so the variant gate passes and the
|
||||
// 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))
|
||||
}
|
||||
invBody := fmt.Sprintf(`{"variant":"scrabble_en","invitee_ids":[%q]}`, opp.String())
|
||||
invBody := fmt.Sprintf(`{"variant":"erudit_ru","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" {
|
||||
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
@@ -120,8 +121,8 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
||||
t.Fatalf("final game not finished: %+v", last.Game)
|
||||
}
|
||||
|
||||
w0, l0, d0, mg0, _, ok0 := readStats(t, seats[0])
|
||||
w1, l1, d1, mg1, _, ok1 := readStats(t, seats[1])
|
||||
w0, l0, d0, mg0, mw0, ok0 := readStats(t, seats[0])
|
||||
w1, l1, d1, mg1, mw1, ok1 := readStats(t, seats[1])
|
||||
if !ok0 || !ok1 {
|
||||
t.Fatal("both players must have a stats row")
|
||||
}
|
||||
@@ -133,6 +134,52 @@ func TestGameLifecycleAndStats(t *testing.T) {
|
||||
if !decisive && !draw {
|
||||
t.Errorf("inconsistent W/L/D: p0(%d/%d/%d) p1(%d/%d/%d)", w0, l0, d0, w1, l1, d1)
|
||||
}
|
||||
|
||||
// Each player who made a scoring play gets exactly one per-variant best move (only
|
||||
// scrabble_en was played here); its score equals the aggregate max_word_points and it
|
||||
// carries the decoded word as tiles.
|
||||
accounts := account.NewStore(testDB)
|
||||
for _, p := range []struct {
|
||||
id uuid.UUID
|
||||
maxWord int
|
||||
}{{seats[0], mw0}, {seats[1], mw1}} {
|
||||
if p.maxWord == 0 {
|
||||
continue // a player who only passed has no best move
|
||||
}
|
||||
st, err := accounts.GetStats(ctx, p.id)
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("want one best move (only scrabble_en played), got %d: %+v", len(st.BestMoves), st.BestMoves)
|
||||
}
|
||||
bm := st.BestMoves[0]
|
||||
if bm.Variant != engine.VariantEnglish.String() {
|
||||
t.Errorf("best move variant = %q, want %q", bm.Variant, engine.VariantEnglish.String())
|
||||
}
|
||||
if bm.Score != p.maxWord {
|
||||
t.Errorf("best move score %d != max_word_points %d", bm.Score, p.maxWord)
|
||||
}
|
||||
if len(bm.Tiles) == 0 {
|
||||
t.Error("best move word is empty")
|
||||
}
|
||||
for _, tl := range bm.Tiles {
|
||||
if tl.Letter == "" {
|
||||
t.Error("best move tile has empty letter")
|
||||
}
|
||||
if tl.Blank && tl.Value != 0 {
|
||||
t.Errorf("blank tile must score 0, got %d", tl.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplayEquivalence plays a few moves through one service, then proves a
|
||||
@@ -369,6 +416,15 @@ func TestHintPolicy(t *testing.T) {
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); err != nil { // spends the allowance
|
||||
t.Fatalf("first hint: %v", err)
|
||||
}
|
||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
||||
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
|
||||
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||
if err != nil {
|
||||
t.Fatalf("state: %v", err)
|
||||
}
|
||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
||||
}
|
||||
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||
}
|
||||
@@ -377,8 +433,18 @@ func TestHintPolicy(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("wallet hint: %v", err)
|
||||
}
|
||||
if res.HintsRemaining != 1 {
|
||||
t.Errorf("hints remaining = %d, want 1", res.HintsRemaining)
|
||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
||||
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{
|
||||
@@ -477,6 +543,12 @@ func TestEvaluatePlayPreview(t *testing.T) {
|
||||
if bad.Valid {
|
||||
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
|
||||
|
||||
@@ -26,9 +26,10 @@ import (
|
||||
// assembly, and the stats reader. Helpers used by a single test file stay in
|
||||
// that file; everything reused across files lives here.
|
||||
|
||||
// newGameService builds a game service over the shared pool and registry.
|
||||
// newGameService builds a game service over the shared pool and registry, with a
|
||||
// deterministic first-move draw so the suite keeps a stable turn order.
|
||||
func newGameService() *game.Service {
|
||||
return game.NewService(
|
||||
svc := game.NewService(
|
||||
game.NewStore(testDB),
|
||||
account.NewStore(testDB),
|
||||
testRegistry,
|
||||
@@ -40,6 +41,39 @@ func newGameService() *game.Service {
|
||||
},
|
||||
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
|
||||
@@ -86,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
|
||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||
func provisionGuest(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
|
||||
@@ -74,9 +74,11 @@ func TestMatchmakingOpensThenJoins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the
|
||||
// caller gets their existing open game rather than a second one.
|
||||
func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
|
||||
// TestMatchmakingReEnqueueOpensNewGame checks a re-enqueue opens a fresh open game
|
||||
// rather than returning the caller's existing one: tapping "random opponent" again while
|
||||
// still waiting starts a new search instead of bouncing the player back into the pending
|
||||
// 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()
|
||||
clearOpenGames(t)
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
|
||||
@@ -90,8 +92,13 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("re-enqueue: %v", err)
|
||||
}
|
||||
if r2.Game.ID != r1.Game.ID || r2.Matched {
|
||||
t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
|
||||
if r2.Matched || r2.Game.ID == r1.Game.ID {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
|
||||
func TestInvitationBlockedInvitee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
social := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
soc := newSocialService()
|
||||
|
||||
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
|
||||
inviter := provisionAccount(t)
|
||||
invitee := provisionAccount(t)
|
||||
if err := social.Block(ctx, invitee, inviter); err != nil {
|
||||
blockedInvitee := provisionAccount(t)
|
||||
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create with a blocked invitee = %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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,34 @@ 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) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
@@ -110,8 +138,15 @@ func TestAccountMergeCore(t *testing.T) {
|
||||
|
||||
setStats(t, primary, 1, 0, 0, 100, 90)
|
||||
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, 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"
|
||||
bindEmailIdentity(t, secondary, email)
|
||||
@@ -153,6 +188,26 @@ func TestAccountMergeCore(t *testing.T) {
|
||||
if 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
|
||||
|
||||
@@ -18,8 +18,9 @@ import (
|
||||
// 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.
|
||||
|
||||
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
|
||||
// moves first) whose fresh two-player English opening rack has a legal move.
|
||||
// evenOpeningSeed returns a seed whose fresh two-player English opening rack (seat 0) has a
|
||||
// legal move, for tests that drive or attempt an opening play. It scans even seeds; seat
|
||||
// order no longer depends on the seed — the first-move draw decides it (docs/ARCHITECTURE.md §6).
|
||||
func evenOpeningSeed(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
for seed := int64(2); seed <= 400; seed += 2 {
|
||||
@@ -49,7 +50,7 @@ func openParams(seed int64) game.CreateParams {
|
||||
|
||||
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
|
||||
t.Helper()
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open game: %v", err)
|
||||
}
|
||||
@@ -59,9 +60,9 @@ func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) ga
|
||||
return g
|
||||
}
|
||||
|
||||
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
|
||||
// while the game is open, after which it is the empty opponent seat's turn and the
|
||||
// starter just waits.
|
||||
// TestOpenGameStarterMovesThenWaits checks the starter may move on their turn while the game
|
||||
// is open (the first-move draw, run when the game opened, put them at seat 0), after which it
|
||||
// is the empty opponent seat's turn and the starter just waits.
|
||||
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
@@ -89,28 +90,30 @@ func TestOpenGameStarterMovesThenWaits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
|
||||
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
|
||||
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the first-move draw seats
|
||||
// the starter at seat 1 (the synthetic opponent won the open draw), the still-empty seat 0 is
|
||||
// to move, so the starter cannot act until an opponent fills it.
|
||||
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
svc.SetFirstMoveEntropy(seatOneFirstMove) // the synthetic opponent wins → the caller sits at seat 1
|
||||
starter := provisionAccount(t)
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
|
||||
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
|
||||
t.Fatalf("opponent-won open draw: 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) {
|
||||
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 starter has already made their first move, landing on the board mid-opening and
|
||||
// able to act on their turn.
|
||||
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after the
|
||||
// starter has already made their first move: the seats were fixed by the draw at open, so the
|
||||
// joiner takes the empty seat without disturbing the journal, and can act on their turn.
|
||||
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
@@ -128,7 +131,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
}
|
||||
|
||||
joiner := provisionAccount(t)
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("join: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
||||
// to confirm a promo deep-link start-param seeds a brand-new account's variant
|
||||
// preferences (English Scrabble alongside the default Erudit), that a new account with no
|
||||
// such payload keeps the Erudit-only default, and that an existing account is never
|
||||
// re-seeded on a later login (the new-user-only contract).
|
||||
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
||||
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: &captureNotifier{},
|
||||
})
|
||||
h := srv.Handler()
|
||||
|
||||
post := func(ext, startParam string) {
|
||||
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
|
||||
if startParam != "" {
|
||||
body += `,"start_param":"` + startParam + `"`
|
||||
}
|
||||
body += `}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
|
||||
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())
|
||||
}
|
||||
}
|
||||
store := account.NewStore(testDB)
|
||||
reload := func(ext string) []string {
|
||||
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup %s: %v", ext, err)
|
||||
}
|
||||
return acc.VariantPreferences
|
||||
}
|
||||
|
||||
// A brand-new account reached through a promo deep-link is seeded with English
|
||||
// Scrabble alongside the default Erudit.
|
||||
promoExt := "tg-" + uuid.NewString()
|
||||
post(promoExt, "verudit_ru-scrabble_en")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("promo new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A brand-new account with no promo payload keeps the Erudit-only default.
|
||||
plainExt := "tg-" + uuid.NewString()
|
||||
post(plainExt, "")
|
||||
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
|
||||
t.Errorf("plain new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A later login of the promo account, even via a different payload, must not re-seed:
|
||||
// the seed is first-contact only.
|
||||
post(promoExt, "vscrabble_ru")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -181,8 +181,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
|
||||
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
|
||||
// TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's
|
||||
// turn: nothing before the 9 h floor of the 9-12 h gap, exactly one once past its 12 h ceiling.
|
||||
func TestRobotProactiveNudge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
@@ -206,18 +206,20 @@ func TestRobotProactiveNudge(t *testing.T) {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
|
||||
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
// A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00,
|
||||
// both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the
|
||||
// sleep window, decides the outcome. No nudge at 8 h idle (before the 9 h floor); exactly one
|
||||
// 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)
|
||||
|
||||
robots.Drive(ctx, start.Add(30*time.Minute))
|
||||
robots.Drive(ctx, start.Add(8*time.Hour))
|
||||
if n := countNudges(t, g.ID, robotID); n != 0 {
|
||||
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
|
||||
t.Errorf("robot nudges = %d at 8h idle, want 0 (before the 9h floor)", n)
|
||||
}
|
||||
robots.Drive(ctx, start.Add(2*time.Hour))
|
||||
robots.Drive(ctx, start.Add(24*time.Hour))
|
||||
if n := countNudges(t, g.ID, robotID); n != 1 {
|
||||
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
|
||||
t.Errorf("robot nudges = %d at 24h idle, want 1 (past the 12h ceiling)", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,3 +251,124 @@ func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, hum
|
||||
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)
|
||||
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
|
||||
DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC",
|
||||
DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"},
|
||||
}); err != nil {
|
||||
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.
|
||||
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
|
||||
DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC",
|
||||
DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"},
|
||||
}); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||
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
|
||||
// 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.
|
||||
@@ -121,24 +135,60 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
|
||||
|
||||
// Toggle: the addressee does not accept friend requests.
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
|
||||
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true, VariantPreferences: []string{"erudit_ru"}}); err != nil {
|
||||
t.Fatalf("set toggle: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
|
||||
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
|
||||
// Block: the addressee has blocked the requester.
|
||||
c, d := provisionAccount(t), provisionAccount(t)
|
||||
if err := svc.Block(ctx, d, c); err != nil {
|
||||
// Block from the requester's own side: a requester who has blocked the addressee is
|
||||
// refused outright (they are the blocker and aware of it). The reverse direction — the
|
||||
// addressee having blocked the requester — is instead silently suppressed, covered by
|
||||
// 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)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
|
||||
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
|
||||
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSeversFriendship(t *testing.T) {
|
||||
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the
|
||||
// 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()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
|
||||
t.Errorf("friendship must be severed by a block, got %v", friends)
|
||||
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +353,7 @@ func TestChatPostListAndBlocks(t *testing.T) {
|
||||
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
|
||||
t.Fatalf("post 2: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
|
||||
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true, VariantPreferences: []string{"erudit_ru"}}); err != nil {
|
||||
t.Fatalf("set block_chat: %v", err)
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
|
||||
@@ -529,32 +588,6 @@ 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
|
||||
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
|
||||
func TestAdminListMessages(t *testing.T) {
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
|
||||
st := account.NewStore(testDB)
|
||||
uniq := uuid.NewString()
|
||||
|
||||
human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
|
||||
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision human: %v", err)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("provision robot: %v", err)
|
||||
}
|
||||
guest, err := st.ProvisionGuest(ctx)
|
||||
guest, err := st.ProvisionGuest(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//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)
|
||||
}
|
||||
@@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
|
||||
}
|
||||
intents := make([]notify.Intent, 0, len(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))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
@@ -211,6 +219,11 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
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 {
|
||||
if seen[id] {
|
||||
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
|
||||
@@ -222,13 +235,22 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
}
|
||||
return Invitation{}, err
|
||||
}
|
||||
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
|
||||
// A block the inviter placed refuses the invite (the inviter is aware; the picker hides
|
||||
// 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 {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if blocked {
|
||||
if iBlock {
|
||||
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()
|
||||
@@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
svc.emitInvitation(ctx, inv, inviteeIDs)
|
||||
// Notify every invitee except those who have blocked the inviter (their copy stays
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -38,11 +38,15 @@ type RobotProvider interface {
|
||||
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
|
||||
}
|
||||
|
||||
// Blocker reports whether two accounts have a block between them (either
|
||||
// direction). social.Service satisfies it; the lobby uses it to refuse
|
||||
// invitations between blocked accounts.
|
||||
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it.
|
||||
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a
|
||||
// block the invitee placed on the inviter is silently suppressed; auto-match consults
|
||||
// BlockedWith so a block either way keeps the pair out of the same anonymous game.
|
||||
type Blocker interface {
|
||||
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
// Blocks reports whether blocker has blocked blocked (exact direction).
|
||||
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
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
||||
// it.
|
||||
type GameMatcher interface {
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
|
||||
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
|
||||
// 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)
|
||||
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
@@ -35,8 +38,9 @@ type GameMatcher interface {
|
||||
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
|
||||
// concurrent use.
|
||||
//
|
||||
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
|
||||
// friends, chat and invitations between known players).
|
||||
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
|
||||
// paired into a game whose waiting opponent they have blocked, or who has blocked them
|
||||
// (either direction), so a block keeps two players apart everywhere.
|
||||
type Matchmaker struct {
|
||||
games GameMatcher
|
||||
robots RobotProvider
|
||||
@@ -44,6 +48,7 @@ type Matchmaker struct {
|
||||
jitter time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
blocker Blocker
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -75,6 +80,16 @@ 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
|
||||
// in, and whether it already had an opponent (they joined a waiting game) rather than
|
||||
// being freshly opened and still awaiting one.
|
||||
@@ -85,11 +100,22 @@ type EnqueueResult struct {
|
||||
|
||||
// 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
|
||||
// game awaiting an opponent, the caller's own still-open game (a re-enqueue is
|
||||
// idempotent), or another player's open game they just joined. When the caller joins
|
||||
// an existing game, opponent_joined is pushed to that game's waiting starter.
|
||||
// game awaiting an opponent, or another player's open game they just joined. A
|
||||
// re-enqueue while already waiting opens another game rather than returning the
|
||||
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
|
||||
// that game's waiting starter.
|
||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
|
||||
// Exclude every account the caller has a block with (either direction) from the
|
||||
// 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 {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
@@ -180,7 +206,6 @@ func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID
|
||||
return
|
||||
}
|
||||
intent := notify.OpponentJoined(starter, g.ID, state)
|
||||
intent.Language = g.Variant.Language()
|
||||
m.pub.Publish(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ type stubMatcher struct {
|
||||
openErr error
|
||||
openCalls int
|
||||
lastDeadline time.Time
|
||||
lastExclude []uuid.UUID
|
||||
|
||||
expired []game.OpenGame
|
||||
|
||||
@@ -39,9 +40,10 @@ type stubMatcher struct {
|
||||
createParams game.CreateParams
|
||||
}
|
||||
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) {
|
||||
s.openCalls++
|
||||
s.lastDeadline = deadline
|
||||
s.lastExclude = exclude
|
||||
return s.openGame, s.openJoined, s.openErr
|
||||
}
|
||||
|
||||
@@ -95,6 +97,33 @@ type capturePub struct{ intents []notify.Intent }
|
||||
|
||||
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
|
||||
// (uuid.Nil for a still-empty opponent seat).
|
||||
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
||||
|
||||
@@ -83,6 +83,7 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
Alphabet: alphabet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,6 +216,16 @@ func BannerChanged(userID uuid.UUID) Intent {
|
||||
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.
|
||||
func eventID() string {
|
||||
if id, err := uuid.NewV7(); err == nil {
|
||||
|
||||
@@ -35,6 +35,13 @@ const (
|
||||
// KindGameOver announces a finished game to each seated player, driving the
|
||||
// out-of-app "game over" push.
|
||||
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
|
||||
@@ -62,6 +69,16 @@ const (
|
||||
// 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.
|
||||
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
|
||||
@@ -72,11 +89,6 @@ type Intent struct {
|
||||
Kind string
|
||||
Payload []byte
|
||||
EventID string
|
||||
// Language routes an out-of-app push to a specific per-language bot: for a
|
||||
// game event it is the game's language ("en"/"ru"), so the notification comes from the
|
||||
// game's bot rather than the recipient's last-login bot. Empty falls back to the
|
||||
// recipient's service language at the gateway.
|
||||
Language string
|
||||
}
|
||||
|
||||
// Publisher accepts live-event intents. Implementations must be safe for
|
||||
|
||||
@@ -56,6 +56,7 @@ type PlayerState struct {
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
Alphabet []AlphabetLetter
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// 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 AccountBestMove struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Variant string `sql:"primary_key"`
|
||||
Score int32
|
||||
Tiles string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -20,4 +20,6 @@ type AccountStats struct {
|
||||
MaxGamePoints int32
|
||||
MaxWordPoints int32
|
||||
UpdatedAt time.Time
|
||||
Moves int32
|
||||
HintsUsed int32
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,6 @@ type Accounts struct {
|
||||
PaidAccount bool
|
||||
MergedInto *uuid.UUID
|
||||
MergedAt *time.Time
|
||||
ServiceLanguage *string
|
||||
FlaggedHighRateAt *time.Time
|
||||
VariantPreferences pq.StringArray
|
||||
}
|
||||
|
||||
@@ -20,12 +20,11 @@ type FeedbackMessages struct {
|
||||
AttachmentName *string
|
||||
SenderIP *string
|
||||
Channel string
|
||||
Lang *string
|
||||
ChannelLang *string
|
||||
ReadAt *time.Time
|
||||
ArchivedAt *time.Time
|
||||
ReplyBody *string
|
||||
RepliedAt *time.Time
|
||||
ReplyReadAt *time.Time
|
||||
CreatedAt time.Time
|
||||
Lang *string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// 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 AccountBestMove = newAccountBestMoveTable("backend", "account_best_move", "")
|
||||
|
||||
type accountBestMoveTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
Variant postgres.ColumnString
|
||||
Score postgres.ColumnInteger
|
||||
Tiles postgres.ColumnString
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type AccountBestMoveTable struct {
|
||||
accountBestMoveTable
|
||||
|
||||
EXCLUDED accountBestMoveTable
|
||||
}
|
||||
|
||||
// AS creates new AccountBestMoveTable with assigned alias
|
||||
func (a AccountBestMoveTable) AS(alias string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new AccountBestMoveTable with assigned schema name
|
||||
func (a AccountBestMoveTable) FromSchema(schemaName string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new AccountBestMoveTable with assigned table prefix
|
||||
func (a AccountBestMoveTable) WithPrefix(prefix string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new AccountBestMoveTable with assigned table suffix
|
||||
func (a AccountBestMoveTable) WithSuffix(suffix string) *AccountBestMoveTable {
|
||||
return newAccountBestMoveTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newAccountBestMoveTable(schemaName, tableName, alias string) *AccountBestMoveTable {
|
||||
return &AccountBestMoveTable{
|
||||
accountBestMoveTable: newAccountBestMoveTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newAccountBestMoveTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newAccountBestMoveTableImpl(schemaName, tableName, alias string) accountBestMoveTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
VariantColumn = postgres.StringColumn("variant")
|
||||
ScoreColumn = postgres.IntegerColumn("score")
|
||||
TilesColumn = postgres.StringColumn("tiles")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, VariantColumn, ScoreColumn, TilesColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{ScoreColumn, TilesColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return accountBestMoveTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
Variant: VariantColumn,
|
||||
Score: ScoreColumn,
|
||||
Tiles: TilesColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type accountStatsTable struct {
|
||||
MaxGamePoints postgres.ColumnInteger
|
||||
MaxWordPoints postgres.ColumnInteger
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
Moves postgres.ColumnInteger
|
||||
HintsUsed postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -72,9 +74,11 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
|
||||
MaxGamePointsColumn = postgres.IntegerColumn("max_game_points")
|
||||
MaxWordPointsColumn = postgres.IntegerColumn("max_word_points")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn}
|
||||
MovesColumn = postgres.IntegerColumn("moves")
|
||||
HintsUsedColumn = postgres.IntegerColumn("hints_used")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
|
||||
mutableColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
|
||||
defaultColumns = postgres.ColumnList{WinsColumn, LossesColumn, DrawsColumn, MaxGamePointsColumn, MaxWordPointsColumn, UpdatedAtColumn, MovesColumn, HintsUsedColumn}
|
||||
)
|
||||
|
||||
return accountStatsTable{
|
||||
@@ -88,6 +92,8 @@ func newAccountStatsTableImpl(schemaName, tableName, alias string) accountStatsT
|
||||
MaxGamePoints: MaxGamePointsColumn,
|
||||
MaxWordPoints: MaxWordPointsColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
Moves: MovesColumn,
|
||||
HintsUsed: HintsUsedColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -33,8 +33,8 @@ type accountsTable struct {
|
||||
PaidAccount postgres.ColumnBool
|
||||
MergedInto postgres.ColumnString
|
||||
MergedAt postgres.ColumnTimestampz
|
||||
ServiceLanguage postgres.ColumnString
|
||||
FlaggedHighRateAt postgres.ColumnTimestampz
|
||||
VariantPreferences postgres.ColumnStringArray
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -92,11 +92,11 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
PaidAccountColumn = postgres.BoolColumn("paid_account")
|
||||
MergedIntoColumn = postgres.StringColumn("merged_into")
|
||||
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
||||
ServiceLanguageColumn = postgres.StringColumn("service_language")
|
||||
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, 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, ServiceLanguageColumn, FlaggedHighRateAtColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
|
||||
VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
|
||||
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, FlaggedHighRateAtColumn, VariantPreferencesColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
|
||||
)
|
||||
|
||||
return accountsTable{
|
||||
@@ -119,8 +119,8 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
PaidAccount: PaidAccountColumn,
|
||||
MergedInto: MergedIntoColumn,
|
||||
MergedAt: MergedAtColumn,
|
||||
ServiceLanguage: ServiceLanguageColumn,
|
||||
FlaggedHighRateAt: FlaggedHighRateAtColumn,
|
||||
VariantPreferences: VariantPreferencesColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -24,14 +24,13 @@ type feedbackMessagesTable struct {
|
||||
AttachmentName postgres.ColumnString
|
||||
SenderIP postgres.ColumnString
|
||||
Channel postgres.ColumnString
|
||||
Lang postgres.ColumnString
|
||||
ChannelLang postgres.ColumnString
|
||||
ReadAt postgres.ColumnTimestampz
|
||||
ArchivedAt postgres.ColumnTimestampz
|
||||
ReplyBody postgres.ColumnString
|
||||
RepliedAt postgres.ColumnTimestampz
|
||||
ReplyReadAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
Lang postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -80,16 +79,15 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
||||
AttachmentNameColumn = postgres.StringColumn("attachment_name")
|
||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||
ChannelColumn = postgres.StringColumn("channel")
|
||||
LangColumn = postgres.StringColumn("lang")
|
||||
ChannelLangColumn = postgres.StringColumn("channel_lang")
|
||||
ReadAtColumn = postgres.TimestampzColumn("read_at")
|
||||
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
|
||||
ReplyBodyColumn = postgres.StringColumn("reply_body")
|
||||
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
|
||||
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
||||
LangColumn = postgres.StringColumn("lang")
|
||||
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, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
@@ -104,14 +102,13 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
||||
AttachmentName: AttachmentNameColumn,
|
||||
SenderIP: SenderIPColumn,
|
||||
Channel: ChannelColumn,
|
||||
Lang: LangColumn,
|
||||
ChannelLang: ChannelLangColumn,
|
||||
ReadAt: ReadAtColumn,
|
||||
ArchivedAt: ArchivedAtColumn,
|
||||
ReplyBody: ReplyBodyColumn,
|
||||
RepliedAt: RepliedAtColumn,
|
||||
ReplyReadAt: ReplyReadAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
Lang: LangColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// 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,6 +31,7 @@ func UseSchema(schema string) {
|
||||
GameInvitations = GameInvitations.FromSchema(schema)
|
||||
GameMoves = GameMoves.FromSchema(schema)
|
||||
GamePlayers = GamePlayers.FromSchema(schema)
|
||||
GameSetupDraws = GameSetupDraws.FromSchema(schema)
|
||||
Games = Games.FromSchema(schema)
|
||||
Identities = Identities.FromSchema(schema)
|
||||
Sessions = Sessions.FromSchema(schema)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user