Compare commits
372 Commits
8c8f8c4d42
..
v1.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f95a6cb9c8 | |||
| 5d677cb282 | |||
| c9a1eee510 | |||
| 83e9a90d40 | |||
| 356f490546 | |||
| 6b6baf5710 | |||
| b720907db2 | |||
| 34385240b9 | |||
| 3fd279cf8c | |||
| 5928be40b0 | |||
| e16076c89e | |||
| b8787a4123 | |||
| f5c2404123 | |||
| 353dff20c4 | |||
| 3632c2239f | |||
| 06c8039281 | |||
| 2b0b1c0035 | |||
| 35666e1705 | |||
| d3657fdf5c | |||
| 74683f294f | |||
| cdf616d6c4 | |||
| 2cb2b57cdb | |||
| 512ad4dfb9 | |||
| a420d6a2cd | |||
| f916d5e0ca | |||
| 29d1193a0a | |||
| 3899ffda0f | |||
| 10412fee8e | |||
| 3856b34f8a | |||
| 71b054227a | |||
| d0c1306d9b | |||
| 1bbf0bc654 | |||
| 4fd82335db | |||
| 54497374e4 | |||
| b15fd30c4f | |||
| f6bffd1f57 | |||
| 645a503532 | |||
| c94cd3c3bf | |||
| 09fec2b83c | |||
| 1d0bafaabb | |||
| c0b46a7ca6 | |||
| 635f2fd9fc | |||
| 6886efb6c0 | |||
| 831ecd0cab | |||
| 4a07d48a7b | |||
| dce3edacee | |||
| efbaf657c6 | |||
| 0ea35fe991 | |||
| ee8d4fd85e | |||
| 8700fbfae1 |
@@ -0,0 +1,376 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Single gated pipeline for the test contour. Gitea cannot express
|
||||||
|
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
|
||||||
|
# one workflow.
|
||||||
|
#
|
||||||
|
# Branch model (CLAUDE.md): feature branches are cut from `development`; a commit
|
||||||
|
# to a feature branch triggers nothing. The pipeline runs on a PR into
|
||||||
|
# `development` or `master` (the full test suite — the merge gate) and on a push
|
||||||
|
# to `development` (after a merge). The deploy job runs only for `development`
|
||||||
|
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
|
||||||
|
# workflow.
|
||||||
|
#
|
||||||
|
# Path-conditional jobs: `unit`/`integration`/`ui` run only when their
|
||||||
|
# code changed (the `changes` job decides). Because a skipped required check would
|
||||||
|
# block a merge under branch protection, the always-running `gate` job aggregates
|
||||||
|
# their results and is the ONLY required status check; it passes when every
|
||||||
|
# upstream job either succeeded or was skipped.
|
||||||
|
#
|
||||||
|
# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
|
||||||
|
# `--progress plain`) so the Gitea logs stay readable.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [development, master]
|
||||||
|
push:
|
||||||
|
branches: [development]
|
||||||
|
|
||||||
|
# The dictionary release the test suite validates against — the current
|
||||||
|
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
|
||||||
|
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||||
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||||
|
env:
|
||||||
|
DICT_VERSION: v1.3.0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
|
# irrelevant. It defaults to running everything when the diff cannot be computed.
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
outputs:
|
||||||
|
go: ${{ steps.filter.outputs.go }}
|
||||||
|
ui: ${{ steps.filter.outputs.ui }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect changed paths
|
||||||
|
id: filter
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
git fetch -q origin "${{ github.base_ref }}" || true
|
||||||
|
range="origin/${{ github.base_ref }}...HEAD"
|
||||||
|
else
|
||||||
|
before="${{ github.event.before }}"
|
||||||
|
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
|
||||||
|
range="HEAD~1...HEAD"
|
||||||
|
else
|
||||||
|
range="${before}...HEAD"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "comparison range: $range"
|
||||||
|
# Default to running everything; narrow only when the diff is computable.
|
||||||
|
go=true; ui=true
|
||||||
|
files="$(git diff --name-only "$range" 2>/dev/null || echo __DIFF_FAILED__)"
|
||||||
|
if [ "$files" != "__DIFF_FAILED__" ]; then
|
||||||
|
echo "changed files:"; echo "$files"
|
||||||
|
go=false; ui=false
|
||||||
|
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
||||||
|
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
||||||
|
# A workflow or deploy change re-runs everything as a safety net.
|
||||||
|
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
||||||
|
else
|
||||||
|
echo "diff failed; running all jobs"
|
||||||
|
fi
|
||||||
|
echo "selected: go=$go ui=$ui"
|
||||||
|
echo "go=$go" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "ui=$ui" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
unit:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.go == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
# The engine consumes the published scrabble-solver module from this Gitea;
|
||||||
|
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
|
||||||
|
GOPRIVATE: gitea.iliadenisov.ru/*
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Fetch dictionary DAWGs
|
||||||
|
run: |
|
||||||
|
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||||
|
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
run: |
|
||||||
|
unformatted="$(gofmt -l .)"
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: vet
|
||||||
|
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
env:
|
||||||
|
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...
|
||||||
|
|
||||||
|
integration:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.go == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
# Ryuk (testcontainers' reaper) does not start cleanly on every runner; the
|
||||||
|
# suite's TestMain terminates its own container, so disable it.
|
||||||
|
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||||
|
GOPRIVATE: gitea.iliadenisov.ru/*
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Fetch dictionary DAWGs
|
||||||
|
run: |
|
||||||
|
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||||
|
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.work
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Integration tests
|
||||||
|
# -count=1 disables the cache; -p=1 -parallel=1 keeps the container-backed
|
||||||
|
# tests serial; the 15-minute timeout bounds a stuck container pull.
|
||||||
|
env:
|
||||||
|
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
|
||||||
|
|
||||||
|
ui:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.ui == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
working-directory: ui
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.0.9
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: pnpm run check
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: pnpm run test:unit
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Bundle-size budget
|
||||||
|
run: node scripts/bundle-size.mjs
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: pnpm exec playwright install chromium webkit
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
- name: E2E smoke (mock)
|
||||||
|
run: pnpm run test:e2e
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
# gate is the single branch-protection required check. It always runs and passes
|
||||||
|
# only when each upstream job succeeded or was skipped (a path-filtered no-op),
|
||||||
|
# failing the merge if any actually failed or was cancelled.
|
||||||
|
gate:
|
||||||
|
needs: [unit, integration, ui]
|
||||||
|
if: always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Aggregate required checks
|
||||||
|
run: |
|
||||||
|
fail=
|
||||||
|
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do
|
||||||
|
name="${r%%:*}"; res="${r#*:}"
|
||||||
|
echo "$name = $res"
|
||||||
|
case "$res" in
|
||||||
|
success|skipped) ;;
|
||||||
|
*) echo "::error::$name=$res"; fail=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -z "$fail" ] || { echo "one or more required jobs failed"; exit 1; }
|
||||||
|
echo "all required jobs passed or were skipped"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
# Auto test-deploy on a PR into development and on the push that merges it.
|
||||||
|
# A PR into master is test-only (this job is skipped); prod deploy is manual.
|
||||||
|
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when
|
||||||
|
# some test jobs were path-skipped.
|
||||||
|
needs: [gate]
|
||||||
|
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
NO_COLOR: "1"
|
||||||
|
DOCKER_CLI_HINTS: "false"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and (re)deploy the test contour
|
||||||
|
working-directory: deploy
|
||||||
|
env:
|
||||||
|
# Sensitive values -> secrets; non-sensitive -> variables. The compose
|
||||||
|
# interpolates these unprefixed names (see deploy/.env.example).
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
|
||||||
|
AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
|
||||||
|
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
|
||||||
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
|
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||||
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
|
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
||||||
|
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||||
|
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||||
|
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
|
||||||
|
# The promo button reuses the UI's Mini App link variable.
|
||||||
|
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
|
# The test contour always uses Telegram's test environment — pinned here,
|
||||||
|
# not an operator variable. The prod workflow leaves it false.
|
||||||
|
TELEGRAM_TEST_ENV: "true"
|
||||||
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
|
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
|
||||||
|
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||||
|
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||||
|
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
|
||||||
|
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
|
||||||
|
DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
|
||||||
|
LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
|
||||||
|
run: |
|
||||||
|
# Seed the config files to a stable host path. The runner checks out into
|
||||||
|
# an ephemeral act workspace that is removed after the job, which would
|
||||||
|
# dangle the compose config bind mounts in the long-lived containers
|
||||||
|
# (e.g. Grafana then logs "no such file or directory"). Bind from a stable
|
||||||
|
# dir instead (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring).
|
||||||
|
conf="$HOME/.scrabble-deploy"
|
||||||
|
rm -rf "$conf"
|
||||||
|
mkdir -p "$conf"
|
||||||
|
cp -r caddy otelcol prometheus tempo grafana "$conf"/
|
||||||
|
export SCRABBLE_CONFIG_DIR="$conf"
|
||||||
|
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
|
||||||
|
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
|
||||||
|
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
|
||||||
|
# together so they always share the fresh CA (see deploy/gen-certs.sh).
|
||||||
|
bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
|
||||||
|
# App version for the About screen: the git tag if present, else the short SHA
|
||||||
|
# (the test checkout is shallow/untagged, so this is the SHA here — fine).
|
||||||
|
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
|
||||||
|
# The telegram-local profile brings the bot + its VPN sidecar; prod runs the
|
||||||
|
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
||||||
|
# main host omits both. Without the profile they would not start here.
|
||||||
|
docker compose --ansi never --profile telegram-local build --progress plain
|
||||||
|
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
||||||
|
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||||
|
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||||
|
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
||||||
|
# pick up the fresh config.
|
||||||
|
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
||||||
|
|
||||||
|
- name: Probe the landing, gateway and backend
|
||||||
|
run: |
|
||||||
|
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
|
||||||
|
if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
|
||||||
|
docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ &&
|
||||||
|
docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
|
||||||
|
echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
echo "probe failed; recent landing + gateway + backend logs:"
|
||||||
|
docker logs --tail 50 scrabble-landing || true
|
||||||
|
docker logs --tail 50 scrabble-gateway || true
|
||||||
|
docker logs --tail 50 scrabble-backend || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Probe the Telegram validator and bot liveness
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
# The gateway/backend probes cannot see a crash-looping validator or bot
|
||||||
|
# (the validator answers only internal gRPC; the bot long-polls + egresses
|
||||||
|
# through the VPN sidecar with no public ingress). Inspect the containers
|
||||||
|
# directly: each must be running, not restarting, with a stable restart
|
||||||
|
# count. A grace period lets the VPN handshake and the bot-link dial settle.
|
||||||
|
sleep 20
|
||||||
|
for name in scrabble-telegram-validator scrabble-telegram-bot; do
|
||||||
|
ok=
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)"
|
||||||
|
restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
|
||||||
|
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
||||||
|
c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||||
|
sleep 5
|
||||||
|
c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||||
|
if [ "$c1" = "$c2" ]; then
|
||||||
|
echo "$name healthy: status=$status restarts=$c2"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "$name still restarting ($c1 -> $c2); waiting"
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ -z "$ok" ]; then
|
||||||
|
echo "$name not healthy; recent logs:"
|
||||||
|
docker logs --tail 80 "$name" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Prune dangling images
|
||||||
|
if: always()
|
||||||
|
run: docker image prune -f
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
name: Tests · Go
|
|
||||||
|
|
||||||
# Fast unit tests for the Go side of the monorepo. Runs on every push and pull
|
|
||||||
# request whose path filter matches a Go source directory. The module list
|
|
||||||
# grows as new go.work modules (gateway, pkg/*, platform/*) are added by later
|
|
||||||
# stages.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'gateway/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'platform/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/go-unit.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'gateway/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'platform/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/go-unit.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
# The engine consumes the published scrabble-solver module from this Gitea;
|
|
||||||
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
|
|
||||||
# DICT_VERSION selects the dictionary DAWG release the engine tests load.
|
|
||||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
|
||||||
DICT_VERSION: v1.0.0
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fetch dictionary DAWGs
|
|
||||||
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
|
|
||||||
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
|
|
||||||
# sibling clone). They ship as a release artifact, one semver per set.
|
|
||||||
run: |
|
|
||||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
|
||||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.work
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: gofmt
|
|
||||||
run: |
|
|
||||||
unformatted="$(gofmt -l .)"
|
|
||||||
if [ -n "$unformatted" ]; then
|
|
||||||
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: vet
|
|
||||||
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
|
||||||
|
|
||||||
- name: test
|
|
||||||
# -count=1 disables the test cache so a green run never depends on a
|
|
||||||
# previous runner's cached state. BACKEND_DICT_DIR points the engine
|
|
||||||
# tests at the DAWGs fetched from the dictionary release.
|
|
||||||
env:
|
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
|
||||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
name: Tests · Integration
|
|
||||||
|
|
||||||
# Postgres-backed integration tests for the Go backend, gated behind the
|
|
||||||
# `integration` build tag. They spin a throwaway postgres:17-alpine container via
|
|
||||||
# testcontainers-go, which reaches the host Docker daemon through the socket the
|
|
||||||
# Gitea runner exposes. Slower than the unit job (go-unit.yaml); run serially
|
|
||||||
# (-p=1) with Ryuk disabled — TestMain terminates its own container. The module
|
|
||||||
# list grows as new go.work modules are added by later stages.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/integration.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'pkg/**'
|
|
||||||
- 'go.work'
|
|
||||||
- 'go.work.sum'
|
|
||||||
- '.gitea/workflows/integration.yaml'
|
|
||||||
- '!**/*.md'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
integration:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
# Ryuk (testcontainers' reaper) does not start cleanly on every runner;
|
|
||||||
# the suite's TestMain terminates its own container, so disable it.
|
|
||||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
|
||||||
# The engine consumes the published scrabble-solver module from this Gitea
|
|
||||||
# (GOPRIVATE -> direct fetch, skipping the public proxy/checksum DB);
|
|
||||||
# DICT_VERSION selects the dictionary DAWG release the engine tests load.
|
|
||||||
GOPRIVATE: gitea.iliadenisov.ru/*
|
|
||||||
DICT_VERSION: v1.0.0
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fetch dictionary DAWGs
|
|
||||||
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
|
|
||||||
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
|
|
||||||
# sibling clone). They ship as a release artifact; the engine's untagged
|
|
||||||
# tests (compiled here too) load them.
|
|
||||||
run: |
|
|
||||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
|
||||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.work
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Integration tests
|
|
||||||
# -count=1 disables the test cache; -p=1 -parallel=1 keeps the
|
|
||||||
# container-backed tests serial; the 15-minute timeout bounds a stuck
|
|
||||||
# container pull. The engine package's (untagged) tests also compile and
|
|
||||||
# run here, so BACKEND_DICT_DIR points them at the DAWGs from the release.
|
|
||||||
env:
|
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
|
||||||
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
|
|
||||||
@@ -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"
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
name: Tests · UI
|
|
||||||
|
|
||||||
# Hermetic UI checks: type-check, Vitest unit tests, production build with a
|
|
||||||
# bundle-size budget, and a Playwright smoke (Chromium + WebKit) against the in-memory
|
|
||||||
# mock transport (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not
|
|
||||||
# regenerated (the same model as the Go committed jet/fbs output).
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'ui/**'
|
|
||||||
- '.gitea/workflows/ui-test.yaml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'ui/**'
|
|
||||||
- '.gitea/workflows/ui-test.yaml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
working-directory: ui
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
run: npm install -g pnpm@11.0.9
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Type-check
|
|
||||||
run: pnpm run check
|
|
||||||
|
|
||||||
- name: Unit tests
|
|
||||||
run: pnpm run test:unit
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: pnpm run build
|
|
||||||
|
|
||||||
- name: Bundle-size budget
|
|
||||||
run: node scripts/bundle-size.mjs
|
|
||||||
|
|
||||||
# The Playwright system libraries are provisioned once on the runner host
|
|
||||||
# (`sudo npx playwright@<version> install-deps chromium`), so the job needs no
|
|
||||||
# apt and no sudo: it only downloads the browser binaries into the runner cache
|
|
||||||
# (persisted by the host executor) and runs the suite. WebKit's Debian build
|
|
||||||
# bundles most of its own libraries and runs headless without extra host deps; if
|
|
||||||
# a runner ever lacks one, provision it once on the host with
|
|
||||||
# `sudo npx playwright install-deps webkit`. The timeouts guard against a future
|
|
||||||
# hang. Keep this in lockstep with @playwright/test in package.json — re-run
|
|
||||||
# install-deps on the host after a major bump.
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: pnpm exec playwright install chromium webkit
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
- name: E2E smoke (mock)
|
|
||||||
run: pnpm run test:e2e
|
|
||||||
timeout-minutes: 5
|
|
||||||
@@ -16,3 +16,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,83 +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
|
||||||
|
|
||||||
- Trunk is **`master`** (owner preference). From Stage 1, work on `feature/*`
|
- **Two long-lived branches**: **`development`** is the integration branch; **`master`**
|
||||||
and merge via PR with a green CI gate. The genesis commit (Stage 0) lands on
|
is the production trunk. Cut `feature/*` from `development` and PR back into it;
|
||||||
`master` by necessity (an empty branch has nothing to PR into).
|
promote `development → master` via PR when ready to release. Both branches require
|
||||||
- After any push, watch the run to green before declaring a stage done — use the
|
one approval + the `CI / gate` check.
|
||||||
ready-made watcher, never an inline poll loop:
|
- A commit to a `feature/*` branch triggers nothing. The single workflow
|
||||||
`python3 ~/.claude/bin/gitea-ci-watch.py` (background). It reads `$GITEA_URL`
|
`.gitea/workflows/ci.yaml` runs the full suite (`unit` + `integration` + `ui`) on a
|
||||||
/ `$GITEA_TOKEN`; `gitea.iliadenisov.ru` is allow-listed in
|
PR into `development` or `master`, and the gated **`deploy`** job auto-rolls the
|
||||||
`.claude/settings.json`. Remote: `origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
|
**test contour** on a PR into — or a push to — `development`
|
||||||
|
(`docker compose up -d --build` on the runner host + landing/SPA/backend probes). A
|
||||||
|
PR into `master` is test-only.
|
||||||
|
- **Production is live on two hosts** (main + the Telegram bot host) and deploys
|
||||||
|
**only manually** (`workflow_dispatch`), never automatically:
|
||||||
|
- **`.gitea/workflows/prod-deploy.yaml`** (`confirm=deploy`, from `master`) builds +
|
||||||
|
pushes the images to the registry, then SSH-deploys both hosts — rolling per
|
||||||
|
service in dependency order, health-gated, **auto-rollback to the previous tag**;
|
||||||
|
a schema migration adds a maintenance window + a consistent `pg_dump`. Four visible
|
||||||
|
jobs: build → deploy-main → deploy-bot → verify.
|
||||||
|
- **`.gitea/workflows/prod-rollback.yaml`** (`confirm=rollback`) re-deploys a prior
|
||||||
|
release (blank `target_version` = the previous deployed version) — image-only,
|
||||||
|
rolling, health-gated.
|
||||||
|
- **Releases are git tags `vX.Y.Z` on `master`**; the deploy stamps `git describe
|
||||||
|
--tags` into the image tag, every binary (`pkg/version` via `-ldflags` → the
|
||||||
|
`service.version` telemetry attribute) and the SPA About screen. Tag the release
|
||||||
|
before deploying.
|
||||||
|
- Hosts are provisioned idempotently by **`deploy/ansible/`**. Per-contour
|
||||||
|
secrets/variables use the `TEST_` / `PROD_` prefix (Gitea 1.26 has no deployment
|
||||||
|
environments). Migrations must be **expand-contract** (backward-compatible) so
|
||||||
|
image rollback stays DB-safe. Full runbook + variable list in `deploy/README.md`.
|
||||||
|
- After any push, merge or deploy, **watch the run to green** before declaring done —
|
||||||
|
use the ready-made watcher (run it in the background), never an inline poll loop:
|
||||||
|
`python3 ~/.claude/bin/gitea-ci-watch.py`. It reads `$GITEA_URL` / `$GITEA_TOKEN`;
|
||||||
|
`gitea.iliadenisov.ru` is allow-listed in `.claude/settings.json`. Remote:
|
||||||
|
`origin git@gitea.iliadenisov.ru:developer/scrabble-game.git`.
|
||||||
|
|
||||||
## Stack
|
## 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
|
||||||
@@ -86,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)
|
||||||
@@ -110,9 +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
|
||||||
|
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
||||||
|
loadtest/ # module scrabble/loadtest: the load/stress harness
|
||||||
|
docs/ .gitea/workflows/ CLAUDE.md README.md
|
||||||
|
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||||
|
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
@@ -122,14 +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 --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
||||||
|
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||||
|
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
|
||||||
|
docker compose -f deploy/docker-compose.yml config # validate the full contour
|
||||||
```
|
```
|
||||||
|
|
||||||
The `ui` module is a Node project (pnpm), **not** in `go.work`; its CI is
|
The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` job of
|
||||||
`.gitea/workflows/ui-test.yaml`. Committed edge codegen under `ui/src/gen/`
|
the single `.gitea/workflows/ci.yaml`. 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
|
||||||
|
|
||||||
@@ -80,3 +78,24 @@ pnpm dev # against a running gateway (Vite proxies the RPC path to :8081)
|
|||||||
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
|
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
|
||||||
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
|
||||||
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
|
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
|
||||||
|
|
||||||
|
## Deploy (`deploy/`)
|
||||||
|
|
||||||
|
The full contour is [`deploy/docker-compose.yml`](deploy/docker-compose.yml):
|
||||||
|
`backend` + `gateway` (with the UI embedded via `go:embed`, baked in by its node
|
||||||
|
build stage) + Postgres + the Telegram connector (with a VPN sidecar) + an
|
||||||
|
observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a front
|
||||||
|
**caddy** that owns a single `/_gm` Basic-Auth (admin console + Grafana). The Go
|
||||||
|
services build from multi-stage distroless `*/Dockerfile`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
|
||||||
|
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
|
||||||
|
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
|
||||||
|
```
|
||||||
|
|
||||||
|
CI auto-deploys the **test contour** on a PR into — or push to — `development`
|
||||||
|
(`.gitea/workflows/ci.yaml`); the **prod contour** is a manual deploy after
|
||||||
|
`development → master`. Env reference: [`deploy/.env.example`](deploy/.env.example);
|
||||||
|
the topology and the two-contour model are in
|
||||||
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §13.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Multi-stage build for the backend service. Mirrors platform/telegram/Dockerfile:
|
||||||
|
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
||||||
|
#
|
||||||
|
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
||||||
|
# — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
||||||
|
# binary at them. The published solver module is fetched directly from Gitea
|
||||||
|
# (GOPRIVATE), so the build stage needs git and network.
|
||||||
|
#
|
||||||
|
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||||
|
# in the Docker context. DICT_VERSION has no default — the caller supplies the
|
||||||
|
# scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md
|
||||||
|
# "Bumping the dictionary version"):
|
||||||
|
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
|
||||||
|
|
||||||
|
# --- dictionary artifact -----------------------------------------------------
|
||||||
|
FROM alpine:3.20 AS dawg
|
||||||
|
ARG DICT_VERSION
|
||||||
|
RUN apk add --no-cache curl tar
|
||||||
|
RUN mkdir -p /dawg \
|
||||||
|
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||||
|
"https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \
|
||||||
|
&& tar xzf /tmp/dawg.tar.gz -C /dawg
|
||||||
|
|
||||||
|
# --- build -------------------------------------------------------------------
|
||||||
|
FROM golang:1.26.3-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
# git: the published solver module is fetched from Gitea directly (GOPRIVATE).
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ENV GOPRIVATE=gitea.iliadenisov.ru/*
|
||||||
|
|
||||||
|
COPY go.work go.work.sum ./
|
||||||
|
COPY pkg ./pkg
|
||||||
|
COPY backend ./backend
|
||||||
|
|
||||||
|
# Reduce the workspace to what the backend needs: backend + pkg. loadtest and the
|
||||||
|
# gateway replace it requires are not in this context, so drop both.
|
||||||
|
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0
|
||||||
|
# VERSION (the deploy passes the git tag) is stamped into the binary via the linker.
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/backend ./backend/cmd/backend
|
||||||
|
|
||||||
|
# --- runtime -----------------------------------------------------------------
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
# Re-declare the build arg in this stage so it labels the seed dictionary. One
|
||||||
|
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
|
||||||
|
# label the binary pins, so the resident version equals the release tag.
|
||||||
|
ARG DICT_VERSION
|
||||||
|
COPY --from=build /out/backend /usr/local/bin/backend
|
||||||
|
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||||
|
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||||
|
# 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_VERSION=${DICT_VERSION}
|
||||||
|
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||||
+141
-63
@@ -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 a human-like name pool. 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 right-skewed
|
win (≈ 40%), targets a small score margin — with an occasional off-strategy move that tapers to
|
||||||
delay, a night-sleep window anchored to the opponent's timezone, and nudge
|
none as the bag empties — and times its moves with a move-number-aware
|
||||||
|
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
|
||||||
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
behaviour — all derived deterministically from the game seed, so it keeps no extra
|
||||||
state. The matchmaker now substitutes a pooled robot after a 10-second wait and
|
state. 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
|
||||||
|
|
||||||
|
|||||||
+70
-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
|
||||||
@@ -132,14 +137,23 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
hub := notify.NewHub(0)
|
hub := notify.NewHub(0)
|
||||||
|
|
||||||
accounts := account.NewStore(db)
|
accounts := account.NewStore(db)
|
||||||
|
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||||
|
// 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)
|
||||||
@@ -147,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,
|
||||||
@@ -184,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,
|
||||||
@@ -192,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
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
@@ -25,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"
|
||||||
@@ -56,27 +55,32 @@ 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
|
||||||
|
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
|
||||||
|
// zero time for an unflagged account, otherwise when the gateway-reported
|
||||||
|
// rate-limiter rejections first crossed the sustained threshold. An
|
||||||
|
// operator clears it in the admin console; it never gates any request.
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -94,11 +98,16 @@ type Identity struct {
|
|||||||
// Store is the Postgres-backed query surface for accounts and identities.
|
// Store is the Postgres-backed query surface for accounts and identities.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
metrics *accountMetrics
|
||||||
|
// 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.
|
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
||||||
|
// SetMetrics installs the real one during startup wiring.
|
||||||
func NewStore(db *sql.DB) *Store {
|
func NewStore(db *sql.DB) *Store {
|
||||||
return &Store{db: db}
|
return &Store{db: db, metrics: defaultAccountMetrics(), suspensions: newSuspensionCache()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
||||||
@@ -110,13 +119,70 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
|||||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
// ProvisionEmail returns the account owning the email identity externalID, creating
|
||||||
// identity. On first contact only, it seeds the new account's preferred language
|
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
|
||||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
|
||||||
// its display name from firstName (falling back to username); an already-existing
|
// and leaves an existing account untouched, so a returning user's saved zone is never
|
||||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
// overwritten. The email account is created here (the code-request step), not at the
|
||||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
// later login, so this is where its zone is seeded.
|
||||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
|
||||||
|
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
|
||||||
|
// member: a KindRobot identity carrying displayName, with chat blocked but friend
|
||||||
|
// requests NOT blocked — a request to a robot is accepted as pending and, since the
|
||||||
|
// robot never responds, simply expires (friendRequestTTL), exactly mirroring a human
|
||||||
|
// who ignores the request. Robot names are system-generated, not player-edited, so they
|
||||||
|
// bypass the editable display-name validation and may carry forms the editor rejects (an
|
||||||
|
// abbreviated surname like "Peter J."). It is idempotent: repeated calls converge the
|
||||||
|
// display name and both block flags.
|
||||||
|
func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName string) (Account, error) {
|
||||||
|
acc, err := s.provision(ctx, KindRobot, externalID, provisionSeed{displayName: displayName})
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
if acc.DisplayName == displayName && acc.BlockChat && !acc.BlockFriendRequests {
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
stmt := table.Accounts.UPDATE(
|
||||||
|
table.Accounts.DisplayName, table.Accounts.BlockChat,
|
||||||
|
table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
postgres.String(displayName), postgres.Bool(true),
|
||||||
|
postgres.Bool(false), postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(acc.ID))).
|
||||||
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
|
var row model.Accounts
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Account{}, fmt.Errorf("account: provision robot %q: %w", externalID, err)
|
||||||
|
}
|
||||||
|
return modelToAccount(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity,
|
||||||
|
// reporting whether this call created it (first contact). On first contact only, it
|
||||||
|
// seeds the new account's preferred language from the Telegram client languageCode
|
||||||
|
// (when it maps to a supported language) and its display name sanitized from firstName
|
||||||
|
// (falling back to username, then to a generated placeholder when neither yields any
|
||||||
|
// letters); an already-existing account is returned unchanged, so a later profile edit
|
||||||
|
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
|
||||||
|
// chat write access on first registration — the path of a user who joined the chat
|
||||||
|
// before registering, whom no chat_member event covers.
|
||||||
|
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) {
|
||||||
|
// Pre-check whether the identity already exists so the caller can act on first
|
||||||
|
// contact. A race with a concurrent create only over- or under-reports created for
|
||||||
|
// that one call, which the idempotent chat-access re-evaluation tolerates.
|
||||||
|
_, err := s.findByIdentity(ctx, KindTelegram, externalID)
|
||||||
|
created := errors.Is(err, ErrNotFound)
|
||||||
|
if err != nil && !created {
|
||||||
|
return Account{}, false, err
|
||||||
|
}
|
||||||
|
seed := telegramSeed(languageCode, username, firstName)
|
||||||
|
seed.timeZone = seedZone(browserTZ)
|
||||||
|
acc, err := s.provision(ctx, KindTelegram, externalID, seed)
|
||||||
|
return acc, created, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// provision finds the account for (kind, externalID) or creates it with seed,
|
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||||
@@ -143,29 +209,50 @@ 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 from firstName or, failing that,
|
// region-tagged like "ru-RU"), and a display name. The name precedence is the real
|
||||||
// username (capped to maxDisplayName runes).
|
// name (firstName, sanitized to the editable format) → the @username taken verbatim
|
||||||
|
// (already a valid handle, only trimmed and length-capped, never character-stripped)
|
||||||
|
// → a generated placeholder in the seeded language (placeholderDisplayName), reached
|
||||||
|
// only when firstName has no usable letters and no username is set.
|
||||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||||
var seed provisionSeed
|
var seed provisionSeed
|
||||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
seed.preferredLanguage = lang
|
seed.preferredLanguage = lang
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(firstName)
|
name := sanitizeDisplayName(firstName)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
// 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)
|
name = strings.TrimSpace(username)
|
||||||
|
if r := []rune(name); len(r) > maxDisplayName {
|
||||||
|
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
|
||||||
}
|
}
|
||||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
}
|
||||||
name = string([]rune(name)[:maxDisplayName])
|
if name == "" {
|
||||||
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
}
|
}
|
||||||
seed.displayName = name
|
seed.displayName = name
|
||||||
return seed
|
return seed
|
||||||
@@ -259,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) {
|
||||||
@@ -297,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
|
||||||
@@ -331,6 +432,11 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
|
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
|
||||||
}
|
}
|
||||||
|
// Count genuinely new durable accounts; robots are a fixed provisioned pool,
|
||||||
|
// not users, so they are excluded.
|
||||||
|
if kind != KindRobot {
|
||||||
|
s.metrics.recordCreated(ctx, kind)
|
||||||
|
}
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,21 +446,28 @@ 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
|
||||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
||||||
}
|
}
|
||||||
|
s.metrics.recordCreated(ctx, kindGuest)
|
||||||
return modelToAccount(row), nil
|
return modelToAccount(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,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
|
||||||
}
|
}
|
||||||
@@ -406,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,
|
||||||
@@ -425,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).
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/metric"
|
||||||
|
"go.opentelemetry.io/otel/metric/noop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// meterName scopes the account domain's OpenTelemetry instruments.
|
||||||
|
const meterName = "scrabble/backend/account"
|
||||||
|
|
||||||
|
// kindGuest labels guest accounts in accounts_created_total. Guests carry no
|
||||||
|
// identity, so they have no identity Kind; this is the metric label for them.
|
||||||
|
const kindGuest = "guest"
|
||||||
|
|
||||||
|
// accountMetrics holds the account domain's operational instruments. It defaults
|
||||||
|
// to no-ops (see defaultAccountMetrics); SetMetrics installs the real meter during
|
||||||
|
// startup wiring.
|
||||||
|
type accountMetrics struct {
|
||||||
|
created metric.Int64Counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultAccountMetrics returns instruments backed by a no-op meter.
|
||||||
|
func defaultAccountMetrics() *accountMetrics {
|
||||||
|
return newAccountMetrics(noop.NewMeterProvider().Meter(meterName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAccountMetrics builds the instruments on meter, falling back to a no-op
|
||||||
|
// counter on the (rare) construction error.
|
||||||
|
func newAccountMetrics(meter metric.Meter) *accountMetrics {
|
||||||
|
c, err := meter.Int64Counter("accounts_created_total",
|
||||||
|
metric.WithDescription("New accounts created, labelled by kind (telegram/email/guest); robots are not counted."))
|
||||||
|
if err != nil {
|
||||||
|
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("accounts_created_total")
|
||||||
|
}
|
||||||
|
return &accountMetrics{created: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMetrics installs the meter the account store records to. It must be called
|
||||||
|
// during startup wiring; the default is a no-op meter.
|
||||||
|
func (s *Store) SetMetrics(meter metric.Meter) {
|
||||||
|
if meter == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.metrics = newAccountMetrics(meter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordCreated counts one newly created account of the given kind.
|
||||||
|
func (m *accountMetrics) recordCreated(ctx context.Context, kind string) {
|
||||||
|
m.created.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
|
||||||
|
}
|
||||||
@@ -4,14 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-jet/jet/v2/postgres"
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
"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"
|
||||||
@@ -21,14 +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 or trailing separator and no two adjacent separators,
|
// by a single space. No leading separator and no two adjacent separators (except
|
||||||
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
|
// "<dot|underscore> <space>"). The name may end with EITHER a single trailing "."
|
||||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
|
// (an initial, "Anna B.") OR a run of 1–5 digits (a handle's number or year,
|
||||||
|
// "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).
|
||||||
@@ -47,6 +59,46 @@ type ProfileUpdate struct {
|
|||||||
BlockChat bool
|
BlockChat bool
|
||||||
BlockFriendRequests bool
|
BlockFriendRequests bool
|
||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
|
// VariantPreferences is the set of game variants the player allows themselves to
|
||||||
|
// be matched into (engine.Variant stable labels). UpdateProfile cleans it to a
|
||||||
|
// deduplicated, canonically ordered subset of the known variants and rejects an
|
||||||
|
// empty set.
|
||||||
|
VariantPreferences []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownVariants is the closed set of game-variant labels (engine.Variant stable
|
||||||
|
// labels) a profile's variant preferences may contain. It lives here so the store
|
||||||
|
// does not depend on the engine package; the server handler additionally validates
|
||||||
|
// against engine.ParseVariant, and a DB check enforces the same subset.
|
||||||
|
var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true}
|
||||||
|
|
||||||
|
// canonicalVariantOrder is the deterministic order variant preferences are stored
|
||||||
|
// in (Erudit, Russian Scrabble, English), independent of the client's order.
|
||||||
|
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
|
||||||
|
|
||||||
|
// validateVariantPreferences cleans a profile's variant-preference set: it drops
|
||||||
|
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
|
||||||
|
// returns the preferences in canonicalVariantOrder so the stored value is
|
||||||
|
// deterministic regardless of the order the client sent.
|
||||||
|
func validateVariantPreferences(prefs []string) ([]string, error) {
|
||||||
|
seen := make(map[string]bool, len(prefs))
|
||||||
|
for _, p := range prefs {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if !knownVariants[p] {
|
||||||
|
return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p)
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
}
|
||||||
|
if len(seen) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for _, v := range canonicalVariantOrder {
|
||||||
|
if seen[v] {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||||
@@ -68,17 +120,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)
|
||||||
|
|
||||||
@@ -107,9 +168,51 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeDisplayName best-effort cleans a platform-supplied name (e.g. a Telegram
|
||||||
|
// first name) to the editable display-name format: it keeps the maximal runs of
|
||||||
|
// Unicode letters and joins them with a single space, dropping every other rune
|
||||||
|
// (emoji, digits, punctuation), then caps the result to maxDisplayName runes. The
|
||||||
|
// result therefore always satisfies ValidateDisplayName, or is empty when the input
|
||||||
|
// carries no letters — in which case the caller substitutes placeholderDisplayName.
|
||||||
|
// Mirroring the profile editor's rule means a connector-provisioned name is editable
|
||||||
|
// later without first failing validation.
|
||||||
|
func sanitizeDisplayName(raw string) string {
|
||||||
|
fields := strings.FieldsFunc(raw, func(r rune) bool { return !unicode.IsLetter(r) })
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := strings.Join(fields, " ")
|
||||||
|
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||||
|
name = strings.TrimRight(string([]rune(name)[:maxDisplayName]), " ")
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderDisplayName builds a fallback display name for a platform account whose
|
||||||
|
// supplied name had no usable letters: "Player-NNNNN" for lang "en" (the default) or
|
||||||
|
// "Игрок-NNNNN" for "ru", with five random digits. The generated name intentionally
|
||||||
|
// carries digits and a hyphen, so it lies outside the editable format and the player
|
||||||
|
// is expected to rename it; provisioned names bypass that editor validation.
|
||||||
|
func placeholderDisplayName(lang string) string {
|
||||||
|
prefix := "Player"
|
||||||
|
if lang == "ru" {
|
||||||
|
prefix = "Игрок"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%05d", prefix, rand.IntN(100000))
|
||||||
|
}
|
||||||
|
|
||||||
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
// validateAwayWindow checks that the daily away window's duration, wrapping across
|
||||||
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
|
||||||
// "no away time" and is allowed.
|
// "no away time" and is allowed.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -8,7 +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 trimming.
|
// the real-name → @username (verbatim) → placeholder display-name precedence, and
|
||||||
|
// the sanitization of the real name (emoji, digits, punctuation stripped to the
|
||||||
|
// editable format). The username, when used, is kept verbatim.
|
||||||
func TestTelegramSeed(t *testing.T) {
|
func TestTelegramSeed(t *testing.T) {
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
languageCode, username, firstName string
|
languageCode, username, firstName string
|
||||||
@@ -21,8 +24,12 @@ func TestTelegramSeed(t *testing.T) {
|
|||||||
"empty language": {"", "neo", "Neo", "", "Neo"},
|
"empty language": {"", "neo", "Neo", "", "Neo"},
|
||||||
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
|
||||||
"username fallback": {"en", "handle", "", "en", "handle"},
|
"username fallback": {"en", "handle", "", "en", "handle"},
|
||||||
"both empty": {"en", "", "", "en", ""},
|
|
||||||
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
|
||||||
|
"emoji stripped": {"en", "user", "🎮Kaya🎮", "en", "Kaya"},
|
||||||
|
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
|
||||||
|
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
|
||||||
|
"garbage to username": {"en", "good", "123!@#", "en", "good"},
|
||||||
|
"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) {
|
||||||
@@ -37,6 +44,28 @@ func TestTelegramSeed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTelegramSeedPlaceholder checks that a name with no usable letters falls back to
|
||||||
|
// a generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
|
||||||
|
func TestTelegramSeedPlaceholder(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
languageCode, username, firstName string
|
||||||
|
wantRe string
|
||||||
|
}{
|
||||||
|
"en empty": {"en", "", "", `^Player-\d{5}$`},
|
||||||
|
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
|
||||||
|
"default en": {"fr", "", "", `^Player-\d{5}$`},
|
||||||
|
"name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
|
||||||
|
}
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
got := telegramSeed(tc.languageCode, tc.username, tc.firstName).displayName
|
||||||
|
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
|
||||||
|
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
|
||||||
// maxDisplayName runes (counted in runes, not bytes).
|
// maxDisplayName runes (counted in runes, not bytes).
|
||||||
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserListItem is the admin user-list projection: a small subset of the account plus
|
||||||
|
// whether it is a robot (derived from its identities), so the console can label the kind
|
||||||
|
// without a per-row identity query.
|
||||||
|
type UserListItem struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
PreferredLanguage string
|
||||||
|
IsGuest bool
|
||||||
|
IsRobot bool
|
||||||
|
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
||||||
|
// as a badge in the console list.
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
||||||
|
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
||||||
|
// one char) matched case-insensitively against the display name / any identity's external
|
||||||
|
// id. An empty mask means no filter on that field.
|
||||||
|
type UserFilter struct {
|
||||||
|
Robots bool
|
||||||
|
NameMask string
|
||||||
|
ExternalIDMask string
|
||||||
|
}
|
||||||
|
|
||||||
|
// robotExists is the correlated subquery testing whether account a is a robot.
|
||||||
|
const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')`
|
||||||
|
|
||||||
|
// IsRobot reports whether the account is a robot pool member (it carries a robot
|
||||||
|
// identity). The admin console uses it to label a game's robot seats.
|
||||||
|
func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`,
|
||||||
|
accountID).Scan(&ok)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("account: is-robot %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userListWhere builds the shared WHERE clause and its positional args (from $1).
|
||||||
|
func userListWhere(f UserFilter) (string, []any) {
|
||||||
|
args := []any{f.Robots}
|
||||||
|
where := robotExists + ` = $1`
|
||||||
|
if name := LikePattern(f.NameMask); name != "" {
|
||||||
|
args = append(args, name)
|
||||||
|
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||||
|
}
|
||||||
|
if ext := LikePattern(f.ExternalIDMask); ext != "" {
|
||||||
|
args = append(args, ext)
|
||||||
|
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||||
|
}
|
||||||
|
return where, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns the filtered admin user list, newest first, paginated.
|
||||||
|
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
||||||
|
where, args := userListWhere(f)
|
||||||
|
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
|
||||||
|
FROM backend.accounts a WHERE ` + where +
|
||||||
|
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []UserListItem
|
||||||
|
for rows.Next() {
|
||||||
|
var it UserListItem
|
||||||
|
var flagged sql.NullTime
|
||||||
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||||
|
}
|
||||||
|
if flagged.Valid {
|
||||||
|
it.FlaggedHighRateAt = flagged.Time
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlaggedAccount is one row of the console's high-rate review queue.
|
||||||
|
type FlaggedAccount struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
FlaggedHighRateAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// flaggedListCap bounds the console's flagged-account list; the operator clears
|
||||||
|
// flags as they are reviewed, so the queue stays short in practice.
|
||||||
|
const flaggedListCap = 200
|
||||||
|
|
||||||
|
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
|
||||||
|
// recently flagged first.
|
||||||
|
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT account_id, display_name, flagged_high_rate_at
|
||||||
|
FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL
|
||||||
|
ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list flagged: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []FlaggedAccount
|
||||||
|
for rows.Next() {
|
||||||
|
var fa FlaggedAccount
|
||||||
|
if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: scan flagged: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, fa)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsers counts the filtered admin user list, for pagination.
|
||||||
|
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
||||||
|
where, args := userListWhere(f)
|
||||||
|
var n int
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("account: count users: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
|
||||||
|
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
|
||||||
|
func LikePattern(mask string) string {
|
||||||
|
mask = strings.TrimSpace(mask)
|
||||||
|
if mask == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask)
|
||||||
|
escaped = strings.ReplaceAll(escaped, "*", "%")
|
||||||
|
return strings.ReplaceAll(escaped, "?", "_")
|
||||||
|
}
|
||||||
@@ -21,10 +21,24 @@ func TestValidateDisplayName(t *testing.T) {
|
|||||||
"adjacent specials": {"Name P._Last", "", false},
|
"adjacent specials": {"Name P._Last", "", false},
|
||||||
"two spaces": {"Name Last", "", false},
|
"two spaces": {"Name Last", "", false},
|
||||||
"leading special": {"_Name", "", false},
|
"leading special": {"_Name", "", false},
|
||||||
"trailing special": {"Name.", "", false},
|
"trailing underscore": {"Name_", "", false},
|
||||||
"digit rejected": {"Name2", "", false},
|
"trailing dot ok": {"Anna B.", "Anna B.", true},
|
||||||
|
"double trailing dot": {"Name..", "", 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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -101,3 +118,78 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
|||||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||||
.actions form { margin: 0; }
|
.actions form { margin: 0; }
|
||||||
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */
|
||||||
|
.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; }
|
||||||
|
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||||
|
.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; }
|
||||||
|
.chart .lbl { fill: var(--ink-dim); font-size: 11px; }
|
||||||
|
.chart .ln { fill: none; stroke-width: 1.5; }
|
||||||
|
.chart .ln-min { stroke: var(--ok); }
|
||||||
|
.chart .ln-avg { stroke: var(--accent); }
|
||||||
|
.chart .ln-max { stroke: var(--danger); }
|
||||||
|
.lg { font-weight: 600; }
|
||||||
|
.lg-min { color: var(--ok); }
|
||||||
|
.lg-avg { color: var(--accent); }
|
||||||
|
.lg-max { color: var(--danger); }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChartPoint is one move-number sample of the move-duration chart: the min, mean and
|
||||||
|
// max think time (seconds) the account took on its Ordinal-th move across its games.
|
||||||
|
type ChartPoint struct {
|
||||||
|
Ordinal int
|
||||||
|
Min float64
|
||||||
|
Max float64
|
||||||
|
Avg float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDuration renders a think-time in seconds as a compact human string
|
||||||
|
// ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels.
|
||||||
|
func FormatDuration(secs float64) string {
|
||||||
|
d := time.Duration(secs * float64(time.Second))
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm", int(d.Minutes()+0.5))
|
||||||
|
default:
|
||||||
|
h := int(d.Hours())
|
||||||
|
if m := int(d.Minutes()) - h*60; m > 0 {
|
||||||
|
return fmt.Sprintf("%dh%dm", h, m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDurationChart renders the per-move-number think-time chart as a self-contained,
|
||||||
|
// script-free inline SVG with three series (min, mean, max). The coordinates and
|
||||||
|
// labels are all derived from numeric data, so the result is safe template.HTML.
|
||||||
|
// An empty series renders nothing.
|
||||||
|
func MoveDurationChart(points []ChartPoint) template.HTML {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const (
|
||||||
|
w, h = 640, 240
|
||||||
|
padL = 46
|
||||||
|
padR = 12
|
||||||
|
padT = 10
|
||||||
|
padB = 28
|
||||||
|
)
|
||||||
|
maxOrd := points[len(points)-1].Ordinal
|
||||||
|
if maxOrd < 1 {
|
||||||
|
maxOrd = 1
|
||||||
|
}
|
||||||
|
var maxY float64
|
||||||
|
for _, p := range points {
|
||||||
|
maxY = max(maxY, p.Max)
|
||||||
|
}
|
||||||
|
if maxY <= 0 {
|
||||||
|
maxY = 1
|
||||||
|
}
|
||||||
|
xOf := func(ord int) float64 {
|
||||||
|
if maxOrd == 1 {
|
||||||
|
return padL
|
||||||
|
}
|
||||||
|
return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR)
|
||||||
|
}
|
||||||
|
yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) }
|
||||||
|
line := func(get func(ChartPoint) float64) string {
|
||||||
|
pts := make([]string, len(points))
|
||||||
|
for i, p := range points {
|
||||||
|
pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p)))
|
||||||
|
}
|
||||||
|
return strings.Join(pts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" class="chart" role="img" aria-label="Move duration by move number">`, w, h)
|
||||||
|
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%.1f" class="axis"/>`, padL, padT, padL, float64(h-padB))
|
||||||
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="axis"/>`, padL, float64(h-padB), w-padR, float64(h-padB))
|
||||||
|
for _, frac := range []float64{0, 0.5, 1} {
|
||||||
|
v := maxY * frac
|
||||||
|
y := yOf(v)
|
||||||
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="grid"/>`, padL, y, w-padR, y)
|
||||||
|
fmt.Fprintf(&b, `<text x="%d" y="%.1f" class="lbl" text-anchor="end">%s</text>`, padL-5, y+3, FormatDuration(v))
|
||||||
|
}
|
||||||
|
for _, ord := range xTicks(maxOrd) {
|
||||||
|
fmt.Fprintf(&b, `<text x="%.1f" y="%d" class="lbl" text-anchor="middle">%d</text>`, xOf(ord), h-padB+15, ord)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-max"/>`, line(func(p ChartPoint) float64 { return p.Max }))
|
||||||
|
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-avg"/>`, line(func(p ChartPoint) float64 { return p.Avg }))
|
||||||
|
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-min"/>`, line(func(p ChartPoint) float64 { return p.Min }))
|
||||||
|
b.WriteString(`</svg>`)
|
||||||
|
return template.HTML(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// xTicks returns up to three distinct ordinal labels for the chart's X axis.
|
||||||
|
func xTicks(maxOrd int) []int {
|
||||||
|
if maxOrd <= 2 {
|
||||||
|
out := make([]int, 0, maxOrd)
|
||||||
|
for i := 1; i <= maxOrd; i++ {
|
||||||
|
out = append(out, i)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return []int{1, (maxOrd + 1) / 2, maxOrd}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatDuration(t *testing.T) {
|
||||||
|
cases := map[float64]string{
|
||||||
|
0: "0s", 30: "30s", 59: "59s", 60: "1m", 150: "3m", 3600: "1h", 3660: "1h1m", 7800: "2h10m",
|
||||||
|
}
|
||||||
|
for secs, want := range cases {
|
||||||
|
if got := FormatDuration(secs); got != want {
|
||||||
|
t.Errorf("FormatDuration(%v) = %q, want %q", secs, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveDurationChartEmpty(t *testing.T) {
|
||||||
|
if got := MoveDurationChart(nil); got != "" {
|
||||||
|
t.Errorf("empty chart = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveDurationChart(t *testing.T) {
|
||||||
|
pts := []ChartPoint{{Ordinal: 1, Min: 5, Max: 20, Avg: 10}, {Ordinal: 2, Min: 8, Max: 40, Avg: 18}, {Ordinal: 3, Min: 12, Max: 90, Avg: 30}}
|
||||||
|
svg := string(MoveDurationChart(pts))
|
||||||
|
for _, want := range []string{"<svg", "ln-min", "ln-avg", "ln-max", "</svg>"} {
|
||||||
|
if !strings.Contains(svg, want) {
|
||||||
|
t.Errorf("chart missing %q\n%s", want, svg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := strings.Count(svg, "<polyline"); n != 3 {
|
||||||
|
t.Errorf("polylines = %d, want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXTicks(t *testing.T) {
|
||||||
|
cases := map[int][]int{1: {1}, 2: {1, 2}, 3: {1, 2, 3}, 10: {1, 5, 10}}
|
||||||
|
for maxOrd, want := range cases {
|
||||||
|
got := xTicks(maxOrd)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("xTicks(%d) = %v, want %v", maxOrd, got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("xTicks(%d) = %v, want %v", maxOrd, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package adminconsole
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,14 +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"},
|
||||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
|
{"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"},
|
||||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
|
||||||
|
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
|
||||||
|
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", 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"},
|
||||||
}
|
}
|
||||||
@@ -48,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,8 +16,14 @@
|
|||||||
<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/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>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<h1>Game {{.ID}}</h1>
|
<h1>Game {{.ID}}</h1>
|
||||||
<nav class="subnav"><a href="/_gm/games">« games</a></nav>
|
<nav class="subnav"><a href="/_gm/games">« games</a> · <a href="/_gm/messages?game={{.ID}}">messages</a></nav>
|
||||||
<section class="panel"><h2>Summary</h2>
|
<section class="panel"><h2>Summary</h2>
|
||||||
<ul class="kv">
|
<ul class="kv">
|
||||||
<li><b>Variant</b> {{.Variant}}</li>
|
<li><b>Variant</b> {{.Variant}}</li>
|
||||||
<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>
|
||||||
@@ -17,13 +18,96 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Seats</h2>
|
<section class="panel"><h2>Seats</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
|
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th><th>Robot</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Seats}}
|
{{range .Seats}}
|
||||||
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td><td>{{if .IsRobot}}🤖 {{.RobotIntent}}{{if .NextMove}}<br><small>next move {{.NextMove}}</small>{{end}}{{end}}</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{if .HasRobot}}<p><small>Play-to-win is decided once per game from the bag seed; robots play to win in ~{{.RobotTargetPct}}% of games.</small></p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{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}}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Messages</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<form class="form" method="get" action="/_gm/messages">
|
||||||
|
{{if .GameID}}<input type="hidden" name="game" value="{{.GameID}}">{{end}}
|
||||||
|
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
|
||||||
|
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
||||||
|
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
||||||
|
<label class="check"><input type="checkbox" name="unread" value="1"{{if .UnreadOnly}} checked{{end}}> unread only</label>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||||
|
</form>
|
||||||
|
{{if or .GameID .UserID}}
|
||||||
|
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
||||||
|
{{end}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Read</th><th>Game</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/messages/{{.ID}}">{{.CreatedAt}}</a></td>
|
||||||
|
<td>{{.Source}}</td>
|
||||||
|
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
|
||||||
|
<td>{{.IP}}</td>
|
||||||
|
<td>{{.Body}}</td>
|
||||||
|
<td>{{if .Unread}}unread{{else}}read{{end}}</td>
|
||||||
|
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7"><span class="note">no messages</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .Pager.HasPrev}}<a href="/_gm/messages?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/messages?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,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></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,66 @@
|
|||||||
<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}}
|
||||||
|
<section class="panel"><h2>Move timing</h2>
|
||||||
|
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||||
|
{{.MoveChart}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
<section class="panel"><h2>Identities</h2>
|
<section class="panel"><h2>Identities</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||||
@@ -38,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}}
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
{{define "content" -}}
|
{{define "content" -}}
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/_gm/users"{{if not .Robots}} class="active"{{end}}>People</a> ·
|
||||||
|
<a href="/_gm/users?kind=robots"{{if .Robots}} class="active"{{end}}>Robots</a>
|
||||||
|
</nav>
|
||||||
|
<form class="form" method="get" action="/_gm/users">
|
||||||
|
{{if .Robots}}<input type="hidden" name="kind" value="robots">{{end}}
|
||||||
|
<input name="name" value="{{.NameMask}}" placeholder="display name mask (* ?)">
|
||||||
|
<input name="ext" value="{{.ExternalIDMask}}" placeholder="external id mask (* ?)">
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
|
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<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>
|
||||||
|
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="5"><span class="note">no users</span></td></tr>
|
<tr><td colspan="8"><span class="note">no users</span></td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
{{if .Pager.HasPrev}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
{{if .Pager.HasNext}}<a href="/_gm/users?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package adminconsole
|
package adminconsole
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
// The *View types are the display models the gin handlers fill and the templates
|
// The *View types are the display models the gin handlers fill and the templates
|
||||||
// render. Time values are pre-formatted to strings by the handlers so the
|
// render. Time values are pre-formatted to strings by the handlers so the
|
||||||
// templates stay logic-free.
|
// templates stay logic-free.
|
||||||
@@ -40,7 +42,11 @@ type DashboardView struct {
|
|||||||
Games int
|
Games int
|
||||||
ActiveGames int
|
ActiveGames int
|
||||||
OpenComplaints int
|
OpenComplaints int
|
||||||
|
OpenFeedback int
|
||||||
PendingChanges int
|
PendingChanges int
|
||||||
|
// ActiveVersion is the dictionary version new games pin (the persisted active
|
||||||
|
// version), distinct from the per-variant resident versions.
|
||||||
|
ActiveVersion string
|
||||||
Variants []VariantVersions
|
Variants []VariantVersions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,16 +54,88 @@ type DashboardView struct {
|
|||||||
type UsersView struct {
|
type UsersView struct {
|
||||||
Items []UserRow
|
Items []UserRow
|
||||||
Pager Pager
|
Pager Pager
|
||||||
|
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
||||||
|
// glob filters; FilterQuery is those 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
|
||||||
|
NameMask string
|
||||||
|
ExternalIDMask string
|
||||||
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRow is one account row in the list.
|
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||||
|
// pre-formatted move-duration summary (empty when it has no timed move);
|
||||||
|
// 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
|
||||||
|
FlaggedHighRate bool
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
|
HasMoveStats bool
|
||||||
|
MoveMin string
|
||||||
|
MoveAvg string
|
||||||
|
MoveMax string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
|
||||||
|
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
|
||||||
|
// game or user card); FilterQuery is the active filters 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 {
|
||||||
|
Items []MessageRow
|
||||||
|
Pager Pager
|
||||||
|
NameMask string
|
||||||
|
ExtMask string
|
||||||
|
GameID string
|
||||||
|
UserID string
|
||||||
|
UnreadOnly bool
|
||||||
|
FilterQuery template.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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), time, and whether it is still
|
||||||
|
// unread by at least one recipient.
|
||||||
|
type MessageRow struct {
|
||||||
|
ID string
|
||||||
|
SenderID string
|
||||||
|
SenderName string
|
||||||
|
Source string
|
||||||
|
IP string
|
||||||
|
Body string
|
||||||
|
GameID 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.
|
||||||
@@ -70,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
|
||||||
|
// 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
|
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
|
||||||
@@ -80,6 +164,51 @@ type UserDetailView struct {
|
|||||||
Games []GameRow
|
Games []GameRow
|
||||||
TelegramID string
|
TelegramID string
|
||||||
ConnectorEnabled bool
|
ConnectorEnabled bool
|
||||||
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||||
|
// time (min/mean/max), empty when the account has no timed move.
|
||||||
|
MoveChart template.HTML
|
||||||
|
// 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.
|
||||||
@@ -89,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.
|
||||||
@@ -106,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.
|
||||||
@@ -128,10 +261,39 @@ type GameDetailView struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt string
|
FinishedAt string
|
||||||
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||||
|
VsAI bool
|
||||||
Seats []SeatRow
|
Seats []SeatRow
|
||||||
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||||
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||||
|
HasRobot bool
|
||||||
|
RobotTargetPct int
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeatRow is one seat of a game.
|
// 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
|
||||||
|
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
|
||||||
|
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
|
||||||
type SeatRow struct {
|
type SeatRow struct {
|
||||||
Seat int
|
Seat int
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -139,6 +301,9 @@ type SeatRow struct {
|
|||||||
Score int
|
Score int
|
||||||
HintsUsed int
|
HintsUsed int
|
||||||
Winner bool
|
Winner bool
|
||||||
|
IsRobot bool
|
||||||
|
RobotIntent string
|
||||||
|
NextMove string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComplaintsView is the paginated complaint review queue.
|
// ComplaintsView is the paginated complaint review queue.
|
||||||
@@ -176,9 +341,11 @@ 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 {
|
||||||
|
// ActiveVersion is the dictionary version new games pin; the update form sets it.
|
||||||
|
ActiveVersion string
|
||||||
Variants []VariantVersions
|
Variants []VariantVersions
|
||||||
Changes []DictChangeRow
|
Changes []DictChangeRow
|
||||||
}
|
}
|
||||||
@@ -191,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
|
||||||
@@ -114,6 +126,7 @@ type Game struct {
|
|||||||
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
|
||||||
|
multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
|
||||||
log []MoveRecord
|
log []MoveRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +163,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -248,17 +269,29 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
|
|||||||
// winning regardless of score. A missed-turn timeout reuses Resign in the game
|
// winning regardless of score. A missed-turn timeout reuses Resign in the game
|
||||||
// domain, so it inherits this win/loss.
|
// domain, so it inherits this win/loss.
|
||||||
func (g *Game) Resign() (MoveRecord, error) {
|
func (g *Game) Resign() (MoveRecord, error) {
|
||||||
|
return g.ResignSeat(g.toMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
|
||||||
|
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
|
||||||
|
// skips resigned seats). The turn cursor only advances when the seat that resigned
|
||||||
|
// was the one to move; resigning an off-turn seat leaves the current player's turn
|
||||||
|
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
|
||||||
|
// already-resigned seat.
|
||||||
|
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
|
||||||
if g.over {
|
if g.over {
|
||||||
return MoveRecord{}, ErrGameOver
|
return MoveRecord{}, ErrGameOver
|
||||||
}
|
}
|
||||||
player := g.toMove
|
if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
|
||||||
g.resigned[player] = true
|
return MoveRecord{}, ErrGameOver
|
||||||
g.disposeHand(player)
|
}
|
||||||
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
|
g.resigned[seat] = true
|
||||||
|
g.disposeHand(seat)
|
||||||
|
rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
|
||||||
g.log = append(g.log, rec)
|
g.log = append(g.log, rec)
|
||||||
if g.activeCount() <= 1 {
|
if g.activeCount() <= 1 {
|
||||||
g.finish(EndResign)
|
g.finish(EndResign)
|
||||||
} else {
|
} else if seat == g.toMove {
|
||||||
g.advance()
|
g.advance()
|
||||||
}
|
}
|
||||||
return rec, nil
|
return rec, nil
|
||||||
@@ -267,7 +300,7 @@ func (g *Game) Resign() (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,
|
||||||
@@ -343,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
|
||||||
@@ -426,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +69,39 @@ func TestResignTrailingPlayerLoses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResignSeatOffTurn covers a forfeit on the opponent's turn: after player 0
|
||||||
|
// moves it is player 1's turn, yet player 0 resigns its own seat — the resigner
|
||||||
|
// loses, the opponent wins, and the game ends.
|
||||||
|
func TestResignSeatOffTurn(t *testing.T) {
|
||||||
|
g := openingGame(t)
|
||||||
|
|
||||||
|
hint, ok := g.HintView()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("opening game has no hint")
|
||||||
|
}
|
||||||
|
if _, err := g.SubmitPlay(hint.Tiles); err != nil { // player 0 moves
|
||||||
|
t.Fatalf("player 0 play: %v", err)
|
||||||
|
}
|
||||||
|
if g.ToMove() != 1 {
|
||||||
|
t.Fatalf("after player 0's move, toMove = %d, want 1", g.ToMove())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player 0 resigns although it is player 1's turn.
|
||||||
|
rec, err := g.ResignSeat(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("player 0 off-turn resign: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Player != 0 || rec.Action != ActionResign {
|
||||||
|
t.Errorf("resign record = seat %d action %v, want seat 0 resign", rec.Player, rec.Action)
|
||||||
|
}
|
||||||
|
if !g.Over() || g.Reason() != EndResign {
|
||||||
|
t.Fatalf("game over=%v reason=%v, want over with resign", g.Over(), g.Reason())
|
||||||
|
}
|
||||||
|
if res := g.Result(); res.Winner != 1 {
|
||||||
|
t.Errorf("winner = %d, want 1 (the non-resigner)", res.Winner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestResignOnFinishedGame rejects a second transition.
|
// TestResignOnFinishedGame rejects a second transition.
|
||||||
func TestResignOnFinishedGame(t *testing.T) {
|
func TestResignOnFinishedGame(t *testing.T) {
|
||||||
g := newEnglishGame(t, 1)
|
g := newEnglishGame(t, 1)
|
||||||
@@ -132,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)
|
||||||
}
|
}
|
||||||
@@ -212,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
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A move's "duration" is the think time from the previous move's commit (the moment
|
||||||
|
// the turn started) to this move's commit. Only play/pass/exchange moves count;
|
||||||
|
// timeouts and resignations are not think time. The very first move of a game has no
|
||||||
|
// previous move, so its baseline is the game's creation time. The figures are derived
|
||||||
|
// from the move journal (game_moves.created_at), so no schema change is needed.
|
||||||
|
//
|
||||||
|
// timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for
|
||||||
|
// every timed move; the two reports aggregate it differently.
|
||||||
|
const timedMovesCTE = `
|
||||||
|
SELECT gp.account_id AS aid,
|
||||||
|
m.game_id AS gid,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord,
|
||||||
|
EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs
|
||||||
|
FROM backend.game_moves m
|
||||||
|
JOIN backend.games g ON g.game_id = m.game_id
|
||||||
|
LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1
|
||||||
|
JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat
|
||||||
|
WHERE m.action IN ('play', 'pass', 'exchange')`
|
||||||
|
|
||||||
|
// MoveDurationStat is the min, max and mean per-move think time (in seconds) for an
|
||||||
|
// account across all its games, with the number of timed moves counted.
|
||||||
|
type MoveDurationStat struct {
|
||||||
|
MinSecs float64
|
||||||
|
MaxSecs float64
|
||||||
|
AvgSecs float64
|
||||||
|
Moves int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDurationStats returns the move-duration summary for each of accountIDs that has
|
||||||
|
// at least one timed move; accounts with none are absent from the map. It powers the
|
||||||
|
// admin user-list columns. The scan over the journal is acceptable for the low-traffic
|
||||||
|
// console; per-human analysis is the authoritative use (the live metric aggregates all
|
||||||
|
// seats including robots).
|
||||||
|
func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
|
||||||
|
if len(accountIDs) == 0 {
|
||||||
|
return map[uuid.UUID]MoveDurationStat{}, nil
|
||||||
|
}
|
||||||
|
q := `WITH d AS (` + timedMovesCTE + `)
|
||||||
|
SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("game: move-duration stats: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs))
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var st MoveDurationStat
|
||||||
|
if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil {
|
||||||
|
return nil, fmt.Errorf("game: scan move-duration stat: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = st
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move
|
||||||
|
// (Ordinal) across all its games.
|
||||||
|
type OrdinalDuration struct {
|
||||||
|
Ordinal int
|
||||||
|
MinSecs float64
|
||||||
|
MaxSecs float64
|
||||||
|
AvgSecs float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDurationByOrdinal returns the account's per-move-number think-time summary,
|
||||||
|
// ordered by move number, for the admin user-detail chart. The ordinal counts the
|
||||||
|
// account's own moves within each game (its 1st, 2nd, … move).
|
||||||
|
func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
|
||||||
|
q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1)
|
||||||
|
SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("game: move-duration by ordinal: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []OrdinalDuration
|
||||||
|
for rows.Next() {
|
||||||
|
var od OrdinalDuration
|
||||||
|
if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil {
|
||||||
|
return nil, fmt.Errorf("game: scan ordinal duration: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, od)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an
|
||||||
|
// ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe.
|
||||||
|
func uuidArrayLiteral(ids []uuid.UUID) string {
|
||||||
|
ss := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
ss[i] = id.String()
|
||||||
|
}
|
||||||
|
return "{" + strings.Join(ss, ",") + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDurationStats exposes the store report to the admin console handlers.
|
||||||
|
func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) {
|
||||||
|
return svc.store.MoveDurationStats(ctx, accountIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDurationByOrdinal exposes the per-move-number report to the admin console.
|
||||||
|
func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) {
|
||||||
|
return svc.store.MoveDurationByOrdinal(ctx, accountID)
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ type gameCache struct {
|
|||||||
|
|
||||||
type cachedGame struct {
|
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
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DraftTile is one tile a player has laid on the board but not yet submitted.
|
||||||
|
type DraftTile struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
Letter string `json:"letter"`
|
||||||
|
Blank bool `json:"blank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft is a player's persisted client-side composition for a game: the
|
||||||
|
// preferred rack tile order and the board tiles laid but not yet submitted. The server
|
||||||
|
// keeps it so a reload or a second device resumes the same arrangement.
|
||||||
|
type Draft struct {
|
||||||
|
RackOrder string
|
||||||
|
BoardTiles []DraftTile
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDraft returns the player's draft for a game, or a zero Draft when none is stored.
|
||||||
|
func (svc *Service) GetDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
|
||||||
|
return svc.store.getDraft(ctx, gameID, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDraft upserts the player's draft; the account must be seated in the game.
|
||||||
|
func (svc *Service) SaveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
|
||||||
|
seats, _, _, err := svc.Participants(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !slices.Contains(seats, accountID) {
|
||||||
|
return ErrNotAPlayer
|
||||||
|
}
|
||||||
|
return svc.store.saveDraft(ctx, gameID, accountID, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDraft reads one draft row, returning a zero Draft when absent.
|
||||||
|
func (s *Store) getDraft(ctx context.Context, gameID, accountID uuid.UUID) (Draft, error) {
|
||||||
|
var rackOrder string
|
||||||
|
var boardJSON []byte
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT rack_order, board_tiles FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
|
||||||
|
gameID, accountID).Scan(&rackOrder, &boardJSON)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Draft{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Draft{}, fmt.Errorf("game: get draft %s: %w", gameID, err)
|
||||||
|
}
|
||||||
|
d := Draft{RackOrder: rackOrder}
|
||||||
|
if len(boardJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(boardJSON, &d.BoardTiles); err != nil {
|
||||||
|
return Draft{}, fmt.Errorf("game: decode draft tiles: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveDraft upserts the player's draft.
|
||||||
|
func (s *Store) saveDraft(ctx context.Context, gameID, accountID uuid.UUID, d Draft) error {
|
||||||
|
tiles := d.BoardTiles
|
||||||
|
if tiles == nil {
|
||||||
|
tiles = []DraftTile{}
|
||||||
|
}
|
||||||
|
boardJSON, err := json.Marshal(tiles)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("game: encode draft tiles: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO backend.game_drafts (game_id, account_id, rack_order, board_tiles, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
ON CONFLICT (game_id, account_id)
|
||||||
|
DO UPDATE SET rack_order = $3, board_tiles = $4, updated_at = now()`,
|
||||||
|
gameID, accountID, d.RackOrder, boardJSON); err != nil {
|
||||||
|
return fmt.Errorf("game: save draft: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearDraft drops a player's draft row (their composition is consumed or discarded).
|
||||||
|
func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM backend.game_drafts WHERE game_id = $1 AND account_id = $2`,
|
||||||
|
gameID, accountID); err != nil {
|
||||||
|
return fmt.Errorf("game: clear draft: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
|
||||||
|
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
|
||||||
|
// rack order is kept.
|
||||||
|
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
|
||||||
|
if len(cells) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
occupied := make(map[[2]int]bool, len(cells))
|
||||||
|
for _, c := range cells {
|
||||||
|
occupied[[2]int{c.Row, c.Col}] = true
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT account_id, board_tiles FROM backend.game_drafts
|
||||||
|
WHERE game_id = $1 AND account_id <> $2 AND board_tiles <> '[]'::jsonb`,
|
||||||
|
gameID, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("game: scan drafts for conflict: %w", err)
|
||||||
|
}
|
||||||
|
var toClear []uuid.UUID
|
||||||
|
func() {
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
for rows.Next() {
|
||||||
|
var acc uuid.UUID
|
||||||
|
var boardJSON []byte
|
||||||
|
if err = rows.Scan(&acc, &boardJSON); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tiles []DraftTile
|
||||||
|
if json.Unmarshal(boardJSON, &tiles) != nil {
|
||||||
|
continue // skip a malformed draft
|
||||||
|
}
|
||||||
|
for _, t := range tiles {
|
||||||
|
if occupied[[2]int{t.Row, t.Col}] {
|
||||||
|
toClear = append(toClear, acc)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("game: read drafts for conflict: %w", err)
|
||||||
|
}
|
||||||
|
for _, acc := range toClear {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE backend.game_drafts SET board_tiles = '[]'::jsonb, updated_at = now()
|
||||||
|
WHERE game_id = $1 AND account_id = $2`,
|
||||||
|
gameID, acc); err != nil {
|
||||||
|
return fmt.Errorf("game: clear conflicting draft: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// draftTilesFrom projects a play's committed tiles into draft cells, for the conflict scan.
|
||||||
|
func draftTilesFrom(rec engine.MoveRecord) []DraftTile {
|
||||||
|
out := make([]DraftTile, 0, len(rec.Tiles))
|
||||||
|
for _, t := range rec.Tiles {
|
||||||
|
out = append(out, DraftTile{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/engine"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingPublisher captures every published intent for assertions.
|
||||||
|
type recordingPublisher struct{ intents []notify.Intent }
|
||||||
|
|
||||||
|
func (p *recordingPublisher) Publish(in ...notify.Intent) { p.intents = append(p.intents, in...) }
|
||||||
|
|
||||||
|
// TestEmitMoveNotifiesActor checks a committed move sends opponent_moved to every
|
||||||
|
// seat — including the actor's own account, so the mover's other devices refresh —
|
||||||
|
// and your_turn only to the next mover.
|
||||||
|
func TestEmitMoveNotifiesActor(t *testing.T) {
|
||||||
|
actor, opp := uuid.New(), uuid.New()
|
||||||
|
pub := &recordingPublisher{}
|
||||||
|
svc := &Service{pub: pub}
|
||||||
|
g := Game{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Status: StatusActive,
|
||||||
|
ToMove: 1,
|
||||||
|
TurnStartedAt: time.Now(),
|
||||||
|
TurnTimeout: time.Hour,
|
||||||
|
Seats: []Seat{{Seat: 0, AccountID: actor, Score: 19}, {Seat: 1, AccountID: opp, Score: 13}},
|
||||||
|
}
|
||||||
|
svc.emitMove(context.Background(), g, engine.MoveRecord{Player: 0, Action: engine.ActionPlay, Words: []string{"HELLO"}, Score: 10, Total: 19}, 80)
|
||||||
|
|
||||||
|
kinds := map[uuid.UUID][]string{}
|
||||||
|
var yourTurn notify.Intent
|
||||||
|
for _, in := range pub.intents {
|
||||||
|
kinds[in.UserID] = append(kinds[in.UserID], in.Kind)
|
||||||
|
if in.UserID == opp && in.Kind == notify.KindYourTurn {
|
||||||
|
yourTurn = in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Contains(kinds[actor], notify.KindOpponentMoved) {
|
||||||
|
t.Errorf("actor should get opponent_moved, got %v", kinds[actor])
|
||||||
|
}
|
||||||
|
if !slices.Contains(kinds[opp], notify.KindOpponentMoved) {
|
||||||
|
t.Errorf("opponent should get opponent_moved, got %v", kinds[opp])
|
||||||
|
}
|
||||||
|
if !slices.Contains(kinds[opp], notify.KindYourTurn) {
|
||||||
|
t.Errorf("next mover should get your_turn, got %v", kinds[opp])
|
||||||
|
}
|
||||||
|
if slices.Contains(kinds[actor], notify.KindYourTurn) {
|
||||||
|
t.Errorf("actor is not next to move, should not get your_turn")
|
||||||
|
}
|
||||||
|
// The your_turn push is enriched: the last move's action and word, and a recipient-first
|
||||||
|
// score line (the next mover, seat 1, first). The opponent name needs the account store and
|
||||||
|
// is left empty by this store-less unit (covered at the render layer).
|
||||||
|
yt := fb.GetRootAsYourTurnEvent(yourTurn.Payload, 0)
|
||||||
|
if got := string(yt.LastAction()); got != "play" {
|
||||||
|
t.Errorf("your_turn last_action = %q, want play", got)
|
||||||
|
}
|
||||||
|
if got := string(yt.LastWord()); got != "HELLO" {
|
||||||
|
t.Errorf("your_turn last_word = %q, want HELLO", got)
|
||||||
|
}
|
||||||
|
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
|
||||||
|
t.Errorf("your_turn score_line = %q, want 13:19", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,12 +16,13 @@ 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 {
|
||||||
replay metric.Float64Histogram
|
replay metric.Float64Histogram
|
||||||
validate metric.Float64Histogram
|
validate metric.Float64Histogram
|
||||||
|
moveDur metric.Float64Histogram
|
||||||
started metric.Int64Counter
|
started metric.Int64Counter
|
||||||
abandoned metric.Int64Counter
|
abandoned metric.Int64Counter
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ func newGameMetrics(meter metric.Meter) *gameMetrics {
|
|||||||
return &gameMetrics{
|
return &gameMetrics{
|
||||||
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
|
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
|
||||||
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
|
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
|
||||||
|
moveDur: histogram(meter, "game_move_duration", "Seconds a seat spent on a committed move (play/pass/exchange), by variant and phase. Aggregates all seats including robots; per-human analysis lives in the admin console."),
|
||||||
started: counter(meter, "games_started_total", "Games created and started."),
|
started: counter(meter, "games_started_total", "Games created and started."),
|
||||||
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
|
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
|
||||||
}
|
}
|
||||||
@@ -75,15 +77,46 @@ func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, star
|
|||||||
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
|
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordStarted counts one started game of variant.
|
// recordMoveDuration records how long a seat spent on a committed move, attributed by
|
||||||
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
|
// variant and the game phase derived from moveCount. A non-positive duration (a clock
|
||||||
m.started.Add(ctx, 1, variantAttr(v))
|
// skew or a move with no recorded turn start) is dropped.
|
||||||
|
func (m *gameMetrics) recordMoveDuration(ctx context.Context, v engine.Variant, moveCount int, d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.moveDur.Record(ctx, d.Seconds(),
|
||||||
|
metric.WithAttributes(attribute.String("variant", v.String()), attribute.String("phase", phaseOf(moveCount))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// phaseOf buckets a move ordinal into the game phase used as a metric attribute. The
|
||||||
|
// thresholds reflect a typical ~28-move game (docs/ARCHITECTURE.md §7).
|
||||||
|
func phaseOf(moveCount int) string {
|
||||||
|
switch {
|
||||||
|
case moveCount <= 8:
|
||||||
|
return "opening"
|
||||||
|
case moveCount <= 20:
|
||||||
|
return "middle"
|
||||||
|
default:
|
||||||
|
return "endgame"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordStarted counts one started game of variant, split by whether it is an
|
||||||
|
// honest-AI game (the vs_ai attribute), so AI and human games chart separately.
|
||||||
|
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,12 +20,16 @@ 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, 0) // non-positive: dropped
|
||||||
|
|
||||||
var rm metricdata.ResourceMetrics
|
var rm metricdata.ResourceMetrics
|
||||||
if err := reader.Collect(ctx, &rm); err != nil {
|
if err := reader.Collect(ctx, &rm); err != nil {
|
||||||
@@ -33,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)
|
||||||
@@ -45,6 +53,19 @@ func TestGameMetrics(t *testing.T) {
|
|||||||
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
|
||||||
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
|
||||||
}
|
}
|
||||||
|
if c := histogramCount(t, rm, "game_move_duration"); c != 1 {
|
||||||
|
t.Errorf("game_move_duration observations = %d, want 1 (zero-duration dropped)", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhaseOf checks the move-ordinal to phase bucketing.
|
||||||
|
func TestPhaseOf(t *testing.T) {
|
||||||
|
cases := map[int]string{1: "opening", 8: "opening", 9: "middle", 20: "middle", 21: "endgame", 50: "endgame"}
|
||||||
|
for mc, want := range cases {
|
||||||
|
if got := phaseOf(mc); got != want {
|
||||||
|
t.Errorf("phaseOf(%d) = %q, want %q", mc, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// counterByAttr sums the int64 counter named name, grouped by the value of the
|
// counterByAttr sums the int64 counter named name, grouped by the value of the
|
||||||
@@ -63,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user