Compare commits
329 Commits
f95a6cb9c8
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 5a3f0951ae | |||
| fb0ddab0f1 | |||
| e850ecd83b | |||
| 2c24d54047 | |||
| 2f4aa1b75b | |||
| 712ef205c1 | |||
| 7cd4474945 | |||
| 236152ea7b | |||
| c3b3cafcdd | |||
| b71f006ddf | |||
| 20f2a5a011 | |||
| a7f3df9346 | |||
| aaac816dc2 | |||
| d53ff18a67 | |||
| 63ab85a5e5 | |||
| 05d83ced86 | |||
| 2a034ff9be | |||
| 12d128f1cc | |||
| e9dfa4ccb8 | |||
| 9c30bdf8e7 | |||
| 51d7c4e005 | |||
| 14b5f61af9 | |||
| 6d66545062 | |||
| 183e08ec80 | |||
| ac1c89c0ee | |||
| 52f2caae51 | |||
| 27871f2a1d | |||
| dd45af20ef | |||
| 115c92b39a | |||
| 9f83962bf7 | |||
| dc582e9f73 | |||
| 5fb0daa746 | |||
| 3b20abe0bd | |||
| 9e72e2c799 | |||
| 53c6e34c13 | |||
| cb4a31a860 | |||
| a5f066224c | |||
| 00a33c227b | |||
| 0946a3f66c | |||
| f59c8dcd43 | |||
| 3bceafbc12 | |||
| 9e6899bb7d | |||
| d2a9441287 | |||
| aa765a0c06 | |||
| 91d5c341ef | |||
| bd0482c376 | |||
| 8e0d7f9e17 | |||
| 4d6df4bd8b | |||
| 4b454db219 | |||
| 8073971fca | |||
| 00129414e5 | |||
| 853730823b | |||
| 01d2d1f368 | |||
| 03eb8044ff | |||
| 6679260d0a | |||
| 800a692766 | |||
| 30a7c24140 | |||
| 711fe6e594 | |||
| 277954c47f | |||
| 55ed87fb11 | |||
| 49b67a0354 | |||
| 2a4ce319d9 | |||
| 1ae43080ec | |||
| 5287794a72 | |||
| 419ea11b14 | |||
| fc848157d6 | |||
| 192e4a2433 | |||
| d3bedbb5b6 | |||
| ac62d29ef7 | |||
| a80952a835 | |||
| 290874720f | |||
| d1ba666495 | |||
| 9d85090075 | |||
| 5f53ec81b9 | |||
| 02681ae9e0 | |||
| fc28e43e43 | |||
| 02ef31c464 | |||
| 9ec72c8377 | |||
| 7716353f84 | |||
| c9021fc070 | |||
| 4f2fc795ec | |||
| 222eaf730f | |||
| ff87a3bf62 | |||
| c7e177f911 | |||
| abe1038333 | |||
| 553152e195 | |||
| 1cc6c0d56e | |||
| f3914af793 | |||
| 56dbf86472 | |||
| 5312b11f0e | |||
| cf70e6b1fc | |||
| 38fa104f7f | |||
| 4409253dce | |||
| 16402e64c0 | |||
| 315bcf75ae | |||
| 0f3671f42d | |||
| 5ff07da025 | |||
| a4e6727c70 | |||
| 1d41cf8222 | |||
| 94534ad0f2 | |||
| efaf633691 | |||
| a3cb917ec7 | |||
| c305363ccd | |||
| 10dc1f0d48 | |||
| cb75623677 | |||
| 107add13b6 | |||
| 359758a01a | |||
| f67a357e62 | |||
| eeb078d528 | |||
| 641ac88b2d | |||
| 5fa51d04d9 | |||
| f1e77b5826 | |||
| f73f76220d | |||
| 0b57400c6f | |||
| b56a45f0e0 | |||
| 74455c7b12 | |||
| d4a1616d03 | |||
| 390b4c756f | |||
| c32a15730a | |||
| 9277a70565 | |||
| 5648f4a0bb | |||
| 883212f9d1 | |||
| 92f48a3b12 | |||
| feee3d6511 | |||
| a41c35d5f9 | |||
| 6268b9d2a2 | |||
| e68fe61e39 | |||
| 4c65923544 | |||
| b14cc38919 | |||
| ac29dca865 | |||
| 5c8b8bf658 | |||
| fbd67c085c | |||
| 7b85f4bd68 | |||
| 29f655aacd | |||
| 1b3d7dc256 | |||
| ad91bc728b | |||
| fc1261e078 | |||
| f8b6b7f2e3 | |||
| 225188e4b5 | |||
| 2a48df9b83 | |||
| f23da88028 | |||
| 8eee018728 | |||
| c16f27475f | |||
| 04263a17ca | |||
| 7210bed560 | |||
| 40ccfb9514 | |||
| c6e0dac940 | |||
| b47c47e969 | |||
| 1079878654 | |||
| c31ac7088c | |||
| 8881214213 | |||
| a372343797 | |||
| d4ef951db9 | |||
| 7ec17cdd53 | |||
| 41a642ef97 | |||
| e3b08461f0 | |||
| 7e75c32d07 | |||
| f20a4b49ff | |||
| ab58062565 | |||
| 8878711cf3 | |||
| c23ac94c4e | |||
| a2265a122e | |||
| 422bd14b53 | |||
| 0c55574ddd | |||
| aa137e3558 | |||
| bf3ee62711 | |||
| 8bfc44aad0 | |||
| bf07f77078 | |||
| 26aa154547 | |||
| 70e3fab512 | |||
| bf7dca0a09 | |||
| 265e442252 | |||
| d87c0fb10b | |||
| 84ecc85f51 | |||
| efa1d0bd22 | |||
| ef61b778fc | |||
| 844f26bbae | |||
| f166ff30fe | |||
| 6956dad354 | |||
| 13361c098c | |||
| 4999478ded | |||
| a7c566d2d1 | |||
| a84e9d8cb7 | |||
| 70110effd9 | |||
| 295e45486d | |||
| a132edd40a | |||
| 461e330bfc | |||
| c96d714fec | |||
| 7e34897d6d | |||
| 645df52c0b |
+75
-44
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Single gated pipeline for the test contour (Stage 16/17). Gitea cannot express
|
# Single gated pipeline for the test contour. Gitea cannot express
|
||||||
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
|
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
|
||||||
# one workflow.
|
# one workflow.
|
||||||
#
|
#
|
||||||
@@ -9,9 +9,9 @@ name: CI
|
|||||||
# `development` or `master` (the full test suite — the merge gate) and on a push
|
# `development` or `master` (the full test suite — the merge gate) and on a push
|
||||||
# to `development` (after a merge). The deploy job runs only for `development`
|
# to `development` (after a merge). The deploy job runs only for `development`
|
||||||
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
|
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
|
||||||
# workflow (Stage 18).
|
# workflow.
|
||||||
#
|
#
|
||||||
# Path-conditional jobs (Stage 17): `unit`/`integration`/`ui` run only when their
|
# Path-conditional jobs: `unit`/`integration`/`ui` run only when their
|
||||||
# code changed (the `changes` job decides). Because a skipped required check would
|
# code changed (the `changes` job decides). Because a skipped required check would
|
||||||
# block a merge under branch protection, the always-running `gate` job aggregates
|
# block a merge under branch protection, the always-running `gate` job aggregates
|
||||||
# their results and is the ONLY required status check; it passes when every
|
# their results and is the ONLY required status check; it passes when every
|
||||||
@@ -26,6 +26,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [development]
|
branches: [development]
|
||||||
|
|
||||||
|
# The dictionary release the test suite validates against — the current
|
||||||
|
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
||||||
|
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||||
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||||
|
env:
|
||||||
|
DICT_VERSION: v1.3.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
# irrelevant. It defaults to running everything when the diff cannot be computed.
|
# irrelevant. It defaults to running everything when the diff cannot be computed.
|
||||||
@@ -64,7 +71,7 @@ jobs:
|
|||||||
if [ "$files" != "__DIFF_FAILED__" ]; then
|
if [ "$files" != "__DIFF_FAILED__" ]; then
|
||||||
echo "changed files:"; echo "$files"
|
echo "changed files:"; echo "$files"
|
||||||
go=false; ui=false
|
go=false; ui=false
|
||||||
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|go\.work)'; then go=true; fi
|
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
||||||
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
||||||
# A workflow or deploy change re-runs everything as a safety net.
|
# A workflow or deploy change re-runs everything as a safety net.
|
||||||
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
||||||
@@ -86,7 +93,6 @@ jobs:
|
|||||||
# The engine consumes the published scrabble-solver module from this Gitea;
|
# The engine consumes the published scrabble-solver module from this Gitea;
|
||||||
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
|
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
|
||||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
GOPRIVATE: gitea.iliadenisov.ru/*
|
||||||
DICT_VERSION: v1.0.0
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -112,15 +118,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: vet
|
- name: vet
|
||||||
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
env:
|
env:
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
integration:
|
integration:
|
||||||
needs: changes
|
needs: changes
|
||||||
@@ -134,7 +140,6 @@ jobs:
|
|||||||
# suite's TestMain terminates its own container, so disable it.
|
# suite's TestMain terminates its own container, so disable it.
|
||||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
GOPRIVATE: gitea.iliadenisov.ru/*
|
||||||
DICT_VERSION: v1.0.0
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -254,23 +259,25 @@ jobs:
|
|||||||
AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
|
AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
|
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,
|
# The test contour always uses Telegram's test environment — pinned here,
|
||||||
# not an operator variable. Stage 18's prod workflow leaves it false.
|
# not an operator variable. The prod workflow leaves it false.
|
||||||
TELEGRAM_TEST_ENV: "true"
|
TELEGRAM_TEST_ENV: "true"
|
||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
|
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||||
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||||
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||||
@@ -287,58 +294,82 @@ jobs:
|
|||||||
mkdir -p "$conf"
|
mkdir -p "$conf"
|
||||||
cp -r caddy otelcol prometheus tempo grafana "$conf"/
|
cp -r caddy otelcol prometheus tempo grafana "$conf"/
|
||||||
export SCRABBLE_CONFIG_DIR="$conf"
|
export SCRABBLE_CONFIG_DIR="$conf"
|
||||||
|
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
|
||||||
|
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
|
||||||
|
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
||||||
|
# together so they always share the fresh CA (see deploy/gen-certs.sh).
|
||||||
|
bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
|
||||||
# App version for the About screen: the git tag if present, else the short SHA
|
# App version for the About screen: the git tag if present, else the short SHA
|
||||||
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
|
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
|
||||||
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
|
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
|
||||||
docker compose --ansi never build --progress plain
|
# The telegram-local profile brings the bot + its VPN sidecar; prod runs the
|
||||||
docker compose --ansi never up -d --remove-orphans
|
# 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`
|
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||||
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
||||||
# pick up the fresh config.
|
# pick up the fresh config.
|
||||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
||||||
|
|
||||||
- name: Probe the gateway through caddy
|
- name: Probe the landing, gateway and backend
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
|
# Three probes. "/" is the static landing container and "/app/" the
|
||||||
|
# gateway-served SPA shell (both through the contour caddy on the edge net).
|
||||||
|
# The backend /readyz is probed on the internal net as well: the caddy probes
|
||||||
|
# are blind to a crash-looping backend (the landing is static and the SPA
|
||||||
|
# shell is served without it), which let a bad deploy go green while the
|
||||||
|
# backend was down — so check it directly here.
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/; then
|
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
|
||||||
echo "healthy: GET http://scrabble/"
|
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ &&
|
||||||
|
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
|
||||||
|
echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
echo "probe failed; recent 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-gateway || true
|
||||||
|
docker logs --tail 50 scrabble-backend || true
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Probe the Telegram connector liveness
|
- name: Probe the Telegram validator and bot liveness
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
# The gateway probe cannot see a crash-looping connector (it long-polls and
|
# The gateway/backend probes cannot see a crash-looping validator or bot
|
||||||
# egresses through the VPN sidecar, with no public ingress). Inspect the
|
# (the validator answers only internal gRPC; the bot long-polls + egresses
|
||||||
# container directly: it must be running, not restarting, with a stable
|
# through the VPN sidecar with no public ingress). Inspect the containers
|
||||||
# restart count. A grace period lets the VPN handshake settle (the connector
|
# directly: each must be running, not restarting, with a stable restart
|
||||||
# may restart a few times first).
|
# count. A grace period lets the VPN handshake and the bot-link dial settle.
|
||||||
sleep 20
|
sleep 20
|
||||||
for i in $(seq 1 20); do
|
for name in scrabble-telegram-validator scrabble-telegram-bot; do
|
||||||
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
|
ok=
|
||||||
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
|
for i in $(seq 1 20); do
|
||||||
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)"
|
||||||
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
|
||||||
sleep 5
|
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
||||||
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||||
if [ "$c1" = "$c2" ]; then
|
sleep 5
|
||||||
echo "connector healthy: status=$status restarts=$c2"
|
c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||||
exit 0
|
if [ "$c1" = "$c2" ]; then
|
||||||
|
echo "$name healthy: status=$status restarts=$c2"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "$name still restarting ($c1 -> $c2); waiting"
|
||||||
fi
|
fi
|
||||||
echo "connector still restarting ($c1 -> $c2); waiting"
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ -z "$ok" ]; then
|
||||||
|
echo "$name not healthy; recent logs:"
|
||||||
|
docker logs --tail 80 "$name" || true
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 3
|
|
||||||
done
|
done
|
||||||
echo "connector not healthy; recent logs:"
|
|
||||||
docker logs --tail 80 scrabble-telegram || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Prune dangling images
|
- name: Prune dangling images
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -16,3 +16,10 @@
|
|||||||
# Local, unstaged env overrides
|
# Local, unstaged env overrides
|
||||||
**/.env.local
|
**/.env.local
|
||||||
**/.env.*.local
|
**/.env.*.local
|
||||||
|
|
||||||
|
# Bot-link mTLS material: private keys never belong in the repo. The test contour
|
||||||
|
# generates them with deploy/gen-certs.sh; prod supplies them from PROD_ secrets.
|
||||||
|
deploy/certs/
|
||||||
|
|
||||||
|
# Claude Code harness runtime artifacts
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|||||||
@@ -1,94 +1,97 @@
|
|||||||
# scrabble-game — project guide
|
# scrabble-game — project guide
|
||||||
|
|
||||||
Multiplatform Scrabble game. Read this first every session. The owner drives the
|
Multiplatform Scrabble game, **in production** at `https://erudit-game.ru`. Read this
|
||||||
project **one stage per session** (tariff constraint), so the repository — not
|
first every session. The repository — not conversation memory — is the source of
|
||||||
conversation memory — is the source of continuity. Keep it that way.
|
continuity; keep it that way.
|
||||||
|
|
||||||
## Sources of truth (read before changing behaviour)
|
## Sources of truth (read before changing behaviour)
|
||||||
|
|
||||||
- [`PLAN.md`](PLAN.md) — staged plan + **stage tracker** + per-stage *open
|
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport, security,
|
||||||
details to interview*.
|
the decision record. Always describes the current state.
|
||||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — architecture, transport,
|
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) mirror)
|
||||||
security, the decision record. Always describes current state.
|
— per-domain user stories. English authoritative.
|
||||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
|
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the CI gate.
|
||||||
mirror) — per-domain user stories. English authoritative.
|
|
||||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate.
|
|
||||||
- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system.
|
- [`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**
|
- Inspect the relevant code path and the docs above before changing behaviour.
|
||||||
1. Read `PLAN.md` (the stage's scope + *open details*) and the relevant `docs/`.
|
- **Interview the owner on every fork** — do not silently pick borderline decisions;
|
||||||
2. Analyse what the stage actually requires against the current code.
|
offer options with brief pros/cons.
|
||||||
3. **Interview the owner** on every open detail and any fork not already fixed
|
- Smallest correct diff. Prefer compact code; reuse before adding; do not add deps,
|
||||||
in the plan — do not silently pick borderline decisions. Offer options with
|
seams or knobs until they are needed.
|
||||||
brief pros/cons.
|
- **Update or add tests for every functional change**, at the layers
|
||||||
4. Only then implement, strictly within the stage's scope.
|
`docs/TESTING.md` calls out.
|
||||||
|
- **Bake docs in the same PR**: update `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md`
|
||||||
**End of a stage**
|
(+`_ru`), the affected service `README` and Go Doc comments alongside the change.
|
||||||
1. Bake every new agreement back into `PLAN.md`, `docs/ARCHITECTURE.md`,
|
- Document added packages, types, funcs, consts and vars with Go Doc comments.
|
||||||
`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.)
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- All code, comments, identifiers, commits, docs, filenames in **English**.
|
- All code, comments, identifiers, commits, docs, filenames in **English**.
|
||||||
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian,
|
- Chat with the owner follows the user-level `~/.claude/CLAUDE.md` (Russian, the
|
||||||
the agreed persona and translation rules).
|
agreed persona and translation rules).
|
||||||
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md`
|
- Mirror every point edit of `docs/FUNCTIONAL.md` into `docs/FUNCTIONAL_ru.md` in the
|
||||||
in the same patch (translate only the touched paragraphs).
|
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.
|
|
||||||
|
|
||||||
## Branching & CI
|
## Branching, CI & production
|
||||||
|
|
||||||
- **Two long-lived branches** (Stage 16 onward): **`development`** is the
|
- **Two long-lived branches**: **`development`** is the integration branch; **`master`**
|
||||||
integration branch; **`master`** is the production trunk. Cut `feature/*`
|
is the production trunk. Cut `feature/*` from `development` and PR back into it;
|
||||||
branches **from `development`** and PR them back into it. (Stages 0–15 used
|
promote `development → master` via PR when ready to release. Both branches require
|
||||||
`master` as the trunk with `feature/* → master`; the genesis Stage 0 commit is
|
one approval + the `CI / gate` check.
|
||||||
on `master` by necessity.)
|
- A commit to a `feature/*` branch triggers nothing. The single workflow
|
||||||
- A commit to a `feature/*` branch triggers **nothing**. The single workflow
|
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) on a
|
||||||
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`)
|
PR into `development` or `master`, and the gated **`deploy`** job auto-rolls the
|
||||||
on a PR into `development` or `master`, and the gated **`deploy`** job auto-rolls
|
**test contour** on a PR into — or a push to — `development`
|
||||||
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
|
||||||
(`docker compose up -d --build` on the runner host + a `GET /` probe). A PR into
|
PR into `master` is test-only.
|
||||||
`master` is test-only.
|
- **Production is live on two hosts** (main + the Telegram bot host) and deploys
|
||||||
- Merge `development → master` only when CI is green; the **prod** deploy is then a
|
**only manually** (`workflow_dispatch`), never automatically:
|
||||||
**manual** workflow (Stage 18), never automatic. Secrets/variables are prefixed
|
- **`.gitea/workflows/prod-deploy.yaml`** (`confirm=deploy`, from `master`) builds +
|
||||||
`TEST_` / `PROD_` per contour (Gitea 1.26 has no deployment environments).
|
pushes the images to the registry, then SSH-deploys both hosts — rolling per
|
||||||
- After any push, watch the run to green before declaring a stage done — use the
|
service in dependency order, health-gated, **auto-rollback to the previous tag**;
|
||||||
ready-made watcher, never an inline poll loop:
|
a schema migration adds a maintenance window + a consistent `pg_dump`. Four visible
|
||||||
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
jobs: build → deploy-main → deploy-bot → verify.
|
||||||
/ `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in
|
- **`.gitea/workflows/prod-rollback.yaml`** (`confirm=rollback`) re-deploys a prior
|
||||||
`.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
|
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
|
## Stack
|
||||||
|
|
||||||
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Dependencies are
|
Go 1.26.3, `go.work` monorepo, module paths `scrabble/<name>`. Backend uses `gin` +
|
||||||
added **when first used** (incremental): backend uses `gin` + `zap` +
|
`zap` + `pgx`/`go-jet`/`goose`/OTel. Client↔gateway is Connect-RPC + FlatBuffers
|
||||||
`pgx`/`go-jet`/`goose`/OTel (added in Stage 1). Client↔gateway is Connect-RPC +
|
(h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC server-stream for live
|
||||||
FlatBuffers (h2c); gateway↔backend is REST/JSON + `X-User-ID` plus a gRPC
|
events. UI is pure HTML5/CSS on plain Svelte + Vite, packaged to native with
|
||||||
server-stream for live events. UI is pure HTML5/CSS on plain Svelte + Vite,
|
Capacitor. No Redis.
|
||||||
packaged to native with Capacitor. Likely no Redis.
|
|
||||||
|
|
||||||
## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3)
|
## Reused engine: `../scrabble-solver` (module `scrabble-solver`, Go 1.26.3)
|
||||||
|
|
||||||
Embedded **in-process as a library** — there is no per-game container. Public
|
Embedded **in-process as a library** (`replace scrabble-solver => ../scrabble-solver`
|
||||||
API to reuse (do not reimplement):
|
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,
|
- `scrabble.NewSolver(rs, finder)` → `GenerateMoves(b, r, mode)` (ranked, highest
|
||||||
highest score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`;
|
score first), `ValidatePlay(b, dir, tiles)`, `ScorePlay(...)`; `scrabble.Apply(b, m)`;
|
||||||
`scrabble.Apply(b, m)`; types `Move/Word/Placement/Direction/Mode`
|
types `Move/Word/Placement/Direction/Mode`
|
||||||
(`scrabble-solver/scrabble/{solver,move,apply}.go`).
|
(`scrabble-solver/scrabble/{solver,move,apply}.go`).
|
||||||
- `rules.English() / RussianScrabble() / Erudit()`
|
- `rules.English() / RussianScrabble() / Erudit()` (`scrabble-solver/rules/rules.go`).
|
||||||
(`scrabble-solver/rules/rules.go`).
|
|
||||||
- `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`;
|
- `board.New / Parse / Clone / Transpose`; `rack.New / Add / Remove / Clone`;
|
||||||
`selfplay.NewBag / Draw / Len` (bag pattern).
|
`selfplay.NewBag / Draw / Len` (bag pattern).
|
||||||
- Load committed dictionaries with `dawg.Load(path)` from
|
- Load committed dictionaries with `dawg.Load(path)` from
|
||||||
@@ -97,20 +100,17 @@ API to reuse (do not reimplement):
|
|||||||
|
|
||||||
Constraints:
|
Constraints:
|
||||||
- Words/tiles are **alphabet-index bytes**, meaningful only with the matching
|
- 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
|
to real characters before persisting history** (history must be
|
||||||
dictionary-independent — see `docs/ARCHITECTURE.md` §9.1).
|
dictionary-independent — see `docs/ARCHITECTURE.md` §9.1).
|
||||||
- The solver's `internal/*` is NOT importable from this sibling module.
|
- 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.
|
- **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
|
- The solver uses published `github.com/iliadenisov/{alphabet,dafsa}` (no local replace).
|
||||||
**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).
|
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
```
|
```
|
||||||
go.work # use the existing modules; grows per stage
|
go.work # the go.work monorepo
|
||||||
backend/ # module scrabble/backend
|
backend/ # module scrabble/backend
|
||||||
cmd/backend/ # main: telemetry -> db+migrate -> cache -> server
|
cmd/backend/ # main: telemetry -> db+migrate -> cache -> server
|
||||||
cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container)
|
cmd/jetgen/ # dev tool: regenerate go-jet code (throwaway container)
|
||||||
@@ -121,11 +121,14 @@ backend/ # module scrabble/backend
|
|||||||
internal/session/ # opaque tokens, sessions store, cache, service
|
internal/session/ # opaque tokens, sessions store, cache, service
|
||||||
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
|
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
|
||||||
internal/inttest/ # //go:build integration Postgres-backed tests
|
internal/inttest/ # //go:build integration Postgres-backed tests
|
||||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
gateway/ # module scrabble/gateway: Connect-RPC edge, embeds the SPA
|
||||||
gateway/ ui/ pkg/ # added by their stages
|
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
||||||
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
||||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile # multi-stage distroless (Stage 16)
|
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
||||||
deploy/ # docker-compose + caddy + otelcol/prometheus/tempo/grafana (Stage 16)
|
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
|
## Build & test
|
||||||
@@ -135,19 +138,19 @@ go build ./backend/... # per module ('./...' from the root won't span t
|
|||||||
go vet ./backend/...
|
go vet ./backend/...
|
||||||
gofmt -l . # must print nothing
|
gofmt -l . # must print nothing
|
||||||
go test -count=1 ./backend/...
|
go test -count=1 ./backend/...
|
||||||
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9)
|
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot
|
||||||
go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
||||||
|
|
||||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||||
pnpm start # UI mock mode: lobby -> game, no backend
|
pnpm start # UI mock mode: lobby -> game, no backend
|
||||||
|
|
||||||
docker build -f backend/Dockerfile -t scrabble-backend . # images (Stage 16); gateway embeds the UI
|
docker build --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 -t scrabble-gateway .
|
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||||
|
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
||||||
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
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
|
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job of
|
||||||
of the single `.gitea/workflows/ci.yaml` (Stage 16 folded the former go-unit /
|
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||||
integration / ui-test workflows into it). Committed edge codegen under `ui/src/gen/`
|
|
||||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
|||||||
|
|
||||||
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
- **`gateway`** — the only public ingress: anti-abuse, platform authentication
|
||||||
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
|
(resolves the player and injects `X-User-ID`), routing to `backend`, and an
|
||||||
admin surface behind Basic Auth. *(added in a later stage)*
|
admin surface behind Basic Auth.
|
||||||
- **`backend`** — internal-only service that owns every domain concern and
|
- **`backend`** — internal-only service that owns every domain concern and
|
||||||
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
|
||||||
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
|
||||||
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
+ FlatBuffers, embeddable in platform webviews and packageable to native via
|
||||||
Capacitor. See [`ui/README.md`](ui/README.md).
|
Capacitor. See [`ui/README.md`](ui/README.md).
|
||||||
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
|
||||||
*(added in a later stage)*
|
|
||||||
|
|
||||||
## Documentation (sources of truth)
|
## Documentation (sources of truth)
|
||||||
|
|
||||||
@@ -23,9 +22,8 @@ supports English Scrabble, Russian Scrabble and Эрудит.
|
|||||||
security, cross-service contracts.
|
security, cross-service contracts.
|
||||||
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) —
|
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)) —
|
||||||
per-domain user stories.
|
per-domain user stories.
|
||||||
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the per-stage CI gate.
|
- [`docs/TESTING.md`](docs/TESTING.md) — test layers and the CI gate.
|
||||||
- [`PLAN.md`](PLAN.md) — the staged implementation plan and stage tracker.
|
- [`CLAUDE.md`](CLAUDE.md) — project guide and development workflow.
|
||||||
- [`CLAUDE.md`](CLAUDE.md) — project guide and the mandatory per-stage workflow.
|
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
@@ -91,13 +89,13 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
|
|||||||
services build from multi-stage distroless `*/Dockerfile`.
|
services build from multi-stage distroless `*/Dockerfile`.
|
||||||
|
|
||||||
```sh
|
```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 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)
|
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
||||||
```
|
```
|
||||||
|
|
||||||
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
||||||
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
||||||
`development → master` (Stage 18). Env reference: [`deploy/.env.example`](deploy/.env.example);
|
`development → master`. Env reference: [`deploy/.env.example`](deploy/.env.example);
|
||||||
the topology and the two-contour model are in
|
the topology and the two-contour model are in
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
||||||
|
|||||||
+23
-8
@@ -2,17 +2,19 @@
|
|||||||
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
||||||
#
|
#
|
||||||
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
||||||
# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
# — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
||||||
# binary at them. The published solver module is fetched directly from Gitea
|
# binary at them. The published solver module is fetched directly from Gitea
|
||||||
# (GOPRIVATE), so the build stage needs git and network.
|
# (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
|
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||||
# in the Docker context:
|
# in the Docker context. DICT_VERSION has no default — the caller supplies the
|
||||||
# docker build -f backend/Dockerfile -t scrabble-backend .
|
# 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 -----------------------------------------------------
|
# --- dictionary artifact -----------------------------------------------------
|
||||||
FROM alpine:3.20 AS dawg
|
FROM alpine:3.20 AS dawg
|
||||||
ARG DICT_VERSION=v1.0.0
|
ARG DICT_VERSION
|
||||||
RUN apk add --no-cache curl tar
|
RUN apk add --no-cache curl tar
|
||||||
RUN mkdir -p /dawg \
|
RUN mkdir -p /dawg \
|
||||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||||
@@ -30,13 +32,26 @@ COPY go.work go.work.sum ./
|
|||||||
COPY pkg ./pkg
|
COPY pkg ./pkg
|
||||||
COPY backend ./backend
|
COPY backend ./backend
|
||||||
|
|
||||||
# Reduce the workspace to what the backend needs: backend + pkg.
|
# Reduce the workspace to what the backend needs: backend + pkg. loadtest and the
|
||||||
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram
|
# gateway replace it requires are not in this context, so drop both.
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
|
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0
|
||||||
|
# VERSION (the deploy passes the git tag) is stamped into the binary via the linker.
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/backend ./backend/cmd/backend
|
||||||
|
|
||||||
# --- runtime -----------------------------------------------------------------
|
# --- runtime -----------------------------------------------------------------
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
# Re-declare the build arg in this stage so it labels the seed dictionary. One
|
||||||
|
# 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
|
||||||
COPY --from=build /out/backend /usr/local/bin/backend
|
COPY --from=build /out/backend /usr/local/bin/backend
|
||||||
COPY --from=dawg /dawg /opt/dawg
|
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||||
|
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||||
|
# can write new version subdirectories at runtime. The volume preserves uploaded
|
||||||
|
# versions across deploys and, once seeded, is not re-seeded — so after bootstrap
|
||||||
|
# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5).
|
||||||
|
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
|
||||||
ENV BACKEND_DICT_DIR=/opt/dawg
|
ENV BACKEND_DICT_DIR=/opt/dawg
|
||||||
|
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
|
||||||
ENTRYPOINT ["/usr/local/bin/backend"]
|
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||||
|
|||||||
+140
-62
@@ -1,93 +1,141 @@
|
|||||||
# backend
|
# backend
|
||||||
|
|
||||||
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
|
||||||
It owns identity/sessions, accounts, and — in later stages — the lobby, game
|
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
|
||||||
runtime, robot, chat, history and administration. Its only network consumers are
|
and administration. Its only network consumers are the `gateway` and the platform
|
||||||
the `gateway` and the platform side-services; it is never exposed publicly.
|
side-services; it is never exposed publicly.
|
||||||
|
|
||||||
As of Stage 1 the backend provides the foundation: configuration, the HTTP
|
The backend provides the foundation: configuration, the HTTP listener with the
|
||||||
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
|
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
|
||||||
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
|
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
|
||||||
and the durable accounts / identities / sessions data model. The session and
|
accounts / identities / sessions data model. The session and account REST
|
||||||
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
|
endpoints live in the `gateway`; the backend ships the store/service layer they
|
||||||
store/service layer they will call.
|
call.
|
||||||
|
|
||||||
Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver`
|
`internal/engine` is the in-process bridge to the `scrabble-solver`
|
||||||
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
library: a versioned dictionary registry, a deterministic tile bag, and a pure
|
||||||
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
|
||||||
detection) that emits dictionary-independent move records. It is a library only;
|
detection) that emits dictionary-independent move records. It is a library only;
|
||||||
the game domain wires it into the process in Stage 3.
|
the game domain wires it into the process.
|
||||||
|
|
||||||
Stage 3 adds `internal/game`, the game domain over the engine. Active games are
|
`internal/game` is the game domain over the engine. Active games are
|
||||||
event-sourced: a `games` row plus an append-only decoded move journal, with the
|
event-sourced: a `games` row plus an append-only decoded move journal, with the
|
||||||
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
|
||||||
provides create, the play/pass/exchange/resign transitions, an unlimited
|
provides create, the play/pass/exchange/resign transitions, an unlimited
|
||||||
score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
word/score/legality preview, the hint (per-game allowance plus a profile wallet), the
|
||||||
word-check tool with complaint capture, per-player game state, history and GCG
|
word-check tool with complaint capture, per-player game state, history and GCG
|
||||||
export, per-account statistics on finish, and a background turn-timeout sweeper
|
export, per-account statistics on finish, and a background turn-timeout sweeper
|
||||||
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
that auto-resigns overdue turns (honouring each player's daily away window). Like
|
||||||
Stages 1–2 it is a service/store layer; the HTTP surface lands with the
|
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
|
||||||
`gateway` (Stage 6).
|
|
||||||
|
|
||||||
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
|
The lobby and social fabric. `internal/lobby` runs **auto-match** — `Enqueue` opens a
|
||||||
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
|
real game seating the caller with an **empty opponent seat** (status `open`) or, when
|
||||||
|
another player already waits for the same variant and per-turn word rule, seats the
|
||||||
|
caller into that open game and starts it — and
|
||||||
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
friend-game invitations (invite → accept, starting a 2–4 player game once every
|
||||||
invitee accepts). `internal/social` owns the friend graph (request/accept),
|
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
|
||||||
|
player's active quick games — status `active`/`open`, excluding invitation-linked friend
|
||||||
|
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
|
||||||
|
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
|
||||||
|
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
|
||||||
|
`internal/social` owns the friend graph (request/accept),
|
||||||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
||||||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||||
including obfuscated forms) and stored with the sender's IP. `internal/account`
|
including obfuscated forms) and stored with the sender's IP. Each message carries an
|
||||||
|
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
|
||||||
|
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
|
||||||
|
clears a nudge when its recipient moves — both record the publish-to-read latency.
|
||||||
|
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
|
||||||
|
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
|
||||||
|
background reaper drops a robot friend request once its game has been finished for **7 days**.
|
||||||
|
`internal/account`
|
||||||
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
|
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
|
||||||
development log mailer). The engine now also handles **multi-player drop-out**: in
|
development log mailer). The engine now also handles **multi-player drop-out**: in
|
||||||
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
||||||
(the tile disposition is a per-game setting), the game ending when one active seat
|
(the tile disposition is a per-game setting), the game ending when one active seat
|
||||||
remains. As before this is a service/store layer — chat and nudges are persisted
|
remains. As before this is a service/store layer — chat and nudges are persisted
|
||||||
but their live delivery, and all REST endpoints, arrive with the `gateway`
|
but their live delivery, and all REST endpoints, live in the `gateway`; the
|
||||||
(Stage 6); the services are exposed via `Server` accessors for those handlers.
|
services are exposed via `Server` accessors for those handlers.
|
||||||
|
|
||||||
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
|
The robot opponent (`internal/robot`). A pool of durable accounts —
|
||||||
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
each a `kind='robot'` identity, provisioned at startup with chat and friend
|
||||||
requests blocked — backs human-like, per-language composed names. A background driver plays the
|
requests blocked — carries human-like names. The disguised reaper stamps a **fresh per-game name** on
|
||||||
|
the robot's seat (a `game_players.display_name` snapshot — which also freezes humans' names per game, so
|
||||||
|
a later rename never rewrites past games) drawn from a wide composed corpus (Western locales, native
|
||||||
|
Japanese/Chinese, a gender-agreed Russian pool, and handles); a Russian game stays Cyrillic with ≤20%
|
||||||
|
Latin and no CJK script, an English game uses the full corpus (`namevariety.go`, `PickNamed`). A
|
||||||
|
background driver plays the
|
||||||
robot's moves through the public game API as an ordinary seated player (so only
|
robot's moves through the public game API as an ordinary seated player (so only
|
||||||
`internal/engine` imports the solver): it decides once per game whether to play to
|
`internal/engine` imports the solver): it decides once per game whether to play to
|
||||||
win (≈ 40%), targets a small score margin, and times its moves with a move-number-aware
|
win (≈ 40%), targets a small score margin — with an occasional off-strategy move that tapers to
|
||||||
|
none as the bag empties — and times its moves with a move-number-aware
|
||||||
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||||
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
|
state. In a dead-drawn endgame — the last two journal moves are both passes, so the robot is bound
|
||||||
exposes `Poll` so a waiting player can collect the started game (the live
|
to pass again — it shortens that delay to a `[0.8, 1.5]×` band around the human's last-move think
|
||||||
match-found notification arrives with the `gateway`).
|
time (the gap between the last two moves), clamped to `[30 s, 8 min]` and `min`-ed with the normal
|
||||||
|
delay, so a decided game is not dragged out while the robot never moves slower than usual. A background **reaper** seats a pooled robot (matching the game's language) in any open
|
||||||
|
game whose wait window — a fixed **90 s** plus a random **0–90 s** (so **90–180 s**) — has
|
||||||
|
elapsed, and the waiting starter is told an opponent took the seat by an in-app
|
||||||
|
**opponent_joined** push (carrying their refreshed game state) that fills the opponent card and
|
||||||
|
re-enables resign and chat in place.
|
||||||
|
|
||||||
Stage 6 opens the backend to the edge. The route groups gain their first
|
The same robot also backs an **honest-AI quick game** (`games.vs_ai`), the alternative to the random
|
||||||
|
path that the player chooses on New Game. `Matchmaker.StartVsAI` picks a pooled robot and creates a
|
||||||
|
game **already seated and active** (random seat order) — it never enters the open pool. The robot
|
||||||
|
driver has a `vs_ai` branch (no sleep, no proactive nudge, zero delay) plus a focused
|
||||||
|
`DriveGame`/`TriggerMove` fast path wired from the game service's after-create/after-commit hook
|
||||||
|
(`SetAITrigger`), so the robot replies the instant the player moves. AI games keep the same strength
|
||||||
|
(`playToWin`), have **no per-move timeout** (`turn_timeout_secs = AIInactivityTimeout`, 7 days, so an
|
||||||
|
abandoned game is lost after a week of inactivity — only the human is ever on the clock), record **no
|
||||||
|
statistics** (skipped in `commit`), and disable chat/nudge (`social` `VsAI` → `ErrGameVsAI`).
|
||||||
|
|
||||||
|
The backend opens to the edge. The route groups gain their first
|
||||||
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
|
||||||
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
|
||||||
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
slice of authenticated `/api/v1/user` operations (profile, submit play, game
|
||||||
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
|
state, lobby enqueue, chat). The social/account/history operations under
|
||||||
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
|
||||||
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||||
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
|
||||||
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
|
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
|
||||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||||
gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's
|
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||||
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
|
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
|
||||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login
|
||||||
seed a new account's language and display name from the launch fields, and adds
|
seeds a new account's language and display name from the launch fields, and the
|
||||||
migration `00007` (`accounts.notifications_in_app_only`, default true).
|
`accounts.notifications_in_app_only` flag (default true).
|
||||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the
|
||||||
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
|
bot's join-time query) or an account id (a `chat_access_changed` event) to its
|
||||||
|
**moderated-chat write eligibility** — `registered AND NOT suspended AND NOT chat_muted`.
|
||||||
|
That event is emitted on an admin block/unblock, a `chat_muted` role grant/revoke, or — via
|
||||||
|
the `account.SuspensionSweeper` started in `cmd/backend` — a temporary block lapsing;
|
||||||
|
`chat_muted` is an `account.KnownRoles` entry, a chat-only mute distinct from the game
|
||||||
|
suspension (which dominates it).
|
||||||
|
`accounts.is_guest` marks an ephemeral guest — a durable row
|
||||||
|
with no identity, excluded from statistics. The server-rendered
|
||||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
||||||
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
|
||||||
**complaint resolution** lifecycle (migration `00008` adds `disposition`/`resolution_note`/
|
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
|
||||||
`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change
|
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
|
||||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar.gz` release
|
||||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` +
|
||||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the
|
||||||
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
|
`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a
|
||||||
migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram
|
backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's
|
||||||
user last signed in through, written on every login and returned by
|
**bot-link relay** — each broadcast renders through the bot in an operator-chosen language
|
||||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
and the relay awaits the bot's delivery ack. There is one bot,
|
||||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
so `/internal/push-target` returns the recipient's `preferred_language` as the render
|
||||||
|
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
|
||||||
|
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
|
||||||
|
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
|
||||||
|
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
|
||||||
|
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
|
||||||
|
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
|
||||||
|
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||||
|
|
||||||
Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||||
attached to the current account, and when the identity already has its own account
|
attached to the current account, and when the identity already has its own account
|
||||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||||
@@ -96,8 +144,23 @@ friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (s
|
|||||||
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
||||||
The current account is primary, except a guest initiator whose linked identity has a
|
The current account is primary, except a guest initiator whose linked identity has a
|
||||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||||
Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the
|
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||||
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||||
|
|
||||||
|
Rate-limit observability: the gateway posts its periodic rejection
|
||||||
|
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
|
||||||
|
bounded in-memory episode window for the console's **Throttled** page and applies the
|
||||||
|
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
|
||||||
|
rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible
|
||||||
|
`accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a
|
||||||
|
**Clear** action on the user card; never an automatic ban).
|
||||||
|
|
||||||
|
The gateway also syncs its active IP bans (prod-only — see ARCHITECTURE §11) to
|
||||||
|
`POST /api/v1/internal/bans/sync`; `internal/banview` mirrors them for the console's
|
||||||
|
**Throttled** page (an **Active IP bans** panel with an **Unban** action) and returns
|
||||||
|
the operator's pending unbans in the response, which the gateway applies on its next
|
||||||
|
sync. Like `ratewatch` it is in-memory and resets on restart — the enforced ban lives
|
||||||
|
in the gateway, not here.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
@@ -110,17 +173,21 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
|||||||
migrations/ # embedded *.sql (goose), schema `backend`
|
migrations/ # embedded *.sql (goose), schema `backend`
|
||||||
jet/ # generated go-jet models + table builders (committed)
|
jet/ # generated go-jet models + table builders (committed)
|
||||||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
||||||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11)
|
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
|
||||||
internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11)
|
internal/link/ # link/merge orchestrator over account + accountmerge + session
|
||||||
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
|
||||||
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||||||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||||||
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
|
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
|
||||||
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
|
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
|
||||||
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
|
internal/feedback/ # user feedback: messages + attachment (bytea), anti-spam gate, admin review/reply
|
||||||
|
internal/lobby/ # auto-match (DB-backed open games + robot substitution) + friend-game invitations
|
||||||
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
|
||||||
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
|
||||||
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
|
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
|
||||||
|
internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts)
|
||||||
|
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
|
||||||
|
internal/banview/ # gateway active-ban mirror: the console's Active IP bans panel + the operator unban backchannel
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration (environment)
|
## Configuration (environment)
|
||||||
@@ -139,7 +206,7 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b
|
|||||||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
||||||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
||||||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
||||||
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
|
| `BACKEND_DICT_VERSION` | `v1` | Version label for the flat dictionary dir. Recorded in a `.seed_version` marker on first boot and authoritative after: on a seeded volume a changed value is ignored (it seeds only a fresh volume) — the seed-drift guard (ARCHITECTURE.md §5). |
|
||||||
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
||||||
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
|
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
|
||||||
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
|
||||||
@@ -150,16 +217,18 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b
|
|||||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
||||||
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
| `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
|
||||||
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
|
||||||
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
|
||||||
|
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
|
||||||
|
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
||||||
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
|
||||||
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
|
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.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_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||||
BACKEND_DICT_DIR=/tmp/dawg \
|
BACKEND_DICT_DIR=/tmp/dawg \
|
||||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
||||||
@@ -176,7 +245,10 @@ warmed.
|
|||||||
## Migrations & generated code
|
## Migrations & generated code
|
||||||
|
|
||||||
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
|
||||||
`NNNNN_name.sql`), embedded and applied at startup. After changing the schema,
|
`NNNNN_name.sql`), embedded and applied at startup. The incremental history was
|
||||||
|
squashed into a single `00001_baseline.sql` before the first production deploy
|
||||||
|
(there was no production data); new schema changes append as `00002_*` onward.
|
||||||
|
After changing the schema,
|
||||||
regenerate the committed go-jet code (needs Docker):
|
regenerate the committed go-jet code (needs Docker):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -194,9 +266,15 @@ local solver co-development you may add a temporary replace — see `go.work`).
|
|||||||
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
||||||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
||||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
||||||
`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency
|
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
|
||||||
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14
|
(a missing dictionary aborts the boot). The flat directory is the seed version,
|
||||||
(TODO-1/TODO-2).
|
labelled `BACKEND_DICT_VERSION`; uploaded versions live in `<version>/`
|
||||||
|
subdirectories the admin console writes and a restart re-loads. Because the DAWGs
|
||||||
|
carry no embedded version, the first boot records the seed in a `.seed_version`
|
||||||
|
marker that is authoritative after: on a seeded volume a changed `BACKEND_DICT_VERSION`
|
||||||
|
is ignored (it seeds only a fresh volume) — the seed-drift guard — so a live contour's
|
||||||
|
dictionary is changed through the console, never by bumping the build seed
|
||||||
|
(ARCHITECTURE.md §5).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+69
-11
@@ -3,8 +3,8 @@
|
|||||||
// loads the dictionaries into the engine registry, warms the session cache,
|
// loads the dictionaries into the engine registry, warms the session cache,
|
||||||
// constructs the game domain and starts its turn-timeout sweeper, constructs the
|
// constructs the game domain and starts its turn-timeout sweeper, constructs the
|
||||||
// lobby and social domains, then serves the HTTP listener with the infrastructure
|
// 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
|
// probes and the /api/v1 route group, behind which the domains expose their HTTP
|
||||||
// with the gateway in a later stage described in PLAN.md.
|
// endpoints to the gateway.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,19 +15,24 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
"scrabble/backend/internal/accountmerge"
|
||||||
|
"scrabble/backend/internal/ads"
|
||||||
|
"scrabble/backend/internal/banview"
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
"scrabble/backend/internal/connector"
|
"scrabble/backend/internal/connector"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/feedback"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/server"
|
"scrabble/backend/internal/server"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -107,7 +112,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.String("dir", cfg.Game.DictDir),
|
zap.String("dir", cfg.Game.DictDir),
|
||||||
zap.String("version", cfg.Game.DictVersion))
|
zap.String("version", cfg.Game.DictVersion))
|
||||||
|
|
||||||
// Stage 10 admin console: an optional backend client to the Telegram connector
|
// Admin console: an optional backend client to the Telegram connector
|
||||||
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
|
||||||
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
// leaves broadcasts disabled — the console shows a "not configured" notice.
|
||||||
var conn *connector.Client
|
var conn *connector.Client
|
||||||
@@ -134,13 +139,21 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
accounts := account.NewStore(db)
|
accounts := account.NewStore(db)
|
||||||
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||||
|
// Reconcile the persisted active dictionary version with the registry: a
|
||||||
|
// version activated through the admin console (and written to the dictionary
|
||||||
|
// volume) is adopted again after a restart; otherwise the configured seed
|
||||||
|
// version is kept and persisted (docs/ARCHITECTURE.md §5).
|
||||||
|
if err := games.InitActiveVersion(ctx); err != nil {
|
||||||
|
return fmt.Errorf("init active dictionary version: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
|
||||||
games.SetNotifier(hub)
|
games.SetNotifier(hub)
|
||||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||||
logger.Info("game turn-timeout sweeper started",
|
logger.Info("game turn-timeout sweeper started",
|
||||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
||||||
|
|
||||||
// Stage 12 TODO-3: reap abandoned guest accounts (no game seat, account age past
|
// Reap abandoned guest accounts (no game seat, account age past
|
||||||
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
|
||||||
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
|
||||||
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
go guestReaper.Run(ctx, cfg.GuestReapInterval)
|
||||||
@@ -148,34 +161,74 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.Duration("interval", cfg.GuestReapInterval),
|
zap.Duration("interval", cfg.GuestReapInterval),
|
||||||
zap.Duration("retention", cfg.GuestRetention))
|
zap.Duration("retention", cfg.GuestRetention))
|
||||||
|
|
||||||
// Stage 4 lobby & social domains. Their REST and stream surface is added with
|
// Re-evaluate moderated-chat write access when a temporary block self-expires:
|
||||||
// the gateway in Stage 6, so they are handed to the server (like the route
|
// no operator action fires then, so the sweeper emits the chat-access-changed
|
||||||
// groups) for the handlers to come.
|
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
|
||||||
|
chatSweeper := account.NewSuspensionSweeper(accounts, func(id uuid.UUID) {
|
||||||
|
hub.Publish(notify.ChatAccessChanged(id))
|
||||||
|
}, logger)
|
||||||
|
go chatSweeper.Run(ctx)
|
||||||
|
logger.Info("suspension expiry sweeper started", zap.Duration("interval", chatSweeper.Interval()))
|
||||||
|
|
||||||
|
// Lobby & social domains. Their REST and stream surface lives in the gateway,
|
||||||
|
// so they are handed to the server (like the route groups) for the handlers.
|
||||||
mailer := newMailer(cfg.SMTP, logger)
|
mailer := newMailer(cfg.SMTP, logger)
|
||||||
emails := account.NewEmailService(accounts, mailer)
|
emails := account.NewEmailService(accounts, mailer)
|
||||||
// Stage 11 account linking & merge: the orchestrator over the account, merge and
|
// Account linking & merge: the orchestrator over the account, merge and
|
||||||
// session layers. Wired to the /api/v1/user/link REST surface below.
|
// session layers. Wired to the /api/v1/user/link REST surface below.
|
||||||
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
|
||||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||||
socialSvc.SetNotifier(hub)
|
socialSvc.SetNotifier(hub)
|
||||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||||
|
// A nudge the recipient answered by moving is marked read on the move path.
|
||||||
|
games.SetNudgeClearer(socialSvc.ClearNudges)
|
||||||
|
// Reap per-game disguised-robot friend requests once their game is long finished
|
||||||
|
// (the robot ignores them; the row only pins the in-game "request sent" state).
|
||||||
|
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
|
||||||
|
go robotReqReaper.Run(ctx)
|
||||||
|
logger.Info("robot friend request reaper started",
|
||||||
|
zap.Duration("interval", robotReqReaper.Interval()),
|
||||||
|
zap.Duration("retention", robotReqReaper.Retention()))
|
||||||
|
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
||||||
|
feedbackSvc.SetNotifier(hub)
|
||||||
|
|
||||||
// Stage 5 robot opponent: provision its durable account pool (a hard startup
|
// Robot opponent: provision its durable account pool (a hard startup
|
||||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||||
// substitutes a pooled robot for a missing human after the wait window.
|
// substitutes a pooled robot for a missing human after the wait window.
|
||||||
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
|
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
|
||||||
if err := robots.EnsurePool(ctx); err != nil {
|
if err := robots.EnsurePool(ctx); err != nil {
|
||||||
return fmt.Errorf("provision robot pool: %w", err)
|
return fmt.Errorf("provision robot pool: %w", err)
|
||||||
}
|
}
|
||||||
|
// Honest-AI fast path: a move in a vs_ai game triggers the robot's reply at once
|
||||||
|
// (the periodic driver below is the fallback). Set after the pool is provisioned.
|
||||||
|
games.SetAITrigger(robots.TriggerMove)
|
||||||
go robots.Run(ctx, cfg.Robot.DriveInterval)
|
go robots.Run(ctx, cfg.Robot.DriveInterval)
|
||||||
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
|
logger.Info("robot driver started", zap.Duration("interval", cfg.Robot.DriveInterval))
|
||||||
|
|
||||||
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, logger)
|
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
|
||||||
matchmaker.SetNotifier(hub)
|
matchmaker.SetNotifier(hub)
|
||||||
|
matchmaker.SetBlocker(socialSvc)
|
||||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||||
invitations.SetNotifier(hub)
|
invitations.SetNotifier(hub)
|
||||||
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
|
logger.Info("lobby and social domains ready",
|
||||||
|
zap.Duration("robot_wait", cfg.Lobby.RobotWait),
|
||||||
|
zap.Duration("robot_wait_jitter", cfg.Lobby.RobotWaitJitter))
|
||||||
|
|
||||||
|
// Rate-limit observability: ingest the gateway's rejection reports for the
|
||||||
|
// admin throttled view and the conservative high-rate auto-flag.
|
||||||
|
rateWatch := ratewatch.New(cfg.RateWatch, accounts, logger)
|
||||||
|
logger.Info("rate watch ready",
|
||||||
|
zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold),
|
||||||
|
zap.Duration("flag_window", cfg.RateWatch.FlagWindow))
|
||||||
|
|
||||||
|
// Ban observability: mirror the gateway's active IP bans for the admin console's
|
||||||
|
// active-bans panel and collect operator unban requests.
|
||||||
|
banView := banview.New()
|
||||||
|
|
||||||
|
// Advertising-banner domain: campaign rotation feeding the profile.get banner
|
||||||
|
// block and the banner admin console section.
|
||||||
|
adsSvc := ads.NewService(ads.NewStore(db))
|
||||||
|
|
||||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
@@ -185,6 +238,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Accounts: accounts,
|
Accounts: accounts,
|
||||||
Games: games,
|
Games: games,
|
||||||
|
Feedback: feedbackSvc,
|
||||||
Social: socialSvc,
|
Social: socialSvc,
|
||||||
Matchmaker: matchmaker,
|
Matchmaker: matchmaker,
|
||||||
Invitations: invitations,
|
Invitations: invitations,
|
||||||
@@ -193,6 +247,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Registry: registry,
|
Registry: registry,
|
||||||
DictDir: cfg.Game.DictDir,
|
DictDir: cfg.Game.DictDir,
|
||||||
Connector: conn,
|
Connector: conn,
|
||||||
|
RateWatch: rateWatch,
|
||||||
|
BanView: banView,
|
||||||
|
Ads: adsSvc,
|
||||||
|
Notifier: hub,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ module scrabble/backend
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0
|
gitea.iliadenisov.ru/developer/scrabble-solver v1.1.1
|
||||||
github.com/XSAM/otelsql v0.42.0
|
github.com/XSAM/otelsql v0.42.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-jet/jet/v2 v2.14.1
|
github.com/go-jet/jet/v2 v2.14.1
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import (
|
|||||||
|
|
||||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||||
// alongside platform identities; its confirmed flag is driven by the email
|
// alongside platform identities; its confirmed flag is driven by the email
|
||||||
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
|
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||||
// robot opponent is a durable account bound to one robot identity (Stage 5).
|
// robot opponent is a durable account bound to one robot identity.
|
||||||
const (
|
const (
|
||||||
KindTelegram = "telegram"
|
KindTelegram = "telegram"
|
||||||
KindEmail = "email"
|
KindEmail = "email"
|
||||||
@@ -55,29 +55,34 @@ type Account struct {
|
|||||||
HintBalance int
|
HintBalance int
|
||||||
BlockChat bool
|
BlockChat bool
|
||||||
BlockFriendRequests bool
|
BlockFriendRequests bool
|
||||||
// ServiceLanguage is the language tag (en/ru) of the bot the account last
|
// VariantPreferences is the set of game variants (engine.Variant stable labels:
|
||||||
// authenticated through (its last Telegram ValidateInitData); it routes the
|
// "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
|
||||||
// account's out-of-app push back through the right bot. Empty when the account
|
// into. It gates the New Game picker, the matchmaker and the friend-invite the
|
||||||
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the
|
// player creates; an invited friend may still accept any variant. A new account
|
||||||
// interface language) and from a game's variant language.
|
// defaults to Erudit only. Never empty — enforced on update and by a DB check.
|
||||||
ServiceLanguage string
|
VariantPreferences []string
|
||||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||||
// excluded from statistics, friends and history.
|
// excluded from statistics, friends and history.
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||||
// true (the default): the platform side-service skips out-of-app push for the
|
// true (the default): the platform side-service skips out-of-app push for the
|
||||||
// account (Stage 9).
|
// account.
|
||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
||||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
||||||
// never lost when accounts are consolidated (Stage 11).
|
// never lost when accounts are consolidated.
|
||||||
PaidAccount bool
|
PaidAccount bool
|
||||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
// MergedInto is the primary account a retired (merged) secondary points at, or
|
||||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||||
// foreign keys of a shared finished game stay valid (Stage 11).
|
// foreign keys of a shared finished game stay valid.
|
||||||
MergedInto uuid.UUID
|
MergedInto uuid.UUID
|
||||||
CreatedAt time.Time
|
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||||
UpdatedAt time.Time
|
// zero time for an unflagged account, otherwise when the gateway-reported
|
||||||
|
// rate-limiter rejections first crossed the sustained threshold. An
|
||||||
|
// operator clears it in the admin console; it never gates any request.
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity is one of an account's platform/email identities, surfaced on the
|
// Identity is one of an account's platform/email identities, surfaced on the
|
||||||
@@ -94,12 +99,15 @@ type Identity struct {
|
|||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
metrics *accountMetrics
|
metrics *accountMetrics
|
||||||
|
// suspensions caches each account's current manual block, read by the suspension gate on
|
||||||
|
// every authenticated request and invalidated on Suspend/LiftSuspension. See suspension.go.
|
||||||
|
suspensions *suspensionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
||||||
// SetMetrics installs the real one during startup wiring.
|
// SetMetrics installs the real one during startup wiring.
|
||||||
func NewStore(db *sql.DB) *Store {
|
func NewStore(db *sql.DB) *Store {
|
||||||
return &Store{db: db, metrics: defaultAccountMetrics()}
|
return &Store{db: db, metrics: defaultAccountMetrics(), suspensions: newSuspensionCache()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
||||||
@@ -111,6 +119,16 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
|||||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
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
|
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||||
// member: a KindRobot identity carrying displayName, with chat blocked but friend
|
// 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
|
// requests NOT blocked — a request to a robot is accepted as pending and, since the
|
||||||
@@ -143,14 +161,28 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
|
|||||||
return modelToAccount(row), nil
|
return modelToAccount(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity,
|
||||||
// identity. On first contact only, it seeds the new account's preferred language
|
// reporting whether this call created it (first contact). On first contact only, it
|
||||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
// seeds the new account's preferred language from the Telegram client languageCode
|
||||||
// its display name sanitized from firstName (falling back to username, then to a
|
// (when it maps to a supported language) and its display name sanitized from firstName
|
||||||
// generated placeholder when neither yields any letters); an already-existing
|
// (falling back to username, then to a generated placeholder when neither yields any
|
||||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
// letters); an already-existing account is returned unchanged, so a later profile edit
|
||||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
|
||||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
// 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,
|
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||||
@@ -177,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
|
// provisionSeed carries the optional create-time profile seed for a brand-new
|
||||||
// account (Telegram first contact). Empty fields fall back to the accounts table
|
// account (first contact). Empty fields fall back to the accounts table defaults,
|
||||||
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
|
// so an unknown language keeps the 'en' default, an empty name keeps the ” default
|
||||||
// the ” default.
|
// and an empty time zone keeps the 'UTC' default.
|
||||||
type provisionSeed struct {
|
type provisionSeed struct {
|
||||||
preferredLanguage string
|
preferredLanguage string
|
||||||
displayName 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
|
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||||
// region-tagged like "ru-RU"), and a display name sanitized from firstName or,
|
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
|
||||||
// failing that, username (sanitizeDisplayName strips disallowed characters to the
|
// name (firstName, sanitized to the editable format) → the @username taken verbatim
|
||||||
// editable format). When neither yields any letters, it falls back to a generated
|
// (already a valid handle, only trimmed and length-capped, never character-stripped)
|
||||||
// placeholder in the seeded language (placeholderDisplayName).
|
// → 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 {
|
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||||
var seed provisionSeed
|
var seed provisionSeed
|
||||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
@@ -198,7 +243,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
|||||||
}
|
}
|
||||||
name := sanitizeDisplayName(firstName)
|
name := sanitizeDisplayName(firstName)
|
||||||
if name == "" {
|
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 == "" {
|
if name == "" {
|
||||||
name = placeholderDisplayName(seed.preferredLanguage)
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
@@ -295,6 +346,14 @@ func (s *Store) CountAccounts(ctx context.Context) (int, error) {
|
|||||||
return int(dest.Count), nil
|
return int(dest.Count), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound
|
||||||
|
// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access
|
||||||
|
// resolver uses it to tell a registered Telegram user (eligible to be granted chat
|
||||||
|
// write access) from an unregistered one (left muted).
|
||||||
|
func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||||
|
return s.findByIdentity(ctx, kind, externalID)
|
||||||
|
}
|
||||||
|
|
||||||
// findByIdentity joins identities to accounts and returns the matching account,
|
// findByIdentity joins identities to accounts and returns the matching account,
|
||||||
// or ErrNotFound.
|
// or ErrNotFound.
|
||||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||||
@@ -333,16 +392,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
|
|
||||||
var created Account
|
var created Account
|
||||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
// Seed the new row's display name and language (Telegram first contact); an
|
// Seed the new row's display name, language and time zone (first contact); an
|
||||||
// empty seed reproduces the table defaults ('' and 'en') the other callers
|
// empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
|
||||||
// relied on, so their behaviour is unchanged.
|
// 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
|
lang := seed.preferredLanguage
|
||||||
if lang == "" {
|
if lang == "" {
|
||||||
lang = "en"
|
lang = "en"
|
||||||
}
|
}
|
||||||
|
tz := seed.timeZone
|
||||||
|
if tz == "" {
|
||||||
|
tz = "UTC"
|
||||||
|
}
|
||||||
insertAccount := table.Accounts.
|
insertAccount := table.Accounts.
|
||||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
|
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
|
||||||
VALUES(accountID, seed.displayName, lang).
|
VALUES(accountID, seed.displayName, lang, tz).
|
||||||
RETURNING(table.Accounts.AllColumns)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
var row model.Accounts
|
var row model.Accounts
|
||||||
@@ -381,15 +446,21 @@ const guestDisplayName = "Guest"
|
|||||||
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
|
// 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
|
// 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
|
// foreign-key the accounts table) while being excluded from statistics, friends
|
||||||
// and history. Guests are not reused — each bootstrap mints a new account.
|
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
|
||||||
func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
|
// (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()
|
accountID, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Account{}, fmt.Errorf("account: new guest id: %w", err)
|
return Account{}, fmt.Errorf("account: new guest id: %w", err)
|
||||||
}
|
}
|
||||||
|
tz := seedZone(browserTZ)
|
||||||
|
if tz == "" {
|
||||||
|
tz = "UTC"
|
||||||
|
}
|
||||||
stmt := table.Accounts.
|
stmt := table.Accounts.
|
||||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest).
|
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
|
||||||
VALUES(accountID, guestDisplayName, true).
|
VALUES(accountID, guestDisplayName, true, tz).
|
||||||
RETURNING(table.Accounts.AllColumns)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
var row model.Accounts
|
var row model.Accounts
|
||||||
@@ -422,22 +493,63 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
|||||||
return n > 0, nil
|
return n > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
|
||||||
// user authenticated through. It is called on every Telegram login — new and
|
// positive: the additive update can only raise the balance, never lower it, so it enforces the
|
||||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
|
||||||
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
|
// It returns ErrNotFound when no account matches.
|
||||||
// (a non-Telegram login carries none) and does not bump updated_at (an infra
|
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
|
||||||
// routing field, not a user profile edit).
|
if n <= 0 {
|
||||||
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
|
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
|
||||||
if language == "" {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
stmt := table.Accounts.
|
stmt := table.Accounts.
|
||||||
UPDATE(table.Accounts.ServiceLanguage).
|
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||||
SET(postgres.String(language)).
|
SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())).
|
||||||
|
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||||
|
RETURNING(table.Accounts.HintBalance)
|
||||||
|
|
||||||
|
var row model.Accounts
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return 0, ErrNotFound
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("account: grant hints %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return int(row.HintBalance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
|
||||||
|
// the account is not already flagged — the first sustained episode wins, and a
|
||||||
|
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
|
||||||
|
// a profile edit, so updated_at is untouched; it never gates any request.
|
||||||
|
// It reports whether the flag was newly set.
|
||||||
|
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
|
||||||
|
stmt := table.Accounts.
|
||||||
|
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||||
|
SET(postgres.TimestampzT(at.UTC())).
|
||||||
|
WHERE(
|
||||||
|
table.Accounts.AccountID.EQ(postgres.UUID(id)).
|
||||||
|
AND(table.Accounts.FlaggedHighRateAt.IS_NULL()),
|
||||||
|
)
|
||||||
|
res, err := stmt.ExecContext(ctx, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("account: flag high rate %s: %w", id, err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("account: flag high rate rows %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearHighRateFlag removes the high-rate marker — the operator's reversible
|
||||||
|
// action in the admin console. Clearing an unflagged account is a no-op.
|
||||||
|
func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
|
||||||
|
stmt := table.Accounts.
|
||||||
|
UPDATE(table.Accounts.FlaggedHighRateAt).
|
||||||
|
SET(postgres.NULL).
|
||||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
return fmt.Errorf("account: set service language %s: %w", id, err)
|
return fmt.Errorf("account: clear high-rate flag %s: %w", id, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -448,15 +560,15 @@ func modelToAccount(row model.Accounts) Account {
|
|||||||
if row.MergedInto != nil {
|
if row.MergedInto != nil {
|
||||||
mergedInto = *row.MergedInto
|
mergedInto = *row.MergedInto
|
||||||
}
|
}
|
||||||
var serviceLanguage string
|
var flaggedHighRateAt time.Time
|
||||||
if row.ServiceLanguage != nil {
|
if row.FlaggedHighRateAt != nil {
|
||||||
serviceLanguage = *row.ServiceLanguage
|
flaggedHighRateAt = *row.FlaggedHighRateAt
|
||||||
}
|
}
|
||||||
return Account{
|
return Account{
|
||||||
ID: row.AccountID,
|
ID: row.AccountID,
|
||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
PreferredLanguage: row.PreferredLanguage,
|
PreferredLanguage: row.PreferredLanguage,
|
||||||
ServiceLanguage: serviceLanguage,
|
VariantPreferences: []string(row.VariantPreferences),
|
||||||
TimeZone: row.TimeZone,
|
TimeZone: row.TimeZone,
|
||||||
AwayStart: row.AwayStart,
|
AwayStart: row.AwayStart,
|
||||||
AwayEnd: row.AwayEnd,
|
AwayEnd: row.AwayEnd,
|
||||||
@@ -467,6 +579,7 @@ func modelToAccount(row model.Accounts) Account {
|
|||||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||||
PaidAccount: row.PaidAccount,
|
PaidAccount: row.PaidAccount,
|
||||||
MergedInto: mergedInto,
|
MergedInto: mergedInto,
|
||||||
|
FlaggedHighRateAt: flaggedHighRateAt,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ var (
|
|||||||
// ErrInvalidEmail is returned for an unparseable email address.
|
// ErrInvalidEmail is returned for an unparseable email address.
|
||||||
ErrInvalidEmail = errors.New("account: invalid email address")
|
ErrInvalidEmail = errors.New("account: invalid email address")
|
||||||
// ErrEmailTaken is returned when the email is already confirmed by another
|
// ErrEmailTaken is returned when the email is already confirmed by another
|
||||||
// account; binding it would be a merge, which Stage 11 owns.
|
// account; binding it would be a merge, which the link/merge flow owns.
|
||||||
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
ErrEmailTaken = errors.New("account: email already confirmed by another account")
|
||||||
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
|
||||||
// requesting account.
|
// requesting account.
|
||||||
@@ -52,8 +52,8 @@ var (
|
|||||||
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
// Mailer and verifies it, binding a confirmed email identity to the requesting
|
||||||
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
|
||||||
// matching the session model. Binding an email already confirmed by a different
|
// matching the session model. Binding an email already confirmed by a different
|
||||||
// account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
|
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
|
||||||
// using an email as a login is Stage 6, which reuses this mechanism.
|
// and using an email as a login reuses this mechanism.
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
store *Store
|
store *Store
|
||||||
mailer Mailer
|
mailer Mailer
|
||||||
@@ -128,16 +128,18 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
|
|
||||||
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
// RequestLoginCode issues a login confirm-code to the account that owns email,
|
||||||
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
|
||||||
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
|
// the unauthenticated email-login entry point and, unlike RequestCode,
|
||||||
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
// does not refuse an already-confirmed email — that is the ordinary returning-user
|
||||||
// login. The code is mailed to the address, so only its real owner can complete
|
// login. The code is mailed to the address, so only its real owner can complete
|
||||||
// the login. It returns the target account id for the subsequent LoginWithCode.
|
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
|
||||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) {
|
// 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)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr)
|
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrIdentityTaken is returned when a platform identity being linked already
|
// ErrIdentityTaken is returned when a platform identity being linked already
|
||||||
// belongs to another account; the caller turns it into a merge (Stage 11).
|
// belongs to another account; the caller turns it into a merge.
|
||||||
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
|
||||||
|
|
||||||
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
// RequestLinkCode issues and mails a confirm-code for email to accountID,
|
||||||
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
// replacing any prior pending code. Unlike RequestCode it never refuses up front
|
||||||
// (taken or already-confirmed): possession of the address is the authorization for
|
// (taken or already-confirmed): possession of the address is the authorization for
|
||||||
// a later link or merge, and the merge is only revealed once the code is verified,
|
// a later link or merge, and the merge is only revealed once the code is verified,
|
||||||
// so a probe cannot learn whether an address is registered (Stage 11).
|
// so a probe cannot learn whether an address is registered.
|
||||||
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,7 +94,7 @@ func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUI
|
|||||||
|
|
||||||
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
|
||||||
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
|
||||||
// flow (Stage 11).
|
// flow.
|
||||||
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
|
||||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -109,7 +109,7 @@ func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string
|
|||||||
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
// AttachIdentity links a new (kind, externalID) identity to an existing account.
|
||||||
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
// A unique-constraint violation means the identity was taken meanwhile, surfaced
|
||||||
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
|
||||||
// to the current account during linking (Stage 11).
|
// to the current account during linking.
|
||||||
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +129,7 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
|
||||||
// to a durable account once it gains its first identity (Stage 11). It is a no-op
|
// to a durable account once it gains its first identity. It is a no-op
|
||||||
// for an already-durable account.
|
// for an already-durable account.
|
||||||
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
|
||||||
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
@@ -23,15 +24,23 @@ import (
|
|||||||
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
// is unbounded; auto-provisioned platform names bypass this editor validation).
|
||||||
const maxDisplayName = 32
|
const maxDisplayName = 32
|
||||||
|
|
||||||
|
// maxDisplayNameSpecials caps the total special characters (every name rune that is
|
||||||
|
// neither a letter, a space, nor a digit — i.e. the "." / "_" separators) an editable
|
||||||
|
// display name may carry, so a still-well-formed name cannot be made of mostly
|
||||||
|
// punctuation. A trailing digit run is bounded separately by displayNameRe.
|
||||||
|
const maxDisplayNameSpecials = 5
|
||||||
|
|
||||||
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
|
||||||
const maxAwayWindow = 12 * time.Hour
|
const maxAwayWindow = 12 * time.Hour
|
||||||
|
|
||||||
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
// displayNameRe enforces the editable display-name format: Unicode letters
|
||||||
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
||||||
// by a single space. No leading separator and no two adjacent separators (except
|
// by a single space. No leading separator and no two adjacent separators (except
|
||||||
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so
|
// "<dot|underscore> <space>"). The name may end with EITHER a single trailing "."
|
||||||
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
|
// (an initial, "Anna B.") OR a run of 1–5 digits (a handle's number or year,
|
||||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
|
// "Player2007"), but not both; digits never appear elsewhere. So "Name_P. Last",
|
||||||
|
// "Anna B." and "Аня2007" are valid, while "Name P._Last" and "Dark2Wolf" are not.
|
||||||
|
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*(?:\.|[0-9]{1,5})?$`)
|
||||||
|
|
||||||
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
||||||
// field (an unknown language, an invalid timezone, or an over-long display name).
|
// field (an unknown language, an invalid timezone, or an over-long display name).
|
||||||
@@ -50,6 +59,106 @@ type ProfileUpdate struct {
|
|||||||
BlockChat bool
|
BlockChat bool
|
||||||
BlockFriendRequests bool
|
BlockFriendRequests bool
|
||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
|
// VariantPreferences is the set of game variants the player allows themselves to
|
||||||
|
// be matched into (engine.Variant stable labels). UpdateProfile cleans it to a
|
||||||
|
// deduplicated, canonically ordered subset of the known variants and rejects an
|
||||||
|
// empty set.
|
||||||
|
VariantPreferences []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownVariants is the closed set of game-variant labels (engine.Variant stable
|
||||||
|
// labels) a profile's variant preferences may contain. It lives here so the store
|
||||||
|
// does not depend on the engine package; the server handler additionally validates
|
||||||
|
// against engine.ParseVariant, and a DB check enforces the same subset.
|
||||||
|
var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true}
|
||||||
|
|
||||||
|
// canonicalVariantOrder is the deterministic order variant preferences are stored
|
||||||
|
// in (Erudit, Russian Scrabble, English), independent of the client's order.
|
||||||
|
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
|
||||||
|
|
||||||
|
// validateVariantPreferences cleans a profile's variant-preference set: it drops
|
||||||
|
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
|
||||||
|
// returns the preferences in canonicalVariantOrder so the stored value is
|
||||||
|
// deterministic regardless of the order the client sent.
|
||||||
|
func validateVariantPreferences(prefs []string) ([]string, error) {
|
||||||
|
seen := make(map[string]bool, len(prefs))
|
||||||
|
for _, p := range prefs {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if !knownVariants[p] {
|
||||||
|
return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p)
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
}
|
||||||
|
if len(seen) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for _, v := range canonicalVariantOrder {
|
||||||
|
if seen[v] {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||||
@@ -71,17 +180,26 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
|
|||||||
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
|
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
|
||||||
return Account{}, err
|
return Account{}, err
|
||||||
}
|
}
|
||||||
|
prefs, err := validateVariantPreferences(p.VariantPreferences)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
|
||||||
stmt := table.Accounts.UPDATE(
|
stmt := table.Accounts.UPDATE(
|
||||||
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
||||||
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
||||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
||||||
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
|
table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences,
|
||||||
|
table.Accounts.UpdatedAt,
|
||||||
).SET(
|
).SET(
|
||||||
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
||||||
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
||||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
|
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
|
||||||
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
|
postgres.Bool(p.NotificationsInAppOnly),
|
||||||
|
// prefs are validated against the closed knownVariants set; bind as a text[]
|
||||||
|
// parameter (lib/pq encodes the array, the cast pins the column type).
|
||||||
|
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}),
|
||||||
|
postgres.TimestampzT(time.Now().UTC()),
|
||||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||||
RETURNING(table.Accounts.AllColumns)
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
@@ -110,6 +228,15 @@ func ValidateDisplayName(raw string) (string, error) {
|
|||||||
if !displayNameRe.MatchString(name) {
|
if !displayNameRe.MatchString(name) {
|
||||||
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
|
||||||
}
|
}
|
||||||
|
specials := 0
|
||||||
|
for _, r := range name {
|
||||||
|
if r != ' ' && !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||||
|
specials++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if specials > maxDisplayNameSpecials {
|
||||||
|
return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials)
|
||||||
|
}
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package account
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,11 +13,11 @@ import (
|
|||||||
|
|
||||||
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
// TestUpdateProfileValidation checks that bad fields are rejected before any
|
||||||
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
// database access, so a nil-backed Store is enough to exercise the guards. It also
|
||||||
// confirms UpdateProfile wires the Stage 8 validators (name format, away window,
|
// confirms UpdateProfile wires the validators (name format, away window,
|
||||||
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
// offset/IANA timezone), not just their unit tests in validate_test.go.
|
||||||
func TestUpdateProfileValidation(t *testing.T) {
|
func TestUpdateProfileValidation(t *testing.T) {
|
||||||
s := &Store{}
|
s := &Store{}
|
||||||
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"}
|
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}}
|
||||||
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
|
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) {
|
|||||||
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
|
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
|
||||||
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
|
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
|
||||||
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
|
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
|
||||||
|
{"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }},
|
||||||
|
{"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -39,3 +42,22 @@ func TestUpdateProfileValidation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateVariantPreferences checks the cleaning of a profile's variant set:
|
||||||
|
// duplicates collapse, the result is canonically ordered (Erudit, Russian Scrabble,
|
||||||
|
// English) regardless of input order, and an empty or unknown set is rejected.
|
||||||
|
func TestValidateVariantPreferences(t *testing.T) {
|
||||||
|
got, err := validateVariantPreferences([]string{"scrabble_en", "erudit_ru", "scrabble_en"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate: %v", err)
|
||||||
|
}
|
||||||
|
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if _, err := validateVariantPreferences(nil); !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("empty err = %v, want ErrInvalidProfile", err)
|
||||||
|
}
|
||||||
|
if _, err := validateVariantPreferences([]string{"chess"}); !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("unknown err = %v, want ErrInvalidProfile", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
|
|
||||||
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
|
||||||
// create-time account seed: supported-language detection (bare and region-tagged),
|
// create-time account seed: supported-language detection (bare and region-tagged),
|
||||||
// the first-name / username display-name precedence, and the sanitization that
|
// the real-name → @username (verbatim) → placeholder display-name precedence, and
|
||||||
// strips disallowed characters (emoji, digits, punctuation) to the editable format.
|
// 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) {
|
func TestTelegramSeed(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
|
|||||||
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||||
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||||
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||||
|
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
@@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
|
|||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
wantRe string
|
wantRe string
|
||||||
}{
|
}{
|
||||||
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||||
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||||
"default en": {"fr", "", "", `^Player-\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 {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-account roles. A role is a named capability or restriction attached to an
|
||||||
|
// account, the reusable replacement for per-feature boolean flags. The set is
|
||||||
|
// expected to grow; roles are validated against KnownRoles in Go so adding one
|
||||||
|
// needs no migration. Granted/revoked from the admin console (/users and the
|
||||||
|
// feedback section).
|
||||||
|
const (
|
||||||
|
// RoleFeedbackBanned forbids the account from submitting feedback (only that;
|
||||||
|
// it is not a full account suspension). See internal/feedback.
|
||||||
|
RoleFeedbackBanned = "feedback_banned"
|
||||||
|
|
||||||
|
// RoleNoBanner suppresses the in-app advertising banner for the account
|
||||||
|
// unconditionally, overriding the usual eligibility (a free account with an
|
||||||
|
// empty hint wallet otherwise sees it). See internal/ads.
|
||||||
|
RoleNoBanner = "no_banner"
|
||||||
|
|
||||||
|
// RoleChatMuted forbids the account from writing in the moderated Telegram
|
||||||
|
// discussion chat, without otherwise restricting the game (the chat-only
|
||||||
|
// counterpart to a full account suspension). It is one input to the chat-access
|
||||||
|
// gate; an active admin suspension mutes the player regardless, so this role only
|
||||||
|
// matters for an account that is not suspended. Granting or revoking it re-pushes
|
||||||
|
// the chat-gate command for a member currently in the chat.
|
||||||
|
RoleChatMuted = "chat_muted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KnownRoles is the set of roles the console may grant or revoke; an operator
|
||||||
|
// cannot assign an unrecognised role.
|
||||||
|
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner, RoleChatMuted}
|
||||||
|
|
||||||
|
// IsKnownRole reports whether role is a recognised account role.
|
||||||
|
func IsKnownRole(role string) bool {
|
||||||
|
for _, r := range KnownRoles {
|
||||||
|
if r == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantRole gives the account the role, idempotently (a repeat grant is a no-op).
|
||||||
|
func (s *Store) GrantRole(ctx context.Context, accountID uuid.UUID, role string) error {
|
||||||
|
stmt := table.AccountRoles.
|
||||||
|
INSERT(table.AccountRoles.AccountID, table.AccountRoles.Role).
|
||||||
|
VALUES(accountID, role).
|
||||||
|
ON_CONFLICT(table.AccountRoles.AccountID, table.AccountRoles.Role).DO_NOTHING()
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("account: grant role %q to %s: %w", role, accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRole removes the role from the account, idempotently.
|
||||||
|
func (s *Store) RevokeRole(ctx context.Context, accountID uuid.UUID, role string) error {
|
||||||
|
stmt := table.AccountRoles.
|
||||||
|
DELETE().
|
||||||
|
WHERE(table.AccountRoles.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(table.AccountRoles.Role.EQ(postgres.String(role))))
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("account: revoke role %q from %s: %w", role, accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRole reports whether the account holds the role.
|
||||||
|
func (s *Store) HasRole(ctx context.Context, accountID uuid.UUID, role string) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM backend.account_roles WHERE account_id = $1 AND role = $2)`,
|
||||||
|
accountID, role).Scan(&ok)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("account: has-role %q %s: %w", role, accountID, err)
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoles returns the account's roles, oldest grant first.
|
||||||
|
func (s *Store) ListRoles(ctx context.Context, accountID uuid.UUID) ([]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT role FROM backend.account_roles WHERE account_id = $1 ORDER BY granted_at ASC`,
|
||||||
|
accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list roles %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var role string
|
||||||
|
if err := rows.Scan(&role); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan role: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, role)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -13,16 +14,43 @@ import (
|
|||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BestMoveTile is one letter cell of a best-move word: its concrete letter (the
|
||||||
|
// designated letter for a blank), its tile point value (0 for a blank) and whether it
|
||||||
|
// is a blank. It is the persisted/served shape: the game domain marshals a slice of
|
||||||
|
// these into account_best_move.tiles, and the statistics screen renders them as game
|
||||||
|
// tiles without consulting the variant's alphabet.
|
||||||
|
type BestMoveTile struct {
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
Blank bool `json:"blank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BestMove is an account's highest-scoring single play within one game variant: the
|
||||||
|
// move's total score (every word it formed plus the all-tiles bonus, matching
|
||||||
|
// MaxWordPoints) and its main word as an ordered slice of tiles.
|
||||||
|
type BestMove struct {
|
||||||
|
Variant string
|
||||||
|
Score int
|
||||||
|
Tiles []BestMoveTile
|
||||||
|
}
|
||||||
|
|
||||||
// Stats is a durable account's lifetime record, written by the game domain on each
|
// Stats is a durable account's lifetime record, written by the game domain on each
|
||||||
// finish and read for the player's statistics screen. MaxGamePoints is the best
|
// finish and read for the player's statistics screen. MaxGamePoints is the best
|
||||||
// single game's total; MaxWordPoints is the best single move's score (which already
|
// single game's total; MaxWordPoints is the best single move's score (which already
|
||||||
// includes every word it formed plus the all-tiles bonus).
|
// includes every word it formed plus the all-tiles bonus). BestMoves holds the same
|
||||||
|
// best move broken down per variant, with the word itself — empty for an account with
|
||||||
|
// no recorded play yet, and never carrying a variant the account has not played.
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Wins int
|
Wins int
|
||||||
Losses int
|
Losses int
|
||||||
Draws int
|
Draws int
|
||||||
MaxGamePoints int
|
MaxGamePoints int
|
||||||
MaxWordPoints int
|
MaxWordPoints int
|
||||||
|
// Moves is the lifetime count of the account's plays (tile placements); HintsUsed is the
|
||||||
|
// lifetime count of hints taken. The statistics screen shows the hint share (HintsUsed / Moves).
|
||||||
|
Moves int
|
||||||
|
HintsUsed int
|
||||||
|
BestMoves []BestMove
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStats returns the lifetime statistics for id. An account with no account_stats
|
// GetStats returns the lifetime statistics for id. An account with no account_stats
|
||||||
@@ -40,11 +68,48 @@ func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) {
|
|||||||
}
|
}
|
||||||
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
|
return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err)
|
||||||
}
|
}
|
||||||
|
best, err := s.bestMoves(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return Stats{}, err
|
||||||
|
}
|
||||||
return Stats{
|
return Stats{
|
||||||
Wins: int(row.Wins),
|
Wins: int(row.Wins),
|
||||||
Losses: int(row.Losses),
|
Losses: int(row.Losses),
|
||||||
Draws: int(row.Draws),
|
Draws: int(row.Draws),
|
||||||
MaxGamePoints: int(row.MaxGamePoints),
|
MaxGamePoints: int(row.MaxGamePoints),
|
||||||
MaxWordPoints: int(row.MaxWordPoints),
|
MaxWordPoints: int(row.MaxWordPoints),
|
||||||
|
Moves: int(row.Moves),
|
||||||
|
HintsUsed: int(row.HintsUsed),
|
||||||
|
BestMoves: best,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bestMoves reads an account's per-variant best moves, ordered by variant for a stable
|
||||||
|
// response. Each row's tiles JSON is decoded into the served BestMoveTile slice. An
|
||||||
|
// account with no recorded play yields an empty (nil) slice rather than an error.
|
||||||
|
func (s *Store) bestMoves(ctx context.Context, id uuid.UUID) ([]BestMove, error) {
|
||||||
|
stmt := postgres.SELECT(
|
||||||
|
table.AccountBestMove.Variant,
|
||||||
|
table.AccountBestMove.Score,
|
||||||
|
table.AccountBestMove.Tiles,
|
||||||
|
).
|
||||||
|
FROM(table.AccountBestMove).
|
||||||
|
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(id))).
|
||||||
|
ORDER_BY(table.AccountBestMove.Variant.ASC())
|
||||||
|
var rows []model.AccountBestMove
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("account: best moves %s: %w", id, err)
|
||||||
|
}
|
||||||
|
out := make([]BestMove, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
var tiles []BestMoveTile
|
||||||
|
if err := json.Unmarshal([]byte(r.Tiles), &tiles); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: decode best-move tiles %s/%s: %w", id, r.Variant, err)
|
||||||
|
}
|
||||||
|
out = append(out, BestMove{Variant: r.Variant, Score: int(r.Score), Tiles: tiles})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suspension is an account's currently-in-force manual block, the operator's hard counterpart
|
||||||
|
// to the soft, reversible FlaggedHighRateAt marker. It is named "suspension" to stay distinct
|
||||||
|
// from the peer-to-peer blocks in internal/social (one player muting another); the vocabulary
|
||||||
|
// the player sees is "blocked". BlockedUntil is nil for a permanent block and the expiry
|
||||||
|
// instant for a temporary one. ReasonEn and ReasonRu are the reason-text snapshot taken at
|
||||||
|
// block time (both empty when no reason was cited), so editing or deleting the picklist entry
|
||||||
|
// never changes what an already-blocked player is shown.
|
||||||
|
type Suspension struct {
|
||||||
|
AccountID uuid.UUID
|
||||||
|
BlockedAt time.Time
|
||||||
|
BlockedUntil *time.Time
|
||||||
|
ReasonEn string
|
||||||
|
ReasonRu string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent reports whether the suspension has no expiry.
|
||||||
|
func (s Suspension) Permanent() bool { return s.BlockedUntil == nil }
|
||||||
|
|
||||||
|
// LocalizedReason returns the reason text in the given language ("ru" selects Russian, anything
|
||||||
|
// else English), or empty when no reason was cited. The two snapshots are always both set or
|
||||||
|
// both empty, since a reason is chosen from the en+ru picklist.
|
||||||
|
func (s Suspension) LocalizedReason(language string) string {
|
||||||
|
if language == "ru" {
|
||||||
|
return s.ReasonRu
|
||||||
|
}
|
||||||
|
return s.ReasonEn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reason is one entry of the operator-editable suspension-reason picklist, carrying the English
|
||||||
|
// and Russian text shown to a blocked player in their language.
|
||||||
|
type Reason struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
TextEn string
|
||||||
|
TextRu string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend records a new manual block on the account: permanent when until is nil, otherwise in
|
||||||
|
// force until that instant. reasonEn and reasonRu are the optional reason-text snapshot (both
|
||||||
|
// empty for no reason) and reasonID is the loose picklist link (nil when none, nulled later if
|
||||||
|
// that entry is deleted). It returns the persisted Suspension. Suspending an already-blocked
|
||||||
|
// account simply appends another block; CurrentSuspension always reflects the strongest.
|
||||||
|
func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Time, reasonEn, reasonRu string, reasonID *uuid.UUID) (Suspension, error) {
|
||||||
|
suspensionID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return Suspension{}, fmt.Errorf("account: new suspension id: %w", err)
|
||||||
|
}
|
||||||
|
stmt := table.AccountSuspensions.INSERT(
|
||||||
|
table.AccountSuspensions.SuspensionID,
|
||||||
|
table.AccountSuspensions.AccountID,
|
||||||
|
table.AccountSuspensions.BlockedUntil,
|
||||||
|
table.AccountSuspensions.ReasonEn,
|
||||||
|
table.AccountSuspensions.ReasonRu,
|
||||||
|
table.AccountSuspensions.ReasonID,
|
||||||
|
).VALUES(
|
||||||
|
suspensionID,
|
||||||
|
accountID,
|
||||||
|
nullableTimestamp(until),
|
||||||
|
nullableString(reasonEn),
|
||||||
|
nullableString(reasonRu),
|
||||||
|
nullableUUID(reasonID),
|
||||||
|
).RETURNING(table.AccountSuspensions.AllColumns)
|
||||||
|
|
||||||
|
var row model.AccountSuspensions
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
s.invalidateSuspension(accountID)
|
||||||
|
return modelToSuspension(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiftSuspension lifts every in-force block on the account (the operator's manual unblock),
|
||||||
|
// stamping lifted_at on each. It is a no-op when the account is not currently blocked. Lifting
|
||||||
|
// does not un-resign games already forfeited at block time — those stay lost.
|
||||||
|
func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
stmt := table.AccountSuspensions.
|
||||||
|
UPDATE(table.AccountSuspensions.LiftedAt).
|
||||||
|
SET(postgres.TimestampzT(now)).
|
||||||
|
WHERE(
|
||||||
|
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(activeSuspensionPredicate(now)),
|
||||||
|
)
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("account: lift suspension %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
s.invalidateSuspension(accountID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentSuspension returns the account's in-force block and true, or false when the account is
|
||||||
|
// not blocked. When several blocks overlap it returns the strongest — a permanent one first,
|
||||||
|
// otherwise the latest-expiring — which is what the player's blocked screen shows. The gate
|
||||||
|
// middleware calls it on every authenticated request, served by account_suspensions_account_idx.
|
||||||
|
func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Suspension, bool, error) {
|
||||||
|
// A store with no database (the zero-value store used by the routing unit tests) has no
|
||||||
|
// suspensions; report not-blocked rather than dereferencing a nil pool. A real pool that
|
||||||
|
// errors still surfaces the error to the gate, which fails closed.
|
||||||
|
if s.db == nil {
|
||||||
|
return Suspension{}, false, nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if s.suspensions != nil {
|
||||||
|
if e, ok := s.suspensions.get(accountID); ok {
|
||||||
|
if !e.found {
|
||||||
|
return Suspension{}, false, nil // cached: not blocked
|
||||||
|
}
|
||||||
|
if suspensionActiveAt(e.susp, now) {
|
||||||
|
return e.susp, true, nil // cached block still in force
|
||||||
|
}
|
||||||
|
// The cached block has lapsed since it was cached; refresh from the database below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
susp, found, err := s.queryCurrentSuspension(ctx, accountID, now)
|
||||||
|
if err != nil {
|
||||||
|
return Suspension{}, false, err
|
||||||
|
}
|
||||||
|
if s.suspensions != nil {
|
||||||
|
s.suspensions.put(accountID, suspensionCacheEntry{susp: susp, found: found})
|
||||||
|
}
|
||||||
|
return susp, found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryCurrentSuspension reads the account's strongest in-force block straight from the database
|
||||||
|
// (no cache), as of now. It backs CurrentSuspension on a cache miss or a lapsed entry.
|
||||||
|
func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, now time.Time) (Suspension, bool, error) {
|
||||||
|
stmt := postgres.SELECT(table.AccountSuspensions.AllColumns).
|
||||||
|
FROM(table.AccountSuspensions).
|
||||||
|
WHERE(
|
||||||
|
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(activeSuspensionPredicate(now)),
|
||||||
|
).
|
||||||
|
ORDER_BY(table.AccountSuspensions.BlockedUntil.DESC().NULLS_FIRST()).
|
||||||
|
LIMIT(1)
|
||||||
|
|
||||||
|
var row model.AccountSuspensions
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Suspension{}, false, nil
|
||||||
|
}
|
||||||
|
return Suspension{}, false, fmt.Errorf("account: current suspension %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return modelToSuspension(row), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the
|
||||||
|
// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The
|
||||||
|
// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires,
|
||||||
|
// since no operator action fires then. An account that still has another active block may be
|
||||||
|
// included; the eligibility resolver returns the true state, so emitting for it is harmless.
|
||||||
|
func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT DISTINCT account_id FROM backend.account_suspensions
|
||||||
|
WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`,
|
||||||
|
since.UTC(), until.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: suspensions expired between: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []uuid.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan expired suspension: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
|
||||||
|
// Called after Suspend and LiftSuspension.
|
||||||
|
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
|
||||||
|
if s.suspensions != nil {
|
||||||
|
s.suspensions.invalidate(accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspensionActiveAt reports whether a suspension is in force at now: permanent, or not yet
|
||||||
|
// expired. The lifted check is implicit — only non-lifted blocks are ever cached or returned.
|
||||||
|
func suspensionActiveAt(susp Suspension, now time.Time) bool {
|
||||||
|
return susp.BlockedUntil == nil || susp.BlockedUntil.After(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspensionCache is the gate's write-through cache of each account's current block, keyed by
|
||||||
|
// account id. The suspension gate reads it on every authenticated request; Suspend and
|
||||||
|
// LiftSuspension invalidate the account's entry. An entry holds the strongest active block at
|
||||||
|
// query time (or a not-blocked marker), re-evaluated against the wall clock on read, so a
|
||||||
|
// temporary block lapses without an explicit invalidation. It is single-instance, matching the
|
||||||
|
// deployment (one shared Store); a multi-instance deployment would need a shared cache.
|
||||||
|
type suspensionCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[uuid.UUID]suspensionCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspensionCacheEntry is a cached lookup: the strongest active block when found, else a
|
||||||
|
// not-blocked marker (found=false).
|
||||||
|
type suspensionCacheEntry struct {
|
||||||
|
susp Suspension
|
||||||
|
found bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSuspensionCache() *suspensionCache {
|
||||||
|
return &suspensionCache{m: make(map[uuid.UUID]suspensionCacheEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *suspensionCache) get(id uuid.UUID) (suspensionCacheEntry, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
e, ok := c.m[id]
|
||||||
|
return e, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *suspensionCache) put(id uuid.UUID, e suspensionCacheEntry) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.m[id] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *suspensionCache) invalidate(id uuid.UUID) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.m, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and
|
||||||
|
// either permanent or not yet expired.
|
||||||
|
func activeSuspensionPredicate(now time.Time) postgres.BoolExpression {
|
||||||
|
return table.AccountSuspensions.LiftedAt.IS_NULL().
|
||||||
|
AND(
|
||||||
|
table.AccountSuspensions.BlockedUntil.IS_NULL().
|
||||||
|
OR(table.AccountSuspensions.BlockedUntil.GT(postgres.TimestampzT(now))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReasons returns the suspension-reason picklist, oldest first.
|
||||||
|
func (s *Store) ListReasons(ctx context.Context) ([]Reason, error) {
|
||||||
|
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
|
||||||
|
FROM(table.SuspensionReasons).
|
||||||
|
ORDER_BY(table.SuspensionReasons.CreatedAt.ASC())
|
||||||
|
var rows []model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list reasons: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Reason, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, modelToReason(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReason loads one picklist entry, or ErrNotFound when it is absent.
|
||||||
|
func (s *Store) GetReason(ctx context.Context, id uuid.UUID) (Reason, error) {
|
||||||
|
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
|
||||||
|
FROM(table.SuspensionReasons).
|
||||||
|
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
|
||||||
|
LIMIT(1)
|
||||||
|
var row model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Reason{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Reason{}, fmt.Errorf("account: get reason %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return modelToReason(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReason inserts a new picklist entry with the given English and Russian text.
|
||||||
|
func (s *Store) CreateReason(ctx context.Context, textEn, textRu string) (Reason, error) {
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return Reason{}, fmt.Errorf("account: new reason id: %w", err)
|
||||||
|
}
|
||||||
|
stmt := table.SuspensionReasons.INSERT(
|
||||||
|
table.SuspensionReasons.ReasonID,
|
||||||
|
table.SuspensionReasons.TextEn,
|
||||||
|
table.SuspensionReasons.TextRu,
|
||||||
|
).VALUES(id, textEn, textRu).
|
||||||
|
RETURNING(table.SuspensionReasons.AllColumns)
|
||||||
|
var row model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Reason{}, fmt.Errorf("account: create reason: %w", err)
|
||||||
|
}
|
||||||
|
return modelToReason(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateReason rewrites a picklist entry's English and Russian text, returning ErrNotFound when
|
||||||
|
// it is absent. Existing suspensions keep their text snapshot, so the change only affects future
|
||||||
|
// blocks.
|
||||||
|
func (s *Store) UpdateReason(ctx context.Context, id uuid.UUID, textEn, textRu string) (Reason, error) {
|
||||||
|
stmt := table.SuspensionReasons.
|
||||||
|
UPDATE(table.SuspensionReasons.TextEn, table.SuspensionReasons.TextRu, table.SuspensionReasons.UpdatedAt).
|
||||||
|
SET(postgres.String(textEn), postgres.String(textRu), postgres.TimestampzT(time.Now().UTC())).
|
||||||
|
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
|
||||||
|
RETURNING(table.SuspensionReasons.AllColumns)
|
||||||
|
var row model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Reason{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Reason{}, fmt.Errorf("account: update reason %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return modelToReason(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReason removes a picklist entry. It is a hard delete: the reason_id link on past
|
||||||
|
// suspensions is nulled by the foreign key, but their text snapshot is untouched.
|
||||||
|
func (s *Store) DeleteReason(ctx context.Context, id uuid.UUID) error {
|
||||||
|
stmt := table.SuspensionReasons.
|
||||||
|
DELETE().
|
||||||
|
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id)))
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("account: delete reason %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelToSuspension projects a generated row into the public Suspension struct.
|
||||||
|
func modelToSuspension(row model.AccountSuspensions) Suspension {
|
||||||
|
s := Suspension{AccountID: row.AccountID, BlockedAt: row.BlockedAt, BlockedUntil: row.BlockedUntil}
|
||||||
|
if row.ReasonEn != nil {
|
||||||
|
s.ReasonEn = *row.ReasonEn
|
||||||
|
}
|
||||||
|
if row.ReasonRu != nil {
|
||||||
|
s.ReasonRu = *row.ReasonRu
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelToReason projects a generated row into the public Reason struct.
|
||||||
|
func modelToReason(row model.SuspensionReasons) Reason {
|
||||||
|
return Reason{ID: row.ReasonID, TextEn: row.TextEn, TextRu: row.TextRu, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableString renders an empty string as SQL NULL, otherwise the string literal.
|
||||||
|
func nullableString(v string) postgres.Expression {
|
||||||
|
if v == "" {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableTimestamp renders a nil time as SQL NULL, otherwise the UTC timestamp literal.
|
||||||
|
func nullableTimestamp(v *time.Time) postgres.Expression {
|
||||||
|
if v == nil {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.TimestampzT(v.UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableUUID renders a nil id as SQL NULL, otherwise the uuid literal.
|
||||||
|
func nullableUUID(v *uuid.UUID) postgres.Expression {
|
||||||
|
if v == nil {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.UUID(*v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSuspensionActiveAt covers the wall-clock re-evaluation the cache relies on: a permanent
|
||||||
|
// block is always active, a future-dated one is active, a past-dated one is not.
|
||||||
|
func TestSuspensionActiveAt(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
future := now.Add(time.Hour)
|
||||||
|
past := now.Add(-time.Hour)
|
||||||
|
|
||||||
|
if !suspensionActiveAt(Suspension{BlockedUntil: nil}, now) {
|
||||||
|
t.Error("a permanent block must be active")
|
||||||
|
}
|
||||||
|
if !suspensionActiveAt(Suspension{BlockedUntil: &future}, now) {
|
||||||
|
t.Error("a future-dated block must be active")
|
||||||
|
}
|
||||||
|
if suspensionActiveAt(Suspension{BlockedUntil: &past}, now) {
|
||||||
|
t.Error("a past-dated block must be inactive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSuspensionCache covers the cache primitives: a miss on an empty cache, a hit after put for
|
||||||
|
// both the not-blocked and blocked markers, and a miss after invalidate.
|
||||||
|
func TestSuspensionCache(t *testing.T) {
|
||||||
|
c := newSuspensionCache()
|
||||||
|
id := uuid.New()
|
||||||
|
|
||||||
|
if _, ok := c.get(id); ok {
|
||||||
|
t.Fatal("empty cache must miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.put(id, suspensionCacheEntry{found: false})
|
||||||
|
if e, ok := c.get(id); !ok || e.found {
|
||||||
|
t.Fatalf("cached not-blocked entry = (%+v, ok %v), want hit with found=false", e, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.put(id, suspensionCacheEntry{susp: Suspension{AccountID: id}, found: true})
|
||||||
|
if e, ok := c.get(id); !ok || !e.found {
|
||||||
|
t.Fatalf("cached blocked entry = (%+v, ok %v), want hit with found=true", e, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.invalidate(id)
|
||||||
|
if _, ok := c.get(id); ok {
|
||||||
|
t.Fatal("invalidated entry must miss")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// suspensionSweepInterval is how often the sweeper re-checks for temporary blocks
|
||||||
|
// that lapsed. A minute is well under the coarsest block grain (operators pick day
|
||||||
|
// presets) while keeping the query trivial.
|
||||||
|
const suspensionSweepInterval = time.Minute
|
||||||
|
|
||||||
|
// suspensionExpiryQuerier is the slice of the account store the sweeper depends on:
|
||||||
|
// the accounts whose temporary block lapsed in a window. *Store satisfies it; a fake
|
||||||
|
// drives the sweeper's unit tests.
|
||||||
|
type suspensionExpiryQuerier interface {
|
||||||
|
SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspensionSweeper re-evaluates chat write access when a temporary block self-
|
||||||
|
// expires. No operator action fires on expiry — the suspension gate just re-reads
|
||||||
|
// the wall clock — so without this a temporarily blocked player would stay muted in
|
||||||
|
// the moderated discussion chat after their block lapsed. Each tick it finds blocks
|
||||||
|
// that expired since the previous tick and calls onExpire for the affected accounts;
|
||||||
|
// onExpire is wired to publish the chat-access-changed event, after which the gateway
|
||||||
|
// re-resolves the true eligibility. A liberal call (an account that still has another
|
||||||
|
// active block) is therefore harmless. The window is in-memory, so a block that
|
||||||
|
// expires while the process is down is not re-granted until the next operator action
|
||||||
|
// or the player rejoins — an accepted best-effort gap.
|
||||||
|
type SuspensionSweeper struct {
|
||||||
|
store suspensionExpiryQuerier
|
||||||
|
onExpire func(accountID uuid.UUID)
|
||||||
|
log *zap.Logger
|
||||||
|
// since is the upper bound of the previous swept window; the next sweep covers
|
||||||
|
// (since, now]. It advances only on a successful query, so a failed tick retries
|
||||||
|
// the same window rather than dropping expiries.
|
||||||
|
since time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSuspensionSweeper builds the sweeper over the account store, the per-account
|
||||||
|
// expiry callback (publishing the chat-access-changed event) and a logger. The first
|
||||||
|
// window opens at construction time, so blocks that lapsed earlier are not re-emitted.
|
||||||
|
func NewSuspensionSweeper(store *Store, onExpire func(accountID uuid.UUID), log *zap.Logger) *SuspensionSweeper {
|
||||||
|
if log == nil {
|
||||||
|
log = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &SuspensionSweeper{store: store, onExpire: onExpire, log: log, since: time.Now().UTC()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interval reports the sweep cadence, for the startup log line.
|
||||||
|
func (w *SuspensionSweeper) Interval() time.Duration { return suspensionSweepInterval }
|
||||||
|
|
||||||
|
// Run sweeps every Interval until ctx is cancelled.
|
||||||
|
func (w *SuspensionSweeper) Run(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(suspensionSweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.sweep(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep emits a chat-access-changed signal for every account whose temporary block
|
||||||
|
// lapsed in (since, now], then advances the window. On a query error it keeps the
|
||||||
|
// window so the next tick retries it.
|
||||||
|
func (w *SuspensionSweeper) sweep(ctx context.Context) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ids, err := w.store.SuspensionsExpiredBetween(ctx, w.since, now)
|
||||||
|
if err != nil {
|
||||||
|
w.log.Warn("suspension expiry sweep failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.since = now
|
||||||
|
for _, id := range ids {
|
||||||
|
w.onExpire(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeExpiryQuerier records the `since` bound of each call and replays a scripted
|
||||||
|
// result/error per call, so the sweeper's window and dispatch logic is testable
|
||||||
|
// without a database.
|
||||||
|
type fakeExpiryQuerier struct {
|
||||||
|
results [][]uuid.UUID
|
||||||
|
errs []error
|
||||||
|
sinces []time.Time
|
||||||
|
idx int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeExpiryQuerier) SuspensionsExpiredBetween(_ context.Context, since, _ time.Time) ([]uuid.UUID, error) {
|
||||||
|
f.sinces = append(f.sinces, since)
|
||||||
|
i := f.idx
|
||||||
|
f.idx++
|
||||||
|
if i < len(f.errs) && f.errs[i] != nil {
|
||||||
|
return nil, f.errs[i]
|
||||||
|
}
|
||||||
|
if i < len(f.results) {
|
||||||
|
return f.results[i], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSweeper(store suspensionExpiryQuerier, onExpire func(uuid.UUID)) *SuspensionSweeper {
|
||||||
|
return &SuspensionSweeper{
|
||||||
|
store: store,
|
||||||
|
onExpire: onExpire,
|
||||||
|
log: zap.NewNop(),
|
||||||
|
since: time.Now().Add(-time.Minute).UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuspensionSweeperDispatchesAndAdvances(t *testing.T) {
|
||||||
|
id1, id2 := uuid.New(), uuid.New()
|
||||||
|
fake := &fakeExpiryQuerier{results: [][]uuid.UUID{{id1, id2}, nil}}
|
||||||
|
var got []uuid.UUID
|
||||||
|
w := newSweeper(fake, func(id uuid.UUID) { got = append(got, id) })
|
||||||
|
|
||||||
|
first := w.since
|
||||||
|
w.sweep(context.Background())
|
||||||
|
assert.Equal(t, []uuid.UUID{id1, id2}, got, "every expired account is dispatched")
|
||||||
|
assert.True(t, w.since.After(first), "the window advances on success")
|
||||||
|
|
||||||
|
// A second sweep opens the next window at the previous upper bound.
|
||||||
|
prev := w.since
|
||||||
|
w.sweep(context.Background())
|
||||||
|
require.Len(t, fake.sinces, 2)
|
||||||
|
assert.True(t, fake.sinces[1].After(fake.sinces[0]), "consecutive windows are contiguous and forward")
|
||||||
|
assert.True(t, fake.sinces[1].Equal(prev), "the next window starts at the previous upper bound")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuspensionSweeperKeepsWindowOnError(t *testing.T) {
|
||||||
|
fake := &fakeExpiryQuerier{errs: []error{errors.New("db down")}}
|
||||||
|
w := newSweeper(fake, func(uuid.UUID) { t.Fatal("onExpire must not run when the query fails") })
|
||||||
|
|
||||||
|
before := w.since
|
||||||
|
w.sweep(context.Background())
|
||||||
|
assert.True(t, w.since.Equal(before), "the window is retained on error so the next tick retries it")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSuspensionSweeperDefaults(t *testing.T) {
|
||||||
|
w := NewSuspensionSweeper(nil, func(uuid.UUID) {}, nil)
|
||||||
|
assert.Equal(t, time.Minute, w.Interval())
|
||||||
|
assert.NotNil(t, w.log, "a nil logger is tolerated")
|
||||||
|
assert.WithinDuration(t, time.Now().UTC(), w.since, time.Second, "the first window opens at construction time")
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
|
||||||
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
|
// profile editor stores (an offset dropdown rather than an IANA name).
|
||||||
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
|
||||||
|
|
||||||
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package account
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +19,9 @@ type UserListItem struct {
|
|||||||
PreferredLanguage string
|
PreferredLanguage string
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
IsRobot bool
|
IsRobot bool
|
||||||
|
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
||||||
|
// as a badge in the console list.
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@ func userListWhere(f UserFilter) (string, []any) {
|
|||||||
// ListUsers returns the filtered admin user list, newest first, paginated.
|
// ListUsers returns the filtered admin user list, newest first, paginated.
|
||||||
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
||||||
where, args := userListWhere(f)
|
where, args := userListWhere(f)
|
||||||
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.created_at, ` + robotExists + ` AS is_robot
|
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
|
||||||
FROM backend.accounts a WHERE ` + where +
|
FROM backend.accounts a WHERE ` + where +
|
||||||
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
@@ -77,14 +81,51 @@ FROM backend.accounts a WHERE ` + where +
|
|||||||
var out []UserListItem
|
var out []UserListItem
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var it UserListItem
|
var it UserListItem
|
||||||
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &it.CreatedAt, &it.IsRobot); err != nil {
|
var flagged sql.NullTime
|
||||||
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
|
||||||
return nil, fmt.Errorf("account: scan user: %w", err)
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||||
}
|
}
|
||||||
|
if flagged.Valid {
|
||||||
|
it.FlaggedHighRateAt = flagged.Time
|
||||||
|
}
|
||||||
out = append(out, it)
|
out = append(out, it)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlaggedAccount is one row of the console's high-rate review queue.
|
||||||
|
type FlaggedAccount struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// flaggedListCap bounds the console's flagged-account list; the operator clears
|
||||||
|
// flags as they are reviewed, so the queue stays short in practice.
|
||||||
|
const flaggedListCap = 200
|
||||||
|
|
||||||
|
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
|
||||||
|
// recently flagged first.
|
||||||
|
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT account_id, display_name, flagged_high_rate_at
|
||||||
|
FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL
|
||||||
|
ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list flagged: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []FlaggedAccount
|
||||||
|
for rows.Next() {
|
||||||
|
var fa FlaggedAccount
|
||||||
|
if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan flagged: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, fa)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// CountUsers counts the filtered admin user list, for pagination.
|
// CountUsers counts the filtered admin user list, for pagination.
|
||||||
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
||||||
where, args := userListWhere(f)
|
where, args := userListWhere(f)
|
||||||
|
|||||||
@@ -24,9 +24,21 @@ func TestValidateDisplayName(t *testing.T) {
|
|||||||
"trailing underscore": {"Name_", "", false},
|
"trailing underscore": {"Name_", "", false},
|
||||||
"trailing dot ok": {"Anna B.", "Anna B.", true},
|
"trailing dot ok": {"Anna B.", "Anna B.", true},
|
||||||
"double trailing dot": {"Name..", "", false},
|
"double trailing dot": {"Name..", "", false},
|
||||||
"digit rejected": {"Name2", "", false},
|
"trailing digit ok": {"Name2", "Name2", true},
|
||||||
|
"trailing year ok": {"Аня2007", "Аня2007", true},
|
||||||
|
"five digits ok": {"Player12345", "Player12345", true},
|
||||||
|
"six digits rejected": {"Player123456", "", false},
|
||||||
|
"mid digit rejected": {"Dark2Wolf", "", false},
|
||||||
|
"all digits rejected": {"12345", "", false},
|
||||||
|
"digit then dot": {"Name2.", "", false},
|
||||||
|
"dot then digit": {"Anna B.2", "", false},
|
||||||
|
"sep plus digits ok": {"Night.Fox2007", "Night.Fox2007", true}, // "." is the only special; digits do not count
|
||||||
|
"max specials+digits": {"a.a.a.a.a.a2007", "a.a.a.a.a.a2007", true}, // 5 dots + a digit run still passes
|
||||||
"blank": {" ", "", false},
|
"blank": {" ", "", false},
|
||||||
"too long": {strings.Repeat("a", 33), "", false},
|
"too long": {strings.Repeat("a", 33), "", false},
|
||||||
|
"five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots
|
||||||
|
"six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots
|
||||||
|
"initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -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,8 +1,9 @@
|
|||||||
// Package accountmerge retires a secondary account into a primary one in a single
|
// Package accountmerge retires a secondary account into a primary one in a single
|
||||||
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
|
// transaction: it sums statistics (merging the per-variant best moves), sums the hint
|
||||||
|
// wallet, ORs the paid flag, repoints
|
||||||
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
// the secondary's identities, transfers its games/chat/complaints/invitations,
|
||||||
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
|
||||||
// (accounts.merged_into). It is the data core of Stage 11 account linking & merge
|
// (accounts.merged_into). It is the data core of account linking & merge
|
||||||
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
|
||||||
// one layer up (the link service), since the in-memory session cache lives there.
|
// one layer up (the link service), since the in-memory session cache lives there.
|
||||||
package accountmerge
|
package accountmerge
|
||||||
@@ -68,6 +69,9 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
|||||||
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := mergeBestMoves(ctx, tx, primary, secondary, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -147,8 +151,8 @@ func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
|
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws and
|
||||||
// summed, max points kept) and deletes the secondary row.
|
// the moves/hints-used counters summed, max points kept) and deletes the secondary row.
|
||||||
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||||
var sec model.AccountStats
|
var sec model.AccountStats
|
||||||
err := postgres.SELECT(table.AccountStats.AllColumns).
|
err := postgres.SELECT(table.AccountStats.AllColumns).
|
||||||
@@ -178,13 +182,16 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
|
|||||||
|
|
||||||
upd := table.AccountStats.UPDATE(
|
upd := table.AccountStats.UPDATE(
|
||||||
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
|
||||||
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
|
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints,
|
||||||
|
table.AccountStats.Moves, table.AccountStats.HintsUsed, table.AccountStats.UpdatedAt,
|
||||||
).SET(
|
).SET(
|
||||||
postgres.Int(int64(pri.Wins+sec.Wins)),
|
postgres.Int(int64(pri.Wins+sec.Wins)),
|
||||||
postgres.Int(int64(pri.Losses+sec.Losses)),
|
postgres.Int(int64(pri.Losses+sec.Losses)),
|
||||||
postgres.Int(int64(pri.Draws+sec.Draws)),
|
postgres.Int(int64(pri.Draws+sec.Draws)),
|
||||||
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
|
||||||
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
|
||||||
|
postgres.Int(int64(pri.Moves+sec.Moves)),
|
||||||
|
postgres.Int(int64(pri.HintsUsed+sec.HintsUsed)),
|
||||||
postgres.TimestampzT(now),
|
postgres.TimestampzT(now),
|
||||||
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
|
||||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||||
@@ -198,6 +205,41 @@ func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, n
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeBestMoves folds secondary's per-variant best moves into primary, keeping the
|
||||||
|
// higher-scoring play per variant (the same rule the per-game upsert uses), then deletes
|
||||||
|
// the secondary's rows — the secondary is only tombstoned, not removed, so without this
|
||||||
|
// they would linger on a dead account and never reach the merged statistics screen.
|
||||||
|
func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||||
|
var srows []model.AccountBestMove
|
||||||
|
err := postgres.SELECT(table.AccountBestMove.AllColumns).
|
||||||
|
FROM(table.AccountBestMove).
|
||||||
|
WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary))).
|
||||||
|
QueryContext(ctx, tx, &srows)
|
||||||
|
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return fmt.Errorf("accountmerge: load secondary best moves: %w", err)
|
||||||
|
}
|
||||||
|
for _, s := range srows {
|
||||||
|
ins := table.AccountBestMove.
|
||||||
|
INSERT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant,
|
||||||
|
table.AccountBestMove.Score, table.AccountBestMove.Tiles, table.AccountBestMove.UpdatedAt).
|
||||||
|
VALUES(primary, s.Variant, s.Score, s.Tiles, postgres.TimestampzT(now)).
|
||||||
|
ON_CONFLICT(table.AccountBestMove.AccountID, table.AccountBestMove.Variant).
|
||||||
|
DO_UPDATE(postgres.SET(
|
||||||
|
table.AccountBestMove.Score.SET(table.AccountBestMove.EXCLUDED.Score),
|
||||||
|
table.AccountBestMove.Tiles.SET(table.AccountBestMove.EXCLUDED.Tiles),
|
||||||
|
table.AccountBestMove.UpdatedAt.SET(table.AccountBestMove.EXCLUDED.UpdatedAt),
|
||||||
|
).WHERE(table.AccountBestMove.EXCLUDED.Score.GT(table.AccountBestMove.Score)))
|
||||||
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: merge best move %s: %w", s.Variant, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
del := table.AccountBestMove.DELETE().WHERE(table.AccountBestMove.AccountID.EQ(postgres.UUID(secondary)))
|
||||||
|
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("accountmerge: delete secondary best moves: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||||
// all other profile fields stay the primary's.
|
// all other profile fields stay the primary's.
|
||||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
|||||||
.subnav a.active { color: var(--ink); }
|
.subnav a.active { color: var(--ink); }
|
||||||
|
|
||||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
||||||
|
.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; }
|
||||||
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
||||||
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||||
.form input, .form select, .form textarea {
|
.form input, .form select, .form textarea {
|
||||||
@@ -85,6 +86,22 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
.form textarea { min-height: 4rem; resize: vertical; }
|
.form textarea { min-height: 4rem; resize: vertical; }
|
||||||
|
/* A static help aside (e.g. message-formatting hints) beside an intro note at the top of a
|
||||||
|
section: the note fills the row, the aside takes ~40% and both wrap on a narrow viewport. */
|
||||||
|
.help-top { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin: 0.2rem 0 0.6rem; }
|
||||||
|
.help-top .note { flex: 1 1 16rem; margin: 0; }
|
||||||
|
.help {
|
||||||
|
flex: 0 1 40%;
|
||||||
|
min-width: 16rem;
|
||||||
|
background: var(--panel-hi);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.help h4 { margin: 0 0 0.4rem; font-size: 0.85rem; color: var(--ink); }
|
||||||
|
.help p { margin: 0.35rem 0; }
|
||||||
button {
|
button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #06121f;
|
color: #06121f;
|
||||||
@@ -115,3 +132,64 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
|||||||
.lg-min { color: var(--ok); }
|
.lg-min { color: var(--ok); }
|
||||||
.lg-avg { color: var(--accent); }
|
.lg-avg { color: var(--accent); }
|
||||||
.lg-max { color: var(--danger); }
|
.lg-max { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Feedback: user-controlled message bodies are wrapped and escaped (never HTML);
|
||||||
|
an image attachment is previewed inline, bounded so it cannot dominate the page. */
|
||||||
|
.msgbody { white-space: pre-wrap; word-break: break-word; background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 6px; margin: 0.6rem 0; }
|
||||||
|
.attach { max-width: 100%; max-height: 480px; height: auto; border: 1px solid var(--line); border-radius: 6px; }
|
||||||
|
|
||||||
|
/* Game replay (admin): a script-stepped board with rack panels around it, a move log and the
|
||||||
|
first-move draw. A placed tile shows its value as a subscript; a 0 value (a blank) shows none. */
|
||||||
|
.replay-stage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas: ". top ." "left board right" ". bottom .";
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.rack-top { grid-area: top; }
|
||||||
|
.rack-bottom { grid-area: bottom; }
|
||||||
|
.rack-left { grid-area: left; }
|
||||||
|
.rack-right { grid-area: right; }
|
||||||
|
.replay-board { grid-area: board; overflow: auto; }
|
||||||
|
.board-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4rem repeat(15, 1.7rem);
|
||||||
|
grid-auto-rows: 1.7rem;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.board-grid .bh { display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: var(--ink-dim); background: var(--panel); }
|
||||||
|
.board-grid .cell { position: relative; display: flex; align-items: center; justify-content: center; background: var(--panel-hi); }
|
||||||
|
.cell .prem { font-size: 0.55rem; color: var(--ink); opacity: 0.8; }
|
||||||
|
.cell.tw { background: #7a2230; }
|
||||||
|
.cell.dw { background: #a8506a; }
|
||||||
|
.cell.tl { background: #235a7a; }
|
||||||
|
.cell.dl { background: #3f87a8; }
|
||||||
|
.cell.centre .prem { font-size: 0.95rem; color: var(--warn); opacity: 1; }
|
||||||
|
.tile {
|
||||||
|
display: inline-flex; align-items: baseline; justify-content: center;
|
||||||
|
min-width: 1.35rem; height: 1.35rem; padding: 0 0.12rem;
|
||||||
|
background: #e8d9a0; color: #1b1408; border-radius: 3px;
|
||||||
|
font-weight: 700; font-size: 0.8rem; line-height: 1.35rem;
|
||||||
|
}
|
||||||
|
.tile sub { font-size: 0.5rem; font-weight: 600; line-height: 1; align-self: flex-end; margin-left: 1px; }
|
||||||
|
.tile.blank { background: #cdbfe0; }
|
||||||
|
.cell.filled { background: var(--panel-hi); }
|
||||||
|
.cell.filled .tile { width: 100%; height: 100%; border-radius: 2px; }
|
||||||
|
.rack-slot { padding: 0.25rem; border-radius: 6px; }
|
||||||
|
.rack-slot.active { outline: 2px solid var(--accent); background: var(--panel-hi); }
|
||||||
|
.rack-name { font-size: 0.72rem; color: var(--ink-dim); margin-bottom: 0.2rem; text-align: center; }
|
||||||
|
.rack-tiles { display: flex; gap: 2px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
.rack-left .rack-tiles, .rack-right .rack-tiles { flex-direction: column; }
|
||||||
|
.replay-controls { display: flex; align-items: center; gap: 0.8rem; justify-content: center; margin: 0.6rem 0; }
|
||||||
|
.replay-controls button { background: var(--panel-hi); color: var(--ink); border: 1px solid var(--line); font-weight: 600; }
|
||||||
|
.replay-controls button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.replay-pos { color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
||||||
|
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||||
|
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package adminconsole
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,15 +21,32 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
data any
|
data any
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
|
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||||
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||||
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||||
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||||
|
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
|
||||||
|
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
|
||||||
|
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
|
||||||
|
}, `/_gm/users/c3`},
|
||||||
|
{"throttled", ThrottledView{
|
||||||
|
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
|
||||||
|
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
|
||||||
|
FlagThreshold: 1000, FlagWindow: "10m0s",
|
||||||
|
}, "Recent episodes"},
|
||||||
|
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "scrabble_en", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
||||||
|
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
|
||||||
|
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||||
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
{"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"},
|
||||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
|
{"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"},
|
||||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
|
||||||
|
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"},
|
||||||
|
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
||||||
|
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
|
||||||
|
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
|
||||||
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
||||||
}
|
}
|
||||||
@@ -49,6 +67,56 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPagerLinksPreserveFilterQuery guards the paginated lists whose links carry a
|
||||||
|
// pre-encoded filter query (url.Values.Encode) past the page number. The query fragment
|
||||||
|
// must reach the link verbatim: the contextual escaper would otherwise re-encode its
|
||||||
|
// structural "=" and "&" (turning "kind=robots" into the broken "kind%3drobots"), dropping
|
||||||
|
// the active filter on every page step. FilterQuery is typed template.URL to prevent that.
|
||||||
|
func TestPagerLinksPreserveFilterQuery(t *testing.T) {
|
||||||
|
r := MustNewRenderer()
|
||||||
|
|
||||||
|
t.Run("users", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
view := UsersView{Robots: true, FilterQuery: template.URL("kind=robots"), Pager: NewPager(2, 50, 200)}
|
||||||
|
if err := r.Render(&buf, "users", PageData{Title: "Users", Data: view}); err != nil {
|
||||||
|
t.Fatalf("render users: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`href="/_gm/users?kind=robots&page=1"`,
|
||||||
|
`href="/_gm/users?kind=robots&page=3"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("users pager: missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "kind%3drobots") {
|
||||||
|
t.Error("users pager: filter query was double-encoded (kind%3drobots)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("messages", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
view := MessagesView{FilterQuery: template.URL("game=abc&user=def"), Pager: NewPager(2, 50, 200)}
|
||||||
|
if err := r.Render(&buf, "messages", PageData{Title: "Messages", Data: view}); err != nil {
|
||||||
|
t.Fatalf("render messages: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`href="/_gm/messages.csv?game=abc&user=def"`,
|
||||||
|
`href="/_gm/messages?game=abc&user=def&page=1"`,
|
||||||
|
`href="/_gm/messages?game=abc&user=def&page=3"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("messages pager: missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "%3d") || strings.Contains(out, "%26") {
|
||||||
|
t.Error("messages pager: filter query was double-encoded (%3d / %26)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestRendererUnknownPage reports an error for a page that does not exist.
|
// TestRendererUnknownPage reports an error for a page that does not exist.
|
||||||
func TestRendererUnknownPage(t *testing.T) {
|
func TestRendererUnknownPage(t *testing.T) {
|
||||||
r := MustNewRenderer()
|
r := MustNewRenderer()
|
||||||
|
|||||||
@@ -16,7 +16,11 @@
|
|||||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||||
|
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
||||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||||
|
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||||
|
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||||
|
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note"><a href="/_gm/banners">← all campaigns</a></p>
|
||||||
|
<h1>{{.Name}}{{if .IsDefault}} <span class="pill">default</span>{{end}}</h1>
|
||||||
|
<section class="panel"><h2>Settings</h2>
|
||||||
|
{{if .IsDefault}}<p class="note">The default campaign is perpetual and fills the unsold remainder — only its name and messages are editable.</p>{{end}}
|
||||||
|
<form class="form col" method="post" action="/_gm/banners/{{.ID}}">
|
||||||
|
<label>Name <input type="text" name="name" value="{{.Name}}" maxlength="80" required></label>
|
||||||
|
{{if not .IsDefault}}
|
||||||
|
<label>Weight (%) <input type="number" name="weight" min="1" max="100" value="{{.Weight}}" required></label>
|
||||||
|
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
||||||
|
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
||||||
|
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
||||||
|
{{end}}
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
{{if not .IsDefault}}
|
||||||
|
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
||||||
|
<button type="submit" class="danger">Delete campaign</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Messages</h2>
|
||||||
|
<div class="help-top">
|
||||||
|
<p class="note">Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.</p>
|
||||||
|
<aside class="help">
|
||||||
|
<h4>Formatting</h4>
|
||||||
|
<p>Plain text is shown as-is (any HTML is escaped).</p>
|
||||||
|
<p>Add a link with markdown — <code>[visible text](https://example.com)</code> — which renders as the linked <em>visible text</em>.</p>
|
||||||
|
<p>Only <code>https://</code>, <code>http://</code> and root-relative <code>/path</code> targets become links; any other scheme (e.g. <code>javascript:</code>, <code>ftp:</code>) is dropped and shown as plain text.</p>
|
||||||
|
<p>Keep it short: an over-long line scrolls in the strip; the same formatting applies when editing a message above.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{{range .Messages}}
|
||||||
|
<div class="row">
|
||||||
|
<form class="form col" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}">
|
||||||
|
<label>English <textarea name="body_en" rows="3" maxlength="500" required>{{.BodyEn}}</textarea></label>
|
||||||
|
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required>{{.BodyRu}}</textarea></label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="actions">
|
||||||
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="up"><button type="submit"{{if .First}} disabled{{end}}>↑</button></form>
|
||||||
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/move"><input type="hidden" name="dir" value="down"><button type="submit"{{if .Last}} disabled{{end}}>↓</button></form>
|
||||||
|
<form class="form" method="post" action="/_gm/banners/{{$.Data.ID}}/messages/{{.ID}}/delete" onsubmit="return confirm('Delete this message?')"><button type="submit" class="danger">Delete</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="note">no messages yet</p>{{end}}
|
||||||
|
<h3>Add message</h3>
|
||||||
|
<form class="form col" method="post" action="/_gm/banners/{{.ID}}/messages">
|
||||||
|
<label>English <textarea name="body_en" rows="3" maxlength="500" required></textarea></label>
|
||||||
|
<label>Russian <textarea name="body_ru" rows="3" maxlength="500" required></textarea></label>
|
||||||
|
<div><button type="submit">Add message</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<p class="note"><a href="/_gm/banners">← all campaigns</a></p>
|
||||||
|
<h1>Banner display timings</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Global timings the client's banner rotator reads. Out-of-range values are clamped on save. The transition between messages is fade-out, then a gap, then fade-in (skipped under reduce-motion).</p>
|
||||||
|
<section class="panel">
|
||||||
|
<form class="form col" method="post" action="/_gm/banner-settings">
|
||||||
|
<label>Hold (ms) — how long one message shows <input type="number" name="hold_ms" min="3000" max="600000" value="{{.HoldMs}}" required></label>
|
||||||
|
<label>Edge pause (ms) — pause at each end before/after scrolling a long message <input type="number" name="edge_pause_ms" min="0" max="60000" value="{{.EdgePauseMs}}" required></label>
|
||||||
|
<label>Scroll speed (px/s) — for a message wider than the strip <input type="number" name="scroll_px_per_sec" min="5" max="1000" value="{{.ScrollPxPerSec}}" required></label>
|
||||||
|
<label>Fade-out (ms) <input type="number" name="fade_out_ms" min="0" max="5000" value="{{.FadeOutMs}}" required></label>
|
||||||
|
<label>Gap (ms) — pause between fade-out and fade-in <input type="number" name="gap_ms" min="0" max="5000" value="{{.GapMs}}" required></label>
|
||||||
|
<label>Fade-in (ms) <input type="number" name="fade_in_ms" min="0" max="5000" value="{{.FadeInMs}}" required></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Advertising banners</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Campaigns compete for the one-line banner shown to free users (no paid account, an empty hint wallet, and no <code>no_banner</code> role). A campaign's weight is a show percent; the perpetual <strong>default</strong> campaign fills the remainder up to 100% and drops out of the rotation when timed campaigns already total 100%. <a href="/_gm/banner-settings">Display timings →</a></p>
|
||||||
|
<section class="panel"><h2>Add campaign</h2>
|
||||||
|
<form class="form col" method="post" action="/_gm/banners">
|
||||||
|
<label>Name <input type="text" name="name" maxlength="80" required></label>
|
||||||
|
<label>Weight (%) <input type="number" name="weight" min="1" max="100" value="10" required></label>
|
||||||
|
<label>Starts (UTC) <input type="datetime-local" name="starts_at"></label>
|
||||||
|
<label>Ends (UTC) <input type="datetime-local" name="ends_at"></label>
|
||||||
|
<label><input type="checkbox" name="enabled" checked> Enabled</label>
|
||||||
|
<div><button type="submit">Add</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Campaigns</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Name</th><th class="num">Weight</th><th>Window</th><th class="num">Messages</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/banners/{{.ID}}">{{.Name}}</a>{{if .IsDefault}} <span class="pill">default</span>{{end}}</td>
|
||||||
|
<td class="num">{{if .IsDefault}}remainder{{else}}{{.Weight}}%{{end}}</td>
|
||||||
|
<td>{{.Window}}</td>
|
||||||
|
<td class="num">{{.Messages}}</td>
|
||||||
|
<td>{{if .ActiveNow}}live{{else if .Enabled}}idle{{else}}disabled{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no campaigns</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
{{if .ConnectorEnabled}}
|
{{if .ConnectorEnabled}}
|
||||||
<form class="form col" method="post" action="/_gm/broadcast">
|
<form class="form col" method="post" action="/_gm/broadcast">
|
||||||
<label>Message <textarea name="text" required></textarea></label>
|
<label>Message <textarea name="text" required></textarea></label>
|
||||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
|
||||||
<div><button type="submit">Post to channel</button></div>
|
<div><button type="submit">Post to channel</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>Message</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/messages">« messages</a> · <a href="/_gm/games/{{.GameID}}">game</a> · <a href="/_gm/messages?game={{.GameID}}">game messages</a></nav>
|
||||||
|
<section class="panel"><h2>Summary</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>Time</b> {{.CreatedAt}}</li>
|
||||||
|
<li><b>Sender</b> <a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a> ({{.Source}})</li>
|
||||||
|
<li><b>IP</b> {{.IP}}</li>
|
||||||
|
<li><b>Kind</b> {{.Kind}}</li>
|
||||||
|
<li><b>Read</b> {{if .Unread}}unread{{else}}read{{end}}</li>
|
||||||
|
<li><b>Message</b> {{.Body}}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Read by seat</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Seat</th><th>Player</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Seats}}
|
||||||
|
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}</a></td><td>{{if eq .Role "read"}}<span class="ok">read</span>{{else if eq .Role "unread"}}unread{{else}}sender{{end}}</td></tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="3"><span class="note">no seats</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
|
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
|
||||||
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
|
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
|
||||||
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
|
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
|
||||||
|
<a class="card" href="/_gm/feedback?status=unread"><h2>Unread feedback</h2><p class="bignum">{{.OpenFeedback}}</p></a>
|
||||||
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
||||||
</div>
|
</div>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
<h1>Dictionary</h1>
|
<h1>Dictionary</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
|
<section class="panel"><h2>Active version</h2>
|
||||||
|
<p>New games pin <span class="pill">{{.ActiveVersion}}</span>. Games already in progress keep the version they started on.</p>
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Resident versions</h2>
|
<section class="panel"><h2>Resident versions</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
|
<thead><tr><th>Variant</th><th>Resident</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Variants}}
|
{{range .Variants}}
|
||||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
<tr><td>{{.Variant}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Hot-reload a version</h2>
|
<section class="panel"><h2>Update dictionaries</h2>
|
||||||
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.</p>
|
<p class="note">Upload the release archive <code>scrabble-dawg-vX.Y.Z.tar.gz</code> downloaded from the scrabble-dictionary repository. The next step previews the added and removed words per variant; nothing is installed until you confirm.</p>
|
||||||
<form class="form" method="post" action="/_gm/dictionary/reload">
|
<form class="form" method="post" action="/_gm/dictionary/upload" enctype="multipart/form-data">
|
||||||
<label>Version <input type="text" name="version" placeholder="v2" required></label>
|
<label>Release archive <input type="file" name="archive" accept=".gz,.tgz,application/gzip" required></label>
|
||||||
<div><button type="submit">Reload</button></div>
|
<div><button type="submit">Upload & preview</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Pending dictionary changes</h2>
|
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||||
@@ -30,12 +33,12 @@
|
|||||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||||
<label>Mark applied for variant
|
<label>Mark applied for variant
|
||||||
<select name="variant">
|
<select name="variant">
|
||||||
<option value="english">english</option>
|
<option value="scrabble_en">scrabble_en</option>
|
||||||
<option value="russian_scrabble">russian_scrabble</option>
|
<option value="scrabble_ru">scrabble_ru</option>
|
||||||
<option value="erudit">erudit</option>
|
<option value="erudit_ru">erudit_ru</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
<label>In version <input type="text" name="version" value="{{.ActiveVersion}}" required></label>
|
||||||
<div><button type="submit">Mark applied</button></div>
|
<div><button type="submit">Mark applied</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Dictionary update — preview</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<p>Comparing the uploaded archive against the active version <span class="pill">{{.ActiveVersion}}</span>. Review the changes below, then confirm to install and activate.</p>
|
||||||
|
<form class="form" method="post" action="/_gm/dictionary/install">
|
||||||
|
<input type="hidden" name="token" value="{{.Token}}">
|
||||||
|
<label>Version <input type="text" name="version" value="{{.Version}}" required></label>
|
||||||
|
<div><button type="submit">Update dictionaries</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{range .Variants}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{{.Variant}}</h2>
|
||||||
|
<p><strong>{{.AddedCount}}</strong> added, <strong>{{.RemovedCount}}</strong> removed.
|
||||||
|
{{if .LargeRemoval}}<span class="warn">Large removal — double-check this is expected before updating.</span>{{end}}</p>
|
||||||
|
<h3>Added{{if .AddedTruncated}} (showing first {{len .AddedSample}}){{end}}</h3>
|
||||||
|
<p>{{range .AddedSample}}<code>{{.}}</code> {{else}}<span class="note">none</span>{{end}}</p>
|
||||||
|
{{if .AddedTruncated}}<p class="note">… and more; the full list is not shown.</p>{{end}}
|
||||||
|
<h3>Removed{{if .RemovedTruncated}} (showing first {{len .RemovedSample}}){{end}}</h3>
|
||||||
|
<p>{{range .RemovedSample}}<code>{{.}}</code> {{else}}<span class="note">none</span>{{end}}</p>
|
||||||
|
{{if .RemovedTruncated}}<p class="note">… and more; the full list is not shown.</p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<p><a href="/_gm/dictionary">Cancel</a></p>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Feedback</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/_gm/feedback?status=unread"{{if eq .Status "unread"}} class="active"{{end}}>unread</a> ·
|
||||||
|
<a href="/_gm/feedback?status=read"{{if eq .Status "read"}} class="active"{{end}}>read</a> ·
|
||||||
|
<a href="/_gm/feedback?status=archived"{{if eq .Status "archived"}} class="active"{{end}}>archived</a>
|
||||||
|
</nav>
|
||||||
|
<form class="form" method="get" action="/_gm/feedback">
|
||||||
|
<input type="hidden" name="status" value="{{.Status}}">
|
||||||
|
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
|
||||||
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
|
<input name="ext" value="{{.ExtMask}}" placeholder="external id mask (* ?)">
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Sender</th><th>Source</th><th>Channel</th><th>Attach</th><th>Reply</th><th>State</th><th>Filed</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/feedback/{{.ID}}">{{.SenderName}}</a></td>
|
||||||
|
<td>{{.Source}}</td>
|
||||||
|
<td>{{.Channel}}</td>
|
||||||
|
<td>{{if .HasAttachment}}yes{{else}}<span class="note">—</span>{{end}}</td>
|
||||||
|
<td>{{if .Replied}}<span class="ok">replied</span>{{else}}<span class="note">—</span>{{end}}</td>
|
||||||
|
<td>{{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="7"><span class="note">no feedback</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .Pager.HasPrev}}<a href="/_gm/feedback?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/feedback?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>Feedback</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/feedback">« feedback</a> · <a href="/_gm/users/{{.AccountID}}">user</a></nav>
|
||||||
|
<section class="panel"><h2>Message</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
|
||||||
|
<li><b>Channel</b> {{.Channel}}</li>
|
||||||
|
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
|
||||||
|
<li><b>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}} 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>
|
||||||
|
<div class="msgbody">{{.Body}}</div>
|
||||||
|
{{if .HasAttachment}}
|
||||||
|
<h3>Attachment</h3>
|
||||||
|
{{if .IsImage}}<p><img class="attach" src="/_gm/feedback/{{.ID}}/attachment" alt="attachment"></p>{{end}}
|
||||||
|
<p><a href="/_gm/feedback/{{.ID}}/attachment" download>download {{.AttachmentName}}</a></p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{if .Replied}}
|
||||||
|
<section class="panel"><h2>Current reply</h2>
|
||||||
|
<div class="msgbody">{{.ReplyBody}}</div>
|
||||||
|
<p class="note">sent {{.RepliedAt}}</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel"><h2>{{if .Replied}}Re-reply{{else}}Reply{{end}}</h2>
|
||||||
|
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/reply">
|
||||||
|
<label>Reply <textarea name="reply" required></textarea></label>
|
||||||
|
<div><button type="submit">Send reply</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Actions</h2>
|
||||||
|
{{if not .Read}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/read"><button type="submit">Mark read</button></form>{{end}}
|
||||||
|
{{if not .Archived}}<form class="form" method="post" action="/_gm/feedback/{{.ID}}/archive"><button type="submit">Archive</button></form>{{end}}
|
||||||
|
<form class="form col" method="post" action="/_gm/feedback/{{.ID}}/delete">
|
||||||
|
<label><input type="checkbox" name="block" value="1"> ban the player from feedback</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
<button type="submit" formaction="/_gm/feedback/{{.ID}}/delete-all">Delete all from this player</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<li><b>Variant</b> {{.Variant}}</li>
|
<li><b>Variant</b> {{.Variant}}</li>
|
||||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||||
|
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
||||||
<li><b>Players</b> {{.Players}}</li>
|
<li><b>Players</b> {{.Players}}</li>
|
||||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||||
@@ -26,5 +27,87 @@
|
|||||||
</table>
|
</table>
|
||||||
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
|
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
{{if .SetupDraws}}
|
||||||
|
<section class="panel"><h2>First-move draw</h2>
|
||||||
|
<p class="note">Each player draws a tile; the one closest to “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}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<nav class="subnav">
|
<nav class="subnav">
|
||||||
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
|
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
|
||||||
|
<a href="/_gm/games?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
|
||||||
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
|
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
|
||||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||||
</nav>
|
</nav>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
|
|||||||
@@ -6,25 +6,28 @@
|
|||||||
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
|
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
|
||||||
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
||||||
|
<label class="check"><input type="checkbox" name="unread" value="1"{{if .UnreadOnly}} checked{{end}}> unread only</label>
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
|
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||||
</form>
|
</form>
|
||||||
{{if or .GameID .UserID}}
|
{{if or .GameID .UserID}}
|
||||||
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Game</th></tr></thead>
|
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Read</th><th>Game</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.CreatedAt}}</td>
|
<td><a href="/_gm/messages/{{.ID}}">{{.CreatedAt}}</a></td>
|
||||||
<td>{{.Source}}</td>
|
<td>{{.Source}}</td>
|
||||||
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
|
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
|
||||||
<td>{{.IP}}</td>
|
<td>{{.IP}}</td>
|
||||||
<td>{{.Body}}</td>
|
<td>{{.Body}}</td>
|
||||||
|
<td>{{if .Unread}}unread{{else}}read{{end}}</td>
|
||||||
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
|
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
|
<tr><td colspan="7"><span class="note">no messages</span></td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Suspension reasons</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Reasons offered when blocking a user; each is shown to the blocked player in their own language. Editing or deleting a reason does not change a reason already shown to a currently-blocked player (blocks snapshot the text).</p>
|
||||||
|
<section class="panel"><h2>Add reason</h2>
|
||||||
|
<form class="form col" method="post" action="/_gm/reasons">
|
||||||
|
<label>English <input type="text" name="text_en" required></label>
|
||||||
|
<label>Russian <input type="text" name="text_ru" required></label>
|
||||||
|
<div><button type="submit">Add</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Existing reasons</h2>
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="row">
|
||||||
|
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/update">
|
||||||
|
<input type="text" name="text_en" value="{{.TextEn}}" aria-label="English" required>
|
||||||
|
<input type="text" name="text_ru" value="{{.TextRu}}" aria-label="Russian" required>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/delete">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="note">no reasons yet</p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Throttled</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Rate-limiter rejections reported periodically by the gateway. The episode
|
||||||
|
list is in-memory and resets on a backend restart. An account sustaining
|
||||||
|
{{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review
|
||||||
|
below — never banned automatically; clear the flag on the user card.</p>
|
||||||
|
<section class="panel"><h2>Active IP bans</h2>
|
||||||
|
<p class="note">Temporary IP bans the gateway is currently enforcing (in-memory, prod-only;
|
||||||
|
reset on a gateway restart). Unban applies on the gateway's next sync.</p>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>IP</th><th>Reason</th><th>Since</th><th>Expires</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Bans}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.IP}}</code></td>
|
||||||
|
<td>{{.Reason}}</td>
|
||||||
|
<td>{{.Since}}</td>
|
||||||
|
<td>{{.Expires}}</td>
|
||||||
|
<td><form class="form" method="post" action="/_gm/bans/unban"><input type="hidden" name="ip" value="{{.IP}}"><button type="submit">Unban</button></form></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5"><span class="note">no active bans</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Recent episodes</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Class</th><th>Key</th><th class="num">Rejected</th><th>First seen</th><th>Last seen</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Episodes}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Class}}</td>
|
||||||
|
<td>{{if .UserID}}<a href="/_gm/users/{{.UserID}}">{{.Key}}</a>{{else}}<code>{{.Key}}</code>{{end}}</td>
|
||||||
|
<td class="num">{{.Rejected}}</td>
|
||||||
|
<td>{{.FirstSeen}}</td>
|
||||||
|
<td>{{.LastSeen}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5"><span class="note">nothing throttled recently</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Flagged accounts</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Account</th><th>Display name</th><th>Flagged</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Flagged}}
|
||||||
|
<tr><td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td><td>{{.DisplayName}}</td><td>{{.FlaggedAt}}</td></tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="3"><span class="note">no flagged accounts</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<h1>{{.DisplayName}}</h1>
|
<h1>{{.DisplayName}}</h1>
|
||||||
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a></nav>
|
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a> · <a href="/_gm/feedback?user={{.ID}}">feedback</a></nav>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<section class="panel"><h2>Account</h2>
|
<section class="panel"><h2>Account</h2>
|
||||||
<ul class="kv">
|
<ul class="kv">
|
||||||
@@ -13,8 +13,18 @@
|
|||||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
||||||
|
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
||||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
{{if .FlaggedHighRateAt}}
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/clear-high-rate-flag">
|
||||||
|
<button type="submit">Clear high-rate flag</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
|
||||||
|
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
|
||||||
|
<button type="submit">Grant</button>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Statistics</h2>
|
<section class="panel"><h2>Statistics</h2>
|
||||||
{{if .HasStats}}
|
{{if .HasStats}}
|
||||||
@@ -22,12 +32,60 @@
|
|||||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||||
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||||
|
<li><b>Moves</b> {{.Stats.Moves}}</li>
|
||||||
|
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
|
||||||
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}<p class="note">no statistics</p>{{end}}
|
{{else}}<p class="note">no statistics</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="panel"><h2>Account block</h2>
|
||||||
|
{{$uid := .ID}}
|
||||||
|
{{with .Suspension}}{{if .Blocked}}
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>Status</b> <span class="warn">{{if .Permanent}}blocked (permanent){{else}}blocked until {{.Until}} (UTC){{end}}</span></li>
|
||||||
|
{{if .BlockedAt}}<li><b>Since</b> {{.BlockedAt}}</li>{{end}}
|
||||||
|
{{if .ReasonEn}}<li><b>Reason</b> {{.ReasonEn}} / {{.ReasonRu}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{$uid}}/unblock">
|
||||||
|
<button type="submit">Unblock</button>
|
||||||
|
</form>
|
||||||
|
{{else}}<p class="note">not blocked</p>{{end}}{{end}}
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/block">
|
||||||
|
<label>Duration <select name="duration">
|
||||||
|
<option value="permanent">permanent</option>
|
||||||
|
<option value="1d">1 day</option>
|
||||||
|
<option value="3d">3 days</option>
|
||||||
|
<option value="1w">1 week</option>
|
||||||
|
<option value="1m">1 month</option>
|
||||||
|
<option value="custom">custom date (UTC)</option>
|
||||||
|
</select></label>
|
||||||
|
<label>Custom until (UTC; used when duration is "custom") <input type="datetime-local" name="until"></label>
|
||||||
|
<label>Reason <select name="reason">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{{range .Reasons}}<option value="{{.ID}}">{{.TextEn}}</option>{{end}}
|
||||||
|
</select></label>
|
||||||
|
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Roles</h2>
|
||||||
|
{{$id := .ID}}
|
||||||
|
{{if .Roles}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Role</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Roles}}
|
||||||
|
<tr><td><code>{{.}}</code></td><td><form class="form" method="post" action="/_gm/users/{{$id}}/revoke-role"><input type="hidden" name="role" value="{{.}}"><button type="submit">Revoke</button></form></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}<p class="note">no roles</p>{{end}}
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{$id}}/grant-role">
|
||||||
|
<label>Grant role <select name="role">{{range .KnownRoles}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<div><button type="submit">Grant</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
{{if .MoveChart}}
|
{{if .MoveChart}}
|
||||||
<section class="panel"><h2>Move timing</h2>
|
<section class="panel"><h2>Move timing</h2>
|
||||||
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||||
@@ -44,12 +102,41 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Friends</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Friends}}
|
||||||
|
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Blocks</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Blocks}}
|
||||||
|
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Blocked by</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .BlockedBy}}
|
||||||
|
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
{{if .TelegramID}}
|
{{if .TelegramID}}
|
||||||
<section class="panel"><h2>Send Telegram message</h2>
|
<section class="panel"><h2>Send Telegram message</h2>
|
||||||
{{if .ConnectorEnabled}}
|
{{if .ConnectorEnabled}}
|
||||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||||
<label>Message <textarea name="text" required></textarea></label>
|
<label>Message <textarea name="text" required></textarea></label>
|
||||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
|
||||||
<div><button type="submit">Send to user</button></div>
|
<div><button type="submit">Send to user</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}</td>
|
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||||
<td>{{.Kind}}</td>
|
<td>{{.Kind}}</td>
|
||||||
<td>{{.Language}}</td>
|
<td>{{.Language}}</td>
|
||||||
<td>{{.CreatedAt}}</td>
|
<td>{{.CreatedAt}}</td>
|
||||||
|
|||||||
@@ -42,8 +42,12 @@ type DashboardView struct {
|
|||||||
Games int
|
Games int
|
||||||
ActiveGames int
|
ActiveGames int
|
||||||
OpenComplaints int
|
OpenComplaints int
|
||||||
|
OpenFeedback int
|
||||||
PendingChanges int
|
PendingChanges int
|
||||||
Variants []VariantVersions
|
// ActiveVersion is the dictionary version new games pin (the persisted active
|
||||||
|
// version), distinct from the per-variant resident versions.
|
||||||
|
ActiveVersion string
|
||||||
|
Variants []VariantVersions
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsersView is the paginated account list.
|
// UsersView is the paginated account list.
|
||||||
@@ -51,31 +55,38 @@ type UsersView struct {
|
|||||||
Items []UserRow
|
Items []UserRow
|
||||||
Pager Pager
|
Pager Pager
|
||||||
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
||||||
// glob filters; FilterQuery is those encoded for pager/toggle links.
|
// glob filters; FilterQuery is those URL-encoded for the pager links. It is an
|
||||||
|
// already-escaped query fragment (url.Values.Encode), so it is typed template.URL to
|
||||||
|
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
||||||
|
// percent-encoded again by the contextual escaper.
|
||||||
Robots bool
|
Robots bool
|
||||||
NameMask string
|
NameMask string
|
||||||
ExternalIDMask string
|
ExternalIDMask string
|
||||||
FilterQuery string
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||||
// pre-formatted move-duration summary (empty when it has no timed move).
|
// pre-formatted move-duration summary (empty when it has no timed move);
|
||||||
|
// FlaggedHighRate marks the soft high-rate badge.
|
||||||
type UserRow struct {
|
type UserRow struct {
|
||||||
ID string
|
ID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Kind string
|
Kind string
|
||||||
Language string
|
Language string
|
||||||
Guest bool
|
Guest bool
|
||||||
CreatedAt string
|
FlaggedHighRate bool
|
||||||
HasMoveStats bool
|
CreatedAt string
|
||||||
MoveMin string
|
HasMoveStats bool
|
||||||
MoveAvg string
|
MoveMin string
|
||||||
MoveMax string
|
MoveAvg string
|
||||||
|
MoveMax string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
|
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
|
||||||
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
|
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
|
||||||
// game or user card); FilterQuery is the active filters encoded for the pager links.
|
// game or user card); FilterQuery is the active filters URL-encoded for the pager and CSV
|
||||||
|
// links — an already-escaped query fragment, hence template.URL so it is not re-encoded
|
||||||
|
// inside the link (see UsersView.FilterQuery).
|
||||||
type MessagesView struct {
|
type MessagesView struct {
|
||||||
Items []MessageRow
|
Items []MessageRow
|
||||||
Pager Pager
|
Pager Pager
|
||||||
@@ -83,11 +94,13 @@ type MessagesView struct {
|
|||||||
ExtMask string
|
ExtMask string
|
||||||
GameID string
|
GameID string
|
||||||
UserID string
|
UserID string
|
||||||
FilterQuery string
|
UnreadOnly bool
|
||||||
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
||||||
// card), source, IP, body, game (linked to the game card) and time.
|
// card), source, IP, body, game (linked to the game card), time, and whether it is still
|
||||||
|
// unread by at least one recipient.
|
||||||
type MessageRow struct {
|
type MessageRow struct {
|
||||||
ID string
|
ID string
|
||||||
SenderID string
|
SenderID string
|
||||||
@@ -97,6 +110,32 @@ type MessageRow struct {
|
|||||||
Body string
|
Body string
|
||||||
GameID string
|
GameID string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
|
Unread bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
|
||||||
|
// console message card.
|
||||||
|
type ChatMessageDetailView struct {
|
||||||
|
ID string
|
||||||
|
GameID string
|
||||||
|
SenderID string
|
||||||
|
SenderName string
|
||||||
|
Source string
|
||||||
|
Kind string
|
||||||
|
Body string
|
||||||
|
IP string
|
||||||
|
CreatedAt string
|
||||||
|
Unread bool
|
||||||
|
Seats []ChatSeatStatusRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
|
||||||
|
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
|
||||||
|
type ChatSeatStatusRow struct {
|
||||||
|
Seat int
|
||||||
|
AccountID string
|
||||||
|
DisplayName string
|
||||||
|
Role string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserDetailView is one account with its stats, identities and recent games.
|
// UserDetailView is one account with its stats, identities and recent games.
|
||||||
@@ -109,9 +148,15 @@ type UserDetailView struct {
|
|||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
PaidAccount bool
|
PaidAccount bool
|
||||||
// MergedInto is the primary account id when this account has been retired by a
|
// MergedInto is the primary account id when this account has been retired by a
|
||||||
// merge (Stage 11), or empty for a live account.
|
// merge, or empty for a live account.
|
||||||
MergedInto string
|
MergedInto string
|
||||||
HintBalance int
|
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||||
|
// empty for an unflagged account; the card shows it with the Clear action.
|
||||||
|
FlaggedHighRateAt string
|
||||||
|
HintBalance int
|
||||||
|
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
||||||
|
// server's maxHintGrant), passed through so the policy value lives in one place.
|
||||||
|
HintGrantMax int
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
HasStats bool
|
HasStats bool
|
||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
@@ -122,6 +167,48 @@ type UserDetailView struct {
|
|||||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||||
// time (min/mean/max), empty when the account has no timed move.
|
// time (min/mean/max), empty when the account has no timed move.
|
||||||
MoveChart template.HTML
|
MoveChart template.HTML
|
||||||
|
// Suspension is the account's current manual-block state, shown with the block/unblock
|
||||||
|
// form; Reasons is the operator-editable reason picklist offered in the block form.
|
||||||
|
Suspension SuspensionView
|
||||||
|
Reasons []ReasonOption
|
||||||
|
// Roles is the account's current roles (each revocable); KnownRoles is the set the
|
||||||
|
// grant form offers. The first role is the feedback ban (see internal/account).
|
||||||
|
Roles []string
|
||||||
|
KnownRoles []string
|
||||||
|
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
|
||||||
|
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
|
||||||
|
// other account with the date it happened. They are the full truth; the asymmetric block
|
||||||
|
// suppression that hides relationships from players never applies to the console.
|
||||||
|
Blocks []RelationRow
|
||||||
|
BlockedBy []RelationRow
|
||||||
|
Friends []RelationRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||||
|
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
||||||
|
type RelationRow struct {
|
||||||
|
AccountID string
|
||||||
|
DisplayName string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||||
|
// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent),
|
||||||
|
// when it was applied, and the reason snapshot in both languages (empty when none was cited).
|
||||||
|
type SuspensionView struct {
|
||||||
|
Blocked bool
|
||||||
|
Permanent bool
|
||||||
|
Until string
|
||||||
|
BlockedAt string
|
||||||
|
ReasonEn string
|
||||||
|
ReasonRu string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown.
|
||||||
|
type ReasonOption struct {
|
||||||
|
ID string
|
||||||
|
TextEn string
|
||||||
|
TextRu string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsRow is an account's lifetime statistics.
|
// StatsRow is an account's lifetime statistics.
|
||||||
@@ -131,6 +218,8 @@ type StatsRow struct {
|
|||||||
Draws int
|
Draws int
|
||||||
MaxGamePoints int
|
MaxGamePoints int
|
||||||
MaxWordPoints int
|
MaxWordPoints int
|
||||||
|
Moves int
|
||||||
|
HintsUsed int
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityRow is one platform/email identity of an account.
|
// IdentityRow is one platform/email identity of an account.
|
||||||
@@ -148,6 +237,8 @@ type GameRow struct {
|
|||||||
Status string
|
Status string
|
||||||
Players int
|
Players int
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
||||||
|
VsAI bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GamesView is the paginated games list, optionally filtered by status.
|
// GamesView is the paginated games list, optionally filtered by status.
|
||||||
@@ -170,11 +261,34 @@ type GameDetailView struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt string
|
FinishedAt string
|
||||||
Seats []SeatRow
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||||
|
VsAI bool
|
||||||
|
Seats []SeatRow
|
||||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||||
HasRobot bool
|
HasRobot bool
|
||||||
RobotTargetPct int
|
RobotTargetPct int
|
||||||
|
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
|
||||||
|
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
|
||||||
|
ReplayJSON template.JS
|
||||||
|
HasReplay bool
|
||||||
|
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
|
||||||
|
// and FirstMover is the resolved name of the seat-0 player the draw elected.
|
||||||
|
SetupDraws []SetupDrawRow
|
||||||
|
FirstMover string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the
|
||||||
|
// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an
|
||||||
|
// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a
|
||||||
|
// blank) and its draw rank.
|
||||||
|
type SetupDrawRow struct {
|
||||||
|
Round int
|
||||||
|
Name string
|
||||||
|
AccountID string
|
||||||
|
Letter string
|
||||||
|
Blank bool
|
||||||
|
Rank int
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
||||||
@@ -227,11 +341,13 @@ type ComplaintDetailView struct {
|
|||||||
Resolved bool
|
Resolved bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DictionaryView lists the resident versions per variant and the pending
|
// DictionaryView lists the resident versions per variant, the active version new
|
||||||
// wordlist changes from accepted complaints.
|
// games pin, and the pending wordlist changes from accepted complaints.
|
||||||
type DictionaryView struct {
|
type DictionaryView struct {
|
||||||
Variants []VariantVersions
|
// ActiveVersion is the dictionary version new games pin; the update form sets it.
|
||||||
Changes []DictChangeRow
|
ActiveVersion string
|
||||||
|
Variants []VariantVersions
|
||||||
|
Changes []DictChangeRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// DictChangeRow is one pending wordlist edit.
|
// DictChangeRow is one pending wordlist edit.
|
||||||
@@ -242,14 +358,213 @@ type DictChangeRow struct {
|
|||||||
ResolvedAt string
|
ResolvedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DictionaryPreviewView is the second step of a dictionary update: the version
|
||||||
|
// parsed from the uploaded archive (editable before confirming), the staging token
|
||||||
|
// that names the uploaded files on disk, the active version the diff is against, and
|
||||||
|
// the per-variant word diff.
|
||||||
|
type DictionaryPreviewView struct {
|
||||||
|
Version string
|
||||||
|
Token string
|
||||||
|
ActiveVersion string
|
||||||
|
Variants []VariantDiffRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// VariantDiffRow summarises one variant's word diff in the update preview.
|
||||||
|
// AddedCount/RemovedCount are the full totals; AddedSample/RemovedSample are the
|
||||||
|
// first words shown (capped); AddedTruncated/RemovedTruncated mark a capped list;
|
||||||
|
// LargeRemoval flags a removal large enough to warrant caution before confirming.
|
||||||
|
type VariantDiffRow struct {
|
||||||
|
Variant string
|
||||||
|
AddedCount int
|
||||||
|
RemovedCount int
|
||||||
|
AddedSample []string
|
||||||
|
RemovedSample []string
|
||||||
|
AddedTruncated bool
|
||||||
|
RemovedTruncated bool
|
||||||
|
LargeRemoval bool
|
||||||
|
}
|
||||||
|
|
||||||
// BroadcastView is the operator-broadcast form page.
|
// BroadcastView is the operator-broadcast form page.
|
||||||
type BroadcastView struct {
|
type BroadcastView struct {
|
||||||
ConnectorEnabled bool
|
ConnectorEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThrottledView is the rate-limit observability page: the temporary IP bans the
|
||||||
|
// gateway is currently enforcing, the recent gateway-reported throttle episodes
|
||||||
|
// (in-memory, reset on restart) and the accounts currently carrying the high-rate
|
||||||
|
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning.
|
||||||
|
type ThrottledView struct {
|
||||||
|
Bans []BanRow
|
||||||
|
Episodes []ThrottleEpisodeRow
|
||||||
|
Flagged []FlaggedAccountRow
|
||||||
|
FlagThreshold int
|
||||||
|
FlagWindow string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its
|
||||||
|
// since/expiry timestamps; the row carries an unban action.
|
||||||
|
type BanRow struct {
|
||||||
|
IP string
|
||||||
|
Reason string
|
||||||
|
Since string
|
||||||
|
Expires string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
|
||||||
|
// user card and is set only for the user class (the other classes key by IP).
|
||||||
|
type ThrottleEpisodeRow struct {
|
||||||
|
Class string
|
||||||
|
Key string
|
||||||
|
UserID string
|
||||||
|
Rejected int
|
||||||
|
FirstSeen string
|
||||||
|
LastSeen string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlaggedAccountRow is one account carrying the high-rate flag.
|
||||||
|
type FlaggedAccountRow struct {
|
||||||
|
ID string
|
||||||
|
DisplayName string
|
||||||
|
FlaggedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReasonsView is the suspension-reason picklist management page: every editable reason entry.
|
||||||
|
type ReasonsView struct {
|
||||||
|
Items []ReasonRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReasonRow is one editable suspension-reason entry, with its English and Russian text.
|
||||||
|
type ReasonRow struct {
|
||||||
|
ID string
|
||||||
|
TextEn string
|
||||||
|
TextRu string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
// MessageView is the result page shown after a POST action.
|
// MessageView is the result page shown after a POST action.
|
||||||
type MessageView struct {
|
type MessageView struct {
|
||||||
Heading string
|
Heading string
|
||||||
Body string
|
Body string
|
||||||
Back string
|
Back string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BannersView is the advertising-campaign list page.
|
||||||
|
type BannersView struct {
|
||||||
|
Items []BannerCampaignRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerCampaignRow is one campaign in the list. Window is a human-readable
|
||||||
|
// validity window ("perpetual" for the default); ActiveNow reports whether it
|
||||||
|
// would rotate right now (enabled and within its window).
|
||||||
|
type BannerCampaignRow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Weight int
|
||||||
|
IsDefault bool
|
||||||
|
Enabled bool
|
||||||
|
Window string
|
||||||
|
Messages int
|
||||||
|
ActiveNow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
||||||
|
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
||||||
|
type BannerDetailView struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Weight int
|
||||||
|
IsDefault bool
|
||||||
|
Enabled bool
|
||||||
|
StartsAt string
|
||||||
|
EndsAt string
|
||||||
|
Messages []BannerMessageRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
||||||
|
// reorder buttons (disabled at the ends).
|
||||||
|
type BannerMessageRow struct {
|
||||||
|
ID string
|
||||||
|
BodyEn string
|
||||||
|
BodyRu string
|
||||||
|
First bool
|
||||||
|
Last bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BannerSettingsView is the global display-timings form.
|
||||||
|
type BannerSettingsView struct {
|
||||||
|
HoldMs int
|
||||||
|
EdgePauseMs int
|
||||||
|
ScrollPxPerSec int
|
||||||
|
FadeOutMs int
|
||||||
|
GapMs int
|
||||||
|
FadeInMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedbackView is the paginated user-feedback queue. Status is the active
|
||||||
|
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
|
||||||
|
// UserID pins the list to one account (the per-user link from /users);
|
||||||
|
// FilterQuery is the active filters URL-encoded for the pager links (already
|
||||||
|
// escaped, hence template.URL — see UsersView.FilterQuery).
|
||||||
|
type FeedbackView struct {
|
||||||
|
Items []FeedbackRow
|
||||||
|
Status string
|
||||||
|
NameMask string
|
||||||
|
ExtMask string
|
||||||
|
UserID string
|
||||||
|
Pager Pager
|
||||||
|
FilterQuery template.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedbackRow is one feedback message in the queue: its sender (linked to the user
|
||||||
|
// card), source, channel, whether it has an attachment / a reply, its state and
|
||||||
|
// time.
|
||||||
|
type FeedbackRow struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
SenderName string
|
||||||
|
Source string
|
||||||
|
Channel string
|
||||||
|
HasAttachment bool
|
||||||
|
Read bool
|
||||||
|
Replied bool
|
||||||
|
Archived bool
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedbackDetailView is one feedback message with its body, attachment, state and
|
||||||
|
// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are
|
||||||
|
// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline
|
||||||
|
// <img> preview; Banned shows whether the sender already holds the feedback ban.
|
||||||
|
type FeedbackDetailView struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
SenderName string
|
||||||
|
Source string
|
||||||
|
Channel string
|
||||||
|
// InterfaceLanguage is the sender's interface language (account preference).
|
||||||
|
InterfaceLanguage string
|
||||||
|
IP string
|
||||||
|
Body string
|
||||||
|
HasAttachment bool
|
||||||
|
AttachmentName string
|
||||||
|
IsImage bool
|
||||||
|
Read bool
|
||||||
|
Archived bool
|
||||||
|
Replied bool
|
||||||
|
ReplyBody string
|
||||||
|
RepliedAt string
|
||||||
|
CreatedAt string
|
||||||
|
// 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,188 @@
|
|||||||
|
// Package ads owns the server-driven advertising banner ("advertising network"):
|
||||||
|
// operator-managed campaigns, their bilingual messages and the global display
|
||||||
|
// timings the client's one-line announcement strip rotates through.
|
||||||
|
//
|
||||||
|
// A campaign is one placement order with a show weight (an integer percent).
|
||||||
|
// Simultaneously active campaigns compete for display slots in proportion to
|
||||||
|
// their weights; the single perpetual default campaign fills the unsold
|
||||||
|
// remainder up to 100%. The actual rotation runs client-side (a smooth weighted
|
||||||
|
// round-robin over campaigns, round-robin over a campaign's messages); the server
|
||||||
|
// only computes the effective weighted, language-resolved set a viewer should
|
||||||
|
// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible.
|
||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is returned when a campaign or message does not exist.
|
||||||
|
var ErrNotFound = errors.New("ads: not found")
|
||||||
|
|
||||||
|
// ErrDefaultImmutable is returned when an operation is refused on the perpetual
|
||||||
|
// default campaign (it cannot be deleted, disabled, windowed, or have its
|
||||||
|
// weight or default flag changed).
|
||||||
|
var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way")
|
||||||
|
|
||||||
|
// ErrValidation wraps an operator-facing validation failure (a bad weight,
|
||||||
|
// window or message body).
|
||||||
|
var ErrValidation = errors.New("ads: validation")
|
||||||
|
|
||||||
|
// Campaign is one advertising placement order with its messages.
|
||||||
|
type Campaign struct {
|
||||||
|
ID uuid.UUID // uuid.Nil on create
|
||||||
|
Name string
|
||||||
|
Weight int // show percent, 1..100; nominal (ignored) for the default
|
||||||
|
IsDefault bool
|
||||||
|
Enabled bool
|
||||||
|
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
||||||
|
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
||||||
|
Messages []Message
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is one bilingual creative of a campaign. Both bodies are mandatory;
|
||||||
|
// the client shows the variant for the viewer's bot (service) language. Position
|
||||||
|
// orders the messages within a campaign (the round-robin order).
|
||||||
|
type Message struct {
|
||||||
|
ID uuid.UUID // uuid.Nil on create
|
||||||
|
CampaignID uuid.UUID
|
||||||
|
Position int
|
||||||
|
BodyEn string
|
||||||
|
BodyRu string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timings are the global banner display timings, in milliseconds except
|
||||||
|
// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and
|
||||||
|
// ScrollPxPerSec drive the scroll of a message wider than the strip; a
|
||||||
|
// transition between messages is FadeOutMs then GapMs then FadeInMs.
|
||||||
|
type Timings struct {
|
||||||
|
HoldMs int
|
||||||
|
EdgePauseMs int
|
||||||
|
ScrollPxPerSec int
|
||||||
|
FadeOutMs int
|
||||||
|
GapMs int
|
||||||
|
FadeInMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
||||||
|
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
||||||
|
// language and in display (round-robin) order.
|
||||||
|
type ActiveCampaign struct {
|
||||||
|
Weight int
|
||||||
|
Messages []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eligible reports whether an account should be shown the advertising banner: a
|
||||||
|
// free account (not paid) with an empty hint wallet and without the no_banner
|
||||||
|
// role. The no_banner role suppresses the banner unconditionally; buying a paid
|
||||||
|
// account or any hints also removes it.
|
||||||
|
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
||||||
|
return !paidAccount && hintBalance <= 0 && !hasNoBanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
||||||
|
// at time now, in language lang. Campaigns outside their validity window, and
|
||||||
|
// campaigns with no messages, are dropped. The default campaign's effective
|
||||||
|
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
|
||||||
|
// weights) — so it fills unsold inventory and is dropped entirely when timed
|
||||||
|
// campaigns already reach 100%. Weights are then reduced by their GCD so the
|
||||||
|
// fair rotation cycle stays short. The input is expected to be the enabled
|
||||||
|
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
|
||||||
|
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
|
||||||
|
var timed []Campaign
|
||||||
|
var def *Campaign
|
||||||
|
for i := range campaigns {
|
||||||
|
c := campaigns[i]
|
||||||
|
if len(c.Messages) == 0 {
|
||||||
|
continue // a campaign with no creative cannot fill a slot
|
||||||
|
}
|
||||||
|
if c.IsDefault {
|
||||||
|
def = &campaigns[i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if withinWindow(c, now) {
|
||||||
|
timed = append(timed, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sumTimed := 0
|
||||||
|
for _, c := range timed {
|
||||||
|
sumTimed += c.Weight
|
||||||
|
}
|
||||||
|
out := make([]ActiveCampaign, 0, len(timed)+1)
|
||||||
|
for _, c := range timed {
|
||||||
|
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
|
||||||
|
}
|
||||||
|
if def != nil {
|
||||||
|
if dw := 100 - sumTimed; dw > 0 {
|
||||||
|
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reduceByGCD(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveAt reports whether the campaign would rotate at time now: enabled, and
|
||||||
|
// either the perpetual default or within its validity window. It mirrors the
|
||||||
|
// filter computeActiveSet applies (a campaign with no messages still reports
|
||||||
|
// active here — that is a content gap the console surfaces, not an inactive
|
||||||
|
// campaign).
|
||||||
|
func (c Campaign) ActiveAt(now time.Time) bool {
|
||||||
|
return c.Enabled && (c.IsDefault || withinWindow(c, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// withinWindow reports whether now lies inside the campaign's validity window. A
|
||||||
|
// nil bound is open-ended on that side.
|
||||||
|
func withinWindow(c Campaign, now time.Time) bool {
|
||||||
|
if c.StartsAt != nil && now.Before(*c.StartsAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.EndsAt != nil && now.After(*c.EndsAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBodies projects each message to the body for lang ("ru" picks the
|
||||||
|
// Russian body; anything else picks English), preserving display order.
|
||||||
|
func resolveBodies(msgs []Message, lang string) []string {
|
||||||
|
out := make([]string, len(msgs))
|
||||||
|
for i, m := range msgs {
|
||||||
|
if lang == "ru" {
|
||||||
|
out[i] = m.BodyRu
|
||||||
|
} else {
|
||||||
|
out[i] = m.BodyEn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reduceByGCD divides every weight by the greatest common divisor of all weights
|
||||||
|
// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A
|
||||||
|
// no-op when the set is empty or already coprime.
|
||||||
|
func reduceByGCD(cs []ActiveCampaign) {
|
||||||
|
g := 0
|
||||||
|
for _, c := range cs {
|
||||||
|
g = gcd(g, c.Weight)
|
||||||
|
}
|
||||||
|
if g <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range cs {
|
||||||
|
cs[i].Weight /= g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcd returns the greatest common divisor of a and b (gcd(0, n) == n).
|
||||||
|
func gcd(a, b int) int {
|
||||||
|
for b != 0 {
|
||||||
|
a, b = b, a%b
|
||||||
|
}
|
||||||
|
if a < 0 {
|
||||||
|
return -a
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEligible(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
paidAccount bool
|
||||||
|
hintBalance int
|
||||||
|
hasNoBanner bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "free, empty wallet, no role", want: true},
|
||||||
|
{name: "paid", paidAccount: true, want: false},
|
||||||
|
{name: "has hints", hintBalance: 3, want: false},
|
||||||
|
{name: "no_banner role", hasNoBanner: true, want: false},
|
||||||
|
{name: "paid and has hints", paidAccount: true, hintBalance: 5, want: false},
|
||||||
|
{name: "no_banner overrides everything", hasNoBanner: true, want: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := Eligible(tt.paidAccount, tt.hintBalance, tt.hasNoBanner); got != tt.want {
|
||||||
|
t.Errorf("Eligible(%v,%d,%v) = %v, want %v", tt.paidAccount, tt.hintBalance, tt.hasNoBanner, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeActiveSet(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||||
|
past := now.Add(-24 * time.Hour)
|
||||||
|
future := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
// msg builds a one-message slice with distinct bodies so resolution and
|
||||||
|
// campaign identity are visible in assertions.
|
||||||
|
msg := func(tag string) []Message {
|
||||||
|
return []Message{{BodyEn: tag + "-en", BodyRu: tag + "-ru"}}
|
||||||
|
}
|
||||||
|
def := func(msgs []Message) Campaign {
|
||||||
|
return Campaign{Name: "default", Weight: 100, IsDefault: true, Enabled: true, Messages: msgs}
|
||||||
|
}
|
||||||
|
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
||||||
|
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
campaigns []Campaign
|
||||||
|
lang string
|
||||||
|
want []ActiveCampaign
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default only reduces to weight 1",
|
||||||
|
campaigns: []Campaign{def(msg("house"))},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default fills the remainder",
|
||||||
|
campaigns: []Campaign{def(msg("house")), timed("promo", 30, nil, nil, msg("promo"))},
|
||||||
|
lang: "en",
|
||||||
|
// timed 30, default 100-30=70; gcd 10 -> 3 and 7; timed first, default last.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 3, Messages: []string{"promo-en"}},
|
||||||
|
{Weight: 7, Messages: []string{"house-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timed fills 100, default dropped",
|
||||||
|
campaigns: []Campaign{def(msg("house")), timed("promo", 100, nil, nil, msg("promo"))},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timed over 100, default dropped, proportional",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("a", 60, nil, nil, msg("a")),
|
||||||
|
timed("b", 80, nil, nil, msg("b")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// default dropped (sum 140 >= 100); gcd(60,80)=20 -> 3 and 4.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 3, Messages: []string{"a-en"}},
|
||||||
|
{Weight: 4, Messages: []string{"b-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future and past windows exclude timed",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("future", 50, &future, nil, msg("future")),
|
||||||
|
timed("past", 50, nil, &past, msg("past")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "active window included",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("live", 25, &past, &future, msg("live")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// timed 25, default 75; gcd 25 -> 1 and 3.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 1, Messages: []string{"live-en"}},
|
||||||
|
{Weight: 3, Messages: []string{"house-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "campaign without messages is skipped and not counted",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("empty", 40, nil, nil, nil),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// empty campaign skipped; default fills full 100 -> reduced to 1.
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "russian bodies resolved",
|
||||||
|
campaigns: []Campaign{def(msg("house"))},
|
||||||
|
lang: "ru",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-ru"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three timed split by gcd",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def(msg("house")),
|
||||||
|
timed("a", 50, nil, nil, msg("a")),
|
||||||
|
timed("b", 30, nil, nil, msg("b")),
|
||||||
|
timed("c", 20, nil, nil, msg("c")),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
// sum 100, default dropped; gcd 10 -> 5,3,2.
|
||||||
|
want: []ActiveCampaign{
|
||||||
|
{Weight: 5, Messages: []string{"a-en"}},
|
||||||
|
{Weight: 3, Messages: []string{"b-en"}},
|
||||||
|
{Weight: 2, Messages: []string{"c-en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "campaign with multiple messages keeps order",
|
||||||
|
campaigns: []Campaign{
|
||||||
|
def([]Message{{BodyEn: "one-en", BodyRu: "one-ru"}, {BodyEn: "two-en", BodyRu: "two-ru"}}),
|
||||||
|
},
|
||||||
|
lang: "en",
|
||||||
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeActiveSetEmpty(t *testing.T) {
|
||||||
|
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
||||||
|
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
|
||||||
|
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGCD(t *testing.T) {
|
||||||
|
tests := []struct{ a, b, want int }{
|
||||||
|
{0, 5, 5}, {5, 0, 5}, {12, 18, 6}, {100, 100, 100}, {7, 13, 1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := gcd(tt.a, tt.b); got != tt.want {
|
||||||
|
t.Errorf("gcd(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Operator-input bounds and the display-timing clamps. The timings are clamped
|
||||||
|
// (not rejected) on update so the client rotator can never be driven into a
|
||||||
|
// broken state by a typo in the console.
|
||||||
|
const (
|
||||||
|
maxCampaignName = 80
|
||||||
|
maxMessageBody = 500
|
||||||
|
|
||||||
|
minHoldMs = 3000 // 3s — below this the fade transition would dominate
|
||||||
|
maxHoldMs = 600000 // 10 min
|
||||||
|
|
||||||
|
maxEdgePauseMs = 60000
|
||||||
|
|
||||||
|
minScrollPxPerSec = 5
|
||||||
|
maxScrollPxPerSec = 1000
|
||||||
|
|
||||||
|
maxFadeMs = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service is the domain layer over Store: it validates operator input, enforces
|
||||||
|
// the default campaign's invariants, clamps the display timings, and assembles
|
||||||
|
// the resolved rotation feed (ActiveSet) for a viewer.
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service over store.
|
||||||
|
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||||
|
|
||||||
|
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
||||||
|
// (en/ru) together with the global display timings: the currently-active
|
||||||
|
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
||||||
|
// lang, ready for the client's weighted round-robin.
|
||||||
|
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
||||||
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Timings{}, err
|
||||||
|
}
|
||||||
|
timings, err := s.store.Settings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, Timings{}, err
|
||||||
|
}
|
||||||
|
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCampaigns returns every campaign with its messages, for the admin console.
|
||||||
|
func (s *Service) ListCampaigns(ctx context.Context) ([]Campaign, error) {
|
||||||
|
return s.store.ListCampaigns(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign returns one campaign with its messages, or ErrNotFound.
|
||||||
|
func (s *Service) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
||||||
|
return s.store.Campaign(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCampaign validates and creates a new time-limited campaign (never the
|
||||||
|
// default) and returns its id.
|
||||||
|
func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
|
name, err := validName(c.Name)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
if err := validWeight(c.Weight); err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
return s.store.CreateCampaign(ctx, Campaign{
|
||||||
|
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCampaign validates and updates a campaign. The default campaign keeps its
|
||||||
|
// weight (nominal), enabled flag and (empty) window — only its name is editable;
|
||||||
|
// any submitted weight/window/enabled are ignored for it.
|
||||||
|
func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||||
|
existing, err := s.store.Campaign(ctx, c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name, err := validName(c.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
upd := Campaign{ID: existing.ID, Name: name}
|
||||||
|
if existing.IsDefault {
|
||||||
|
upd.Weight = existing.Weight
|
||||||
|
upd.Enabled = true
|
||||||
|
upd.StartsAt = nil
|
||||||
|
upd.EndsAt = nil
|
||||||
|
} else {
|
||||||
|
if err := validWeight(c.Weight); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
upd.Weight = c.Weight
|
||||||
|
upd.Enabled = c.Enabled
|
||||||
|
upd.StartsAt = c.StartsAt
|
||||||
|
upd.EndsAt = c.EndsAt
|
||||||
|
}
|
||||||
|
return s.store.UpdateCampaign(ctx, upd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCampaign removes a campaign, refusing the perpetual default.
|
||||||
|
func (s *Service) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
|
||||||
|
existing, err := s.store.Campaign(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing.IsDefault {
|
||||||
|
return ErrDefaultImmutable
|
||||||
|
}
|
||||||
|
return s.store.DeleteCampaign(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage validates a bilingual message and appends it to a campaign.
|
||||||
|
func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, bodyRu string) (uuid.UUID, error) {
|
||||||
|
en, ru, err := validBodies(bodyEn, bodyRu)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessage validates and updates the bilingual bodies of a message that
|
||||||
|
// belongs to campaignID (its position is unchanged). It returns ErrNotFound when
|
||||||
|
// the message is not part of that campaign, so a mismatched campaign/message pair
|
||||||
|
// never edits an unrelated campaign's message.
|
||||||
|
func (s *Service) EditMessage(ctx context.Context, campaignID, messageID uuid.UUID, bodyEn, bodyRu string) error {
|
||||||
|
en, ru, err := validBodies(bodyEn, bodyRu)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !campaignOwnsMessage(c, messageID) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return s.store.UpdateMessageBodies(ctx, messageID, en, ru)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveMessage reorders a message within its campaign by swapping its position
|
||||||
|
// with the adjacent message in direction dir (-1 up, +1 down). A move past an
|
||||||
|
// edge is a no-op.
|
||||||
|
func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UUID, dir int) error {
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
for i, m := range c.Messages {
|
||||||
|
if m.ID == messageID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
j := idx + dir
|
||||||
|
if j < 0 || j >= len(c.Messages) {
|
||||||
|
return nil // already at the edge
|
||||||
|
}
|
||||||
|
a, b := c.Messages[idx], c.Messages[j]
|
||||||
|
if err := s.store.SetMessagePosition(ctx, a.ID, j); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.SetMessagePosition(ctx, b.ID, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage removes a message that belongs to campaignID, refusing to remove
|
||||||
|
// the default campaign's last remaining message (the default must always have a
|
||||||
|
// creative to show). It returns ErrNotFound when the message is not part of that
|
||||||
|
// campaign — so a mismatched campaign/message pair can neither delete an
|
||||||
|
// unrelated campaign's message nor bypass the default's last-message guard.
|
||||||
|
func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error {
|
||||||
|
c, err := s.store.Campaign(ctx, campaignID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !campaignOwnsMessage(c, messageID) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if c.IsDefault && len(c.Messages) <= 1 {
|
||||||
|
return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation)
|
||||||
|
}
|
||||||
|
return s.store.DeleteMessage(ctx, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// campaignOwnsMessage reports whether messageID is one of the campaign's messages.
|
||||||
|
func campaignOwnsMessage(c Campaign, messageID uuid.UUID) bool {
|
||||||
|
for _, m := range c.Messages {
|
||||||
|
if m.ID == messageID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings returns the global display timings.
|
||||||
|
func (s *Service) Settings(ctx context.Context) (Timings, error) {
|
||||||
|
return s.store.Settings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings clamps every timing into its safe range and stores them.
|
||||||
|
func (s *Service) UpdateSettings(ctx context.Context, t Timings) error {
|
||||||
|
return s.store.UpdateSettings(ctx, clampTimings(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validName trims and bounds a campaign name.
|
||||||
|
func validName(name string) (string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "", fmt.Errorf("%w: the campaign name is required", ErrValidation)
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > maxCampaignName {
|
||||||
|
return "", fmt.Errorf("%w: the campaign name must be at most %d characters", ErrValidation, maxCampaignName)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validWeight bounds a campaign show weight to the 1..100 percent range.
|
||||||
|
func validWeight(w int) error {
|
||||||
|
if w < 1 || w > 100 {
|
||||||
|
return fmt.Errorf("%w: the weight must be a percent between 1 and 100", ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validWindow rejects an inverted validity window.
|
||||||
|
func validWindow(starts, ends *time.Time) error {
|
||||||
|
if starts != nil && ends != nil && ends.Before(*starts) {
|
||||||
|
return fmt.Errorf("%w: the end must not precede the start", ErrValidation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||||
|
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||||
|
en := strings.TrimSpace(bodyEn)
|
||||||
|
ru := strings.TrimSpace(bodyRu)
|
||||||
|
if en == "" || ru == "" {
|
||||||
|
return "", "", fmt.Errorf("%w: both the English and Russian message bodies are required", ErrValidation)
|
||||||
|
}
|
||||||
|
if len([]rune(en)) > maxMessageBody || len([]rune(ru)) > maxMessageBody {
|
||||||
|
return "", "", fmt.Errorf("%w: a message body must be at most %d characters", ErrValidation, maxMessageBody)
|
||||||
|
}
|
||||||
|
return en, ru, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampTimings forces every display timing into its safe range.
|
||||||
|
func clampTimings(t Timings) Timings {
|
||||||
|
return Timings{
|
||||||
|
HoldMs: clampInt(t.HoldMs, minHoldMs, maxHoldMs),
|
||||||
|
EdgePauseMs: clampInt(t.EdgePauseMs, 0, maxEdgePauseMs),
|
||||||
|
ScrollPxPerSec: clampInt(t.ScrollPxPerSec, minScrollPxPerSec, maxScrollPxPerSec),
|
||||||
|
FadeOutMs: clampInt(t.FadeOutMs, 0, maxFadeMs),
|
||||||
|
GapMs: clampInt(t.GapMs, 0, maxFadeMs),
|
||||||
|
FadeInMs: clampInt(t.FadeInMs, 0, maxFadeMs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package ads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the Postgres-backed query surface for advertising campaigns, their
|
||||||
|
// messages and the single global display-settings row.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore constructs a Store wrapping db.
|
||||||
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
|
|
||||||
|
// ListCampaigns returns every campaign with its messages, the default first then
|
||||||
|
// by creation time, for the admin console.
|
||||||
|
func (s *Store) ListCampaigns(ctx context.Context) ([]Campaign, error) {
|
||||||
|
return s.loadCampaigns(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveCampaigns returns the enabled campaigns with their messages, the default
|
||||||
|
// first then by creation time. Window filtering and the default-remainder weight
|
||||||
|
// are applied by the Service (ActiveSet), not here.
|
||||||
|
func (s *Store) ActiveCampaigns(ctx context.Context) ([]Campaign, error) {
|
||||||
|
return s.loadCampaigns(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCampaigns reads campaigns (optionally only the enabled ones) and attaches
|
||||||
|
// each one's messages in display order, with a single messages query (the table
|
||||||
|
// is small, so this avoids both an N+1 and a join projection).
|
||||||
|
func (s *Store) loadCampaigns(ctx context.Context, enabledOnly bool) ([]Campaign, error) {
|
||||||
|
sel := postgres.SELECT(table.AdCampaigns.AllColumns).FROM(table.AdCampaigns)
|
||||||
|
if enabledOnly {
|
||||||
|
sel = sel.WHERE(table.AdCampaigns.Enabled.EQ(postgres.Bool(true)))
|
||||||
|
}
|
||||||
|
sel = sel.ORDER_BY(table.AdCampaigns.IsDefault.DESC(), table.AdCampaigns.CreatedAt.ASC())
|
||||||
|
|
||||||
|
var rows []model.AdCampaigns
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("ads: list campaigns: %w", err)
|
||||||
|
}
|
||||||
|
byID, err := s.messagesByCampaign(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Campaign, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
c := modelToCampaign(r)
|
||||||
|
c.Messages = byID[r.CampaignID]
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign returns one campaign with its messages, or ErrNotFound.
|
||||||
|
func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
||||||
|
sel := postgres.SELECT(table.AdCampaigns.AllColumns).
|
||||||
|
FROM(table.AdCampaigns).
|
||||||
|
WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id))).
|
||||||
|
LIMIT(1)
|
||||||
|
var row model.AdCampaigns
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Campaign{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Campaign{}, fmt.Errorf("ads: get campaign %s: %w", id, err)
|
||||||
|
}
|
||||||
|
c := modelToCampaign(row)
|
||||||
|
msgs, err := s.messagesFor(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return Campaign{}, err
|
||||||
|
}
|
||||||
|
c.Messages = msgs
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCampaign inserts a campaign (always non-default) and returns its new id.
|
||||||
|
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
|
id := uuid.New()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
stmt := table.AdCampaigns.INSERT(
|
||||||
|
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
||||||
|
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
||||||
|
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||||
|
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
||||||
|
).VALUES(
|
||||||
|
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
||||||
|
postgres.Bool(false), postgres.Bool(c.Enabled),
|
||||||
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||||
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
|
)
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("ads: create campaign: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCampaign updates a campaign's name, weight, enabled flag and validity
|
||||||
|
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||||
|
// campaign matches.
|
||||||
|
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||||
|
stmt := table.AdCampaigns.UPDATE(
|
||||||
|
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
||||||
|
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
||||||
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||||
|
return execOne(ctx, s.db, stmt, "update campaign")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCampaign removes a campaign (its messages cascade). Returns ErrNotFound
|
||||||
|
// when no campaign matches. The Service refuses to delete the default.
|
||||||
|
func (s *Store) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
|
||||||
|
stmt := table.AdCampaigns.DELETE().WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "delete campaign")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage inserts a message into a campaign and returns its new id.
|
||||||
|
func (s *Store) AddMessage(ctx context.Context, m Message) (uuid.UUID, error) {
|
||||||
|
id := uuid.New()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
stmt := table.AdMessages.INSERT(
|
||||||
|
table.AdMessages.MessageID, table.AdMessages.CampaignID, table.AdMessages.Position,
|
||||||
|
table.AdMessages.BodyEn, table.AdMessages.BodyRu,
|
||||||
|
table.AdMessages.CreatedAt, table.AdMessages.UpdatedAt,
|
||||||
|
).VALUES(
|
||||||
|
postgres.UUID(id), postgres.UUID(m.CampaignID), postgres.Int(int64(m.Position)),
|
||||||
|
postgres.String(m.BodyEn), postgres.String(m.BodyRu),
|
||||||
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
|
)
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("ads: add message: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMessageBodies updates a message's bilingual bodies (its position is
|
||||||
|
// unchanged). Returns ErrNotFound when no message matches.
|
||||||
|
func (s *Store) UpdateMessageBodies(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error {
|
||||||
|
stmt := table.AdMessages.UPDATE(
|
||||||
|
table.AdMessages.BodyEn, table.AdMessages.BodyRu, table.AdMessages.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
postgres.String(bodyEn), postgres.String(bodyRu), postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "update message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMessagePosition updates only a message's position (used to reorder).
|
||||||
|
func (s *Store) SetMessagePosition(ctx context.Context, id uuid.UUID, position int) error {
|
||||||
|
stmt := table.AdMessages.UPDATE(table.AdMessages.Position, table.AdMessages.UpdatedAt).
|
||||||
|
SET(postgres.Int(int64(position)), postgres.TimestampzT(time.Now().UTC())).
|
||||||
|
WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "reorder message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage removes a message. Returns ErrNotFound when no message matches.
|
||||||
|
func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) error {
|
||||||
|
stmt := table.AdMessages.DELETE().WHERE(table.AdMessages.MessageID.EQ(postgres.UUID(id)))
|
||||||
|
return execOne(ctx, s.db, stmt, "delete message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings returns the global display timings.
|
||||||
|
func (s *Store) Settings(ctx context.Context) (Timings, error) {
|
||||||
|
sel := postgres.SELECT(table.AdSettings.AllColumns).FROM(table.AdSettings).LIMIT(1)
|
||||||
|
var row model.AdSettings
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Timings{}, fmt.Errorf("ads: get settings: %w", err)
|
||||||
|
}
|
||||||
|
return Timings{
|
||||||
|
HoldMs: int(row.HoldMs),
|
||||||
|
EdgePauseMs: int(row.EdgePauseMs),
|
||||||
|
ScrollPxPerSec: int(row.ScrollPxPerSec),
|
||||||
|
FadeOutMs: int(row.FadeOutMs),
|
||||||
|
GapMs: int(row.GapMs),
|
||||||
|
FadeInMs: int(row.FadeInMs),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings overwrites the single global display-settings row.
|
||||||
|
func (s *Store) UpdateSettings(ctx context.Context, t Timings) error {
|
||||||
|
stmt := table.AdSettings.UPDATE(
|
||||||
|
table.AdSettings.HoldMs, table.AdSettings.EdgePauseMs, table.AdSettings.ScrollPxPerSec,
|
||||||
|
table.AdSettings.FadeOutMs, table.AdSettings.GapMs, table.AdSettings.FadeInMs, table.AdSettings.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
postgres.Int(int64(t.HoldMs)), postgres.Int(int64(t.EdgePauseMs)), postgres.Int(int64(t.ScrollPxPerSec)),
|
||||||
|
postgres.Int(int64(t.FadeOutMs)), postgres.Int(int64(t.GapMs)), postgres.Int(int64(t.FadeInMs)),
|
||||||
|
postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.AdSettings.ID.EQ(postgres.Bool(true)))
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("ads: update settings: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messagesByCampaign loads every message grouped by campaign id, in display order.
|
||||||
|
func (s *Store) messagesByCampaign(ctx context.Context) (map[uuid.UUID][]Message, error) {
|
||||||
|
sel := postgres.SELECT(table.AdMessages.AllColumns).
|
||||||
|
FROM(table.AdMessages).
|
||||||
|
ORDER_BY(table.AdMessages.CampaignID.ASC(), table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC())
|
||||||
|
var rows []model.AdMessages
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("ads: list messages: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[uuid.UUID][]Message, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.CampaignID] = append(out[r.CampaignID], modelToMessage(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messagesFor loads one campaign's messages in display order.
|
||||||
|
func (s *Store) messagesFor(ctx context.Context, campaignID uuid.UUID) ([]Message, error) {
|
||||||
|
sel := postgres.SELECT(table.AdMessages.AllColumns).
|
||||||
|
FROM(table.AdMessages).
|
||||||
|
WHERE(table.AdMessages.CampaignID.EQ(postgres.UUID(campaignID))).
|
||||||
|
ORDER_BY(table.AdMessages.Position.ASC(), table.AdMessages.CreatedAt.ASC())
|
||||||
|
var rows []model.AdMessages
|
||||||
|
if err := sel.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("ads: list messages for %s: %w", campaignID, err)
|
||||||
|
}
|
||||||
|
out := make([]Message, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, modelToMessage(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// execOne runs a statement expected to touch exactly one row, mapping a zero
|
||||||
|
// row-count to ErrNotFound.
|
||||||
|
func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) error {
|
||||||
|
res, err := stmt.ExecContext(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ads: %s: %w", what, err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ads: %s rows: %w", what, err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelToCampaign(r model.AdCampaigns) Campaign {
|
||||||
|
return Campaign{
|
||||||
|
ID: r.CampaignID,
|
||||||
|
Name: r.Name,
|
||||||
|
Weight: int(r.Weight),
|
||||||
|
IsDefault: r.IsDefault,
|
||||||
|
Enabled: r.Enabled,
|
||||||
|
StartsAt: r.StartsAt,
|
||||||
|
EndsAt: r.EndsAt,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelToMessage(r model.AdMessages) Message {
|
||||||
|
return Message{
|
||||||
|
ID: r.MessageID,
|
||||||
|
CampaignID: r.CampaignID,
|
||||||
|
Position: int(r.Position),
|
||||||
|
BodyEn: r.BodyEn,
|
||||||
|
BodyRu: r.BodyRu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsOrNull renders a nullable validity-window bound: NULL for a nil pointer,
|
||||||
|
// otherwise the UTC timestamp.
|
||||||
|
func tsOrNull(t *time.Time) postgres.Expression {
|
||||||
|
if t == nil {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.TimestampzT(t.UTC())
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Package banview mirrors the gateway's active IP bans for the admin console and
|
||||||
|
// collects operator unban requests for the gateway to apply. Like ratewatch it is
|
||||||
|
// in-memory, single-instance and resets on a backend restart by design — the
|
||||||
|
// gateway re-reports its active set on the next sync, and the durable effect (the
|
||||||
|
// ban itself) lives in the gateway, not here.
|
||||||
|
package banview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ban is one active IP ban as reported by the gateway.
|
||||||
|
type Ban struct {
|
||||||
|
IP string
|
||||||
|
Reason string
|
||||||
|
Since time.Time
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// View holds the last-reported active bans and the operator's pending unbans.
|
||||||
|
type View struct {
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
bans map[string]Ban // last reported active set, keyed by IP
|
||||||
|
unban map[string]struct{} // IPs an operator marked for unban
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs an empty View.
|
||||||
|
func New() *View {
|
||||||
|
return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest replaces the mirrored active set with the gateway's latest report,
|
||||||
|
// skipping entries with an empty IP or one that has already expired.
|
||||||
|
func (v *View) Ingest(active []Ban) {
|
||||||
|
now := v.now()
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.bans = make(map[string]Ban, len(active))
|
||||||
|
for _, b := range active {
|
||||||
|
if b.IP == "" || !now.Before(b.Expires) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v.bans[b.IP] = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns the mirrored active bans, most recently banned first.
|
||||||
|
func (v *View) Recent() []Ban {
|
||||||
|
now := v.now()
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
out := make([]Ban, 0, len(v.bans))
|
||||||
|
for _, b := range v.bans {
|
||||||
|
if now.Before(b.Expires) {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestUnban records an operator request to lift the ban on ip; the gateway
|
||||||
|
// applies it on its next sync (so the console reflects it within the sync
|
||||||
|
// interval). An empty ip is ignored.
|
||||||
|
func (v *View) RequestUnban(ip string) {
|
||||||
|
if ip == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.unban[ip] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrainUnbans returns and clears the IPs operators have marked for unban since the
|
||||||
|
// previous drain. It returns nil when there are none.
|
||||||
|
func (v *View) DrainUnbans() []string {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
if len(v.unban) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(v.unban))
|
||||||
|
for ip := range v.unban {
|
||||||
|
out = append(out, ip)
|
||||||
|
}
|
||||||
|
clear(v.unban)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package banview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func viewAt(clk *time.Time) *View {
|
||||||
|
v := New()
|
||||||
|
v.now = func() time.Time { return *clk }
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestRecentDropsExpired(t *testing.T) {
|
||||||
|
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
v := viewAt(&clk)
|
||||||
|
v.Ingest([]Ban{
|
||||||
|
{IP: "1.1.1.1", Reason: "tripwire", Since: clk, Expires: clk.Add(time.Hour)},
|
||||||
|
{IP: "2.2.2.2", Reason: "rejections", Since: clk.Add(-2 * time.Hour), Expires: clk.Add(-time.Hour)}, // expired
|
||||||
|
{IP: "", Reason: "x", Since: clk, Expires: clk.Add(time.Hour)}, // empty IP
|
||||||
|
})
|
||||||
|
got := v.Recent()
|
||||||
|
if len(got) != 1 || got[0].IP != "1.1.1.1" || got[0].Reason != "tripwire" {
|
||||||
|
t.Fatalf("Recent = %+v, want one live ban for 1.1.1.1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestReplaces(t *testing.T) {
|
||||||
|
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
v := viewAt(&clk)
|
||||||
|
v.Ingest([]Ban{{IP: "1.1.1.1", Since: clk, Expires: clk.Add(time.Hour)}})
|
||||||
|
v.Ingest([]Ban{{IP: "2.2.2.2", Since: clk, Expires: clk.Add(time.Hour)}})
|
||||||
|
got := v.Recent()
|
||||||
|
if len(got) != 1 || got[0].IP != "2.2.2.2" {
|
||||||
|
t.Fatalf("Recent = %+v, want only the latest report (2.2.2.2)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentOrdersBySince(t *testing.T) {
|
||||||
|
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
v := viewAt(&clk)
|
||||||
|
v.Ingest([]Ban{
|
||||||
|
{IP: "old", Since: clk.Add(-10 * time.Minute), Expires: clk.Add(time.Hour)},
|
||||||
|
{IP: "new", Since: clk.Add(-1 * time.Minute), Expires: clk.Add(time.Hour)},
|
||||||
|
})
|
||||||
|
got := v.Recent()
|
||||||
|
if len(got) != 2 || got[0].IP != "new" || got[1].IP != "old" {
|
||||||
|
t.Fatalf("Recent order = %+v, want most recent first", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnbanRoundTrip(t *testing.T) {
|
||||||
|
clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
v := viewAt(&clk)
|
||||||
|
v.RequestUnban("3.3.3.3")
|
||||||
|
v.RequestUnban("") // ignored
|
||||||
|
drained := v.DrainUnbans()
|
||||||
|
if len(drained) != 1 || drained[0] != "3.3.3.3" {
|
||||||
|
t.Fatalf("DrainUnbans = %v, want [3.3.3.3]", drained)
|
||||||
|
}
|
||||||
|
if again := v.DrainUnbans(); again != nil {
|
||||||
|
t.Fatalf("second DrainUnbans = %v, want nil (cleared)", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,9 @@ type Config struct {
|
|||||||
Lobby lobby.Config
|
Lobby lobby.Config
|
||||||
// Robot configures the robot opponent driver (scan cadence).
|
// Robot configures the robot opponent driver (scan cadence).
|
||||||
Robot robot.Config
|
Robot robot.Config
|
||||||
|
// RateWatch tunes the conservative high-rate auto-flag applied to the
|
||||||
|
// gateway's rate-limiter rejection reports.
|
||||||
|
RateWatch ratewatch.Config
|
||||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||||
// selects the development log mailer (the code is logged, not sent).
|
// selects the development log mailer (the code is logged, not sent).
|
||||||
SMTP account.SMTPConfig
|
SMTP account.SMTPConfig
|
||||||
@@ -96,6 +100,9 @@ func Load() (Config, error) {
|
|||||||
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
|
if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
|
if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,14 @@ func Load() (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rw := ratewatch.DefaultConfig()
|
||||||
|
if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
|
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -131,6 +146,7 @@ func Load() (Config, error) {
|
|||||||
Game: gm,
|
Game: gm,
|
||||||
Lobby: lb,
|
Lobby: lb,
|
||||||
Robot: rb,
|
Robot: rb,
|
||||||
|
RateWatch: rw,
|
||||||
SMTP: smtp,
|
SMTP: smtp,
|
||||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||||
GuestReapInterval: guestReapInterval,
|
GuestReapInterval: guestReapInterval,
|
||||||
@@ -170,6 +186,9 @@ func (c Config) validate() error {
|
|||||||
if err := c.Robot.Validate(); err != nil {
|
if err := c.Robot.Validate(); err != nil {
|
||||||
return fmt.Errorf("config: %w", err)
|
return fmt.Errorf("config: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.RateWatch.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
if c.GuestReapInterval <= 0 {
|
if c.GuestReapInterval <= 0 {
|
||||||
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// Package connector is the backend's gRPC client for the Telegram platform
|
// Package connector is the backend's gRPC client for operator broadcasts: a direct
|
||||||
// connector side-service. The admin console uses it to send operator broadcasts:
|
// message to one user, or a post to the game channel. It calls the gateway's
|
||||||
// a direct message to one user, or a post to a game channel. Each broadcast
|
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS
|
||||||
// selects the delivering bot by language (an operator choice, since the connector
|
// link and reports back whether it was delivered). The relay lives on the trusted
|
||||||
// hosts one bot per service language). The connector lives on the trusted internal
|
// internal network, so the connection uses insecure (plaintext) transport
|
||||||
// network, so the connection uses insecure (plaintext) transport credentials
|
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
|
||||||
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
|
// narrowed to the two broadcast methods the admin surface needs.
|
||||||
// the two broadcast methods the admin surface needs.
|
|
||||||
package connector
|
package connector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -37,22 +36,21 @@ func New(addr string) (*Client, error) {
|
|||||||
func (c *Client) Close() error { return c.conn.Close() }
|
func (c *Client) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
// SendToUser sends an operator text message to one user, addressed by their
|
// SendToUser sends an operator text message to one user, addressed by their
|
||||||
// platform external_id, through the bot for the given language. delivered reports
|
// platform external_id, through the bot. delivered reports whether the connector
|
||||||
// whether the connector actually sent it (false when the user has not started that
|
// actually sent it (false when the user has not started the bot).
|
||||||
// bot).
|
func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
|
||||||
func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) {
|
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text})
|
||||||
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return resp.GetDelivered(), nil
|
return resp.GetDelivered(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendToGameChannel posts an operator text message to the game channel of the bot
|
// SendToGameChannel posts an operator text message to the bot's game channel.
|
||||||
// for the given language. delivered reports whether the connector sent it (false
|
// delivered reports whether the connector sent it (false when the bot has no
|
||||||
// when that bot has no channel configured).
|
// channel configured).
|
||||||
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
|
func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
|
||||||
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
|
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Package dictadmin stages and installs scrabble-dictionary release archives for
|
||||||
|
// the admin console's online dictionary-update flow. It validates the release
|
||||||
|
// archive (scrabble-dawg-vX.Y.Z.tar.gz), extracts it safely into a per-version
|
||||||
|
// directory under BACKEND_DICT_DIR, and keeps a staging area for the two-step
|
||||||
|
// upload-then-confirm interaction. Extraction is hardened against the usual
|
||||||
|
// archive risks (path traversal, symlinks, decompression bombs).
|
||||||
|
package dictadmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
crand "crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxArchiveBytes bounds an uploaded release archive; the handler wraps the request
|
||||||
|
// body in an http.MaxBytesReader with this limit. The three compressed DAWGs are a
|
||||||
|
// few MB together, so 64 MiB is generous.
|
||||||
|
const MaxArchiveBytes int64 = 64 << 20
|
||||||
|
|
||||||
|
// stagingRoot is the dot-prefixed directory under the dictionary dir holding
|
||||||
|
// not-yet-installed uploads. The engine's boot scan skips dot-prefixed dirs, so a
|
||||||
|
// staged upload never becomes a resident version.
|
||||||
|
const stagingRoot = ".staging"
|
||||||
|
|
||||||
|
// stagingTTL is how long an abandoned staged upload lingers before Stage sweeps it.
|
||||||
|
const stagingTTL = time.Hour
|
||||||
|
|
||||||
|
// maxMemberBytes and maxEntries bound a single archive member and the number of
|
||||||
|
// entries, defeating decompression and entry-count bombs. They are variables so
|
||||||
|
// tests can lower them. maxMemberBytes is per file; a real DAWG is a few MB.
|
||||||
|
var (
|
||||||
|
maxMemberBytes int64 = 32 << 20
|
||||||
|
maxEntries = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
nameRE = regexp.MustCompile(`^scrabble-dawg-(v[0-9]+\.[0-9]+\.[0-9]+)\.tar\.gz$`)
|
||||||
|
versionRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||||
|
tokenRE = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseVersionFromName extracts the version from a release archive filename of the
|
||||||
|
// form scrabble-dawg-vX.Y.Z.tar.gz, or returns an error for any other shape.
|
||||||
|
func ParseVersionFromName(filename string) (string, error) {
|
||||||
|
m := nameRE.FindStringSubmatch(filename)
|
||||||
|
if m == nil {
|
||||||
|
return "", fmt.Errorf("dictadmin: %q is not a scrabble-dawg-vX.Y.Z.tar.gz archive", filename)
|
||||||
|
}
|
||||||
|
return m[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidVersion reports whether v is a vMAJOR.MINOR.PATCH version label. It guards
|
||||||
|
// the operator's manual override before the version is used as a directory name.
|
||||||
|
func ValidVersion(v string) bool {
|
||||||
|
return versionRE.MatchString(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract reads a gzip+tar release archive from r and writes the recognised
|
||||||
|
// dictionary files into destDir, returning the variants found in catalogue order.
|
||||||
|
// Only regular files whose base name is a known DAWG filename are written (their
|
||||||
|
// directory part is discarded, so a traversal entry cannot escape destDir);
|
||||||
|
// symlinks and other types are ignored, and oversize members or an excessive entry
|
||||||
|
// count are rejected.
|
||||||
|
func Extract(r io.Reader, destDir string) ([]engine.Variant, error) {
|
||||||
|
gz, err := gzip.NewReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dictadmin: open gzip: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = gz.Close() }()
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
|
||||||
|
byName := filenameToVariant()
|
||||||
|
seen := make(map[engine.Variant]bool)
|
||||||
|
entries := 0
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dictadmin: read archive: %w", err)
|
||||||
|
}
|
||||||
|
entries++
|
||||||
|
if entries > maxEntries {
|
||||||
|
return nil, fmt.Errorf("dictadmin: archive has more than %d entries", maxEntries)
|
||||||
|
}
|
||||||
|
if hdr.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := filepath.Base(filepath.Clean(hdr.Name))
|
||||||
|
v, ok := byName[base]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[v] {
|
||||||
|
return nil, fmt.Errorf("dictadmin: duplicate %s in archive", base)
|
||||||
|
}
|
||||||
|
if hdr.Size > maxMemberBytes {
|
||||||
|
return nil, fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", base, maxMemberBytes)
|
||||||
|
}
|
||||||
|
if err := writeMember(filepath.Join(destDir, base), tr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var found []engine.Variant
|
||||||
|
for _, v := range engine.Variants() {
|
||||||
|
if seen[v] {
|
||||||
|
found = append(found, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMember copies one archive member into path, bounding it at maxMemberBytes so
|
||||||
|
// a member whose declared size lies cannot exhaust the disk.
|
||||||
|
func writeMember(path string, tr *tar.Reader) error {
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dictadmin: create %s: %w", filepath.Base(path), err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
n, err := io.Copy(f, io.LimitReader(tr, maxMemberBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dictadmin: write %s: %w", filepath.Base(path), err)
|
||||||
|
}
|
||||||
|
if n > maxMemberBytes {
|
||||||
|
return fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", filepath.Base(path), maxMemberBytes)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filenameToVariant inverts the engine's variant-to-filename map.
|
||||||
|
func filenameToVariant() map[string]engine.Variant {
|
||||||
|
files := engine.DictFiles()
|
||||||
|
out := make(map[string]engine.Variant, len(files))
|
||||||
|
for v, name := range files {
|
||||||
|
out[name] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager stages and installs release archives under a dictionary directory
|
||||||
|
// (BACKEND_DICT_DIR).
|
||||||
|
type Manager struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Manager rooted at the dictionary directory dir.
|
||||||
|
func New(dir string) *Manager {
|
||||||
|
return &Manager{dir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage accepts an uploaded archive: it sweeps abandoned previews, allocates a
|
||||||
|
// staging directory under <dir>/.staging/<token>, extracts the archive there and
|
||||||
|
// returns the token and the variants found. The staging directory is removed on an
|
||||||
|
// extraction error.
|
||||||
|
func (m *Manager) Stage(r io.Reader) (string, []engine.Variant, error) {
|
||||||
|
m.cleanStaging()
|
||||||
|
token, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(m.dir, stagingRoot, token)
|
||||||
|
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("dictadmin: create staging dir: %w", err)
|
||||||
|
}
|
||||||
|
variants, err := Extract(r, dest)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(dest)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return token, variants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StagedDir returns the directory of the staged upload named by token, after
|
||||||
|
// validating the token's shape and confirming the directory exists.
|
||||||
|
func (m *Manager) StagedDir(token string) (string, error) {
|
||||||
|
if !tokenRE.MatchString(token) {
|
||||||
|
return "", fmt.Errorf("dictadmin: invalid staging token")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(m.dir, stagingRoot, token)
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return "", fmt.Errorf("dictadmin: staged upload not found")
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionExists reports whether the version directory <dir>/<version> is present.
|
||||||
|
func (m *Manager) VersionExists(version string) bool {
|
||||||
|
info, err := os.Stat(filepath.Join(m.dir, version))
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install promotes a staged upload into its version directory <dir>/<version> by an
|
||||||
|
// atomic rename (same filesystem) and returns the destination path. It rejects an
|
||||||
|
// invalid version and refuses to overwrite an existing version: versions are
|
||||||
|
// immutable, protecting games pinned to that tag.
|
||||||
|
func (m *Manager) Install(token, version string) (string, error) {
|
||||||
|
if !ValidVersion(version) {
|
||||||
|
return "", fmt.Errorf("dictadmin: invalid version %q", version)
|
||||||
|
}
|
||||||
|
staged, err := m.StagedDir(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if m.VersionExists(version) {
|
||||||
|
return "", fmt.Errorf("dictadmin: version %s already exists", version)
|
||||||
|
}
|
||||||
|
dest := filepath.Join(m.dir, version)
|
||||||
|
if err := os.Rename(staged, dest); err != nil {
|
||||||
|
return "", fmt.Errorf("dictadmin: install %s: %w", version, err)
|
||||||
|
}
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard removes the staging directory named by token, best effort. It is called
|
||||||
|
// when a preview is rejected (an incomplete or unusable archive).
|
||||||
|
func (m *Manager) Discard(token string) {
|
||||||
|
if dir, err := m.StagedDir(token); err == nil {
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanStaging removes staging directories older than stagingTTL, best effort.
|
||||||
|
func (m *Manager) cleanStaging() {
|
||||||
|
root := filepath.Join(m.dir, stagingRoot)
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-stagingTTL)
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||||
|
_ = os.RemoveAll(filepath.Join(root, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newToken returns a 32-hex-character random staging token.
|
||||||
|
func newToken() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := crand.Read(b[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("dictadmin: generate token: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package dictadmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dawgNames are the three committed dictionary filenames a release archive holds.
|
||||||
|
func dawgNames() []string {
|
||||||
|
out := make([]string, 0, 3)
|
||||||
|
for _, name := range engine.DictFiles() {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullArchive builds a gzip+tar archive holding all three dictionary files with
|
||||||
|
// dummy content, the shape of a real release archive.
|
||||||
|
func fullArchive(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
files := map[string][]byte{}
|
||||||
|
for _, n := range dawgNames() {
|
||||||
|
files[n] = []byte("dawg:" + n)
|
||||||
|
}
|
||||||
|
return tarGz(t, files, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarGz builds a gzip+tar archive. regular maps a member name to its bytes; extra
|
||||||
|
// lets a test inject crafted headers (symlinks, oversized members, junk entries).
|
||||||
|
func tarGz(t *testing.T, regular map[string][]byte, extra []*tar.Header) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for name, data := range regular {
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
|
||||||
|
t.Fatalf("write header %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(data); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, hdr := range extra {
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
t.Fatalf("write extra header %s: %v", hdr.Name, err)
|
||||||
|
}
|
||||||
|
if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 {
|
||||||
|
if _, err := tw.Write(bytes.Repeat([]byte("x"), int(hdr.Size))); err != nil {
|
||||||
|
t.Fatalf("write extra %s: %v", hdr.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatalf("close tar: %v", err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatalf("close gzip: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVersionFromName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"scrabble-dawg-v1.2.3.tar.gz", "v1.2.3", false},
|
||||||
|
{"scrabble-dawg-v10.0.1.tar.gz", "v10.0.1", false},
|
||||||
|
{"scrabble-dawg-v1.2.tar.gz", "", true},
|
||||||
|
{"scrabble-dawg-1.2.3.tar.gz", "", true},
|
||||||
|
{"scrabble-dawg-v1.2.3.zip", "", true},
|
||||||
|
{"dawg-v1.2.3.tar.gz", "", true},
|
||||||
|
{"scrabble-dawg-v1.2.3.tar.gz.exe", "", true},
|
||||||
|
{"", "", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := ParseVersionFromName(c.name)
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseVersionFromName(%q) = %q, want error", c.name, got)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil || got != c.want {
|
||||||
|
t.Errorf("ParseVersionFromName(%q) = %q, %v; want %q", c.name, got, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidVersion(t *testing.T) {
|
||||||
|
valid := []string{"v1.0.0", "v10.20.30", "v0.0.1"}
|
||||||
|
invalid := []string{"v1.0", "1.0.0", "v1.0.0.0", "v1.0.0/..", "v1.0.0 ", "", "vx.y.z", "../v1.0.0"}
|
||||||
|
for _, v := range valid {
|
||||||
|
if !ValidVersion(v) {
|
||||||
|
t.Errorf("ValidVersion(%q) = false, want true", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range invalid {
|
||||||
|
if ValidVersion(v) {
|
||||||
|
t.Errorf("ValidVersion(%q) = true, want false", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractAcceptsFullArchive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
variants, err := Extract(bytes.NewReader(fullArchive(t)), dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract: %v", err)
|
||||||
|
}
|
||||||
|
if len(variants) != len(engine.Variants()) {
|
||||||
|
t.Errorf("variants = %v, want all %d", variants, len(engine.Variants()))
|
||||||
|
}
|
||||||
|
for _, n := range dawgNames() {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
|
||||||
|
t.Errorf("missing extracted %s: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIgnoresUnexpectedAndTraversal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"pkg/en_sowpods.dawg": []byte("ok"), // a subdir path is stripped to its base
|
||||||
|
"README.txt": []byte("junk"), // an unexpected name is ignored
|
||||||
|
}
|
||||||
|
// A traversal name with a recognised base must still land inside destDir.
|
||||||
|
traversal := &tar.Header{Name: "../ru_scrabble.dawg", Mode: 0o644, Size: 2, Typeflag: tar.TypeReg}
|
||||||
|
// A symlink, even named like a DAWG, is ignored.
|
||||||
|
symlink := &tar.Header{Name: "erudit_ru.dawg", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}
|
||||||
|
|
||||||
|
if _, err := Extract(bytes.NewReader(tarGz(t, files, []*tar.Header{traversal, symlink})), dir); err != nil {
|
||||||
|
t.Fatalf("Extract: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "README.txt")); err == nil {
|
||||||
|
t.Error("README.txt was extracted; unexpected files must be ignored")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "en_sowpods.dawg")); err != nil {
|
||||||
|
t.Errorf("en_sowpods.dawg not extracted from a subdir entry: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "ru_scrabble.dawg")); err != nil {
|
||||||
|
t.Errorf("ru_scrabble.dawg not extracted from a traversal entry: %v", err)
|
||||||
|
}
|
||||||
|
// Nothing escaped the destination directory.
|
||||||
|
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "ru_scrabble.dawg")); err == nil {
|
||||||
|
t.Error("path traversal wrote outside the destination directory")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "erudit_ru.dawg")); err == nil {
|
||||||
|
t.Error("a symlink member was materialised")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsOversizeMember(t *testing.T) {
|
||||||
|
old := maxMemberBytes
|
||||||
|
maxMemberBytes = 8
|
||||||
|
defer func() { maxMemberBytes = old }()
|
||||||
|
|
||||||
|
files := map[string][]byte{"en_sowpods.dawg": bytes.Repeat([]byte("x"), 64)}
|
||||||
|
if _, err := Extract(bytes.NewReader(tarGz(t, files, nil)), t.TempDir()); err == nil {
|
||||||
|
t.Error("Extract accepted an oversize member, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsTooManyEntries(t *testing.T) {
|
||||||
|
var extra []*tar.Header
|
||||||
|
for i := 0; i < maxEntries+1; i++ {
|
||||||
|
extra = append(extra, &tar.Header{Name: fmt.Sprintf("junk-%d.bin", i), Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
|
||||||
|
}
|
||||||
|
if _, err := Extract(bytes.NewReader(tarGz(t, nil, extra)), t.TempDir()); err == nil {
|
||||||
|
t.Error("Extract accepted an archive with too many entries, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerStageInstall(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := New(dir)
|
||||||
|
|
||||||
|
token, variants, err := m.Stage(bytes.NewReader(fullArchive(t)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stage: %v", err)
|
||||||
|
}
|
||||||
|
if len(variants) != len(engine.Variants()) {
|
||||||
|
t.Fatalf("staged variants = %v, want all", variants)
|
||||||
|
}
|
||||||
|
staged, err := m.StagedDir(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StagedDir: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(staged, filepath.Join(".staging", token)) {
|
||||||
|
t.Errorf("staged dir %q not under .staging/%s", staged, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.VersionExists("v1.0.0") {
|
||||||
|
t.Fatal("v1.0.0 should not exist before install")
|
||||||
|
}
|
||||||
|
dest, err := m.Install(token, "v1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Install: %v", err)
|
||||||
|
}
|
||||||
|
if dest != filepath.Join(dir, "v1.0.0") {
|
||||||
|
t.Errorf("install dest = %q, want %q", dest, filepath.Join(dir, "v1.0.0"))
|
||||||
|
}
|
||||||
|
if !m.VersionExists("v1.0.0") {
|
||||||
|
t.Error("VersionExists(v1.0.0) = false after install")
|
||||||
|
}
|
||||||
|
for _, n := range dawgNames() {
|
||||||
|
if _, err := os.Stat(filepath.Join(dest, n)); err != nil {
|
||||||
|
t.Errorf("installed %s missing: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerInstallRejectsExistingVersion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := New(dir)
|
||||||
|
token, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stage: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.Install(token, "v2.0.0"); err != nil {
|
||||||
|
t.Fatalf("first Install: %v", err)
|
||||||
|
}
|
||||||
|
token2, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Stage: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.Install(token2, "v2.0.0"); err == nil {
|
||||||
|
t.Error("Install overwrote an existing version, want error (versions are immutable)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStagedDirRejectsBadToken(t *testing.T) {
|
||||||
|
m := New(t.TempDir())
|
||||||
|
for _, bad := range []string{"", "../etc", "ABC", "zz", strings.Repeat("g", 32)} {
|
||||||
|
if _, err := m.StagedDir(bad); err == nil {
|
||||||
|
t.Errorf("StagedDir(%q) = nil error, want rejection", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestAbortFinishesAsDrawWithoutAdjustment covers Abort, the graceful close used when a
|
||||||
|
// committed game can no longer be reconstructed from its journal. The game ends as a draw
|
||||||
|
// (no winner) regardless of the running scores, and the scores are left untouched (no
|
||||||
|
// end-game rack adjustment).
|
||||||
|
func TestAbortFinishesAsDrawWithoutAdjustment(t *testing.T) {
|
||||||
|
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new game: %v", err)
|
||||||
|
}
|
||||||
|
g.scores[0], g.scores[1] = 10, 5 // a clear leader, so a draw cannot come from equal scores
|
||||||
|
|
||||||
|
g.Abort()
|
||||||
|
|
||||||
|
if !g.Over() {
|
||||||
|
t.Error("aborted game should be over")
|
||||||
|
}
|
||||||
|
if g.Reason() != EndAborted {
|
||||||
|
t.Errorf("reason = %v, want EndAborted", g.Reason())
|
||||||
|
}
|
||||||
|
res := g.Result()
|
||||||
|
if res.Winner != -1 {
|
||||||
|
t.Errorf("winner = %d, want -1 (draw)", res.Winner)
|
||||||
|
}
|
||||||
|
if res.Scores[0] != 10 || res.Scores[1] != 5 {
|
||||||
|
t.Errorf("scores = %v, want [10 5] (no rack adjustment on abort)", res.Scores)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
|
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
|
||||||
// concrete character and its tile point value. It is the dictionary-independent display
|
// concrete character and its tile point value. It is the dictionary-independent display
|
||||||
// table the edge sends to the client (Stage 13), produced from the variant's solver
|
// table the edge sends to the client, produced from the variant's solver
|
||||||
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
|
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
|
||||||
// dictionary.
|
// dictionary.
|
||||||
type AlphabetEntry struct {
|
type AlphabetEntry struct {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
|
|
||||||
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
|
||||||
// contiguous indices, the concrete lower-case characters the solver emits and the standard
|
// contiguous indices, the concrete lower-case characters the solver emits and the standard
|
||||||
// tile values. This is the real parity check the UI no longer carries (Stage 13).
|
// tile values. This is the real parity check the UI no longer carries.
|
||||||
func TestAlphabetTableEnglish(t *testing.T) {
|
func TestAlphabetTableEnglish(t *testing.T) {
|
||||||
tab, err := AlphabetTable(VariantEnglish)
|
tab, err := AlphabetTable(VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(english): %v", err)
|
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
|
||||||
}
|
}
|
||||||
if len(tab) != 26 {
|
if len(tab) != 26 {
|
||||||
t.Fatalf("size = %d, want 26", len(tab))
|
t.Fatalf("size = %d, want 26", len(tab))
|
||||||
@@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) {
|
|||||||
func TestAlphabetTableRussianVariants(t *testing.T) {
|
func TestAlphabetTableRussianVariants(t *testing.T) {
|
||||||
ru, err := AlphabetTable(VariantRussianScrabble)
|
ru, err := AlphabetTable(VariantRussianScrabble)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(russian_scrabble): %v", err)
|
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
|
||||||
}
|
}
|
||||||
er, err := AlphabetTable(VariantErudit)
|
er, err := AlphabetTable(VariantErudit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AlphabetTable(erudit): %v", err)
|
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
|
||||||
}
|
}
|
||||||
if len(ru) != 33 || len(er) != 33 {
|
if len(ru) != 33 || len(er) != 33 {
|
||||||
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
|
||||||
}
|
}
|
||||||
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
if ru[0].Letter != "а" || ru[0].Value != 1 {
|
||||||
t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
|
||||||
}
|
}
|
||||||
if ru[6].Letter != "ё" || ru[6].Value != 3 {
|
if ru[6].Letter != "ё" || ru[6].Value != 3 {
|
||||||
t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
|
||||||
}
|
}
|
||||||
if er[6].Letter != "ё" || er[6].Value != 0 {
|
if er[6].Letter != "ё" || er[6].Value != 0 {
|
||||||
t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
|
||||||
}
|
}
|
||||||
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
if ru[32].Letter != "я" || er[32].Letter != "я" {
|
||||||
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
// ActionResign abandons the game.
|
// ActionResign abandons the game.
|
||||||
ActionResign
|
ActionResign
|
||||||
// ActionTimeout is the auto-resignation a missed turn becomes; recorded by
|
// 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
|
ActionTimeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WordDiff is the set difference between two dictionaries of one variant: the
|
||||||
|
// words present only in the new dictionary (Added) and only in the old one
|
||||||
|
// (Removed), decoded to characters and each sorted by the variant's alphabet
|
||||||
|
// order. It drives the admin dictionary-update preview.
|
||||||
|
type WordDiff struct {
|
||||||
|
Added []string
|
||||||
|
Removed []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DictFiles returns a copy of the variant-to-committed-DAWG-filename map. It lets
|
||||||
|
// callers outside the package (the dictionary-admin upload validation) check an
|
||||||
|
// archive against the expected file set without sharing the engine's own map.
|
||||||
|
func DictFiles() map[Variant]string {
|
||||||
|
out := make(map[Variant]string, len(dictFiles))
|
||||||
|
maps.Copy(out, dictFiles)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenFinder loads the committed DAWG of variant v from dir and returns its
|
||||||
|
// finder. The caller owns the finder and must Close it. It backs enumeration of
|
||||||
|
// a staged, not-yet-registered dictionary version.
|
||||||
|
func OpenFinder(dir string, v Variant) (dawg.Finder, error) {
|
||||||
|
name, ok := dictFiles[v]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
finder, err := dawg.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("engine: load %s dictionary from %s: %w", v, path, err)
|
||||||
|
}
|
||||||
|
return finder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finder returns the loaded finder for the (variant, version) pair so its words
|
||||||
|
// can be enumerated, or ErrUnknownVariant / ErrUnknownVersion when that
|
||||||
|
// dictionary is not resident. The finder remains owned by the registry; callers
|
||||||
|
// must not Close it.
|
||||||
|
func (r *Registry) Finder(v Variant, version string) (dawg.Finder, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
versions, ok := r.entries[v]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||||
|
}
|
||||||
|
e, ok := versions[version]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||||
|
}
|
||||||
|
return e.finder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffWords compares the old and new finders of variant v and returns the words
|
||||||
|
// added and removed, decoded through v's alphabet. Both dictionaries are
|
||||||
|
// enumerated as alphabet-index bytes and merged; only the differing words are
|
||||||
|
// decoded, so a full-dictionary comparison does not materialise two large string
|
||||||
|
// sets.
|
||||||
|
func DiffWords(v Variant, old, updated dawg.Finder) (WordDiff, error) {
|
||||||
|
rs, ok := v.ruleset()
|
||||||
|
if !ok {
|
||||||
|
return WordDiff{}, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||||
|
}
|
||||||
|
oldWords := collectWords(old)
|
||||||
|
newWords := collectWords(updated)
|
||||||
|
|
||||||
|
var diff WordDiff
|
||||||
|
i, j := 0, 0
|
||||||
|
for i < len(oldWords) && j < len(newWords) {
|
||||||
|
switch cmp := bytes.Compare(oldWords[i], newWords[j]); {
|
||||||
|
case cmp == 0:
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
case cmp < 0:
|
||||||
|
s, err := rs.Alphabet.Decode(oldWords[i])
|
||||||
|
if err != nil {
|
||||||
|
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
|
||||||
|
}
|
||||||
|
diff.Removed = append(diff.Removed, s)
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
s, err := rs.Alphabet.Decode(newWords[j])
|
||||||
|
if err != nil {
|
||||||
|
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
|
||||||
|
}
|
||||||
|
diff.Added = append(diff.Added, s)
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ; i < len(oldWords); i++ {
|
||||||
|
s, err := rs.Alphabet.Decode(oldWords[i])
|
||||||
|
if err != nil {
|
||||||
|
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
|
||||||
|
}
|
||||||
|
diff.Removed = append(diff.Removed, s)
|
||||||
|
}
|
||||||
|
for ; j < len(newWords); j++ {
|
||||||
|
s, err := rs.Alphabet.Decode(newWords[j])
|
||||||
|
if err != nil {
|
||||||
|
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
|
||||||
|
}
|
||||||
|
diff.Added = append(diff.Added, s)
|
||||||
|
}
|
||||||
|
return diff, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectWords enumerates every complete word of f as a copy of its
|
||||||
|
// alphabet-index bytes, sorted ascending. The dawg already enumerates in index
|
||||||
|
// order; the explicit sort makes the merge in DiffWords robust to that being an
|
||||||
|
// implementation detail.
|
||||||
|
func collectWords(f dawg.Finder) [][]byte {
|
||||||
|
out := make([][]byte, 0, f.NumAdded())
|
||||||
|
f.EnumerateB(func(_ int, word []byte, final bool) dawg.EnumerationResult {
|
||||||
|
if final && len(word) > 0 {
|
||||||
|
cp := make([]byte, len(word))
|
||||||
|
copy(cp, word)
|
||||||
|
out = append(out, cp)
|
||||||
|
}
|
||||||
|
return dawg.Continue
|
||||||
|
})
|
||||||
|
sort.Slice(out, func(a, b int) bool { return bytes.Compare(out[a], out[b]) < 0 })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/iliadenisov/alphabet"
|
||||||
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildFinderB builds an in-memory finder over idx from the given words, each
|
||||||
|
// expressed as alphabet-index bytes. The words must be supplied in strictly
|
||||||
|
// increasing alphabet-index order, as the builder requires.
|
||||||
|
func buildFinderB(t *testing.T, idx alphabet.Indexer, words ...[]byte) dawg.Finder {
|
||||||
|
t.Helper()
|
||||||
|
b := dawg.New(idx)
|
||||||
|
for _, w := range words {
|
||||||
|
if err := b.AddB(w); err != nil {
|
||||||
|
t.Fatalf("addB %v: %v", w, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.Finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustDecode decodes index bytes through idx or fails the test.
|
||||||
|
func mustDecode(t *testing.T, idx alphabet.Indexer, word []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
s, err := idx.Decode(word)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode %v: %v", word, err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiffWords checks that DiffWords reports the words present only in the new
|
||||||
|
// dictionary as added and those present only in the old one as removed, decoded
|
||||||
|
// to characters through the variant's alphabet.
|
||||||
|
func TestDiffWords(t *testing.T) {
|
||||||
|
rs, _ := VariantEnglish.ruleset()
|
||||||
|
idx := rs.Alphabet
|
||||||
|
|
||||||
|
old := buildFinderB(t, idx, []byte{0}, []byte{0, 1}, []byte{2})
|
||||||
|
defer func() { _ = old.Close() }()
|
||||||
|
updated := buildFinderB(t, idx, []byte{0}, []byte{2}, []byte{2, 3})
|
||||||
|
defer func() { _ = updated.Close() }()
|
||||||
|
|
||||||
|
diff, err := DiffWords(VariantEnglish, old, updated)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiffWords: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAdded := []string{mustDecode(t, idx, []byte{2, 3})}
|
||||||
|
wantRemoved := []string{mustDecode(t, idx, []byte{0, 1})}
|
||||||
|
if !equalStrings(diff.Added, wantAdded) {
|
||||||
|
t.Errorf("added = %v, want %v", diff.Added, wantAdded)
|
||||||
|
}
|
||||||
|
if !equalStrings(diff.Removed, wantRemoved) {
|
||||||
|
t.Errorf("removed = %v, want %v", diff.Removed, wantRemoved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiffWordsIdentical checks that comparing a dictionary with itself yields an
|
||||||
|
// empty diff.
|
||||||
|
func TestDiffWordsIdentical(t *testing.T) {
|
||||||
|
rs, _ := VariantEnglish.ruleset()
|
||||||
|
f := buildFinderB(t, rs.Alphabet, []byte{0}, []byte{1}, []byte{1, 2})
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
diff, err := DiffWords(VariantEnglish, f, f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiffWords: %v", err)
|
||||||
|
}
|
||||||
|
if len(diff.Added) != 0 || len(diff.Removed) != 0 {
|
||||||
|
t.Errorf("diff = %+v, want empty", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDiffWordsUnknownVariant checks that an unrecognised variant is rejected.
|
||||||
|
func TestDiffWordsUnknownVariant(t *testing.T) {
|
||||||
|
rs, _ := VariantEnglish.ruleset()
|
||||||
|
f := buildFinderB(t, rs.Alphabet, []byte{0})
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if _, err := DiffWords(Variant(250), f, f); !errors.Is(err, ErrUnknownVariant) {
|
||||||
|
t.Errorf("err = %v, want ErrUnknownVariant", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenFinder checks that OpenFinder loads a committed DAWG from a directory.
|
||||||
|
func TestOpenFinder(t *testing.T) {
|
||||||
|
f, err := OpenFinder(testDictDir(), VariantEnglish)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFinder: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if f.NumAdded() == 0 {
|
||||||
|
t.Error("english dictionary is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistryFinder checks that Finder returns the resident finder for a pair
|
||||||
|
// and the right sentinel when the version is absent.
|
||||||
|
func TestRegistryFinder(t *testing.T) {
|
||||||
|
if _, err := testReg.Finder(VariantEnglish, testVersion); err != nil {
|
||||||
|
t.Errorf("Finder(scrabble_en, %q): %v", testVersion, err)
|
||||||
|
}
|
||||||
|
if _, err := testReg.Finder(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) {
|
||||||
|
t.Errorf("Finder absent version err = %v, want ErrUnknownVersion", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDictFiles checks that DictFiles exposes the variant filename map as a copy
|
||||||
|
// the caller cannot use to mutate the engine's own map.
|
||||||
|
func TestDictFiles(t *testing.T) {
|
||||||
|
files := DictFiles()
|
||||||
|
if len(files) != len(Variants()) {
|
||||||
|
t.Fatalf("DictFiles has %d entries, want %d", len(files), len(Variants()))
|
||||||
|
}
|
||||||
|
files[VariantEnglish] = "tampered"
|
||||||
|
if again := DictFiles(); again[VariantEnglish] == "tampered" {
|
||||||
|
t.Error("DictFiles returned a shared map; mutating it changed the engine's map")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveDirection infers a play's orientation from the placed tiles and the
|
||||||
|
// board, so a caller need not declare it (docs/ARCHITECTURE.md §5). Two or more
|
||||||
|
// tiles fix the orientation by the line they share: a common row reads
|
||||||
|
// horizontally, otherwise vertically (a non-linear placement is left for
|
||||||
|
// Evaluate to reject). A single tile is ambiguous on its own — it may extend a
|
||||||
|
// word down a column or across a row — so the orientation is the axis along
|
||||||
|
// which it abuts existing tiles, preferring the axis that yields the longer word
|
||||||
|
// and horizontal on a tie. A tile that abuts nothing falls back to horizontal
|
||||||
|
// and is rejected downstream as disconnected (or, on the first move, as too
|
||||||
|
// short).
|
||||||
|
func resolveDirection(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
|
||||||
|
if len(placements) >= 2 {
|
||||||
|
row := placements[0].Row
|
||||||
|
for _, p := range placements[1:] {
|
||||||
|
if p.Row != row {
|
||||||
|
return scrabble.Vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrabble.Horizontal
|
||||||
|
}
|
||||||
|
if len(placements) == 1 {
|
||||||
|
p := placements[0]
|
||||||
|
h := runLength(b, p.Row, p.Col, scrabble.Horizontal)
|
||||||
|
v := runLength(b, p.Row, p.Col, scrabble.Vertical)
|
||||||
|
if v >= 2 && v > h {
|
||||||
|
return scrabble.Vertical
|
||||||
|
}
|
||||||
|
if h >= 2 {
|
||||||
|
return scrabble.Horizontal
|
||||||
|
}
|
||||||
|
if v >= 2 {
|
||||||
|
return scrabble.Vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrabble.Horizontal
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLength returns how many cells the word through (row, col) along dir would
|
||||||
|
// span once a tile is placed on the empty target square: the square itself plus
|
||||||
|
// the runs of filled cells immediately before and after it along dir. A result
|
||||||
|
// below two means the tile forms no word on that axis. Filled treats
|
||||||
|
// off-board coordinates as empty, so the walks stop at the board edge.
|
||||||
|
func runLength(b *board.Board, row, col int, dir scrabble.Direction) int {
|
||||||
|
dr, dc := 0, 1
|
||||||
|
if dir == scrabble.Vertical {
|
||||||
|
dr, dc = 1, 0
|
||||||
|
}
|
||||||
|
n := 1
|
||||||
|
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
)
|
||||||
|
|
||||||
|
// boardWith returns a 15x15 board with the given (row, col) cells occupied. The
|
||||||
|
// concrete letter is irrelevant to direction inference, which reads only
|
||||||
|
// occupancy, so every filler uses alphabet index 0.
|
||||||
|
func boardWith(cells ...[2]int) *board.Board {
|
||||||
|
b := board.New(15, 15)
|
||||||
|
ps := make([]scrabble.Placement, len(cells))
|
||||||
|
for i, c := range cells {
|
||||||
|
ps[i] = scrabble.Placement{Row: c[0], Col: c[1], Letter: 0}
|
||||||
|
}
|
||||||
|
scrabble.Apply(b, scrabble.Move{Tiles: ps})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunLength covers the word-span measurement that drives single-tile
|
||||||
|
// direction inference, including the board edge.
|
||||||
|
func TestRunLength(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filled [][2]int
|
||||||
|
row, col int
|
||||||
|
dir scrabble.Direction
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"isolated horizontal", nil, 7, 7, scrabble.Horizontal, 1},
|
||||||
|
{"isolated vertical", nil, 7, 7, scrabble.Vertical, 1},
|
||||||
|
{"neighbour below", [][2]int{{8, 7}}, 7, 7, scrabble.Vertical, 2},
|
||||||
|
{"neighbour above", [][2]int{{6, 7}}, 7, 7, scrabble.Vertical, 2},
|
||||||
|
{"run below", [][2]int{{8, 7}, {9, 7}}, 7, 7, scrabble.Vertical, 3},
|
||||||
|
{"bridge vertical", [][2]int{{6, 7}, {8, 7}}, 7, 7, scrabble.Vertical, 3},
|
||||||
|
{"neighbour left", [][2]int{{7, 6}}, 7, 7, scrabble.Horizontal, 2},
|
||||||
|
{"neighbour right", [][2]int{{7, 8}}, 7, 7, scrabble.Horizontal, 2},
|
||||||
|
{"bridge horizontal", [][2]int{{7, 6}, {7, 8}}, 7, 7, scrabble.Horizontal, 3},
|
||||||
|
{"perpendicular ignored", [][2]int{{7, 6}}, 7, 7, scrabble.Vertical, 1},
|
||||||
|
{"top edge", [][2]int{{1, 0}}, 0, 0, scrabble.Vertical, 2},
|
||||||
|
{"left edge", [][2]int{{0, 1}}, 0, 0, scrabble.Horizontal, 2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
b := boardWith(tt.filled...)
|
||||||
|
if got := runLength(b, tt.row, tt.col, tt.dir); got != tt.want {
|
||||||
|
t.Errorf("runLength(%d,%d,%v) = %d, want %d", tt.row, tt.col, tt.dir, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveDirection covers orientation inference for both multi-tile plays
|
||||||
|
// (fixed by the shared line) and the ambiguous single tile (the axis it abuts,
|
||||||
|
// longer word winning and horizontal on a tie; disconnected falls back to
|
||||||
|
// horizontal for the downstream rejection).
|
||||||
|
func TestResolveDirection(t *testing.T) {
|
||||||
|
at := func(cells ...[2]int) []scrabble.Placement {
|
||||||
|
ps := make([]scrabble.Placement, len(cells))
|
||||||
|
for i, c := range cells {
|
||||||
|
ps[i] = scrabble.Placement{Row: c[0], Col: c[1]}
|
||||||
|
}
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filled [][2]int
|
||||||
|
play []scrabble.Placement
|
||||||
|
want scrabble.Direction
|
||||||
|
}{
|
||||||
|
{"single extends down", [][2]int{{8, 7}, {9, 7}}, at([2]int{7, 7}), scrabble.Vertical},
|
||||||
|
{"single extends up", [][2]int{{6, 7}, {5, 7}}, at([2]int{7, 7}), scrabble.Vertical},
|
||||||
|
{"single extends left", [][2]int{{7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||||
|
{"single extends right", [][2]int{{7, 8}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||||
|
{"single both axes vertical longer", [][2]int{{6, 7}, {8, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Vertical},
|
||||||
|
{"single both axes horizontal longer", [][2]int{{7, 6}, {7, 8}, {6, 7}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||||
|
{"single both axes equal prefers horizontal", [][2]int{{6, 7}, {7, 6}}, at([2]int{7, 7}), scrabble.Horizontal},
|
||||||
|
{"single disconnected falls back to horizontal", nil, at([2]int{7, 7}), scrabble.Horizontal},
|
||||||
|
{"multi shared row is horizontal", nil, at([2]int{7, 7}, [2]int{7, 8}), scrabble.Horizontal},
|
||||||
|
{"multi shared column is vertical", nil, at([2]int{7, 7}, [2]int{8, 7}), scrabble.Vertical},
|
||||||
|
{"multi non-linear is vertical", nil, at([2]int{7, 7}, [2]int{8, 8}), scrabble.Vertical},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
b := boardWith(tt.filled...)
|
||||||
|
if got := resolveDirection(b, tt.play); got != tt.want {
|
||||||
|
t.Errorf("resolveDirection = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveDirectionEmpty checks the degenerate empty placement does not panic
|
||||||
|
// and falls back to horizontal (Evaluate rejects the empty play downstream).
|
||||||
|
func TestResolveDirectionEmpty(t *testing.T) {
|
||||||
|
if got := resolveDirection(boardWith(), nil); got != scrabble.Horizontal {
|
||||||
|
t.Errorf("resolveDirection(empty) = %v, want Horizontal", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubmitPlaySingleTileVerticalExtension is the regression for the reported
|
||||||
|
// bug: a single tile placed above an existing vertical word forms a legal play
|
||||||
|
// the engine must accept by inferring the vertical orientation. Trusting a
|
||||||
|
// horizontal orientation (the pre-fix client default) wrongly rejects it.
|
||||||
|
func TestSubmitPlaySingleTileVerticalExtension(t *testing.T) {
|
||||||
|
// БАК runs down column 7 (rows 7..9); the mover holds А and plays it at
|
||||||
|
// (6,7), prefixing АБАК. This mirrors the contour game that surfaced the bug.
|
||||||
|
setup := func(t *testing.T) (*Game, []TileRecord) {
|
||||||
|
t.Helper()
|
||||||
|
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new erudit game: %v", err)
|
||||||
|
}
|
||||||
|
idx := func(s string) byte {
|
||||||
|
i, err := g.rules.Alphabet.Index(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||||
|
{Row: 7, Col: 7, Letter: idx("б")},
|
||||||
|
{Row: 8, Col: 7, Letter: idx("а")},
|
||||||
|
{Row: 9, Col: 7, Letter: idx("к")},
|
||||||
|
}})
|
||||||
|
g.hands[0] = []byte{idx("а")}
|
||||||
|
return g, []TileRecord{{Row: 6, Col: 7, Letter: "а"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("inferred direction accepts the play", func(t *testing.T) {
|
||||||
|
g, tiles := setup(t)
|
||||||
|
rec, err := g.SubmitPlay(tiles)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("submit play: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Dir != Vertical {
|
||||||
|
t.Errorf("direction = %v, want Vertical", rec.Dir)
|
||||||
|
}
|
||||||
|
if len(rec.Words) == 0 || rec.Words[0] != "абак" {
|
||||||
|
t.Errorf("words = %v, want main word абак", rec.Words)
|
||||||
|
}
|
||||||
|
if rec.Score <= 0 {
|
||||||
|
t.Errorf("score = %d, want positive", rec.Score)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("trusting horizontal rejects it", func(t *testing.T) {
|
||||||
|
g, tiles := setup(t)
|
||||||
|
if _, err := g.SubmitPlayDir(Horizontal, tiles); !errors.Is(err, ErrIllegalPlay) {
|
||||||
|
t.Errorf("submit horizontal = %v, want ErrIllegalPlay", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -52,11 +52,25 @@ func fromScrabbleDir(d scrabble.Direction) Direction {
|
|||||||
|
|
||||||
// SubmitPlay validates and applies the current player's play described in decoded
|
// SubmitPlay validates and applies the current player's play described in decoded
|
||||||
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
|
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
|
||||||
// when Blank is set) and a board coordinate. It encodes the tiles through the
|
// when Blank is set) and a board coordinate. It infers the play's orientation
|
||||||
|
// from the tiles and the board (resolveDirection), encodes the tiles through the
|
||||||
// ruleset alphabet and delegates to Play, so it returns the same errors
|
// ruleset alphabet and delegates to Play, so it returns the same errors
|
||||||
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
||||||
// letter is outside the variant's alphabet.
|
// letter is outside the variant's alphabet.
|
||||||
func (g *Game) SubmitPlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
|
||||||
|
placements, err := g.placements(tiles)
|
||||||
|
if err != nil {
|
||||||
|
return MoveRecord{}, err
|
||||||
|
}
|
||||||
|
return g.Play(g.playDirection(placements), placements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
|
||||||
|
// It exists for journal replay, which reproduces a committed game exactly from
|
||||||
|
// the stored "H"/"V" rather than re-deriving it (docs/ARCHITECTURE.md §9.1):
|
||||||
|
// re-derivation would tie historical reconstruction to the current resolver, so
|
||||||
|
// replay trusts the recorded direction. Live play uses SubmitPlay.
|
||||||
|
func (g *Game) SubmitPlayDir(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
||||||
placements, err := g.placements(tiles)
|
placements, err := g.placements(tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, err
|
return MoveRecord{}, err
|
||||||
@@ -78,10 +92,13 @@ func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
|
|||||||
|
|
||||||
// EvaluatePlay scores and validates a tentative play without committing it,
|
// EvaluatePlay scores and validates a tentative play without committing it,
|
||||||
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
||||||
// It returns the decoded move (placed tiles, the words it forms and its score)
|
// It infers the play's orientation from the tiles and the board and applies the
|
||||||
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
|
// game's play options exactly as SubmitPlay does, so under the single-word rule
|
||||||
// are left untouched.
|
// perpendicular cross-words are ignored: the preview's legality and score then
|
||||||
func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
// match what submitting the play would yield. It returns the decoded move (placed
|
||||||
|
// tiles, the words it forms, its orientation and its score) or ErrIllegalPlay when
|
||||||
|
// the solver rejects it. The board, racks, bag and turn are left untouched.
|
||||||
|
func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
|
||||||
if g.over {
|
if g.over {
|
||||||
return MoveRecord{}, ErrGameOver
|
return MoveRecord{}, ErrGameOver
|
||||||
}
|
}
|
||||||
@@ -89,7 +106,7 @@ func (g *Game) EvaluatePlay(dir Direction, tiles []TileRecord) (MoveRecord, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, err
|
return MoveRecord{}, err
|
||||||
}
|
}
|
||||||
move, err := g.solver.ValidatePlay(g.board, dir.scrabbleDir(), placements)
|
move, err := g.solver.ValidatePlayOpts(g.board, g.playDirection(placements), placements, g.playOpts())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||||
}
|
}
|
||||||
@@ -152,6 +169,33 @@ func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// playDirection resolves the orientation for a live play. resolveDirection infers it from
|
||||||
|
// geometry alone, preferring the longer word when a single tile abuts the board on both
|
||||||
|
// axes; under the single-word rule that can pick an orientation whose word is not in the
|
||||||
|
// dictionary while the other orientation's is. So for a single tile under that rule the
|
||||||
|
// engine tries both orientations through the solver and keeps the higher-scoring legal one
|
||||||
|
// (horizontal breaks a tie). Multi-tile plays, and every play under the standard rule, keep
|
||||||
|
// the geometric resolution: a multi-tile play's orientation is fixed by the line its tiles
|
||||||
|
// share, and under the standard rule every word the play forms must be valid regardless of
|
||||||
|
// which one is named the main word.
|
||||||
|
func (g *Game) playDirection(placements []scrabble.Placement) scrabble.Direction {
|
||||||
|
geo := resolveDirection(g.board, placements)
|
||||||
|
if len(placements) != 1 || g.multipleWords {
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
best, found, bestScore := geo, false, 0
|
||||||
|
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
|
||||||
|
m, err := g.solver.ValidatePlayOpts(g.board, dir, placements, g.playOpts())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || m.Score > bestScore {
|
||||||
|
best, found, bestScore = dir, true, m.Score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
|
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
|
||||||
// concrete letter) into the internal byte form, wrapping a bad letter as
|
// concrete letter) into the internal byte form, wrapping a bad letter as
|
||||||
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
|
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
rec, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
rec, err := g.SubmitPlay(hint.Tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("submit play: %v", err)
|
t.Fatalf("submit play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
|||||||
boardBefore := g.BoardClone()
|
boardBefore := g.BoardClone()
|
||||||
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
scoreBefore, toMoveBefore, bagBefore := g.Score(0), g.ToMove(), g.BagLen()
|
||||||
|
|
||||||
rec, err := g.EvaluatePlay(hint.Dir, hint.Tiles)
|
rec, err := g.EvaluatePlay(hint.Tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("evaluate play: %v", err)
|
t.Fatalf("evaluate play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
|||||||
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
func TestEvaluatePlayRejectsIllegal(t *testing.T) {
|
||||||
g := newEnglishGame(t, 1)
|
g := newEnglishGame(t, 1)
|
||||||
letter := g.Hand(0)[0]
|
letter := g.Hand(0)[0]
|
||||||
_, err := g.EvaluatePlay(Horizontal, []TileRecord{{Row: 0, Col: 0, Letter: letter}})
|
_, err := g.EvaluatePlay([]TileRecord{{Row: 0, Col: 0, Letter: letter}})
|
||||||
if !errors.Is(err, ErrIllegalPlay) {
|
if !errors.Is(err, ErrIllegalPlay) {
|
||||||
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
t.Errorf("evaluate off-centre opening = %v, want ErrIllegalPlay", err)
|
||||||
}
|
}
|
||||||
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
|
|||||||
word string
|
word string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"english hit", VariantEnglish, "cat", true},
|
{"scrabble_en hit", VariantEnglish, "cat", true},
|
||||||
{"english miss", VariantEnglish, "zzzz", false},
|
{"scrabble_en miss", VariantEnglish, "zzzz", false},
|
||||||
{"russian hit", VariantRussianScrabble, "кот", true},
|
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
|
||||||
{"erudit hit", VariantErudit, "кот", true},
|
{"erudit_ru hit", VariantErudit, "кот", true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
|
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
|
||||||
// replay independently of any dictionary. Second, the engine owns rules and
|
// replay independently of any dictionary. Second, the engine owns rules and
|
||||||
// scoring only: turn scheduling, the 24-hour timeout, persistence and transport
|
// 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
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -38,15 +38,25 @@ const (
|
|||||||
func (v Variant) String() string {
|
func (v Variant) String() string {
|
||||||
switch v {
|
switch v {
|
||||||
case VariantEnglish:
|
case VariantEnglish:
|
||||||
return "english"
|
return "scrabble_en"
|
||||||
case VariantRussianScrabble:
|
case VariantRussianScrabble:
|
||||||
return "russian_scrabble"
|
return "scrabble_ru"
|
||||||
case VariantErudit:
|
case VariantErudit:
|
||||||
return "erudit"
|
return "erudit_ru"
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
|
||||||
|
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
|
||||||
|
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
|
||||||
|
func (v Variant) Language() string {
|
||||||
|
if v == VariantEnglish {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
return "ru"
|
||||||
|
}
|
||||||
|
|
||||||
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
|
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
|
||||||
// (nil, false) for an unrecognised variant.
|
// (nil, false) for an unrecognised variant.
|
||||||
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const (
|
|||||||
EndScoreless
|
EndScoreless
|
||||||
// EndResign fires when a player resigns.
|
// EndResign fires when a player resigns.
|
||||||
EndResign
|
EndResign
|
||||||
|
// EndAborted fires when a committed game can no longer be reconstructed from its
|
||||||
|
// journal — a recorded move became illegal under tightened rules — and is closed as a
|
||||||
|
// draw rather than left unopenable. See (*Game).Abort.
|
||||||
|
EndAborted
|
||||||
)
|
)
|
||||||
|
|
||||||
// String renders the end reason for logs and diagnostics.
|
// String renders the end reason for logs and diagnostics.
|
||||||
@@ -38,6 +42,8 @@ func (r EndReason) String() string {
|
|||||||
return "scoreless"
|
return "scoreless"
|
||||||
case EndResign:
|
case EndResign:
|
||||||
return "resign"
|
return "resign"
|
||||||
|
case EndAborted:
|
||||||
|
return "aborted"
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
@@ -92,6 +98,12 @@ type Options struct {
|
|||||||
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
|
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
|
||||||
// with three or more seats; the zero value removes them from play.
|
// with three or more seats; the zero value removes them from play.
|
||||||
DropoutTiles DropoutTiles
|
DropoutTiles DropoutTiles
|
||||||
|
// MultipleWordsPerTurn selects standard Scrabble when true: every cross-word a
|
||||||
|
// play forms must be a valid word and is scored. When false the game uses the
|
||||||
|
// "single word per turn" rule — only the main word is validated and scored and
|
||||||
|
// perpendicular cross-words are ignored. Callers always set this explicitly; the
|
||||||
|
// zero value (false) is the single-word rule.
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game is the in-memory state of a single match and the pure rules engine over
|
// Game is the in-memory state of a single match and the pure rules engine over
|
||||||
@@ -104,17 +116,18 @@ type Game struct {
|
|||||||
variant Variant
|
variant Variant
|
||||||
version string
|
version string
|
||||||
|
|
||||||
board *board.Board
|
board *board.Board
|
||||||
bag *Bag
|
bag *Bag
|
||||||
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
|
hands [][]byte // per player, alphabet-index bytes with blankTile for blanks
|
||||||
scores []int
|
scores []int
|
||||||
toMove int
|
toMove int
|
||||||
scorelessRun int
|
scorelessRun int
|
||||||
over bool
|
over bool
|
||||||
reason EndReason
|
reason EndReason
|
||||||
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
resigned []bool // per seat; a resigned seat is skipped and cannot win
|
||||||
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
|
||||||
log []MoveRecord
|
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
|
||||||
|
log []MoveRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
// New starts a game described by opts over a dictionary from reg. It resolves
|
// New starts a game described by opts over a dictionary from reg. It resolves
|
||||||
@@ -140,16 +153,17 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
|||||||
|
|
||||||
rs := solver.Rules()
|
rs := solver.Rules()
|
||||||
g := &Game{
|
g := &Game{
|
||||||
solver: solver,
|
solver: solver,
|
||||||
rules: rs,
|
rules: rs,
|
||||||
variant: opts.Variant,
|
variant: opts.Variant,
|
||||||
version: version,
|
version: version,
|
||||||
board: board.New(rs.Rows, rs.Cols),
|
board: board.New(rs.Rows, rs.Cols),
|
||||||
bag: NewBag(rs, opts.Seed),
|
bag: NewBag(rs, opts.Seed),
|
||||||
hands: make([][]byte, opts.Players),
|
hands: make([][]byte, opts.Players),
|
||||||
scores: make([]int, opts.Players),
|
scores: make([]int, opts.Players),
|
||||||
resigned: make([]bool, opts.Players),
|
resigned: make([]bool, opts.Players),
|
||||||
dropoutTiles: opts.DropoutTiles,
|
dropoutTiles: opts.DropoutTiles,
|
||||||
|
multipleWords: opts.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
for i := range g.hands {
|
for i := range g.hands {
|
||||||
g.hands[i] = g.bag.Draw(rs.RackSize)
|
g.hands[i] = g.bag.Draw(rs.RackSize)
|
||||||
@@ -157,6 +171,13 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
|||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// playOpts returns the solver play options for this game's rules. Under the single-word
|
||||||
|
// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main
|
||||||
|
// word is validated and scored, and move generation is not constrained by cross-words.
|
||||||
|
func (g *Game) playOpts() scrabble.PlayOptions {
|
||||||
|
return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords}
|
||||||
|
}
|
||||||
|
|
||||||
// Play validates and applies the current player's placement of tiles forming a
|
// Play validates and applies the current player's placement of tiles forming a
|
||||||
// word in direction dir. It scores the play, refills the rack from the bag,
|
// word in direction dir. It scores the play, refills the rack from the bag,
|
||||||
// advances the turn and may end the game. It returns ErrTilesNotOnRack when the
|
// advances the turn and may end the game. It returns ErrTilesNotOnRack when the
|
||||||
@@ -170,7 +191,7 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
|
|||||||
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
|
if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
|
||||||
return MoveRecord{}, err
|
return MoveRecord{}, err
|
||||||
}
|
}
|
||||||
move, err := g.solver.ValidatePlay(g.board, dir, tiles)
|
move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
|
||||||
}
|
}
|
||||||
@@ -279,7 +300,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
|
|||||||
// GenerateMoves returns every legal play for the current player's rack, ranked
|
// GenerateMoves returns every legal play for the current player's rack, ranked
|
||||||
// by descending score. It is empty when the player has no legal play.
|
// by descending score. It is empty when the player has no legal play.
|
||||||
func (g *Game) GenerateMoves() []scrabble.Move {
|
func (g *Game) GenerateMoves() []scrabble.Move {
|
||||||
return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both)
|
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint returns the highest-scoring legal play for the current player and true,
|
// Hint returns the highest-scoring legal play for the current player and true,
|
||||||
@@ -355,6 +376,17 @@ func (g *Game) finish(reason EndReason) {
|
|||||||
g.applyEndAdjustment(reason)
|
g.applyEndAdjustment(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The
|
||||||
|
// service calls it when a committed game can no longer be reconstructed from its journal —
|
||||||
|
// a recorded move became illegal under tightened rules — so the game ends gracefully
|
||||||
|
// instead of being left unopenable. It is a no-op on an already-finished game.
|
||||||
|
func (g *Game) Abort() {
|
||||||
|
if g.over {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.finish(EndAborted)
|
||||||
|
}
|
||||||
|
|
||||||
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
|
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
|
||||||
// empty, rack empty) they gain the sum of every opponent's rack value and each
|
// empty, rack empty) they gain the sum of every opponent's rack value and each
|
||||||
// opponent loses their own. A scoreless stalemate forfeits each player's own
|
// opponent loses their own. A scoreless stalemate forfeits each player's own
|
||||||
@@ -438,6 +470,9 @@ func (g *Game) winner() int {
|
|||||||
if !g.over {
|
if !g.over {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
if g.reason == EndAborted {
|
||||||
|
return -1 // an aborted game is a draw regardless of the running scores
|
||||||
|
}
|
||||||
best, tie := -1, false
|
best, tie := -1, false
|
||||||
for i := range g.scores {
|
for i := range g.scores {
|
||||||
if g.resigned[i] {
|
if g.resigned[i] {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
dawg "github.com/iliadenisov/dafsa"
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
@@ -30,7 +31,7 @@ type entry struct {
|
|||||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||||
// dictionary version, and the solvers built over them. Several versions of a
|
// 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
|
// 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.
|
// Registry is safe for concurrent use.
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -69,11 +70,21 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
|||||||
// immediate subdirectory of dir: a subdirectory named V contributes, under
|
// immediate subdirectory of dir: a subdirectory named V contributes, under
|
||||||
// version V, the variants whose committed DAWG it carries. This is the
|
// version V, the variants whose committed DAWG it carries. This is the
|
||||||
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
|
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
|
||||||
// at runtime is resident again after a restart. A subdirectory named like the
|
// at runtime is resident again after a restart. The flat dir's version is resolved
|
||||||
// boot version is skipped (the flat dir already is the boot version). A partially
|
// from its .seed_version marker (see resolveSeedVersion): a fresh dir records
|
||||||
// loaded registry is closed before any error is returned.
|
// bootVersion, an already-seeded dir keeps its recorded label and ignores bootVersion,
|
||||||
|
// so a bumped build seed never relabels live bytes. A subdirectory named like the
|
||||||
|
// resolved seed version is skipped (the flat dir already is it). A partially loaded
|
||||||
|
// registry is closed before any error is returned.
|
||||||
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
||||||
r, err := Open(dir, bootVersion)
|
// Resolve the flat dir's version from its seed marker first: on an already-seeded
|
||||||
|
// volume the marker wins and bootVersion is ignored, so a bumped build seed cannot
|
||||||
|
// relabel live bytes (see resolveSeedVersion).
|
||||||
|
seed, err := resolveSeedVersion(dir, bootVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err := Open(dir, seed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -83,7 +94,9 @@ func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
|
|||||||
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() || e.Name() == bootVersion {
|
// Skip non-directories, the resolved seed version (already loaded as the flat
|
||||||
|
// dir) and dot-prefixed directories (the upload staging area, dir/.staging/).
|
||||||
|
if !e.IsDir() || e.Name() == seed || strings.HasPrefix(e.Name(), ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
|
|||||||
func TestRegistryUnknownLookups(t *testing.T) {
|
func TestRegistryUnknownLookups(t *testing.T) {
|
||||||
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open english-only registry: %v", err)
|
t.Fatalf("open scrabble_en-only registry: %v", err)
|
||||||
}
|
}
|
||||||
defer reg.Close()
|
defer reg.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,13 +46,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
|
|||||||
t.Fatalf("load available: %v", err)
|
t.Fatalf("load available: %v", err)
|
||||||
}
|
}
|
||||||
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
if len(loaded) != 1 || loaded[0] != VariantEnglish {
|
||||||
t.Fatalf("loaded = %v, want [english]", loaded)
|
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
|
||||||
}
|
}
|
||||||
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
||||||
t.Errorf("english v2 solver: %v", err)
|
t.Errorf("scrabble_en v2 solver: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
|
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
|
||||||
t.Errorf("russian v2 should be absent: got %v", err)
|
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,17 +78,135 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
if got := reg.Versions(VariantEnglish); len(got) != 2 {
|
||||||
t.Errorf("english versions = %v, want two", got)
|
t.Errorf("scrabble_en versions = %v, want two", got)
|
||||||
}
|
}
|
||||||
latest, _, err := reg.Latest(VariantEnglish)
|
latest, _, err := reg.Latest(VariantEnglish)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("latest english: %v", err)
|
t.Fatalf("latest scrabble_en: %v", err)
|
||||||
}
|
}
|
||||||
if latest != "v2" {
|
if latest != "v2" {
|
||||||
t.Errorf("latest english = %q, want v2", latest)
|
t.Errorf("latest scrabble_en = %q, want v2", latest)
|
||||||
}
|
}
|
||||||
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
|
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
|
||||||
t.Errorf("russian versions = %v, want one (no v2 file)", got)
|
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenWithVersionsSkipsDotDirs verifies the boot scan ignores dot-prefixed
|
||||||
|
// subdirectories (the upload staging area), so a half-extracted archive there
|
||||||
|
// never becomes a resident version.
|
||||||
|
func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, v := range Variants() {
|
||||||
|
copyDawg(t, testDictDir(), dir, v)
|
||||||
|
}
|
||||||
|
copyDawg(t, testDictDir(), filepath.Join(dir, ".staging"), VariantEnglish)
|
||||||
|
|
||||||
|
reg, err := OpenWithVersions(dir, "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open with versions: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = reg.Close() }()
|
||||||
|
|
||||||
|
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
|
||||||
|
t.Errorf("scrabble_en versions = %v, want only [v1] (.staging skipped)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
|
||||||
|
// version in the flat dir's marker, the marker is not mistaken for a version, and a
|
||||||
|
// reboot at the same seed version succeeds.
|
||||||
|
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, v := range Variants() {
|
||||||
|
copyDawg(t, testDictDir(), dir, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := OpenWithVersions(dir, "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first open: %v", err)
|
||||||
|
}
|
||||||
|
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
|
||||||
|
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
|
||||||
|
}
|
||||||
|
_ = reg.Close()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read seed marker: %v", err)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(string(data)); got != "v1" {
|
||||||
|
t.Fatalf("seed marker = %q, want v1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg2, err := OpenWithVersions(dir, "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reboot at same seed: %v", err)
|
||||||
|
}
|
||||||
|
_ = reg2.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenWithVersionsMarkerWinsOverBoot verifies the recorded .seed_version marker
|
||||||
|
// is authoritative: once a directory is seeded, a different bootVersion
|
||||||
|
// (BACKEND_DICT_VERSION) is ignored — the flat dir keeps its recorded label — so a
|
||||||
|
// bumped build seed on a live volume cannot relabel the already-seeded bytes.
|
||||||
|
func TestOpenWithVersionsMarkerWinsOverBoot(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, v := range Variants() {
|
||||||
|
copyDawg(t, testDictDir(), dir, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := OpenWithVersions(dir, "v1") // seeds the marker = v1
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed open: %v", err)
|
||||||
|
}
|
||||||
|
_ = reg.Close()
|
||||||
|
|
||||||
|
// Reboot with a bumped boot version: the marker (v1) wins, no error, v2 ignored.
|
||||||
|
reg2, err := OpenWithVersions(dir, "v2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reboot with bumped boot version: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = reg2.Close() }()
|
||||||
|
if got := reg2.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
|
||||||
|
t.Errorf("versions = %v, want [v1] (marker wins, v2 ignored)", got)
|
||||||
|
}
|
||||||
|
if _, err := reg2.Solver(VariantEnglish, "v2"); !errors.Is(err, ErrUnknownVersion) {
|
||||||
|
t.Errorf("v2 must not be resident: got %v", err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(dir, seedMarkerFile))
|
||||||
|
if got := strings.TrimSpace(string(data)); got != "v1" {
|
||||||
|
t.Errorf("marker = %q, want v1 (unchanged)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenWithVersionsBumpedBootKeepsSubdir mirrors the live-contour case: a volume
|
||||||
|
// seeded as v1 with a v2 subdirectory (uploaded via the console), booted with a bumped
|
||||||
|
// build seed bootVersion=v2. The marker (v1) wins for the flat dir, and the v2
|
||||||
|
// subdirectory is still loaded — not skipped as "the boot version" — so both versions
|
||||||
|
// stay resident. (Skipping it would silently leave only the flat v1 bytes under v2.)
|
||||||
|
func TestOpenWithVersionsBumpedBootKeepsSubdir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, v := range Variants() {
|
||||||
|
copyDawg(t, testDictDir(), dir, v)
|
||||||
|
}
|
||||||
|
reg0, err := OpenWithVersions(dir, "v1") // seed marker = v1
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
_ = reg0.Close()
|
||||||
|
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // console upload
|
||||||
|
|
||||||
|
reg, err := OpenWithVersions(dir, "v2") // bumped build seed
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("boot v2: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = reg.Close() }()
|
||||||
|
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
|
||||||
|
t.Errorf("flat v1 must stay resident: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
|
||||||
|
t.Errorf("v2 subdir must be resident (not skipped): %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func TestResignLeadingPlayerStillLoses(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles)
|
played, err := g.SubmitPlay(hint.Tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("player 0 play: %v", err)
|
t.Fatalf("player 0 play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 scores
|
if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 scores
|
||||||
t.Fatalf("player 0 play: %v", err)
|
t.Fatalf("player 0 play: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func TestResignSeatOffTurn(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
if _, err := g.SubmitPlay(hint.Dir, hint.Tiles); err != nil { // player 0 moves
|
if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 moves
|
||||||
t.Fatalf("player 0 play: %v", err)
|
t.Fatalf("player 0 play: %v", err)
|
||||||
}
|
}
|
||||||
if g.ToMove() != 1 {
|
if g.ToMove() != 1 {
|
||||||
@@ -165,7 +165,7 @@ func TestMultiplayerLastActiveWins(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 takes the lead
|
played, err := g.SubmitPlay(hint.Tiles) // seat 0 takes the lead
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seat 0 play: %v", err)
|
t.Fatalf("seat 0 play: %v", err)
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("opening game has no hint")
|
t.Fatal("opening game has no hint")
|
||||||
}
|
}
|
||||||
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 leads
|
played, err := g.SubmitPlay(hint.Tiles) // seat 0 leads
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seat 0 play: %v", err)
|
t.Fatalf("seat 0 play: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedMarkerFile names the file, in the flat dictionary directory, that records the
|
||||||
|
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
|
||||||
|
// version scan skips it (like the .staging upload area).
|
||||||
|
const seedMarkerFile = ".seed_version"
|
||||||
|
|
||||||
|
// resolveSeedVersion returns the version label the flat dictionary directory is
|
||||||
|
// addressed by, recording it on first use.
|
||||||
|
//
|
||||||
|
// The contour's dictionary lives on a named volume seeded from the image once and
|
||||||
|
// never re-seeded (deploy/docker-compose.yml). The flat DAWGs carry no embedded
|
||||||
|
// version, so the version a volume was first seeded as is recorded in a
|
||||||
|
// .seed_version marker and is **authoritative** from then on:
|
||||||
|
//
|
||||||
|
// - fresh directory (no marker): record bootVersion (the build's
|
||||||
|
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
|
||||||
|
// - already-seeded directory: return the recorded marker and ignore bootVersion.
|
||||||
|
//
|
||||||
|
// So bumping the build seed on a live volume is a harmless no-op (it only takes
|
||||||
|
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
|
||||||
|
// which would void games pinned to the prior label and mis-serve new ones. New games
|
||||||
|
// still pin the active version (DB-persisted, set by the admin console), which is the
|
||||||
|
// real way a running contour moves to a new release.
|
||||||
|
//
|
||||||
|
// A directory that cannot be written makes the first record fail; that also breaks
|
||||||
|
// the admin console (which writes version subdirectories here), so the error is
|
||||||
|
// returned rather than swallowed, matching the package's fail-loud dictionary setup.
|
||||||
|
func resolveSeedVersion(dir, bootVersion string) (string, error) {
|
||||||
|
path := filepath.Join(dir, seedMarkerFile)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if recorded := strings.TrimSpace(string(data)); recorded != "" {
|
||||||
|
return recorded, nil
|
||||||
|
}
|
||||||
|
// An empty/corrupt marker falls through and is rewritten from bootVersion.
|
||||||
|
}
|
||||||
|
if werr := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); werr != nil {
|
||||||
|
return "", fmt.Errorf("engine: record dictionary seed marker %s: %w", path, werr)
|
||||||
|
}
|
||||||
|
return bootVersion, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// SetupTile is one tile of a variant's full bag, decoded for the first-move draw
|
||||||
|
// (docs/ARCHITECTURE.md §6): its concrete letter (or the blank marker), a blank
|
||||||
|
// flag, and its draw rank. Lower rank wins the draw — a blank ranks above every
|
||||||
|
// letter, and letters rank by alphabet index, so the tile closest to the start of
|
||||||
|
// the alphabet ("A") wins. It is dictionary-independent, built from the variant's
|
||||||
|
// solver ruleset alone.
|
||||||
|
type SetupTile struct {
|
||||||
|
// Letter is the concrete character (the case the solver ruleset emits), or
|
||||||
|
// the blank marker "?" for a blank.
|
||||||
|
Letter string
|
||||||
|
// Blank reports whether the tile is a blank.
|
||||||
|
Blank bool
|
||||||
|
// Rank orders the draw: BlankRank for a blank (best), else the letter's
|
||||||
|
// alphabet index (0 = closest to "A").
|
||||||
|
Rank int
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlankRank is the first-move draw rank of a blank: below every letter index, so a
|
||||||
|
// blank always beats a lettered tile, matching the official rule that a blank
|
||||||
|
// supersedes all letters.
|
||||||
|
const BlankRank = -1
|
||||||
|
|
||||||
|
// SetupBag returns variant's full tile bag — every lettered tile expanded by its
|
||||||
|
// count, plus one entry per blank — decoded for the first-move seeding draw. The
|
||||||
|
// order is deterministic (alphabet order, blanks last); callers shuffle it with
|
||||||
|
// their own entropy. It needs no dictionary, so it is built from the variant's
|
||||||
|
// ruleset alone and reports ErrUnknownVariant for an unrecognised variant.
|
||||||
|
func SetupBag(v Variant) ([]SetupTile, error) {
|
||||||
|
rs, ok := v.ruleset()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||||
|
}
|
||||||
|
bag := make([]SetupTile, 0, 128)
|
||||||
|
for i, n := range rs.Counts {
|
||||||
|
ch, err := rs.Alphabet.Character(byte(i))
|
||||||
|
if err != nil {
|
||||||
|
// An offered variant's alphabet never yields a bad index; skip defensively.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for range n {
|
||||||
|
bag = append(bag, SetupTile{Letter: ch, Rank: i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for range rs.Blanks {
|
||||||
|
bag = append(bag, SetupTile{Letter: blankLetter, Blank: true, Rank: BlankRank})
|
||||||
|
}
|
||||||
|
return bag, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupBagEnglish(t *testing.T) {
|
||||||
|
bag, err := SetupBag(VariantEnglish)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetupBag: %v", err)
|
||||||
|
}
|
||||||
|
// English Scrabble: 98 lettered tiles + 2 blanks = 100.
|
||||||
|
if len(bag) != 100 {
|
||||||
|
t.Fatalf("bag size = %d, want 100", len(bag))
|
||||||
|
}
|
||||||
|
blanks, aCount := 0, 0
|
||||||
|
for _, tl := range bag {
|
||||||
|
switch {
|
||||||
|
case tl.Blank:
|
||||||
|
blanks++
|
||||||
|
if tl.Rank != BlankRank {
|
||||||
|
t.Errorf("blank rank = %d, want %d", tl.Rank, BlankRank)
|
||||||
|
}
|
||||||
|
if tl.Letter != blankLetter {
|
||||||
|
t.Errorf("blank letter = %q, want %q", tl.Letter, blankLetter)
|
||||||
|
}
|
||||||
|
case tl.Letter == "a":
|
||||||
|
aCount++
|
||||||
|
if tl.Rank != 0 {
|
||||||
|
t.Errorf("'a' rank = %d, want 0 (closest to A)", tl.Rank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if blanks != 2 {
|
||||||
|
t.Errorf("blanks = %d, want 2", blanks)
|
||||||
|
}
|
||||||
|
if aCount != 9 {
|
||||||
|
t.Errorf("'a' count = %d, want 9", aCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupBagUnknownVariant(t *testing.T) {
|
||||||
|
if _, err := SetupBag(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
|
||||||
|
t.Fatalf("err = %v, want ErrUnknownVariant", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
|
||||||
|
// single-word game ignores perpendicular cross-words, so move generation from a shared
|
||||||
|
// position is a superset of the standard game's; the standard game does not relax them.
|
||||||
|
func TestSingleWordRuleWiring(t *testing.T) {
|
||||||
|
const seed = 7
|
||||||
|
mk := func(multipleWords bool) *Game {
|
||||||
|
g, err := New(testReg, Options{
|
||||||
|
Variant: VariantEnglish,
|
||||||
|
Version: testVersion,
|
||||||
|
Players: 2,
|
||||||
|
Seed: seed,
|
||||||
|
MultipleWordsPerTurn: multipleWords,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new game: %v", err)
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
std, single := mk(true), mk(false)
|
||||||
|
if std.playOpts().IgnoreCrossWords {
|
||||||
|
t.Error("standard game must not ignore cross-words")
|
||||||
|
}
|
||||||
|
if !single.playOpts().IgnoreCrossWords {
|
||||||
|
t.Error("single-word game must ignore cross-words")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play the same opening (the standard game's top move) in both games. Both share the
|
||||||
|
// seed, so the next rack is identical and both still have legal replies. The single-word
|
||||||
|
// rule is not a superset of the standard one — it forbids parallel plays the standard
|
||||||
|
// rule allows and admits in-line plays whose cross-words are invalid — so here the two
|
||||||
|
// move sets only need to be non-empty; their rule-specific differences are covered by the
|
||||||
|
// cross-word and connectivity tests.
|
||||||
|
hint, ok := std.HintView()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("opening game has no hint")
|
||||||
|
}
|
||||||
|
if _, err := std.SubmitPlay(hint.Tiles); err != nil {
|
||||||
|
t.Fatalf("standard opening: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := single.SubmitPlay(hint.Tiles); err != nil {
|
||||||
|
t.Fatalf("single-word opening: %v", err)
|
||||||
|
}
|
||||||
|
if len(std.GenerateMoves()) == 0 || len(single.GenerateMoves()) == 0 {
|
||||||
|
t.Error("both games should have legal replies after the opening")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupSingleWordKran builds an Erudit position that reproduces the test-contour
|
||||||
|
// bug. It replaces the bag-dealt rack with к/а/н and places the existing Р the play
|
||||||
|
// bridges plus perpendicular neighbours (г, е, н) so that each of the three new
|
||||||
|
// tiles of the vertical КРАН forms an *invalid* cross-word (гк, еа, нн). The
|
||||||
|
// multipleWords argument selects the rule. It returns the game and the decoded КРАН
|
||||||
|
// placement (the three new tiles К, А, Н around the existing Р).
|
||||||
|
func setupSingleWordKran(t *testing.T, multipleWords bool) (*Game, []TileRecord) {
|
||||||
|
t.Helper()
|
||||||
|
g, err := New(testReg, Options{
|
||||||
|
Variant: VariantErudit,
|
||||||
|
Version: testVersion,
|
||||||
|
Players: 2,
|
||||||
|
Seed: 1,
|
||||||
|
MultipleWordsPerTurn: multipleWords,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new erudit game: %v", err)
|
||||||
|
}
|
||||||
|
idx := func(s string) byte {
|
||||||
|
i, err := g.rules.Alphabet.Index(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||||
|
{Row: 5, Col: 8, Letter: idx("р")}, // the existing tile the play bridges
|
||||||
|
{Row: 4, Col: 7, Letter: idx("г")}, // left of К(4,8): cross-word "гк"
|
||||||
|
{Row: 6, Col: 7, Letter: idx("е")}, // left of А(6,8): cross-word "еа"
|
||||||
|
{Row: 7, Col: 7, Letter: idx("н")}, // left of Н(7,8): cross-word "нн"
|
||||||
|
}})
|
||||||
|
g.hands[0] = []byte{idx("к"), idx("а"), idx("н")}
|
||||||
|
tiles := []TileRecord{
|
||||||
|
{Row: 4, Col: 8, Letter: "к"},
|
||||||
|
{Row: 6, Col: 8, Letter: "а"},
|
||||||
|
{Row: 7, Col: 8, Letter: "н"},
|
||||||
|
}
|
||||||
|
return g, tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEvaluatePlayHonorsSingleWordRule is the regression for the contour bug: under
|
||||||
|
// the single-word rule the EvaluatePlay preview (the "what would this score, and is
|
||||||
|
// it legal?" tool) must honour the same rule as SubmitPlay and ignore perpendicular
|
||||||
|
// cross-words, so a play whose only flaw is invalid cross-words is reported legal and
|
||||||
|
// scored on its main word alone. Before the fix EvaluatePlay validated under standard
|
||||||
|
// rules and wrongly rejected it.
|
||||||
|
func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) {
|
||||||
|
// The main word must be a real Erudit word, so any rejection can only come from
|
||||||
|
// the (ignored) cross-words rather than the main word itself.
|
||||||
|
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кран"); err != nil || !ok {
|
||||||
|
t.Fatalf("precondition: кран must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("single-word rule accepts the cross-invalid play", func(t *testing.T) {
|
||||||
|
g, tiles := setupSingleWordKran(t, false)
|
||||||
|
rec, err := g.EvaluatePlay(tiles)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluate under single-word rule: %v", err)
|
||||||
|
}
|
||||||
|
if len(rec.Words) != 1 || rec.Words[0] != "кран" {
|
||||||
|
t.Errorf("words = %v, want [кран] only (cross-words ignored)", rec.Words)
|
||||||
|
}
|
||||||
|
if rec.Dir != Vertical {
|
||||||
|
t.Errorf("dir = %v, want Vertical", rec.Dir)
|
||||||
|
}
|
||||||
|
if rec.Score <= 0 {
|
||||||
|
t.Errorf("score = %d, want positive", rec.Score)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("standard rules reject the same play", func(t *testing.T) {
|
||||||
|
g, tiles := setupSingleWordKran(t, true)
|
||||||
|
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
|
||||||
|
t.Errorf("evaluate under standard rules = %v, want ErrIllegalPlay", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("evaluate agrees with submit under the single-word rule", func(t *testing.T) {
|
||||||
|
g, tiles := setupSingleWordKran(t, false)
|
||||||
|
if _, err := g.SubmitPlay(tiles); err != nil {
|
||||||
|
t.Errorf("submit under single-word rule: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleWordRuleSingleTileDirection covers the single-tile half of the single-word
|
||||||
|
// rule: when a lone tile abuts the board on both axes, the engine picks the orientation that
|
||||||
|
// forms a real word, not the geometrically longer one. The lone 'о' spells the non-word
|
||||||
|
// "фоф" across (length 3) but the real word "до" down (length 2); the geometric resolver
|
||||||
|
// prefers the longer "фоф", so before the fix the play was wrongly rejected.
|
||||||
|
func TestSingleWordRuleSingleTileDirection(t *testing.T) {
|
||||||
|
if ok, err := testReg.Lookup(VariantErudit, testVersion, "до"); err != nil || !ok {
|
||||||
|
t.Fatalf("precondition: \"до\" must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
|
||||||
|
}
|
||||||
|
if ok, _ := testReg.Lookup(VariantErudit, testVersion, "фоф"); ok {
|
||||||
|
t.Fatal("precondition: \"фоф\" must not be a word")
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := New(testReg, Options{
|
||||||
|
Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1, MultipleWordsPerTurn: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new erudit game: %v", err)
|
||||||
|
}
|
||||||
|
idx := func(s string) byte {
|
||||||
|
i, err := g.rules.Alphabet.Index(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||||
|
{Row: 4, Col: 8, Letter: idx("д")}, // above 'о': the vertical word "до"
|
||||||
|
{Row: 5, Col: 7, Letter: idx("ф")}, // left of 'о': the across non-word "фоф"
|
||||||
|
{Row: 5, Col: 9, Letter: idx("ф")}, // right of 'о'
|
||||||
|
}})
|
||||||
|
g.hands[0] = []byte{idx("о")}
|
||||||
|
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "о"}}
|
||||||
|
|
||||||
|
rec, err := g.EvaluatePlay(tiles)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluate the single tile under the single-word rule: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Dir != Vertical {
|
||||||
|
t.Errorf("dir = %v, want Vertical (the real word \"до\")", rec.Dir)
|
||||||
|
}
|
||||||
|
if len(rec.Words) != 1 || rec.Words[0] != "до" {
|
||||||
|
t.Errorf("words = %v, want [до]", rec.Words)
|
||||||
|
}
|
||||||
|
if _, err := g.SubmitPlay(tiles); err != nil {
|
||||||
|
t.Errorf("submit the single tile under the single-word rule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleWordRuleSingleTileBestScore covers the rest of rule (2): when a single tile
|
||||||
|
// forms a real word on BOTH axes, the engine keeps the higher-scoring orientation (and
|
||||||
|
// horizontal on a tie), overriding the geometric resolver's tie preference. The lone 'с'
|
||||||
|
// spells "ас" across and "юс" down; the engine must pick whichever scores more.
|
||||||
|
func TestSingleWordRuleSingleTileBestScore(t *testing.T) {
|
||||||
|
for _, w := range []string{"ас", "юс"} {
|
||||||
|
if ok, err := testReg.Lookup(VariantErudit, testVersion, w); err != nil || !ok {
|
||||||
|
t.Fatalf("precondition: %q must be in the Erudit dictionary (ok=%v, err=%v)", w, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new erudit game: %v", err)
|
||||||
|
}
|
||||||
|
idx := func(s string) byte {
|
||||||
|
i, err := g.rules.Alphabet.Index(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||||
|
{Row: 5, Col: 7, Letter: idx("а")}, // left of 'с': the across word "ас"
|
||||||
|
{Row: 4, Col: 8, Letter: idx("ю")}, // above 'с': the down word "юс"
|
||||||
|
}})
|
||||||
|
g.hands[0] = []byte{idx("с")}
|
||||||
|
tiles := []TileRecord{{Row: 5, Col: 8, Letter: "с"}}
|
||||||
|
ps := []scrabble.Placement{{Row: 5, Col: 8, Letter: idx("с")}}
|
||||||
|
|
||||||
|
across, aerr := g.solver.ValidatePlayOpts(g.board, scrabble.Horizontal, ps, g.playOpts())
|
||||||
|
down, derr := g.solver.ValidatePlayOpts(g.board, scrabble.Vertical, ps, g.playOpts())
|
||||||
|
if aerr != nil || derr != nil {
|
||||||
|
t.Fatalf("both orientations should be legal: across=%v down=%v", aerr, derr)
|
||||||
|
}
|
||||||
|
wantDir, wantScore := Horizontal, across.Score
|
||||||
|
if down.Score > across.Score {
|
||||||
|
wantDir, wantScore = Vertical, down.Score
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := g.EvaluatePlay(tiles)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("evaluate the single tile: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Dir != wantDir {
|
||||||
|
t.Errorf("dir = %v, want %v (higher of \"ас\"=%d, \"юс\"=%d)", rec.Dir, wantDir, across.Score, down.Score)
|
||||||
|
}
|
||||||
|
if rec.Score != wantScore {
|
||||||
|
t.Errorf("score = %d, want %d", rec.Score, wantScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleWordRuleRejectsPerpendicularOnlyContour is the backend regression for the
|
||||||
|
// reported contour bug: a multi-tile play whose main word is a real word but which touches
|
||||||
|
// the board only perpendicular to its own line — forming a cross-word, not a word along that
|
||||||
|
// line — does not connect under the single-word rule and is rejected by both the preview and
|
||||||
|
// the submit path. Existing "до" sits down column 8; "кот" laid across row 6 is all-new along
|
||||||
|
// its row and touches the board only through the 'о' below the existing 'о'.
|
||||||
|
func TestSingleWordRuleRejectsPerpendicularOnlyContour(t *testing.T) {
|
||||||
|
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
|
||||||
|
t.Fatalf("precondition: \"кот\" must be a real word (ok=%v, err=%v)", ok, err)
|
||||||
|
}
|
||||||
|
g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new erudit game: %v", err)
|
||||||
|
}
|
||||||
|
idx := func(s string) byte {
|
||||||
|
i, err := g.rules.Alphabet.Index(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
|
||||||
|
{Row: 4, Col: 8, Letter: idx("д")},
|
||||||
|
{Row: 5, Col: 8, Letter: idx("о")},
|
||||||
|
}})
|
||||||
|
g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
|
||||||
|
tiles := []TileRecord{
|
||||||
|
{Row: 6, Col: 7, Letter: "к"},
|
||||||
|
{Row: 6, Col: 8, Letter: "о"},
|
||||||
|
{Row: 6, Col: 9, Letter: "т"},
|
||||||
|
}
|
||||||
|
if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
|
||||||
|
t.Errorf("EvaluatePlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
|
||||||
|
}
|
||||||
|
if _, err := g.SubmitPlay(tiles); !errors.Is(err, ErrIllegalPlay) {
|
||||||
|
t.Errorf("SubmitPlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same
|
||||||
|
// cross-word check while searching for its move: its move source, Candidates ->
|
||||||
|
// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged
|
||||||
|
// КРАН (whose cross-words are invalid) appears among the candidates the robot chooses
|
||||||
|
// from; under standard rules it is correctly absent. The robot submits its pick through
|
||||||
|
// SubmitPlay (covered above), so this holds both before and after the EvaluatePlay fix —
|
||||||
|
// the robot never uses EvaluatePlay.
|
||||||
|
func TestSingleWordRuleRobotCandidates(t *testing.T) {
|
||||||
|
hasKran := func(cands []MoveRecord) bool {
|
||||||
|
for _, c := range cands {
|
||||||
|
if len(c.Words) > 0 && c.Words[0] == "кран" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
single, _ := setupSingleWordKran(t, false)
|
||||||
|
if !hasKran(single.Candidates()) {
|
||||||
|
t.Error("single-word candidates must include the bridged кран play the robot can pick")
|
||||||
|
}
|
||||||
|
std, _ := setupSingleWordKran(t, true)
|
||||||
|
if hasKran(std.Candidates()) {
|
||||||
|
t.Error("standard candidates must not include кран (its cross-words are invalid)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app
|
||||||
|
// push by the game itself (English -> en, the Russian variants -> ru), rather than the recipient's
|
||||||
|
// last-login bot.
|
||||||
|
func TestVariantLanguage(t *testing.T) {
|
||||||
|
cases := map[Variant]string{
|
||||||
|
VariantEnglish: "en",
|
||||||
|
VariantRussianScrabble: "ru",
|
||||||
|
VariantErudit: "ru",
|
||||||
|
}
|
||||||
|
for v, want := range cases {
|
||||||
|
if got := v.Language(); got != want {
|
||||||
|
t.Errorf("%s.Language() = %q, want %q", v, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package feedback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAttachmentBytes caps a single attachment's raw size. Chosen to fit, with the
|
||||||
|
// message text and the FlatBuffers framing, under the gateway's 1 MiB edge body
|
||||||
|
// cap, so the whole submit request passes without weakening that cap.
|
||||||
|
const maxAttachmentBytes = 1_000_000
|
||||||
|
|
||||||
|
// allowedExt is the attachment extension allow-list. It is mirrored on the UI as a
|
||||||
|
// pre-upload gate; the server re-checks here as the trust boundary (metadata only,
|
||||||
|
// the file content is never parsed). Images render inline in the console; the rest
|
||||||
|
// are download-only.
|
||||||
|
var allowedExt = map[string]bool{
|
||||||
|
"png": true, "jpg": true, "jpeg": true, "webp": true, "gif": true, // images
|
||||||
|
"pdf": true, "txt": true, "log": true, "doc": true, "docx": true,
|
||||||
|
"rtf": true, "zip": true, "gz": true, "7z": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageType maps an image extension to the content-type the console serves it with
|
||||||
|
// (loaded only via <img>, which never executes, so a renamed non-image is inert).
|
||||||
|
var imageType = map[string]string{
|
||||||
|
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||||
|
"webp": "image/webp", "gif": "image/gif",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ext returns name's lower-cased extension without the leading dot.
|
||||||
|
func ext(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedAttachment reports whether name's extension is on the allow-list.
|
||||||
|
func AllowedAttachment(name string) bool {
|
||||||
|
return allowedExt[ext(name)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsImage reports whether name is an inline-previewable image by its extension.
|
||||||
|
func IsImage(name string) bool {
|
||||||
|
_, ok := imageType[ext(name)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentType returns the safe content-type the console serves the attachment
|
||||||
|
// with: the matching image type for an image, else application/octet-stream so a
|
||||||
|
// non-image is downloaded rather than rendered.
|
||||||
|
func ContentType(name string) string {
|
||||||
|
if t, ok := imageType[ext(name)]; ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package feedback
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAllowedAttachment(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"png image", "shot.png", true},
|
||||||
|
{"jpeg upper-case ext", "Photo.JPG", true},
|
||||||
|
{"pdf doc", "report.pdf", true},
|
||||||
|
{"archive 7z", "logs.7z", true},
|
||||||
|
{"doc with dotted name", "my.notes.docx", true},
|
||||||
|
{"disallowed exe", "evil.exe", false},
|
||||||
|
{"disallowed svg (xss vector)", "x.svg", false},
|
||||||
|
{"disallowed html", "x.html", false},
|
||||||
|
{"no extension", "README", false},
|
||||||
|
{"empty name", "", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := AllowedAttachment(tt.file); got != tt.want {
|
||||||
|
t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsImageAndContentType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
file string
|
||||||
|
isImage bool
|
||||||
|
ctype string
|
||||||
|
}{
|
||||||
|
{"a.png", true, "image/png"},
|
||||||
|
{"a.jpg", true, "image/jpeg"},
|
||||||
|
{"a.jpeg", true, "image/jpeg"},
|
||||||
|
{"a.webp", true, "image/webp"},
|
||||||
|
{"a.gif", true, "image/gif"},
|
||||||
|
{"a.pdf", false, "application/octet-stream"},
|
||||||
|
{"a.zip", false, "application/octet-stream"},
|
||||||
|
{"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml
|
||||||
|
{"noext", false, "application/octet-stream"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.file, func(t *testing.T) {
|
||||||
|
if got := IsImage(tt.file); got != tt.isImage {
|
||||||
|
t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage)
|
||||||
|
}
|
||||||
|
if got := ContentType(tt.file); got != tt.ctype {
|
||||||
|
t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeChannel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"telegram", "telegram"},
|
||||||
|
{"ios", "ios"},
|
||||||
|
{"android", "android"},
|
||||||
|
{"web", "web"},
|
||||||
|
{" iOS ", "ios"}, // trimmed + lower-cased
|
||||||
|
{"TELEGRAM", "telegram"},
|
||||||
|
{"", "web"}, // unknown -> web
|
||||||
|
{"windows", "web"}, // unknown -> web
|
||||||
|
{"'; DROP", "web"}, // junk -> web
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.in, func(t *testing.T) {
|
||||||
|
if got := normalizeChannel(tt.in); got != tt.want {
|
||||||
|
t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package feedback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxBodyRunes caps a feedback message (and an operator reply) length.
|
||||||
|
maxBodyRunes = 1024
|
||||||
|
// replyVisibleFor is how long an operator reply stays shown to the player after
|
||||||
|
// it is delivered (read).
|
||||||
|
replyVisibleFor = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Submit / reply validation errors. The transport layer maps them to stable result
|
||||||
|
// codes; the UI maps those to the messages shown on the feedback screen.
|
||||||
|
var (
|
||||||
|
ErrEmptyMessage = errors.New("feedback: empty message")
|
||||||
|
ErrMessageTooLong = errors.New("feedback: message too long")
|
||||||
|
ErrAttachmentTooLarge = errors.New("feedback: attachment too large")
|
||||||
|
ErrAttachmentType = errors.New("feedback: attachment type not allowed")
|
||||||
|
ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback")
|
||||||
|
ErrBanned = errors.New("feedback: account is banned from feedback")
|
||||||
|
ErrPendingReview = errors.New("feedback: previous message still pending review")
|
||||||
|
)
|
||||||
|
|
||||||
|
// validChannels enumerates the submitting platforms a client may report; anything
|
||||||
|
// else is normalised to "web" (the channel is informational, never a gate).
|
||||||
|
var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true}
|
||||||
|
|
||||||
|
// Service is the feedback domain: the only writer of feedback_messages. It reads
|
||||||
|
// accounts for the guest/role gates and publishes the reply notification.
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
accounts *account.Store
|
||||||
|
pub notify.Publisher
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service. store owns feedback_messages; accounts supplies
|
||||||
|
// the guest flag and the feedback-ban role.
|
||||||
|
func NewService(store *Store, accounts *account.Store) *Service {
|
||||||
|
return &Service{
|
||||||
|
store: store,
|
||||||
|
accounts: accounts,
|
||||||
|
pub: notify.Nop{},
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNotifier installs the live-event publisher used to push the "you have a reply"
|
||||||
|
// signal to the player. It must be called during startup wiring; the default is
|
||||||
|
// notify.Nop (no live events).
|
||||||
|
func (svc *Service) SetNotifier(p notify.Publisher) {
|
||||||
|
if p != nil {
|
||||||
|
svc.pub = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit stores a feedback message from accountID. It rejects guests, feedback-
|
||||||
|
// banned accounts and a sender who still has a message pending review, then
|
||||||
|
// validates the body (non-empty, within the rune limit) and the optional
|
||||||
|
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
|
||||||
|
// client IP (validated); channel is the submitting platform.
|
||||||
|
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, browserTZ, senderIP string) error {
|
||||||
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if acc.IsGuest {
|
||||||
|
return ErrGuestForbidden
|
||||||
|
}
|
||||||
|
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if banned {
|
||||||
|
return ErrBanned
|
||||||
|
}
|
||||||
|
pending, err := svc.store.HasUnread(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pending {
|
||||||
|
return ErrPendingReview
|
||||||
|
}
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return ErrEmptyMessage
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(body) > maxBodyRunes {
|
||||||
|
return ErrMessageTooLong
|
||||||
|
}
|
||||||
|
if len(attachment) > 0 {
|
||||||
|
if len(attachment) > maxAttachmentBytes {
|
||||||
|
return ErrAttachmentTooLarge
|
||||||
|
}
|
||||||
|
if !AllowedAttachment(attachmentName) {
|
||||||
|
return ErrAttachmentType
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attachmentName = "" // a name without bytes carries no attachment
|
||||||
|
}
|
||||||
|
ch := normalizeChannel(channel)
|
||||||
|
// Snapshot the sender's interface language, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the player's feedback screen state. Reason is "" (can send), "pending"
|
||||||
|
// (a previous message is unreviewed) or "banned". Reply is the operator's answer to
|
||||||
|
// show, or nil. Fetching it delivers any pending replies (delivery counts as read),
|
||||||
|
// clearing the badge.
|
||||||
|
type State struct {
|
||||||
|
CanSend bool
|
||||||
|
BlockedReason string
|
||||||
|
Reply *Reply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply is the operator's answer shown back to the player.
|
||||||
|
type Reply struct {
|
||||||
|
Body string
|
||||||
|
RepliedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// State computes the feedback screen state for accountID and marks any pending
|
||||||
|
// replies delivered.
|
||||||
|
func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) {
|
||||||
|
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
pending, err := svc.store.HasUnread(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
st := State{CanSend: !banned && !pending}
|
||||||
|
switch {
|
||||||
|
case banned:
|
||||||
|
st.BlockedReason = "banned"
|
||||||
|
case pending:
|
||||||
|
st.BlockedReason = "pending"
|
||||||
|
}
|
||||||
|
vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor))
|
||||||
|
if err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt}
|
||||||
|
}
|
||||||
|
// Opening the screen delivers every pending reply; mark them read to clear the
|
||||||
|
// badge (only the latest is shown, but all are now delivered).
|
||||||
|
if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplyUnread reports whether the account has an operator reply not yet delivered —
|
||||||
|
// the lobby/Info badge condition. It has no side effect (the lobby may poll it).
|
||||||
|
func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||||
|
return svc.store.HasUnreadReply(ctx, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminList returns the filtered console feedback list, paginated.
|
||||||
|
func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
|
||||||
|
return svc.store.AdminList(ctx, f, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCount counts the filtered console feedback list.
|
||||||
|
func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
|
||||||
|
return svc.store.AdminCount(ctx, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGet loads one message for the console detail view, or ErrNotFound.
|
||||||
|
func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
|
||||||
|
return svc.store.AdminGet(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnread counts the active (unread, not archived) feedback queue for the
|
||||||
|
// dashboard.
|
||||||
|
func (svc *Service) CountUnread(ctx context.Context) (int, error) {
|
||||||
|
return svc.store.CountUnread(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment returns a message's file name and bytes, reporting false when absent.
|
||||||
|
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||||
|
return svc.store.Attachment(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRead marks a message dealt-with (the manual "read" action).
|
||||||
|
func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return svc.store.MarkRead(ctx, id, svc.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply sets the operator reply on a message (marking it read), then pushes the
|
||||||
|
// "you have a reply" notification to the player.
|
||||||
|
func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
return ErrEmptyMessage
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(body) > maxBodyRunes {
|
||||||
|
return ErrMessageTooLong
|
||||||
|
}
|
||||||
|
accountID, err := svc.store.Reply(ctx, id, body, svc.now())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive files a handled message away (marking it read).
|
||||||
|
func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return svc.store.Archive(ctx, id, svc.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete physically removes a message and its attachment.
|
||||||
|
func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return svc.store.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllByAccount physically removes every message of an account.
|
||||||
|
func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
|
||||||
|
return svc.store.DeleteAllByAccount(ctx, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIP returns a validated canonical IP string, or nil when raw is empty or not
|
||||||
|
// a valid address.
|
||||||
|
func parseIP(raw string) *string {
|
||||||
|
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
canon := addr.String()
|
||||||
|
return &canon
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeChannel lower-cases and validates the client-reported channel, falling
|
||||||
|
// back to "web" for anything unrecognised.
|
||||||
|
func normalizeChannel(c string) string {
|
||||||
|
c = strings.ToLower(strings.TrimSpace(c))
|
||||||
|
if validChannels[c] {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// Package feedback owns user feedback: the flat list of messages a registered
|
||||||
|
// player sends to the operators (each with an optional single attachment), the
|
||||||
|
// anti-spam "one pending message at a time" gate, and the operator's inline reply
|
||||||
|
// delivered back to the player's app. It is modelled on the admin chat-moderation
|
||||||
|
// surface (internal/social/adminchat.go) and uses raw SQL throughout for the
|
||||||
|
// attachment bytea, the COALESCE-based "set once" stamps, and the reply-visibility
|
||||||
|
// window.
|
||||||
|
package feedback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is returned when no feedback message matches the lookup.
|
||||||
|
var ErrNotFound = errors.New("feedback: not found")
|
||||||
|
|
||||||
|
// Store is the Postgres-backed query surface for feedback_messages.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore constructs a Store wrapping db.
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert stores one feedback message from accountID and returns its id. attachment
|
||||||
|
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
||||||
|
// channel are stored as given. lang (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)
|
||||||
|
}
|
||||||
|
var att []byte // a nil []byte binds as bytea NULL
|
||||||
|
if len(attachment) > 0 {
|
||||||
|
att = attachment
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO backend.feedback_messages
|
||||||
|
(message_id, account_id, body, attachment, attachment_name, channel, lang, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullStr maps an empty string to a NULL text bind, else the value.
|
||||||
|
func nullStr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUnread reports whether the account has any message the operator has not yet
|
||||||
|
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
|
||||||
|
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages WHERE account_id = $1 AND read_at IS NULL)`,
|
||||||
|
accountID).Scan(&ok); err != nil {
|
||||||
|
return false, fmt.Errorf("feedback: has-unread %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUnreadReply reports whether the account has an operator reply not yet
|
||||||
|
// delivered to its app (reply_read_at IS NULL) — the badge's condition.
|
||||||
|
func (s *Store) HasUnreadReply(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM backend.feedback_messages
|
||||||
|
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL)`,
|
||||||
|
accountID).Scan(&ok); err != nil {
|
||||||
|
return false, fmt.Errorf("feedback: has-unread-reply %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisibleReply is the operator reply shown back to the player: the reply on their
|
||||||
|
// most recent replied message still inside the visibility window.
|
||||||
|
type VisibleReply struct {
|
||||||
|
Body string
|
||||||
|
RepliedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestVisibleReply returns the reply on the account's **most recent** message, if
|
||||||
|
// that message carries one still visible to the player — not yet delivered, or
|
||||||
|
// delivered after cutoff (one week ago). Binding it to the single latest message
|
||||||
|
// means that the moment the player sends a newer message the previous reply stops
|
||||||
|
// showing (the new message has no reply yet). Reports false when there is none.
|
||||||
|
func (s *Store) LatestVisibleReply(ctx context.Context, accountID uuid.UUID, cutoff time.Time) (VisibleReply, bool, error) {
|
||||||
|
var vr VisibleReply
|
||||||
|
var repliedAt sql.NullTime
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(reply_body, ''), replied_at FROM (
|
||||||
|
SELECT reply_body, replied_at, reply_read_at FROM backend.feedback_messages
|
||||||
|
WHERE account_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
|
) latest
|
||||||
|
WHERE reply_body IS NOT NULL AND (reply_read_at IS NULL OR reply_read_at > $2)`,
|
||||||
|
accountID, cutoff).Scan(&vr.Body, &repliedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return VisibleReply{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return VisibleReply{}, false, fmt.Errorf("feedback: latest visible reply %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
if repliedAt.Valid {
|
||||||
|
vr.RepliedAt = repliedAt.Time
|
||||||
|
}
|
||||||
|
return vr, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRepliesDelivered stamps reply_read_at on every not-yet-delivered reply of the
|
||||||
|
// account (delivery counts as read), clearing the badge when the player opens the
|
||||||
|
// feedback screen.
|
||||||
|
func (s *Store) MarkRepliesDelivered(ctx context.Context, accountID uuid.UUID, at time.Time) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE backend.feedback_messages SET reply_read_at = $2
|
||||||
|
WHERE account_id = $1 AND reply_body IS NOT NULL AND reply_read_at IS NULL`,
|
||||||
|
accountID, at); err != nil {
|
||||||
|
return fmt.Errorf("feedback: mark replies delivered %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRead stamps read_at the first time, marking the message dealt-with without any
|
||||||
|
// other action; a re-mark keeps the original time.
|
||||||
|
func (s *Store) MarkRead(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE backend.feedback_messages SET read_at = COALESCE(read_at, $2) WHERE message_id = $1`,
|
||||||
|
id, at); err != nil {
|
||||||
|
return fmt.Errorf("feedback: mark read %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply sets (or replaces) the operator reply on a message, marks it read, and
|
||||||
|
// resets its delivery so the player is notified again. Returns the message's
|
||||||
|
// account_id for the live notification, or ErrNotFound.
|
||||||
|
func (s *Store) Reply(ctx context.Context, id uuid.UUID, body string, at time.Time) (uuid.UUID, error) {
|
||||||
|
var accountID uuid.UUID
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`UPDATE backend.feedback_messages
|
||||||
|
SET reply_body = $2, replied_at = $3, reply_read_at = NULL, read_at = COALESCE(read_at, $3)
|
||||||
|
WHERE message_id = $1
|
||||||
|
RETURNING account_id`,
|
||||||
|
id, body, at).Scan(&accountID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return uuid.Nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("feedback: reply %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return accountID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive files a handled message away and marks it read.
|
||||||
|
func (s *Store) Archive(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE backend.feedback_messages
|
||||||
|
SET archived_at = COALESCE(archived_at, $2), read_at = COALESCE(read_at, $2)
|
||||||
|
WHERE message_id = $1`,
|
||||||
|
id, at); err != nil {
|
||||||
|
return fmt.Errorf("feedback: archive %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete physically removes a message (with its attachment).
|
||||||
|
func (s *Store) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM backend.feedback_messages WHERE message_id = $1`, id); err != nil {
|
||||||
|
return fmt.Errorf("feedback: delete %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllByAccount physically removes every message of an account.
|
||||||
|
func (s *Store) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM backend.feedback_messages WHERE account_id = $1`, accountID); err != nil {
|
||||||
|
return fmt.Errorf("feedback: delete all %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment returns a message's stored file name and bytes, reporting false when
|
||||||
|
// the message has no attachment or does not exist.
|
||||||
|
func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
||||||
|
var name string
|
||||||
|
var data []byte
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(attachment_name, ''), attachment FROM backend.feedback_messages WHERE message_id = $1`,
|
||||||
|
id).Scan(&name, &data)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, false, fmt.Errorf("feedback: attachment %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return "", nil, false, nil
|
||||||
|
}
|
||||||
|
return name, data, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMessage is one feedback message in the operator console detail view: the
|
||||||
|
// message with its sender's resolved display name and source. The attachment bytes
|
||||||
|
// are not loaded here (served separately); only their presence and name are.
|
||||||
|
type AdminMessage struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
AccountID uuid.UUID
|
||||||
|
SenderName string
|
||||||
|
Source string
|
||||||
|
Body string
|
||||||
|
Channel string
|
||||||
|
// Lang is the sender's interface language, snapshotted at submit time.
|
||||||
|
Lang string
|
||||||
|
// 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
|
||||||
|
Read bool
|
||||||
|
Archived bool
|
||||||
|
Replied bool
|
||||||
|
ReplyBody string
|
||||||
|
RepliedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRow is one feedback message in the console list (a lighter projection).
|
||||||
|
type AdminRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
AccountID uuid.UUID
|
||||||
|
SenderName string
|
||||||
|
Source string
|
||||||
|
Channel string
|
||||||
|
HasAttachment bool
|
||||||
|
Read bool
|
||||||
|
Replied bool
|
||||||
|
Archived bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFilter narrows the console feedback list. Status is one of "unread"
|
||||||
|
// (default), "read" or "archived". NameMask/ExtMask are glob masks
|
||||||
|
// (account.LikePattern) on the sender's display name / any identity's external id;
|
||||||
|
// AccountID, when set, restricts to one account (the per-user link from /users).
|
||||||
|
type AdminFilter struct {
|
||||||
|
Status string
|
||||||
|
NameMask string
|
||||||
|
ExtMask string
|
||||||
|
AccountID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedbackSource projects a sender's source: guest, robot, or its oldest identity
|
||||||
|
// kind ("—" when it has none). Mirrors social.adminMessageSource.
|
||||||
|
const feedbackSource = `CASE
|
||||||
|
WHEN a.is_guest THEN 'guest'
|
||||||
|
WHEN EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot') THEN 'robot'
|
||||||
|
ELSE COALESCE((SELECT i2.kind FROM backend.identities i2 WHERE i2.account_id = a.account_id ORDER BY i2.created_at ASC LIMIT 1), '—')
|
||||||
|
END`
|
||||||
|
|
||||||
|
// adminWhere builds the shared WHERE clause and its positional args (from $1).
|
||||||
|
func adminWhere(f AdminFilter) (string, []any) {
|
||||||
|
var where string
|
||||||
|
switch f.Status {
|
||||||
|
case "archived":
|
||||||
|
where = `m.archived_at IS NOT NULL`
|
||||||
|
case "read":
|
||||||
|
where = `m.read_at IS NOT NULL AND m.archived_at IS NULL`
|
||||||
|
default: // unread
|
||||||
|
where = `m.read_at IS NULL AND m.archived_at IS NULL`
|
||||||
|
}
|
||||||
|
var args []any
|
||||||
|
if f.AccountID != uuid.Nil {
|
||||||
|
args = append(args, f.AccountID)
|
||||||
|
where += fmt.Sprintf(` AND m.account_id = $%d`, len(args))
|
||||||
|
}
|
||||||
|
if name := account.LikePattern(f.NameMask); name != "" {
|
||||||
|
args = append(args, name)
|
||||||
|
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||||
|
}
|
||||||
|
if ext := account.LikePattern(f.ExtMask); ext != "" {
|
||||||
|
args = append(args, ext)
|
||||||
|
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||||
|
}
|
||||||
|
return where, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminList returns the filtered console feedback list, newest first, paginated.
|
||||||
|
func (s *Store) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
|
||||||
|
where, args := adminWhere(f)
|
||||||
|
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.channel,
|
||||||
|
(m.attachment IS NOT NULL), (m.read_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.archived_at IS NOT NULL), m.created_at
|
||||||
|
FROM backend.feedback_messages m
|
||||||
|
JOIN backend.accounts a ON a.account_id = m.account_id
|
||||||
|
WHERE ` + where +
|
||||||
|
fmt.Sprintf(` ORDER BY m.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("feedback: admin list: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []AdminRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r AdminRow
|
||||||
|
if err := rows.Scan(&r.ID, &r.AccountID, &r.SenderName, &r.Source, &r.Channel,
|
||||||
|
&r.HasAttachment, &r.Read, &r.Replied, &r.Archived, &r.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("feedback: scan admin row: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCount counts the filtered console feedback list, for the pager.
|
||||||
|
func (s *Store) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
|
||||||
|
where, args := adminWhere(f)
|
||||||
|
var n int
|
||||||
|
q := `SELECT COUNT(*) FROM backend.feedback_messages m JOIN backend.accounts a ON a.account_id = m.account_id WHERE ` + where
|
||||||
|
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("feedback: admin count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGet loads one message for the console detail view, or ErrNotFound.
|
||||||
|
func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
|
||||||
|
var m AdminMessage
|
||||||
|
var repliedAt sql.NullTime
|
||||||
|
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
||||||
|
COALESCE(m.lang, ''), COALESCE(m.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
|
||||||
|
FROM backend.feedback_messages m
|
||||||
|
JOIN backend.accounts a ON a.account_id = m.account_id
|
||||||
|
WHERE m.message_id = $1`
|
||||||
|
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
||||||
|
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
||||||
|
&m.Lang, &m.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) {
|
||||||
|
return AdminMessage{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return AdminMessage{}, fmt.Errorf("feedback: admin get %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if repliedAt.Valid {
|
||||||
|
m.RepliedAt = repliedAt.Time
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnread counts the active (unread, not archived) feedback queue, for the
|
||||||
|
// console dashboard.
|
||||||
|
func (s *Store) CountUnread(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM backend.feedback_messages WHERE read_at IS NULL AND archived_at IS NULL`,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("feedback: count unread: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ type gameCache struct {
|
|||||||
|
|
||||||
type cachedGame struct {
|
type cachedGame struct {
|
||||||
game *engine.Game
|
game *engine.Game
|
||||||
|
seats []Seat
|
||||||
variant string
|
variant string
|
||||||
lastAccess time.Time
|
lastAccess time.Time
|
||||||
}
|
}
|
||||||
@@ -71,24 +72,27 @@ func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
|
|||||||
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
|
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get returns the live game for id and refreshes its idle timer, or (nil, false).
|
// get returns the live game and its immutable seat list for id and refreshes its idle
|
||||||
func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
|
// timer, or (nil, nil, false). The seats let a read check membership (and label seats)
|
||||||
|
// without re-loading the game from the store, since seats never change after a game starts.
|
||||||
|
func (c *gameCache) get(id uuid.UUID) (*engine.Game, []Seat, bool) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
e, ok := c.entries[id]
|
e, ok := c.entries[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
e.lastAccess = c.now()
|
e.lastAccess = c.now()
|
||||||
return e.game, true
|
return e.game, e.seats, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// put stores g as the live game for id. variant labels the entry so the active-
|
// put stores g as the live game for id together with its seat list. variant labels the
|
||||||
// games gauge can report counts by variant without inspecting engine internals.
|
// entry so the active-games gauge can report counts by variant without inspecting engine
|
||||||
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
|
// internals; seats are the game's immutable seat standings for the membership fast path.
|
||||||
|
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string, seats []Seat) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
|
c.entries[id] = &cachedGame{game: g, seats: seats, variant: variant, lastAccess: c.now()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove drops id from the cache (used on a finished game and after a failed
|
// remove drops id from the cache (used on a finished game and after a failed
|
||||||
|
|||||||
@@ -16,5 +16,5 @@
|
|||||||
// word-check tool with complaint capture, per-player game state, history and GCG
|
// word-check tool with complaint capture, per-player game state, history and GCG
|
||||||
// export, and the per-game turn-timeout sweeper that auto-resigns an overdue
|
// 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
|
// 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
|
package game
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type DraftTile struct {
|
|||||||
Blank bool `json:"blank"`
|
Blank bool `json:"blank"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draft is a player's persisted client-side composition for a game (Stage 17): the
|
// Draft is a player's persisted client-side composition for a game: the
|
||||||
// preferred rack tile order and the board tiles laid but not yet submitted. The server
|
// preferred rack tile order and the board tiles laid but not yet submitted. The server
|
||||||
// keeps it so a reload or a second device resumes the same arrangement.
|
// keeps it so a reload or a second device resumes the same arrangement.
|
||||||
type Draft struct {
|
type Draft struct {
|
||||||
@@ -101,7 +101,7 @@ func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) err
|
|||||||
|
|
||||||
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
|
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
|
||||||
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
|
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
|
||||||
// rack order is kept (Stage 17 #6).
|
// rack order is kept.
|
||||||
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
|
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
|
||||||
if len(cells) == 0 {
|
if len(cells) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package game
|
package game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// recordingPublisher captures every published intent for assertions.
|
// recordingPublisher captures every published intent for assertions.
|
||||||
@@ -29,13 +31,17 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
|
|||||||
ToMove: 1,
|
ToMove: 1,
|
||||||
TurnStartedAt: time.Now(),
|
TurnStartedAt: time.Now(),
|
||||||
TurnTimeout: time.Hour,
|
TurnTimeout: time.Hour,
|
||||||
Seats: []Seat{{Seat: 0, AccountID: actor}, {Seat: 1, AccountID: opp}},
|
Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}},
|
||||||
}
|
}
|
||||||
svc.emitMove(g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Score: 10, Total: 10})
|
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80)
|
||||||
|
|
||||||
kinds := map[uuid.UUID][]string{}
|
kinds := map[uuid.UUID][]string{}
|
||||||
|
var yourTurn notify.Intent
|
||||||
for _, in := range pub.intents {
|
for _, in := range pub.intents {
|
||||||
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
|
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
|
||||||
|
if in.UserID == opp && in.Kind == notify.KindYourTurn {
|
||||||
|
yourTurn = in
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
|
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
|
||||||
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
|
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
|
||||||
@@ -49,4 +55,51 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
|
|||||||
if slices.Contains(kinds[actor], notify.KindYourTurn) {
|
if slices.Contains(kinds[actor], notify.KindYourTurn) {
|
||||||
t.Errorf("actor is not next to move, should not get your_turn")
|
t.Errorf("actor is not next to move, should not get your_turn")
|
||||||
}
|
}
|
||||||
|
// The your_turn push is enriched: the last move's action and word, and a recipient-first
|
||||||
|
// score line (the next mover, seat 1, first). The opponent name needs the account store and
|
||||||
|
// is left empty by this store-less unit (covered at the render layer).
|
||||||
|
yt := fb.GetRootAsYourTurnEvent(yourTurn.Payload, 0)
|
||||||
|
if got := string(yt.LastAction()); got != "play" {
|
||||||
|
t.Errorf("your_turn last_action = %q, want play", got)
|
||||||
|
}
|
||||||
|
if got := string(yt.LastWord()); got != "HELLO" {
|
||||||
|
t.Errorf("your_turn last_word = %q, want HELLO", got)
|
||||||
|
}
|
||||||
|
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
|
||||||
|
t.Errorf("your_turn score_line = %q, want 13:19", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat,
|
||||||
|
// each with its own outcome and a recipient-first final score.
|
||||||
|
func TestEmitMoveAnnouncesGameOver(t *testing.T) {
|
||||||
|
winner, loser := uuid.New(), uuid.New()
|
||||||
|
pub := &recordingPublisher{}
|
||||||
|
svc := &Service{pub: pub}
|
||||||
|
g := Game{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Status: StatusFinished,
|
||||||
|
Players: 2,
|
||||||
|
EndReason: "out_of_tiles",
|
||||||
|
Seats: []Seat{{Seat: 0, AccountID: winner, Score: 120, IsWinner: true}, {Seat: 1, AccountID: loser, Score: 95}},
|
||||||
|
}
|
||||||
|
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Total: 120}, 0)
|
||||||
|
|
||||||
|
over := map[uuid.UUID]notify.Intent{}
|
||||||
|
for _, in := range pub.intents {
|
||||||
|
if in.Kind == notify.KindGameOver {
|
||||||
|
over[in.UserID] = in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(over) != 2 {
|
||||||
|
t.Fatalf("game_over should reach both seats, got %d", len(over))
|
||||||
|
}
|
||||||
|
w := fb.GetRootAsGameOverEvent(over[winner].Payload, 0)
|
||||||
|
if string(w.Result()) != "won" || string(w.ScoreLine()) != "120:95" {
|
||||||
|
t.Errorf("winner game_over = %q / %q, want won / 120:95", w.Result(), w.ScoreLine())
|
||||||
|
}
|
||||||
|
l := fb.GetRootAsGameOverEvent(over[loser].Payload, 0)
|
||||||
|
if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" {
|
||||||
|
t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mappers below project the game domain into the wire-agnostic notify.* input
|
||||||
|
// structs the enriched live events carry. They keep the wire schema out of the
|
||||||
|
// game package: notify owns the FlatBuffers encoding, this file only resolves the
|
||||||
|
// values (seat display names, last-activity sort key) into its input shapes.
|
||||||
|
|
||||||
|
// gameSummary projects a game.Game into the notify.GameSummary embedded in enriched
|
||||||
|
// events. names is the seat-indexed display-name slice from seatNames; LastActivityUnix
|
||||||
|
// mirrors the gateway view (the current turn's start while active, the finish time once
|
||||||
|
// finished).
|
||||||
|
func gameSummary(g Game, names []string) notify.GameSummary {
|
||||||
|
seats := make([]notify.SeatStanding, 0, len(g.Seats))
|
||||||
|
for _, s := range g.Seats {
|
||||||
|
name := ""
|
||||||
|
if s.Seat >= 0 && s.Seat < len(names) {
|
||||||
|
name = names[s.Seat]
|
||||||
|
}
|
||||||
|
// An open game's still-empty opponent seat carries no account: send an empty id
|
||||||
|
// (not the nil-UUID string) so the client renders it as "searching for opponent".
|
||||||
|
accountID := ""
|
||||||
|
if s.AccountID != uuid.Nil {
|
||||||
|
accountID = s.AccountID.String()
|
||||||
|
}
|
||||||
|
seats = append(seats, notify.SeatStanding{
|
||||||
|
Seat: s.Seat,
|
||||||
|
AccountID: accountID,
|
||||||
|
DisplayName: name,
|
||||||
|
Score: s.Score,
|
||||||
|
HintsUsed: s.HintsUsed,
|
||||||
|
IsWinner: s.IsWinner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
last := g.TurnStartedAt
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
last = *g.FinishedAt
|
||||||
|
}
|
||||||
|
return notify.GameSummary{
|
||||||
|
ID: g.ID.String(),
|
||||||
|
Variant: g.Variant.String(),
|
||||||
|
DictVersion: g.DictVersion,
|
||||||
|
Status: g.Status,
|
||||||
|
Players: g.Players,
|
||||||
|
ToMove: g.ToMove,
|
||||||
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
|
VsAI: g.VsAI,
|
||||||
|
MoveCount: g.MoveCount,
|
||||||
|
EndReason: g.EndReason,
|
||||||
|
Seats: seats,
|
||||||
|
LastActivityUnix: last.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playerState projects a StateView into the notify.PlayerState carried by the
|
||||||
|
// match_found / game_started events. The rack is re-encoded to wire alphabet indices;
|
||||||
|
// the variant alphabet display table is embedded when includeAlphabet is set (an
|
||||||
|
// initial view whose recipient may not have cached the variant yet).
|
||||||
|
func playerState(v StateView, names []string, includeAlphabet bool) (notify.PlayerState, error) {
|
||||||
|
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
|
||||||
|
if err != nil {
|
||||||
|
return notify.PlayerState{}, err
|
||||||
|
}
|
||||||
|
ps := notify.PlayerState{
|
||||||
|
Game: gameSummary(v.Game, names),
|
||||||
|
Seat: v.Seat,
|
||||||
|
Rack: rack,
|
||||||
|
BagLen: v.BagLen,
|
||||||
|
HintsRemaining: v.HintsRemaining,
|
||||||
|
WalletBalance: v.WalletBalance,
|
||||||
|
}
|
||||||
|
if includeAlphabet {
|
||||||
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
if err != nil {
|
||||||
|
return notify.PlayerState{}, err
|
||||||
|
}
|
||||||
|
ps.Alphabet = make([]notify.AlphabetLetter, len(tab))
|
||||||
|
for i, e := range tab {
|
||||||
|
ps.Alphabet[i] = notify.AlphabetLetter{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ps, nil
|
||||||
|
}
|
||||||
@@ -36,6 +36,11 @@ func writeGCG(g Game, names []string, moves []HistoryMove) string {
|
|||||||
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
|
fmt.Fprintf(&b, "#note %s timed out (rack %s)\n", nick(mv.Seat), rack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An aborted game ends in a draw because it could no longer be reconstructed; record it
|
||||||
|
// as an impersonal organizer note (free-text #note; GCG readers ignore pragmas).
|
||||||
|
if g.EndReason == "aborted" {
|
||||||
|
fmt.Fprintln(&b, "#note [organizer] game could not be continued and was ended in a draw")
|
||||||
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
|
|||||||
"#character-encoding UTF-8",
|
"#character-encoding UTF-8",
|
||||||
"#player1 p1 Alice",
|
"#player1 p1 Alice",
|
||||||
"#player2 p2 Bob",
|
"#player2 p2 Bob",
|
||||||
"#lexicon english/v1",
|
"#lexicon scrabble_en/v1",
|
||||||
"#title game 00000000-0000-7000-8000-000000000001",
|
"#title game 00000000-0000-7000-8000-000000000001",
|
||||||
">p1: CATSER? 8H CAT +10 10",
|
">p1: CATSER? 8H CAT +10 10",
|
||||||
">p2: AS?E I8 .s +2 2",
|
">p2: AS?E I8 .s +2 2",
|
||||||
@@ -61,6 +61,21 @@ func TestWriteGCG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteGCGAbortedNote(t *testing.T) {
|
||||||
|
g := Game{
|
||||||
|
ID: uuid.MustParse("00000000-0000-7000-8000-000000000002"),
|
||||||
|
Variant: engine.VariantErudit,
|
||||||
|
DictVersion: "v1",
|
||||||
|
Players: 2,
|
||||||
|
Status: StatusFinished,
|
||||||
|
EndReason: "aborted",
|
||||||
|
}
|
||||||
|
out := writeGCG(g, []string{"Alice", "Bob"}, nil)
|
||||||
|
if !strings.Contains(out, "#note [organizer]") {
|
||||||
|
t.Errorf("aborted game GCG missing the organizer note:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGCGTilesUppercasesCyrillic(t *testing.T) {
|
func TestGCGTilesUppercasesCyrillic(t *testing.T) {
|
||||||
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
|
if got := gcgTiles([]string{"к", "о", "т", "?"}); got != "КОТ?" {
|
||||||
t.Errorf("gcgTiles = %q, want КОТ?", got)
|
t.Errorf("gcgTiles = %q, want КОТ?", got)
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ func TestGameCacheEviction(t *testing.T) {
|
|||||||
cur := time.Unix(1_700_000_000, 0)
|
cur := time.Unix(1_700_000_000, 0)
|
||||||
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
cache.put(id, nil, "english")
|
cache.put(id, nil, "scrabble_en", nil)
|
||||||
if _, ok := cache.get(id); !ok {
|
if _, _, ok := cache.get(id); !ok {
|
||||||
t.Fatal("game must be resident after put")
|
t.Fatal("game must be resident after put")
|
||||||
}
|
}
|
||||||
cur = cur.Add(30 * time.Minute)
|
cur = cur.Add(30 * time.Minute)
|
||||||
@@ -104,7 +104,7 @@ func TestGameCacheEviction(t *testing.T) {
|
|||||||
if n := cache.sweep(); n != 1 {
|
if n := cache.sweep(); n != 1 {
|
||||||
t.Errorf("sweep evicted %d, want 1", n)
|
t.Errorf("sweep evicted %d, want 1", n)
|
||||||
}
|
}
|
||||||
if _, ok := cache.get(id); ok {
|
if _, _, ok := cache.get(id); ok {
|
||||||
t.Error("game must be evicted after idle TTL")
|
t.Error("game must be evicted after idle TTL")
|
||||||
}
|
}
|
||||||
if cache.size() != 0 {
|
if cache.size() != 0 {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const meterName = "scrabble/backend/game"
|
const meterName = "scrabble/backend/game"
|
||||||
|
|
||||||
// gameMetrics holds the game domain's operational instruments. Every game-scoped
|
// gameMetrics holds the game domain's operational instruments. Every game-scoped
|
||||||
// measurement carries a "variant" attribute (english/russian/erudit). The
|
// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The
|
||||||
// instruments default to no-ops (see defaultGameMetrics), so recording is always
|
// instruments default to no-ops (see defaultGameMetrics), so recording is always
|
||||||
// safe; SetMetrics installs the real meter during startup wiring.
|
// safe; SetMetrics installs the real meter during startup wiring.
|
||||||
type gameMetrics struct {
|
type gameMetrics struct {
|
||||||
@@ -101,15 +101,22 @@ func phaseOf(moveCount int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordStarted counts one started game of variant.
|
// recordStarted counts one started game of variant, split by whether it is an
|
||||||
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
// honest-AI game (the vs_ai attribute), so AI and human games chart separately.
|
||||||
m.started.Add(ctx, 1, variantAttr(v))
|
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant, vsAI bool) {
|
||||||
|
m.started.Add(ctx, 1, gameKindAttr(v, vsAI))
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
|
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
|
||||||
// variant.
|
// variant, split by the vs_ai attribute (an AI game's abandon is the 7-day rule).
|
||||||
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant) {
|
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant, vsAI bool) {
|
||||||
m.abandoned.Add(ctx, 1, variantAttr(v))
|
m.abandoned.Add(ctx, 1, gameKindAttr(v, vsAI))
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameKindAttr is the (variant, vs_ai) attribute set shared by the started and
|
||||||
|
// abandoned counters so AI and human games are split on the same labels.
|
||||||
|
func gameKindAttr(v engine.Variant, vsAI bool) metric.MeasurementOption {
|
||||||
|
return metric.WithAttributes(attribute.String("variant", v.String()), attribute.Bool("vs_ai", vsAI))
|
||||||
}
|
}
|
||||||
|
|
||||||
// variantAttr is the shared "variant" attribute option, usable for both Record and
|
// variantAttr is the shared "variant" attribute option, usable for both Record and
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||||
m := newGameMetrics(meter)
|
m := newGameMetrics(meter)
|
||||||
|
|
||||||
m.recordStarted(ctx, engine.VariantEnglish)
|
m.recordStarted(ctx, engine.VariantEnglish, false)
|
||||||
m.recordStarted(ctx, engine.VariantEnglish)
|
m.recordStarted(ctx, engine.VariantEnglish, false)
|
||||||
m.recordStarted(ctx, engine.VariantRussianScrabble)
|
m.recordStarted(ctx, engine.VariantRussianScrabble, false)
|
||||||
m.recordAbandoned(ctx, engine.VariantErudit)
|
m.recordStarted(ctx, engine.VariantEnglish, true) // an honest-AI game
|
||||||
|
m.recordAbandoned(ctx, engine.VariantErudit, false)
|
||||||
|
m.recordAbandoned(ctx, engine.VariantEnglish, true) // an AI game abandoned (the 7-day rule)
|
||||||
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
|
||||||
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
|
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
|
||||||
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
|
m.recordMoveDuration(ctx, engine.VariantEnglish, 3, 5*time.Second)
|
||||||
@@ -35,11 +37,15 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
started := counterByAttr(t, rm, "games_started_total", "variant")
|
started := counterByAttr(t, rm, "games_started_total", "variant")
|
||||||
if started["english"] != 2 || started["russian_scrabble"] != 1 {
|
if started["scrabble_en"] != 3 || started["scrabble_ru"] != 1 {
|
||||||
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
|
t.Errorf("games_started_total = %v, want scrabble_en:3 scrabble_ru:1", started)
|
||||||
}
|
}
|
||||||
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 {
|
// The vs_ai attribute splits AI from human games for the per-kind Grafana panels.
|
||||||
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
|
if byKind := counterByAttr(t, rm, "games_started_total", "vs_ai"); byKind["false"] != 3 || byKind["true"] != 1 {
|
||||||
|
t.Errorf("games_started_total by vs_ai = %v, want false:3 true:1", byKind)
|
||||||
|
}
|
||||||
|
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "vs_ai"); abandoned["false"] != 1 || abandoned["true"] != 1 {
|
||||||
|
t.Errorf("games_abandoned_total by vs_ai = %v, want false:1 true:1", abandoned)
|
||||||
}
|
}
|
||||||
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
|
||||||
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
t.Errorf("game_replay_duration observations = %d, want 1", c)
|
||||||
@@ -78,7 +84,7 @@ func counterByAttr(t *testing.T, rm metricdata.ResourceMetrics, name, attr strin
|
|||||||
}
|
}
|
||||||
for _, dp := range sum.DataPoints {
|
for _, dp := range sum.DataPoints {
|
||||||
v, _ := dp.Attributes.Value(attribute.Key(attr))
|
v, _ := dp.Attributes.Value(attribute.Key(attr))
|
||||||
out[v.AsString()] += dp.Value
|
out[v.Emit()] += dp.Value // Emit renders any attribute type (string or bool) to a key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReplayStep is one step of an admin game replay: the move that produced it (nil for the
|
||||||
|
// initial dealt state, step 0) and the resulting position — every seat's rack, the running
|
||||||
|
// scores, whose turn it is and the bag remainder. Step k's board is the union of every
|
||||||
|
// play's placements through step k, which the renderer accumulates onto an empty grid
|
||||||
|
// (docs/ARCHITECTURE.md §9.1 visual replay).
|
||||||
|
type ReplayStep struct {
|
||||||
|
// Move is the journalled move that produced this state, or nil for the initial deal.
|
||||||
|
Move *HistoryMove
|
||||||
|
// Drawn lists the tiles the mover drew from the bag after this move ("?" for a blank);
|
||||||
|
// empty for the initial deal, a pass or a resignation.
|
||||||
|
Drawn []string
|
||||||
|
// Racks holds every seat's rack at this step, indexed by seat ("?" for a blank).
|
||||||
|
Racks [][]string
|
||||||
|
// Scores holds every seat's running score, indexed by seat.
|
||||||
|
Scores []int
|
||||||
|
// ToMove is the seat to move at this step.
|
||||||
|
ToMove int
|
||||||
|
// BagLen is the number of tiles left in the bag at this step.
|
||||||
|
BagLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayTimelineView is the admin replay of a game: the persisted game plus the ordered
|
||||||
|
// replay steps (the initial deal followed by one step per journalled move).
|
||||||
|
type ReplayTimelineView struct {
|
||||||
|
Game Game
|
||||||
|
Steps []ReplayStep
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayTimeline rebuilds a game from its pinned seed and journal and returns the ordered
|
||||||
|
// replay steps for the admin console: the initial deal (step 0) then one step per
|
||||||
|
// journalled move, each carrying the resulting racks, scores, turn cursor, bag size and the
|
||||||
|
// tiles the mover drew. The deterministic bag makes the reconstruction exact. It needs no
|
||||||
|
// dictionary beyond the engine the seed deals, and — like the live replay — stops early if a
|
||||||
|
// committed move became illegal under tightened rules rather than failing.
|
||||||
|
func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (ReplayTimelineView, error) {
|
||||||
|
pre, err := svc.store.GetGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return ReplayTimelineView{}, err
|
||||||
|
}
|
||||||
|
seed, err := svc.store.GameSeed(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return ReplayTimelineView{}, err
|
||||||
|
}
|
||||||
|
g, err := engine.New(svc.registry, engine.Options{
|
||||||
|
Variant: pre.Variant,
|
||||||
|
Version: pre.DictVersion,
|
||||||
|
Players: pre.Players,
|
||||||
|
Seed: seed,
|
||||||
|
DropoutTiles: pre.DropoutTiles,
|
||||||
|
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ReplayTimelineView{}, err
|
||||||
|
}
|
||||||
|
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return ReplayTimelineView{}, err
|
||||||
|
}
|
||||||
|
steps := make([]ReplayStep, 0, len(moves)+1)
|
||||||
|
steps = append(steps, snapshotStep(g, nil, nil))
|
||||||
|
for i := range moves {
|
||||||
|
mv := moves[i]
|
||||||
|
before := g.Hand(mv.Seat)
|
||||||
|
if err := replayMove(g, mv); err != nil {
|
||||||
|
if errors.Is(err, engine.ErrIllegalPlay) {
|
||||||
|
g.Abort()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return ReplayTimelineView{}, fmt.Errorf("game: replay-timeline %s move %d: %w", gameID, mv.Seq, err)
|
||||||
|
}
|
||||||
|
moveCopy := mv
|
||||||
|
steps = append(steps, snapshotStep(g, &moveCopy, drawnTiles(before, g.Hand(mv.Seat), usedTiles(mv))))
|
||||||
|
}
|
||||||
|
return ReplayTimelineView{Game: pre, Steps: steps}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotStep captures the position after applying move (nil for the initial deal): every
|
||||||
|
// seat's rack and score, the turn cursor and the bag size, with the supplied drawn tiles.
|
||||||
|
func snapshotStep(g *engine.Game, move *HistoryMove, drawn []string) ReplayStep {
|
||||||
|
n := g.Players()
|
||||||
|
racks := make([][]string, n)
|
||||||
|
scores := make([]int, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
racks[i] = g.Hand(i)
|
||||||
|
scores[i] = g.Score(i)
|
||||||
|
}
|
||||||
|
return ReplayStep{Move: move, Drawn: drawn, Racks: racks, Scores: scores, ToMove: g.ToMove(), BagLen: g.BagLen()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usedTiles returns the rack tiles a move consumed ("?" for a blank): the placed tiles of a
|
||||||
|
// play or the swapped tiles of an exchange; a pass or resignation consumes none.
|
||||||
|
func usedTiles(mv HistoryMove) []string {
|
||||||
|
switch mv.Action {
|
||||||
|
case "play":
|
||||||
|
used := make([]string, len(mv.Tiles))
|
||||||
|
for i, t := range mv.Tiles {
|
||||||
|
if t.Blank {
|
||||||
|
used[i] = "?" // a placed blank leaves the rack as the blank marker
|
||||||
|
} else {
|
||||||
|
used[i] = t.Letter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return used
|
||||||
|
case "exchange":
|
||||||
|
return mv.Exchanged
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawnTiles returns the tiles the mover drew from the bag: the post-move rack (after) minus
|
||||||
|
// the tiles kept (before minus used). It compares the racks as multisets, so duplicate
|
||||||
|
// letters are counted correctly.
|
||||||
|
func drawnTiles(before, after, used []string) []string {
|
||||||
|
kept := make(map[string]int, len(before))
|
||||||
|
for _, t := range before {
|
||||||
|
kept[t]++
|
||||||
|
}
|
||||||
|
for _, t := range used {
|
||||||
|
if kept[t] > 0 {
|
||||||
|
kept[t]--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var drawn []string
|
||||||
|
for _, t := range after {
|
||||||
|
if kept[t] > 0 {
|
||||||
|
kept[t]--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
drawn = append(drawn, t)
|
||||||
|
}
|
||||||
|
return drawn
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUsedTiles(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mv HistoryMove
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"pass", HistoryMove{Action: "pass"}, nil},
|
||||||
|
{"resign", HistoryMove{Action: "resign"}, nil},
|
||||||
|
{"play with blank", HistoryMove{Action: "play", Tiles: []engine.TileRecord{{Letter: "a"}, {Letter: "b", Blank: true}}}, []string{"a", "?"}},
|
||||||
|
{"exchange", HistoryMove{Action: "exchange", Exchanged: []string{"a", "?"}}, []string{"a", "?"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := usedTiles(tt.mv); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("usedTiles = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawnTiles(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
before, used []string
|
||||||
|
after []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"play refill", []string{"a", "b", "c", "d"}, []string{"a", "b"}, []string{"c", "d", "e", "f"}, []string{"e", "f"}},
|
||||||
|
{"blank played", []string{"?", "a"}, []string{"?"}, []string{"a", "x"}, []string{"x"}},
|
||||||
|
{"pass keeps rack", []string{"a", "b"}, nil, []string{"a", "b"}, nil},
|
||||||
|
{"duplicate letters", []string{"e", "e", "e"}, []string{"e"}, []string{"e", "e", "q"}, []string{"q"}},
|
||||||
|
{"empty bag no refill", []string{"a", "b"}, []string{"a"}, []string{"b"}, nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := drawnTiles(tt.before, tt.after, tt.used); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("drawnTiles = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSeatNamesUsesSnapshot checks seatNames returns each seat's stored display-name
|
||||||
|
// snapshot directly, without consulting the account store (svc.accounts is nil here, so
|
||||||
|
// any lookup would be skipped or panic).
|
||||||
|
func TestSeatNamesUsesSnapshot(t *testing.T) {
|
||||||
|
svc := &Service{}
|
||||||
|
g := Game{
|
||||||
|
Players: 2,
|
||||||
|
Seats: []Seat{
|
||||||
|
{Seat: 0, AccountID: uuid.New(), DisplayName: "Аня2007"},
|
||||||
|
{Seat: 1, AccountID: uuid.New(), DisplayName: "DarkWolf"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := svc.seatNames(context.Background(), g)
|
||||||
|
want := []string{"Аня2007", "DarkWolf"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("seatNames = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeatDisplayNameFallbackEmpty checks the resolver yields "" — rather than panicking
|
||||||
|
// — for a seat with no snapshot when no account store is available (the empty open seat
|
||||||
|
// and pre-snapshot legacy-row paths).
|
||||||
|
func TestSeatDisplayNameFallbackEmpty(t *testing.T) {
|
||||||
|
svc := &Service{}
|
||||||
|
if got := svc.seatDisplayName(context.Background(), Seat{Seat: 0, AccountID: uuid.New()}); got != "" {
|
||||||
|
t.Errorf("seatDisplayName with no snapshot and no store = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := svc.seatDisplayName(context.Background(), Seat{Seat: 1, AccountID: uuid.Nil}); got != "" {
|
||||||
|
t.Errorf("empty-seat name = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+814
-117
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